Compare commits

..

29 Commits

Author SHA1 Message Date
1de1c9dcde remove migrations 2026-04-11 21:57:48 -05:00
2f94039f02 remove migrations 2026-04-11 21:57:35 -05:00
6ebebfad9a prisma 2026-04-11 21:50:41 -05:00
ed0be3b583 network 2026-04-11 17:31:24 -05:00
666c7724c5 executable + rem whitespace 2026-04-11 17:12:25 -05:00
94206afd49 remove ports, remove volume uploads_data 2026-04-11 17:04:08 -05:00
0084c5f05b log 2026-03-12 14:23:32 +01:00
Timo Knuth
d93f43bf01 Merge branch 'master' of https://gitea.bizmatch.net/tknuth/stadtwerke 2026-03-08 11:28:53 +01:00
Timo Knuth
7b22fdbc22 . 2026-03-08 11:28:22 +01:00
37bc8170db docker fehler seed. 2026-03-07 20:24:38 +01:00
1a69cbe462 docker fehler seed 2026-03-07 19:55:45 +01:00
56ea3348d6 Postgres 2026-03-04 14:13:16 +01:00
Timo Knuth
b7d826e29c feat: Implement initial admin and mobile application UI, including styling, layouts, authentication, and legal page components. 2026-03-03 16:54:11 +01:00
Timo Knuth
59f3efaaed feat: Initialize new admin application with a landing page, cookie consent, and theme switching functionality. 2026-03-02 23:33:11 +01:00
Timo Knuth
873c5e53af feat: Implement Next.js middleware for subdomain-based tenant routing and authentication, create the admin application's main page, and add Google site verification. 2026-03-02 23:01:21 +01:00
9d71c16883 Fehler beheben1 2026-03-02 21:32:22 +01:00
9f5e916e60 Fehler beheben 2026-03-02 18:03:27 +01:00
35c23164bf hoffentlich 2026-02-28 18:45:45 +01:00
Timo Knuth
02bb2ed994 npm run build durch gelaufen 2026-02-28 11:56:31 +01:00
Timo Knuth
aec4b93439 fehler 2 2026-02-28 00:04:13 +01:00
Timo Knuth
166a1b5d06 fehler 2026-02-27 23:45:32 +01:00
Timo Knuth
8999cdbab3 feat: Add initial project setup including superadmin seeding, a Docker entrypoint for the admin app, and a comprehensive README. 2026-02-27 21:33:40 +01:00
Timo Knuth
244da5e69a feat: Implement news section with list and detail views, category filtering, and unread indicators. 2026-02-27 19:36:20 +01:00
Timo Knuth
4863d032d9 feat: Implement comprehensive member management with user accounts, roles, and password handling for admin and mobile applications. 2026-02-27 18:50:17 +01:00
Timo Knuth
253c3c1c6d push 2026-02-27 15:19:24 +01:00
b7f8221095 feat: Set up initial monorepo structure for admin and mobile applications with core configurations and database integration. 2026-02-20 12:58:54 +01:00
5e2d5fb3ae feat: Implement initial with admin and mobile clients, authentication, data models, and lead generation scripts. 2026-02-19 16:18:34 +01:00
c53a71a5f9 feat: Implement mobile application and lead processing utilities. 2026-02-19 14:21:51 +01:00
Timo Knuth
fca42db4d2 Rebuild as InnungsApp project: replace stadtwerke analysis with full documentation
- PRD: vollständige Produktspezifikation (5 Module, Scope, Akzeptanzkriterien)
- ARCHITECTURE: Tech Stack, Ordnerstruktur, Multi-Tenancy, Push, Kosten
- DATABASE_SCHEMA: Vollständiges SQL-Schema mit RLS Policies und Views
- USER_STORIES: 40+ Stories nach Rolle (Admin, Mitglied, Azubi, Obermeister)
- PERSONAS: 5 detaillierte Nutzerprofile mit Alltag, Zitaten und Erwartungen
- BUSINESS_MODEL: Preistabellen, Unit Economics, Revenue-Projektionen, Distribution
- ROADMAP: 6 Phasen, Sprint-Planung, Meilensteine und KPIs
- COMPETITIVE_ANALYSIS: Wettbewerbsmatrix, USPs, Preispositionierung
- API_DESIGN: Supabase Query Patterns, Edge Functions, Realtime Subscriptions
- ONBOARDING_FLOWS: 7 User Flows von Setup bis Fehlerfall
- GTM_STRATEGY: 3-Phasen-Vertrieb, Outreach-Sequenz, Einwandbehandlung
- AZUBI_MODULE: Video-Feed, 1-Click-Apply, Chat, Berichtsheft, Quiz
- DSGVO_KONZEPT: Rechtsgrundlagen, TOMs, AVV, Minderjährige, Incident Response
- FEATURES_BACKLOG: 72 Features nach MoSCoW + Technische Schulden

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-18 19:03:37 +01:00
299 changed files with 47776 additions and 6470 deletions

307
AGENTS.md Normal file
View File

@@ -0,0 +1,307 @@
# Universal AI Coding Agent Workflow (Codex / Gemini / Claude)
## Workflow Orchestration
### 1. Plan Mode Default
- Enter planning mode for ANY non-trivial task (3+ steps or architecture decisions)
- Analyze the codebase before making changes
- Break problems into clear subtasks
- Produce an implementation plan before writing code
- If assumptions are uncertain, inspect files or run tools first
- Prefer incremental progress over large rewrites
Plan format:
PLAN
1. Understand the task
2. Identify affected files
3. Design the implementation
4. Implement step-by-step
5. Verify results
---
# Multi-Agent Strategy
### 2. Agent Decomposition
Use specialized agents for complex work.
Core roles:
- Orchestrator Agent
- Research Agent
- Implementation Agent
- Test Agent
- Code Review Agent
- Debug Agent
- Documentation Agent
Rules:
- One responsibility per agent
- Prefer parallel execution
- Agents should operate on independent files when possible
- The orchestrator coordinates execution
---
# Agent Responsibilities
### Orchestrator Agent
- analyzes the user request
- creates task list
- assigns tasks to agents
- merges results
### Research Agent
- scans repository
- searches dependencies
- analyzes architecture
- produces context summary
### Implementation Agent
- writes code
- edits files
- follows project conventions
- implements features
### Test Agent
- writes tests
- verifies functionality
- checks edge cases
### Code Review Agent
- reviews diffs
- checks maintainability
- suggests improvements
### Debug Agent
- analyzes logs
- identifies root causes
- implements fixes
### Documentation Agent
- updates docs
- writes README sections
- explains new features
---
# Execution Pipeline
### 3. Execution Phases
PHASE 1 — Discovery
- explore repository
- load relevant files
- understand architecture
PHASE 2 — Planning
- generate implementation plan
- break plan into tasks
PHASE 3 — Task Creation
Create tasks like:
[ ] analyze codebase
[ ] implement feature
[ ] add tests
[ ] review code
[ ] update documentation
PHASE 4 — Implementation
- execute tasks sequentially or in parallel
- commit progress
PHASE 5 — Verification
- run tests
- check logs
- verify feature works
PHASE 6 — Review
- review code quality
- refactor if necessary
PHASE 7 — Documentation
- document changes
---
# Verification System
### 4. Verification Before Done
Never mark a task complete without proof.
Checks:
- code compiles
- feature works
- tests pass
- no new errors introduced
Ask:
"Would a senior engineer approve this implementation?"
---
# Autonomous Debugging
### 5. Autonomous Bug Fixing
When encountering a bug:
1. analyze error message
2. inspect stack trace
3. identify root cause
4. implement fix
5. verify with tests
Rules:
- Never apply random fixes
- Always understand the root cause first
---
# Context Management
### 6. Context Awareness
Before implementing anything:
- load relevant files
- inspect dependencies
- understand architecture
- read configuration files
Always maintain awareness of:
- system architecture
- data flow
- dependencies
---
# Memory System
### 7. Persistent Memory
Store long-term knowledge in:
memory/
- project_summary.md
- architecture.md
- lessons.md
- coding_standards.md
This prevents repeated mistakes.
---
# Learning Loop
### 8. Self-Improvement
After errors or corrections:
Update:
tasks/lessons.md
Include:
- mistake pattern
- root cause
- prevention rule
Example:
Lesson:
Always validate API responses before processing them.
---
# Safety Rules
### 9. Safety
Never perform dangerous actions automatically.
Rules:
- never delete files without confirmation
- avoid modifying production configuration automatically
- create backups before large refactors
- avoid irreversible operations
---
# Iteration Control
### 10. Infinite Loop Protection
If the same error happens more than 3 times:
STOP
- re-evaluate the strategy
- re-plan the solution
- choose a different debugging approach
---
# Core Engineering Principles
### Simplicity First
Prefer the simplest solution that works.
### Root Cause Fixes
Always fix the underlying problem, not symptoms.
### Minimal Impact
Touch the smallest amount of code possible.
### Maintainability
Code should remain readable and maintainable.
---
# Final Rule
Before delivering a solution ask:
Is this solution correct, maintainable, and verifiable?
If not:
Refine it before presenting it.
---
# Recommended File Usage
You can place this workflow in one of the following files:
AGENT_WORKFLOW.md
CLAUDE.md
AGENTS.md
This allows it to be used by:
- Claude Code Agent Teams
- Codex CLI
- Gemini Code Assist
- Cursor Agents

492
API_DESIGN.md Normal file
View File

@@ -0,0 +1,492 @@
# InnungsApp — API Design
> Supabase stellt die API automatisch via PostgREST bereit. Dieses Dokument beschreibt die wichtigsten Query-Patterns und Edge Functions.
---
## 1. Basis-URL & Auth
```
Base URL: https://<project-id>.supabase.co
REST API: /rest/v1/
Auth API: /auth/v1/
Storage API: /storage/v1/
Edge Functions: /functions/v1/
Headers:
apikey: <anon-key> # Öffentliche Requests
Authorization: Bearer <jwt> # Authentifizierte Requests
```
---
## 2. Auth Endpoints
### Magic Link anfordern
```http
POST /auth/v1/otp
Content-Type: application/json
{
"email": "max.muster@muellerszdk.de",
"options": {
"emailRedirectTo": "innungsapp://auth/verify"
}
}
```
### Session mit Token verifizieren (nach Link-Klick)
```typescript
const { data, error } = await supabase.auth.verifyOtp({
token_hash: params.token_hash,
type: 'magiclink'
});
```
### Aktuelle Session
```typescript
const { data: { session } } = await supabase.auth.getSession();
```
### Logout
```typescript
await supabase.auth.signOut();
```
---
## 3. Members API
### Alle Mitglieder einer Innung abrufen
```typescript
// RLS filtert automatisch nach org_id des eingeloggten Users
const { data, error } = await supabase
.from('members')
.select('id, vorname, nachname, betrieb, sparte, ort, telefon, email, status, ausbildungsbetrieb')
.eq('status', 'aktiv')
.order('nachname');
```
### Mitglieder suchen (Volltext)
```typescript
const { data } = await supabase
.from('members')
.select('*')
.or(`nachname.ilike.%${query}%,betrieb.ilike.%${query}%,ort.ilike.%${query}%`)
.eq('status', 'aktiv');
```
### Mitglied nach Sparte filtern
```typescript
const { data } = await supabase
.from('members')
.select('*')
.eq('sparte', 'Elektrotechnik')
.eq('status', 'aktiv');
```
### Mitglied anlegen (Admin only)
```typescript
const { data, error } = await supabase
.from('members')
.insert({
org_id: currentOrgId,
vorname: 'Max',
nachname: 'Müller',
betrieb: 'Müller Sanitär GmbH',
sparte: 'SHK',
email: 'max@mueller-sanitaer.de',
telefon: '0711-123456',
ort: 'Stuttgart',
status: 'aktiv',
ausbildungsbetrieb: true,
mitglied_seit: 2015
});
```
### Mitglied aktualisieren
```typescript
const { error } = await supabase
.from('members')
.update({ status: 'ausgetreten' })
.eq('id', memberId);
```
---
## 4. News API
### News Feed (veröffentlichte Beiträge)
```typescript
const { data } = await supabase
.from('news')
.select(`
id, title, body, kategorie, published_at, pinned,
author:members(vorname, nachname, betrieb),
attachments:news_attachments(id, filename, storage_url),
read:news_reads(read_at)
`)
.not('published_at', 'is', null)
.lte('published_at', new Date().toISOString())
.order('pinned', { ascending: false })
.order('published_at', { ascending: false })
.limit(20);
```
### Beitrag als gelesen markieren
```typescript
const { error } = await supabase
.from('news_reads')
.upsert({
news_id: newsId,
user_id: userId
}, { onConflict: 'news_id,user_id' });
```
### Beitrag mit Leserate (Admin)
```typescript
const { data } = await supabase
.from('news_with_stats') // VIEW
.select('*')
.order('published_at', { ascending: false });
```
### Beitrag erstellen (Admin)
```typescript
const { data: news } = await supabase
.from('news')
.insert({
org_id: currentOrgId,
title: 'Wichtige Mitteilung: Jahreshauptversammlung',
body: '## Einladung\n\nWir laden alle Mitglieder...',
kategorie: 'Veranstaltung',
published_at: new Date().toISOString() // oder null für Entwurf
})
.select()
.single();
```
---
## 5. Termine API
### Kommende Termine
```typescript
const { data } = await supabase
.from('termine_with_counts') // VIEW
.select('*')
.gte('datum', new Date().toISOString().split('T')[0])
.order('datum');
```
### Anmeldung für Termin
```typescript
const { error } = await supabase
.from('termine_anmeldungen')
.insert({
termin_id: terminId,
member_id: memberId
});
```
### Abmeldung von Termin
```typescript
const { error } = await supabase
.from('termine_anmeldungen')
.delete()
.match({ termin_id: terminId, member_id: memberId });
```
### Anmeldestatus prüfen
```typescript
const { data } = await supabase
.from('termine_anmeldungen')
.select('id, angemeldet_at')
.match({ termin_id: terminId, member_id: memberId })
.maybeSingle();
const isAngemeldet = !!data;
```
### iCal-Export generieren (Client-seitig)
```typescript
function generateICalEvent(termin: Termin): string {
return `BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//InnungsApp//DE
BEGIN:VEVENT
UID:${termin.id}@innungsapp.de
DTSTART:${termin.datum.replace(/-/g, '')}T${termin.uhrzeit_von?.replace(/:/g, '') || '080000'}
DTEND:${termin.datum.replace(/-/g, '')}T${termin.uhrzeit_bis?.replace(/:/g, '') || '100000'}
SUMMARY:${termin.titel}
LOCATION:${termin.ort || ''}
DESCRIPTION:${termin.beschreibung || ''}
END:VEVENT
END:VCALENDAR`;
}
```
---
## 6. Stellen (Lehrlingsbörse) API
### Alle aktiven Stellen (öffentlich, kein Login)
```typescript
// Mit anon key — RLS erlaubt das für aktive Stellen
const { data } = await supabase
.from('stellen')
.select(`
id, sparte, berufsbezeichnung, stellen_anzahl,
verguetung_1, verguetung_2, verguetung_3, verguetung_4,
ausbildungsstart, lehrjahr, schulabschluss,
kontakt_email, kontakt_tel,
member:members(betrieb, ort, plz)
`)
.eq('aktiv', true)
.order('created_at', { ascending: false });
```
### Stellen filtern
```typescript
const { data } = await supabase
.from('stellen')
.select('*')
.eq('aktiv', true)
.eq('sparte', 'SHK')
.ilike('member.ort', '%Stuttgart%');
```
### Stelle anlegen (Mitglied)
```typescript
const { data, error } = await supabase
.from('stellen')
.insert({
org_id: currentOrgId,
member_id: currentMemberId,
sparte: 'Elektrotechnik',
berufsbezeichnung: 'Elektroniker für Energie- und Gebäudetechnik',
stellen_anzahl: 2,
verguetung_1: 800,
verguetung_2: 920,
verguetung_3: 1020,
ausbildungsstart: 'August 2026',
lehrjahr: '1. Lehrjahr',
schulabschluss: 'Hauptschule',
kontakt_email: 'ausbildung@mueller-elektro.de',
kontakt_tel: '0711-98765'
});
```
---
## 7. Storage API
### PDF hochladen (Admin)
```typescript
const { data, error } = await supabase.storage
.from('news-attachments')
.upload(`${orgId}/${newsId}/${file.name}`, file, {
contentType: 'application/pdf',
upsert: false
});
const { data: urlData } = supabase.storage
.from('news-attachments')
.getPublicUrl(`${orgId}/${newsId}/${file.name}`);
```
### Logo hochladen (Admin)
```typescript
const { error } = await supabase.storage
.from('org-assets')
.upload(`${orgId}/logo.png`, file, {
contentType: 'image/png',
upsert: true
});
```
---
## 8. Edge Functions
### `send-invitation` — Einladungsmail senden
```typescript
// apps/supabase/functions/send-invitation/index.ts
import { Resend } from 'npm:resend';
Deno.serve(async (req) => {
const { member_id, org_id } = await req.json();
// Member und Org-Daten laden
const { data: member } = await supabase
.from('members')
.select('*, org:organizations(name)')
.eq('id', member_id)
.single();
// Magic Link generieren
const { data: { properties } } = await supabase.auth.admin
.generateLink({ type: 'magiclink', email: member.email });
// E-Mail senden
const resend = new Resend(Deno.env.get('RESEND_API_KEY'));
await resend.emails.send({
from: 'noreply@innungsapp.de',
to: member.email,
subject: `Einladung zur ${member.org.name}`,
html: invitationEmailTemplate(member, properties.hashed_token)
});
return new Response(JSON.stringify({ success: true }));
});
```
### `send-push-notification` — Push senden bei neuem Beitrag
```typescript
// Wird via Database Webhook nach INSERT auf news getriggert
Deno.serve(async (req) => {
const { record: news } = await req.json();
if (!news.published_at) return new Response('Draft, skip');
// Alle Push Tokens der Innung laden
const { data: tokens } = await supabase
.from('push_tokens')
.select('token')
.eq('org_id', news.org_id); // via user_roles Join
// Expo Push API
const messages = tokens.map(({ token }) => ({
to: token,
title: news.title,
body: news.body.substring(0, 100) + '...',
data: { type: 'news', id: news.id }
}));
await fetch('https://exp.host/--/api/v2/push/send', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(messages)
});
return new Response(JSON.stringify({ sent: messages.length }));
});
```
### `import-members` — CSV-Import
```typescript
Deno.serve(async (req) => {
const formData = await req.formData();
const file = formData.get('file') as File;
const orgId = formData.get('org_id') as string;
const csv = await file.text();
const rows = parseCSV(csv); // eigene Parse-Funktion
const members = rows.map(row => ({
org_id: orgId,
nachname: row['Nachname'],
vorname: row['Vorname'],
betrieb: row['Betrieb'],
sparte: row['Sparte'],
email: row['E-Mail'],
telefon: row['Telefon'],
ort: row['Ort'],
status: 'aktiv'
}));
const { data, error } = await supabase
.from('members')
.insert(members);
return new Response(JSON.stringify({ imported: members.length, error }));
});
```
---
## 9. Realtime Subscriptions
### Live-Updates für News Feed
```typescript
// Neuer Beitrag erscheint sofort in der App
const subscription = supabase
.channel('news-updates')
.on('postgres_changes', {
event: 'INSERT',
schema: 'public',
table: 'news',
filter: `org_id=eq.${currentOrgId}`
}, (payload) => {
queryClient.invalidateQueries({ queryKey: ['news'] });
})
.subscribe();
// Cleanup
return () => subscription.unsubscribe();
```
### Live-Updates für Teilnehmerzahl
```typescript
const subscription = supabase
.channel(`termin-${terminId}`)
.on('postgres_changes', {
event: '*',
schema: 'public',
table: 'termine_anmeldungen',
filter: `termin_id=eq.${terminId}`
}, () => {
queryClient.invalidateQueries({ queryKey: ['termin', terminId] });
})
.subscribe();
```
---
## 10. Error Handling Patterns
```typescript
// Zentrale Error-Handling Utility
export function handleSupabaseError(error: PostgrestError | null): never | void {
if (!error) return;
switch (error.code) {
case '23505': // unique_violation
throw new Error('Dieser Eintrag existiert bereits.');
case '42501': // insufficient_privilege (RLS)
throw new Error('Keine Berechtigung für diese Aktion.');
case 'PGRST116': // no rows returned
throw new Error('Kein Eintrag gefunden.');
default:
console.error('Supabase error:', error);
throw new Error('Ein unbekannter Fehler ist aufgetreten.');
}
}
```

341
ARCHITECTURE.md Normal file
View File

@@ -0,0 +1,341 @@
# InnungsApp — Technische Architektur
> **Version:** 1.0 | **Stand:** Februar 2026
---
## 1. Überblick
InnungsApp besteht aus drei Hauptkomponenten:
```
┌─────────────────────────────────────────────────────────────┐
│ CLIENTS │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ Mobile App │ │ Admin Web App │ │
│ │ (React Native) │ │ (Next.js) │ │
│ │ iOS + Android │ │ Browser │ │
│ └────────┬────────┘ └────────┬────────┘ │
└───────────┼────────────────────┼──────────────────────────┘
│ │
▼ ▼
┌─────────────────────────────────────────────────────────────┐
│ SUPABASE │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌───────────┐ │
│ │ Auth │ │ Postgres │ │ Storage │ │ Realtime │ │
│ │ Magic │ │ + RLS │ │ PDFs │ │ Push + │ │
│ │ Link │ │ Database │ │ Images │ │ Events │ │
│ └──────────┘ └──────────┘ └──────────┘ └───────────┘ │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ EXTERNE DIENSTE │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌───────────┐ │
│ │ Expo │ │ Resend │ │ PostHog │ │ Mux │ │
│ │ Push │ │ E-Mail │ │ Analytics│ │ Video │ │
│ │ (FCM/APNs│ │ Transact.│ │ │ │ (Q2) │ │
│ └──────────┘ └──────────┘ └──────────┘ └───────────┘ │
└─────────────────────────────────────────────────────────────┘
```
---
## 2. Mobile App (React Native + Expo)
### Tech Stack
| Schicht | Technologie | Begründung |
|---|---|---|
| Framework | React Native 0.74 + Expo SDK 51 | Eine Codebasis iOS + Android |
| Navigation | Expo Router v3 (file-based) | Typ-sicher, einfach wartbar |
| State Management | Zustand | Leichtgewichtig, kein Redux-Overhead |
| Data Fetching | TanStack Query v5 | Caching, Background Refetch, Optimistic Updates |
| UI-Komponenten | Custom + NativeWind (Tailwind on Native) | Konsistentes Design, schnelle Entwicklung |
| Push Notifications | Expo Notifications + FCM/APNs | Out-of-the-box mit Expo |
| Auth | Supabase Auth Client | Magic Link Flow |
| Type Safety | TypeScript (strict mode) | Pflicht für Produktionscode |
### Ordnerstruktur
```
apps/mobile/
├── app/ # Expo Router — File-based Navigation
│ ├── (auth)/
│ │ ├── login.tsx # Magic Link Login Screen
│ │ └── verify.tsx # Token Verification
│ ├── (tabs)/
│ │ ├── _layout.tsx # Tab Bar Layout
│ │ ├── index.tsx # Dashboard / Home Feed
│ │ ├── members.tsx # Mitgliederverzeichnis
│ │ ├── news.tsx # Mitteilungen Feed
│ │ ├── termine.tsx # Terminkalender
│ │ └── stellen.tsx # Lehrlingsbörse
│ ├── member/[id].tsx # Mitglied Detailansicht
│ ├── news/[id].tsx # Beitrag Detailansicht
│ ├── termin/[id].tsx # Termin Detailansicht
│ └── _layout.tsx # Root Layout (Auth Guard)
├── components/
│ ├── ui/ # Atomare UI-Komponenten
│ │ ├── Button.tsx
│ │ ├── Card.tsx
│ │ ├── Badge.tsx
│ │ ├── Avatar.tsx
│ │ └── Input.tsx
│ ├── members/ # Feature-spezifische Komponenten
│ ├── news/
│ ├── termine/
│ └── stellen/
├── hooks/
│ ├── useAuth.ts
│ ├── useMembers.ts
│ ├── useNews.ts
│ └── usePushNotifications.ts
├── lib/
│ ├── supabase.ts # Supabase Client Singleton
│ ├── queryClient.ts # TanStack Query Client
│ └── notifications.ts # Push Token Registration
├── store/
│ └── auth.ts # Zustand Auth Store
├── types/
│ └── database.ts # Generierte Supabase Types
└── constants/
├── colors.ts # Design Tokens
└── config.ts # Env-Variablen
```
---
## 3. Admin Web App (Next.js)
### Tech Stack
| Schicht | Technologie |
|---|---|
| Framework | Next.js 14 (App Router) |
| Styling | Tailwind CSS + shadcn/ui |
| Auth | Supabase Auth (SSR) |
| Data Fetching | Server Components + TanStack Query (Client) |
| Tables | TanStack Table v8 |
| Forms | React Hook Form + Zod |
| Charts | Recharts |
| Deployment | Vercel |
### Ordnerstruktur
```
apps/admin/
├── app/
│ ├── (auth)/
│ │ └── login/page.tsx
│ ├── (dashboard)/
│ │ ├── layout.tsx # Sidebar Layout
│ │ ├── page.tsx # Overview Dashboard
│ │ ├── members/
│ │ │ ├── page.tsx # Mitgliederliste
│ │ │ ├── new/page.tsx # Mitglied anlegen
│ │ │ └── [id]/page.tsx # Mitglied bearbeiten
│ │ ├── news/
│ │ │ ├── page.tsx
│ │ │ └── new/page.tsx
│ │ ├── termine/
│ │ ├── stellen/
│ │ └── settings/
│ └── layout.tsx
├── components/
│ ├── ui/ # shadcn/ui Komponenten
│ ├── data-table/
│ └── forms/
└── lib/
├── supabase-server.ts # Supabase SSR Client
└── actions.ts # Server Actions
```
---
## 4. Backend: Supabase
### Warum Supabase?
- **Kein eigener API-Server** nötig für MVP → spart 46 Wochen Entwicklung
- **PostgreSQL** mit vollem SQL-Zugriff → keine NoSQL-Kompromisse
- **Row Level Security** → Multi-Tenancy ohne eigene Middleware
- **Realtime** → Live-Updates ohne WebSocket-Implementierung
- **Storage** → S3-kompatibel, CDN included
- **Auth** → Magic Link, Sessions, JWT out-of-the-box
- **EU-Region Frankfurt** → DSGVO-konform
### Supabase Services genutzt
| Service | Verwendung |
|---|---|
| Auth | Magic Link Login, Session-Management, JWT |
| Database | PostgreSQL, alle Tabellen |
| Row Level Security | Multi-Tenancy, Datenisolation |
| Storage | PDF-Anhänge, Profilbilder, Logos |
| Realtime | Live-Updates (News Feed, Teilnehmerlisten) |
| Edge Functions | Komplexe Businesslogik (Einladungs-E-Mails) |
---
## 5. Multi-Tenancy Konzept
### Datenisolation via Row Level Security (RLS)
Jede Innung ist eine `organization`. Alle Tabellen haben eine `org_id` Spalte.
```sql
-- Beispiel RLS Policy für die members-Tabelle
CREATE POLICY "members_isolation" ON members
FOR ALL
USING (org_id = (
SELECT org_id FROM user_roles
WHERE user_id = auth.uid()
));
```
**Prinzip:**
- Kein Nutzer sieht Daten außerhalb seiner `org_id`
- Policy wird für jede Operation (SELECT, INSERT, UPDATE, DELETE) durchgesetzt
- Supabase prüft dies auf Datenbankebene — kein Bypass möglich
### Tenancy Identifikation
- **MVP:** `org_id` wird beim Login aus `user_roles` geladen und in allen Queries mitgegeben
- **Post-MVP:** Subdomain-Routing (`innung-elektro-stuttgart.innungsapp.de`) mit Middleware-Lookup
---
## 6. Authentifizierung
### Login Flow
```
Nutzer gibt E-Mail ein
Supabase sendet Magic Link
Nutzer klickt Link im E-Mail
App/Browser öffnet sich, Token wird verarbeitet
Supabase Auth gibt Session zurück (JWT)
App lädt user_roles → bestimmt org_id und Rolle
Redirect zu korrekter Startseite
```
### Rollen-System
```sql
CREATE TYPE user_role AS ENUM ('admin', 'member', 'public');
CREATE TABLE user_roles (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid REFERENCES auth.users NOT NULL,
org_id uuid REFERENCES organizations NOT NULL,
role user_role NOT NULL DEFAULT 'member',
created_at timestamptz DEFAULT now(),
UNIQUE(user_id, org_id)
);
```
---
## 7. Push Notifications
### Architektur
```
Admin erstellt Beitrag
Supabase Edge Function wird getriggert (via Database Webhook)
Edge Function fetcht alle Push Tokens der org_id
Expo Push Notification Service (EPNS)
┌──┴──┐
▼ ▼
APNs FCM
(iOS) (Android)
```
### Push Token Registrierung (Mobile)
```typescript
// hooks/usePushNotifications.ts
async function registerPushToken() {
const { status } = await Notifications.requestPermissionsAsync();
if (status !== 'granted') return;
const token = await Notifications.getExpoPushTokenAsync({
projectId: Constants.expoConfig.extra.eas.projectId,
});
await supabase
.from('push_tokens')
.upsert({ user_id: user.id, token: token.data });
}
```
---
## 8. Infrastruktur & Kosten
### Monatliche Kosten (MVP, bis 100 Innungen)
| Service | Plan | Kosten/Monat |
|---|---|---|
| Supabase | Pro | 25 € |
| Vercel | Pro | 20 € |
| Resend (E-Mail) | Starter | 0 € (bis 3.000 Mails) |
| Expo EAS Build | Production | 29 € |
| PostHog | Cloud | 0 € (bis 1 Mio. Events) |
| Apple Developer | (jährlich) | 8 € |
| **Gesamt** | | **~82 €/Monat** |
### Skalierung (ab 500 Innungen)
| Service | Plan | Kosten/Monat |
|---|---|---|
| Supabase | Team | 599 € |
| Vercel | Enterprise | ~400 € |
| Resend | Business | 89 € |
| **Gesamt** | | **~1.100 €/Monat** |
Break-even bei 6 zahlenden Innungen à 200 €.
---
## 9. Deployment & CI/CD
### Pipeline
```
git push → GitHub/Gitea
├── Mobile: Expo EAS Build (iOS + Android)
│ └── App Store / Play Store (manual submit)
└── Web Admin: Vercel Deploy (automatisch)
└── Preview URL für jeden Branch
```
### Environments
| Environment | Supabase | Vercel | Verwendung |
|---|---|---|---|
| `development` | Lokal (Docker) | localhost:3000 | Entwicklung |
| `staging` | Staging-Projekt | staging.innungsapp.de | Pilot-Tests |
| `production` | Pro-Projekt | app.innungsapp.de | Live |

289
AZUBI_MODULE.md Normal file
View File

@@ -0,0 +1,289 @@
# InnungsApp — Azubi-Modul (Advanced)
> **Status:** Post-MVP | **Geplant:** Q2Q3 2026
> **Ziel:** Fachkräftemangel bekämpfen durch Gen-Z-gerechtes Recruiting
---
## 1. Problem: Warum reicht die Basic-Lehrlingsbörse nicht?
Die Basic-Lehrlingsbörse (MVP) ist eine Listenansicht mit Stellenangeboten — funktional, aber kein Differenzierungs-Feature.
**Das echte Problem:**
- Gen Z verbringt 46h täglich auf TikTok und Instagram
- Textbasierte Stellenanzeigen werden nicht gelesen
- "Bewerbung per E-Mail mit CV" schreckt ab
- Kein emotionaler Bezug zum Beruf
- ~250.000 unbesetzte Ausbildungsplätze trotz Nachfrage
**Die Lösung:** Azubis ihren Berufsalltag zeigen — kurze Videos, transparent, ehrlich. Bewerbung mit einem Klick.
---
## 2. Feature: TikTok-Style Video-Feed
### Konzept
Jeder Handwerksbetrieb kann kurze Videos (1560 Sekunden) hochladen, die echten Berufsalltag zeigen:
- "POV: Du bist Dachdecker in München — Tagesstart"
- "Was verdiene ich wirklich als Elektroniker-Azubi?"
- "5 Dinge, die ich als Sanitär-Azubi gelernt habe"
### UX/UI
```
Vertikaler Scroll-Feed (Fullscreen):
┌──────────────────────────────────────────┐
│ │
│ [VIDEO 15s Dachdecker] │
│ "POV: Mein erster Tag" │
│ │
│ ♥ 234 💬 12 📤 │
│ │
│ Dachdecker Müller GmbH │
│ München · 2 offene Stellen │
│ │
│ [Mehr erfahren] [Jetzt bewerben →] │
└──────────────────────────────────────────┘
(Swipe up für nächstes Video)
```
### Technische Umsetzung
| Komponente | Technologie | Begründung |
|---|---|---|
| Video-Upload | Mux via API | Automatische Transcoding, CDN |
| Video-Player | Mux Player React Native | Adaptive Streaming, HLS |
| Feed-Logik | Cursor-based Pagination | Infinite Scroll ohne Offset-Problem |
| Caching | Expo Video Pre-loading | Nächstes Video vorab laden |
| Thumbnail | Mux Thumbnail API | Automatisch |
### Video-Spezifikationen
```
Format: MP4 (H.264)
Auflösung: 1080x1920 (9:16) — vertikal
Länge: 560 Sekunden
Dateigröße: Max 500 MB (Mux transcoded auf ~20MB)
Ton: Pflicht (Untertitel empfohlen)
Upload-Kanal: Admin Web App oder Mobile (Betrieb)
```
---
## 3. Feature: Bewerber-Profil & 1-Click-Apply
### Konzept
Kein Lebenslauf. Kein Anschreiben. Ein kurzes Profil reicht.
### Profil-Felder (Bewerber)
```
Name: [Max Müller ]
Alter: [16 ]
Wohnort: [Stuttgart ]
Schulabschluss: [Realschule ▼ ]
Schulnoten (opt.): [Mathe: 2 Deutsch: 3 ]
Interessen-Tags: [Technik] [Draußen] [Elektro]
Über mich (opt.): [Kurzer Text, max 200 Zeichen]
Telefon (opt.): [________________ ]
```
### Apply-Flow
```
1. Bewerber sieht Video oder Stellenanzeige
2. Klickt [Jetzt bewerben]
3. Profil (falls noch nicht erstellt):
→ Quick-Setup in 2 Minuten
→ Nur Pflichtfelder (Name, Alter, Wohnort, Schulabschluss)
4. Bewerbung absenden:
→ "Möchten Sie sich bei [Betrieb] bewerben?"
→ [Ja, Bewerbung senden] / [Abbrechen]
5. Betrieb erhält Push + E-Mail:
"Neue Bewerbung von Max Müller (16, Stuttgart, Realschule)"
6. Betrieb öffnet Profil, entscheidet:
→ [Zum Gespräch einladen] → Chat öffnet sich
→ [Ablehnen] → Bewerber erhält Benachrichtigung
```
---
## 4. Feature: In-App Chat (Betrieb ↔ Bewerber)
### Scope
Minimaler 1:1-Chat für Bewerbungs-Kommunikation.
Kein allgemeines Messaging-System (zu komplex für MVP).
### Technische Umsetzung
```typescript
// Supabase Realtime-basierter Chat
CREATE TABLE azubi_messages (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
conversation_id uuid NOT NULL,
sender_id uuid REFERENCES auth.users,
body text NOT NULL,
read_at timestamptz,
created_at timestamptz DEFAULT now()
);
CREATE TABLE azubi_conversations (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
stelle_id uuid REFERENCES stellen,
bewerber_id uuid REFERENCES auth.users,
betrieb_member_id uuid REFERENCES members,
status text DEFAULT 'offen', -- offen | eingeladen | abgelehnt
created_at timestamptz DEFAULT now()
);
```
### Moderation
- Betrieb kann Konversation archivieren
- Bewerber kann blockieren
- InnungsApp hat keine Einsicht in Chat-Inhalte (DSGVO)
- Automatische Löschung nach 90 Tagen Inaktivität
---
## 5. Feature: Vergütungs-Rechner
### Konzept
"Was verdiene ich wirklich?" — transparente, vergleichbare Darstellung der Vergütung.
```
Beruf wählen: [Elektroniker ▼]
Bundesland: [Baden-Württemberg ▼]
Ergebnis:
┌────────────────────────────────────────────┐
│ Bruttogehalt nach Tarif (2026): │
│ 1. Lehrjahr: 820 € / Monat │
│ 2. Lehrjahr: 950 € / Monat │
│ 3. Lehrjahr: 1.080 € / Monat │
│ │
│ Abzüge (ca.): │
│ Steuer + Sozialversicherung: ~1520% │
│ │
│ Nettogehalt (ca.): │
│ 1. Jahr: ~680 € | 2. Jahr: ~790 € │
│ │
│ Vergleich: Mindestlohn 2026 = 12,41 €/h │
│ Vollzeit: ~2.152 €/Monat brutto │
│ │
│ Nach Gesellenprüfung: ∅ 2.8003.500 € │
└────────────────────────────────────────────┘
```
---
## 6. Feature: Digitales Berichtsheft
### Konzept
Azubis führen ihr Berichtsheft digital in der App — Fotos, Sprachnotizen, automatische Wochenstruktur.
### Eintrag erfassen
```
Woche: 15 | 07.04.2026 11.04.2026
Montag:
┌──────────────────────────────────────────┐
│ 🎤 Sprachnotiz anhören │
│ "Heute haben wir die neue Verteilung │
│ in der Hauptstraße 5 installiert..." │
│ │
│ 📷 3 Fotos hinzugefügt │
│ [Foto 1] [Foto 2] [Foto 3] │
└──────────────────────────────────────────┘
[+ Tag hinzufügen]
Status: Eingereicht ✓ | [Als PDF exportieren]
```
### Betrieb-Ansicht (Bestätigung)
```
Berichtsheft-Übersicht: Max Müller (Azubi)
─────────────────────────────────────────────
Woche 14: ✓ Bestätigt am 08.04.2026
Woche 15: ⏳ Ausstehend [Bestätigen]
Woche 16: ⏳ Ausstehend
[Alle als PDF exportieren]
```
---
## 7. Feature: Prüfungsvorbereitung
### Konzept
Tägliche 5-Minuten-Quiz-Session für den jeweiligen Ausbildungsberuf.
### User Flow
```
Push Notification täglich um 18:00 Uhr:
"📝 Dein heutiges Quiz: 5 Fragen Elektrotechnik"
App öffnet sich → Quiz startet:
Frage 1 von 5:
┌──────────────────────────────────────────┐
│ Wie viele Drähte hat ein dreiphasiger │
│ Wechselstromkreis (mit Nullleiter)? │
│ │
│ ○ 2 │
│ ○ 3 │
│ ● 4 │
│ ○ 5 │
│ │
│ ✓ Richtig! │
│ Erklärung: L1, L2, L3 + N (Nullleiter). │
└──────────────────────────────────────────┘
[Nächste Frage →]
Ergebnis:
"4 von 5 richtig! 🎉"
"Schwächstes Thema: Schaltkreise — morgen üben!"
```
### Datenbasis
- Fragenkatalog manuell gepflegt pro Gewerk
- Startend mit: Elektrotechnik, SHK, Bau (3 häufigste)
- Erweiterbar via Admin-Interface (Q4)
- Quelle: Gesellenprüfungs-Kataloge der HWK (öffentlich)
---
## 8. Azubi-Modul Pricing
| Modul | Preis (Add-on zu Basis-Plan) | Inklusiv |
|---|---|---|
| Azubi-Recruiting (Video-Feed + Apply) | + 99 €/Monat | Bis 10 Videos |
| Digitales Berichtsheft | + 49 €/Monat | Bis 50 Azubis |
| Prüfungsvorbereitung | + 49 €/Monat | 3 Berufe |
| **Azubi Komplett-Paket** | **+ 179 €/Monat** | Alles kombiniert |
---
## 9. Azubi-Modul Roadmap
| Feature | Quartal | Status |
|---|---|---|
| Basic Lehrlingsbörse (Liste) | Q1 2026 (MVP) | In Planung |
| Video-Feed (Upload + Player) | Q2 2026 | Post-MVP |
| Bewerber-Profil + 1-Click-Apply | Q2 2026 | Post-MVP |
| In-App Chat (Betrieb ↔ Bewerber) | Q3 2026 | Post-MVP |
| Digitales Berichtsheft | Q3 2026 | Post-MVP |
| Vergütungs-Rechner | Q2 2026 | Post-MVP |
| Prüfungsvorbereitung | Q4 2026 | Post-MVP |
| KI-Matching (Azubi ↔ Betrieb) | 2027 | Vision |

221
BUSINESS_MODEL.md Normal file
View File

@@ -0,0 +1,221 @@
# InnungsApp — Business Model & Unit Economics
---
## 1. Geschäftsmodell-Überblick
**Typ:** B2B SaaS (Business-to-Business, Software as a Service)
**Käufer:** Kreisverbände (als Multiplikatoren) & Innungen
**Endnutzer:** Mitglieder der Innungen (Handwerksbetriebe, Azubis)
**Vertrieb:** Multiplier Strategy (targeting 240 Kreisverbände)
---
## 2. Preismodell
### Basis-Pläne (monatlich, pro Innung)
| Plan | Preis | Mitglieder | Laufzeit |
|---|---|---|---|
| **Kreisverband Setup** | 5.000 € (einmalig) | p. Verband | Implementierung |
| **Gilden-Account** | 150300 € / Monat | p. Innung | Jährlich / Monatlich |
| **Starter (Direkt)** | 99 € / Monat | bis 100 | Monatlich kündbar |
| **Standard (Direkt)** | 199 € / Monat | bis 300 | Monatlich kündbar |
| **Pro (Direkt)** | 349 € / Monat | unbegrenzt | Monatlich kündbar |
### Jahresvertrag (15 % Rabatt)
| Plan | Monatspreis (jährlich) | Jahresbetrag |
|---|---|---|
| Starter | 84 € | 1.008 € |
| Standard | 169 € | 2.028 € |
| Pro | 297 € | 3.564 € |
### Feature-Matrix
| Feature | Pilot | Starter | Standard | Pro |
|---|---|---|---|---|
| Mitgliederverzeichnis | ✓ | ✓ | ✓ | ✓ |
| News / Mitteilungen | ✓ | ✓ | ✓ | ✓ |
| Push Notifications | ✓ | ✓ | ✓ | ✓ |
| Lehrlingsbörse | ✓ | ✓ | ✓ | ✓ |
| Terminkalender | ✓ | ✓ | ✓ | ✓ |
| Admin Web Dashboard | ✓ | ✓ | ✓ | ✓ |
| PDF-Anhänge | 5 GB | 10 GB | 25 GB | Unbegrenzt |
| Analytics & Berichte | Basic | Basic | Erweitert | Vollständig |
| CSV Export | - | ✓ | ✓ | ✓ |
| Support | E-Mail | E-Mail | Priority E-Mail | Dedicated Manager |
| White-Label (Logo, Farbe) | - | - | ✓ | ✓ |
| API-Zugang | - | - | - | ✓ |
| HWK-Anbindung | - | - | - | ✓ |
---
## 3. Upsell & Add-ons (Post-MVP)
| Add-on | Preis | Beschreibung |
|---|---|---|
| **Azubi-Modul** | + 99 € / Monat | TikTok-Style Lehrlingsbörse, Video-Feed, 1-Click-Apply |
| **Digitales Berichtsheft** | + 49 € / Monat | App für Azubis, Prüfungsvorbereitung |
| **Dokumentenarchiv** | + 49 € / Monat | Unbegrenzte Dokumentenspeicherung + Versionierung |
| **Premium Job Ad** | 50 € / Monat / Stelle | Hervorgehobene Lehrstellenanzeige |
| **Content Service** | 2.000 € (einmalig) | Wir filmen 10 Videos für die Lehrlingsbörse |
| **Onboarding-Service** | 500 € (einmalig) | Datenimport, Setup, Schulung |
---
## 4. Unit Economics
### Zielwerte
| KPI | Ziel (MVP) | Ziel (12 Monate) |
|---|---|---|
| **ARPU** (Ø Erlös pro Innung/Monat) | 150 € | 220 € |
| **CAC** (Customer Acquisition Cost) | < 400 € | < 300 € |
| **LTV** (Lifetime Value, 24 Monate) | > 3.600 € | > 5.280 € |
| **LTV:CAC Ratio** | > 9:1 | > 17:1 |
| **Churn Rate** (monatlich) | < 2 % | < 1,5 % |
| **Payback Period** | < 3 Monate | < 2 Monate |
### Warum niedriger Churn?
Innungen wechseln selten Software:
- Daten sind einmal importiert und strukturiert
- Mitglieder haben sich an die App gewöhnt
- Kündigung = Chaos + Rückfall auf Excel/WhatsApp
- Entscheidung liegt beim Vorstand (trifft sich quartalsweise)
Erwarteter Churn: **< 1 % pro Monat** (= 11,4 % annual) für das erste Jahr, sinkt auf < 0,5 % bei eingeführten Verbandskunden.
---
## 5. Revenue-Projektionen (Multiplier-Modell)
### Fokus: NRW Markt-Potential
| Segment | Anzahl | Setup Rev. (einmalig) | MRR (recurring) | ARR |
|---|---|---|---|---|
| **Top 10 NRW Kreisverbände** | 10 KV / 311 Gilden | 50.000 € | 61.889 € | 742.668 € |
| **Gesamt NRW Potential** | 40 KV / ~1.000 Gilden | 200.000 € | 199.000 € | 2.388.000 € |
| **Gesamt DE Potential** | 240 KV / ~7.500 Gilden | 1.200.000 € | 1.492.500 € | 17.910.000 € |
*Annahmen: Durchschnitt €199 MRR pro Gilde/Innung; €5.000 Setup pro Kreisverband.*
---
**Top 3 NRW "Cash-Cow" Targets:**
1. **KH Niederrhein:** 41 Innungen → €5.000 Setup + €8.159 MRR.
2. **KH Gütersloh-Bielefeld:** 43 Innungen → €5.000 Setup + €8.557 MRR.
3. **KH Ruhr:** 39 Innungen → €5.000 Setup + €7.761 MRR.
**Break-even:** Bereits nach dem 1. Kreisverband-Setup (NRW Niederrhein) ist die Basis-Infrastruktur für das erste Jahr finanziert.
---
## 6. Kostenstruktur
### Fixkosten (monatlich, MVP)
| Posten | Kosten |
|---|---|
| Supabase Pro | 25 € |
| Vercel Pro | 20 € |
| Resend (E-Mail) | 030 € |
| Expo EAS Build | 29 € |
| Domänen + SSL | 5 € |
| **Gesamt** | **~79 €/Monat** |
### Variable Kosten
| Posten | Kosten |
|---|---|
| Supabase (ab 100 Innungen) | ~1 €/Innung/Monat |
| Push Notifications (Expo) | ~0,50 €/1.000 Nachrichten |
| Storage (PDFs) | ~0,02 €/GB/Monat |
| Transaktions-E-Mails | ~0,001 €/Mail |
**Operative Marge bei 100 Innungen:** ~95 % (SaaS-typisch)
---
## 7. Distributionsstrategie
### Phase 1: Kreisverband Multiplier (NRW)
**Ziel:** 5 Kreisverbände in NRW als Kunden (ca. 250 angeschlossene Innungen)
**Taktik:**
1. Kaltakquise-LinkedIn/Mail an 40 Hauptgeschäftsführer (HGF) in NRW
2. White-Label-Demo für den Kreisverband (Multiplier-Effekt)
3. Demo-Call → "InnungsApp NRW Edition" zeigen
4. Pilot mit einem Verband, Onboarding der ersten 1020 Innungen
**Aufwand:** ~10h/Woche Sales, 1 Person
**Erwartete Conversion:**
- 40 angeschriebene Kreisverbände
- 12 Antworten (30 %)
- 8 Demo-Calls
- 5 Abschlüsse (KV-Setup)
- 250 automatisch erreichte Innungsendnutzer (indirekt)
### Phase 2: Regionale HWK-Partnerschaft (Monat 612)
**Ziel:** 12 Handwerkskammern als Empfehlungspartner
**Taktik:**
1. Referenzfall aus Phase 1 als Case Study aufbereiten
2. HWK-Geschäftsführer ansprechen (LinkedIn + direkter Kontakt)
3. Rabattmodell für HWK-Mitglieder (1020 % Rabatt)
4. HWK empfiehlt App an ihre Mitgliedsinnungen
**Multiplikator:** 1 HWK-Bezirk = 100400 Innungen
### Phase 3: ZDH-Bundesrahmenvertrag (ab Monat 18)
**Ziel:** Zugang zu allen 7.500 Innungen in Deutschland
**Voraussetzung:** >50 zahlende Innungen, NPS > 50, funktionierender Sales-Prozess
**Modell:** Rahmenvertrag mit ZDH (Zentralverband des Deutschen Handwerks)
- ZDH bekommt 1015 % Revenue Share
- Innungen bekommen Sonderkonditionen
- InnungsApp wird offizielle ZDH-empfohlene Lösung
---
## 8. Wettbewerbspositionierung
### Preisvergleich
| Anbieter | Modell | Preis |
|---|---|---|
| InnungsApp | SaaS, mobile-first | 99349 €/Monat |
| Lokale Webagenturen | Einmalentwicklung | 15.00030.000 € |
| Vereinssoftware (ClubDesk) | SaaS, web-only | 50200 €/Monat |
| WhatsApp + Excel | Kostenlos | 0 € (aber DSGVO-Problem) |
| Handwerk-Apps (generisch) | SaaS | 100500 €/Monat |
**Positionierung:** Günstigste branchenspezifische Lösung mit mobil-first UX.
---
## 9. Finanzierungsstrategie
### Phase 1: Bootstrapped
- Keine externe Finanzierung notwendig
- Entwicklungskosten ~80 €/Monat (Infrastruktur)
- Eigenentwicklung (Zeitinvestition)
### Phase 2: Revenue-based Growth
- Reinvestition der ersten Umsätze in Marketing und Sales
- Erste Einstellung: Sales-Person bei MRR > 5.000 €
### Phase 3: Seed-Runde (optional, bei Traktion)
- **Trigger:** 50+ zahlende Innungen oder HWK-Partnerschaft
- **Volumen:** 500k1Mio. €
- **Use of Funds:** Team (Entwickler, Sales), Marketing, HWK-Akquise
- **Investoren:** Tech-fokussierte Angels, Handwerk-nahe Fonds

188
COMPETITIVE_ANALYSIS.md Normal file
View File

@@ -0,0 +1,188 @@
# InnungsApp — Wettbewerbsanalyse
---
## 1. Marktübersicht
Der Markt für Innungs-/Handwerkssoftware lässt sich in drei Kategorien einteilen:
1. **Allgemeine Vereins-/Verbandssoftware** — nicht für Handwerk gebaut
2. **Handwerkssoftware** — für Betriebe, nicht für Innungen
3. **Status-Quo-Tools** — Excel, WhatsApp, E-Mail (der echte Wettbewerb)
---
## 2. Direkte Wettbewerber
### ClubDesk
| Kategorie | Details |
|---|---|
| **Typ** | Vereinsverwaltungssoftware (Schweizer Startup) |
| **Zielgruppe** | Sportvereine, Kulturvereine, NGOs |
| **Preis** | 1999 CHF/Monat |
| **Stärken** | Mitgliederverwaltung, Kassenbuch, Eventverwaltung |
| **Schwächen** | Web-only, keine mobile App, kein Handwerk-Fokus, kein Azubi-Recruiting |
| **Marktposition** | 10.000+ Kunden in DACH |
**InnungsApp-Vorteil:** Mobile-first, Handwerk-spezifische Features (Sparten, Lehrlingsbörse, Prüfungen)
---
### MeinVerein
| Kategorie | Details |
|---|---|
| **Typ** | Deutsche Vereinssoftware (Vogel Communications) |
| **Zielgruppe** | Sportvereine, Ortsvereine, Feuerwehren |
| **Preis** | 19149 €/Monat |
| **Stärken** | SEPA-Lastschrift, Kassenbuch, Mitgliederverwaltung |
| **Schwächen** | Web-only (Mobile App sehr rudimentär), kein Handwerk-Fokus |
| **Marktposition** | 60.000+ Kunden in Deutschland |
**InnungsApp-Vorteil:** Native Mobile App, Lehrlingsbörse, kein Vereins-Overhead (Kassenbuch etc. nicht für Innungen relevant)
---
### Handi (früher: Mein Handwerk App)
| Kategorie | Details |
|---|---|
| **Typ** | Handwerks-Community App |
| **Zielgruppe** | Einzelne Handwerker (Consumer-Fokus) |
| **Preis** | Freemium, bis 30 €/Monat |
| **Stärken** | Bekannte Marke, Handwerk-Fokus |
| **Schwächen** | Consumer-App, kein B2B-Innungs-Fokus, keine Multi-Tenancy, schwache Admin-Tools |
| **Marktposition** | Unbekannte Nutzerzahlen, wenig Traktion |
**InnungsApp-Vorteil:** Echte B2B-Plattform für Innungen als Mandanten, kein Consumer-Fokus
---
### Campai (früher: easyVerein)
| Kategorie | Details |
|---|---|
| **Typ** | Vereinsverwaltung SaaS |
| **Zielgruppe** | Vereine aller Art |
| **Preis** | 20180 €/Monat |
| **Stärken** | Modernes UI, Mitgliederverwaltung, Online-Formulare |
| **Schwächen** | Kein Handwerk-Fokus, Mobile App schwach, keine Lehrlingsbörse |
---
### GuildWork (fiktiv, potenzielle Bedrohung)
Ein von HWK/ZDH selbst entwickeltes Tool wäre die größte Bedrohung. Aktuell gibt es kein solches bundesweites System. Die HWK betreiben meist veraltete, in-house-entwickelte Portale.
---
## 3. Indirekter Wettbewerb (Status Quo)
### WhatsApp-Gruppen
**Warum es trotzdem genutzt wird:**
- Alle kennen es
- Kostenlos
- Funktioniert sofort
**Warum es scheitert:**
- DSGVO: Telefonnummern werden auf US-Servern gespeichert
- Kein Archiv, kein Tracking, keine Ordnung
- Mitglieder können austreten, Gruppen wachsen unkontrolliert
- Kein Admin-Dashboard
---
### Excel + E-Mail-Verteiler
**Warum es trotzdem genutzt wird:**
- Jeder kennt es
- Volle Kontrolle über Daten
- Keine Abhängigkeit von einem Anbieter
**Warum es scheitert:**
- Nicht synchronisiert (nur auf einem Laptop)
- E-Mails landen im Spam
- Keine Lesebestätigung
- Kein Self-Service für Mitglieder
- Manueller Aufwand für jede Änderung
---
## 4. Wettbewerbsmatrix
| Kriterium | InnungsApp | ClubDesk | MeinVerein | WhatsApp+Excel |
|---|---|---|---|---|
| Mobile App (iOS + Android) | ✓ Nativ | Rudimentär | Sehr schwach | ✓ (WhatsApp) |
| Handwerk-spezifisch | ✓ | - | - | - |
| Lehrlingsbörse | ✓ | - | - | - |
| Multi-Tenancy (viele Innungen) | ✓ | ✓ | ✓ | - |
| DSGVO-konform | ✓ (EU) | ✓ (CH) | ✓ | - (WhatsApp) |
| Push Notifications | ✓ | - | - | ✓ (WhatsApp) |
| Leserate-Tracking | ✓ | - | - | - |
| PDF-Anhänge | ✓ | ✓ | ✓ | ✓ (limitiert) |
| Admin Web Dashboard | ✓ | ✓ | ✓ | - |
| Preis (€/Monat) | 99349 | 1999 | 19149 | 0 |
| Onboarding-Zeit | < 15 Min | ~60 Min | ~90 Min | 0 Min |
---
## 5. Differenzierung InnungsApp
### USP #1: Handwerk-DNA von Anfang an
Kein generisches Vereinstool. Jede Feature-Entscheidung orientiert sich an der Realität einer Innung:
- Sparten (Elektro, SHK, Dach, etc.) als First-Class-Citizen
- Lehrlingsbörse als Kernfeature, nicht Afterthought
- Rollen die zu Innung passen (Obermeister, Geschäftsführer, Mitglied)
- Beitrags-Kategorien die Innungs-Alltag widerspiegeln (Prüfung, Förderung, Lossprechung)
### USP #2: Mobile-First für Handwerker
Handwerker sitzen nicht am Schreibtisch. ClubDesk und MeinVerein sind Web-Tools mit schwachen Mobile-Afterthoughts. InnungsApp ist primär für das Smartphone gebaut — weil Handwerker ihr Smartphone auf der Baustelle haben, nicht ihren Laptop.
### USP #3: Einfachstes Onboarding am Markt
Ziel: Eine Innung ist in 15 Minuten live. Mitglieder können sich via Magic Link einloggen — kein Passwort, keine App-Store-Navigation nötig.
### USP #4: Azubi-Recruiting als Differenzierung (Post-MVP)
Kein anderer Anbieter hat einen TikTok-style Video-Feed für Ausbildungsplätze. Das ist kein Feature, das aus einer Vereinsverwaltungslösung entsteht. Es ist ein fundamentaler Neuansatz.
### USP #5: Verbands-Vertriebskanal
Wenn ein HWK-Rahmenvertrag gelingt, kann kein Direktwettbewerber mithalten — der Distributor ist der Moat.
---
## 6. Preispositionierung
```
Preis
│ Bespoke Agency
│ (15k50k€)
│ [InnungsApp Standard 199€] ← Zielzone
│ [InnungsApp Starter 99€]
│ ClubDesk / MeinVerein (50150€)
│ WhatsApp + Excel (0€)
└─────────────────────────────────────────► Feature-Tiefe
Wenig Viel
```
InnungsApp positioniert sich bewusst über generischen Vereinstools, weit unter Bespoke-Lösungen — mit einem Feature-Set, das auf Innungen zugeschnitten ist.
---
## 7. Bedrohungsszenarien
| Szenario | Wahrscheinlichkeit | Impact | Gegenmaßnahme |
|---|---|---|---|
| ZDH entwickelt eigenes Tool | Niedrig (bürokratisch, langsam) | Sehr hoch | Rahmenvertrag mit ZDH anstreben |
| Vereinssoftware kopiert Handwerk-Features | Mittel | Mittel | Schneller Marktausbau, Verbands-Deals |
| Großer HR-Anbieter (Personio) adressiert Innungen | Niedrig | Hoch | Nischenvorsprung, Community-Lock-in |
| Konkurrent kopiert InnungsApp | Mittel | Mittel | Netzwerkeffekte, Verbandsdeal als Moat |

416
DATABASE_SCHEMA.md Normal file
View File

@@ -0,0 +1,416 @@
# InnungsApp — Datenbankschema
> **Datenbank:** PostgreSQL via Supabase | **Stand:** Februar 2026
---
## 1. Entity Relationship Diagram (vereinfacht)
```
organizations
├── user_roles (N:M mit auth.users)
├── members (1:N)
│ └── stellen (1:N)
├── news (1:N)
│ └── news_reads (1:N)
│ └── news_attachments (1:N)
├── termine (1:N)
│ └── termine_anmeldungen (1:N)
└── push_tokens (via user_id)
```
---
## 2. Vollständiges Schema
### organizations — Mandanten
```sql
CREATE TABLE organizations (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
name text NOT NULL, -- "Innung Elektrotechnik Stuttgart"
slug text UNIQUE NOT NULL, -- "innung-elektro-stuttgart"
plan text NOT NULL DEFAULT 'pilot' CHECK (plan IN ('pilot', 'standard', 'pro', 'verband')),
logo_url text, -- Supabase Storage URL
primary_color text DEFAULT '#1a56db', -- Hex-Farbe für White-Label
sparten text[] DEFAULT '{}', -- ['Elektro', 'Sanitär', 'Heizung']
kontakt_email text,
kontakt_tel text,
website text,
adresse text,
plz text,
ort text,
bundesland text,
created_at timestamptz DEFAULT now(),
updated_at timestamptz DEFAULT now()
);
-- Trigger: updated_at automatisch setzen
CREATE TRIGGER organizations_updated_at
BEFORE UPDATE ON organizations
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
```
---
### user_roles — Rollen & Mandantenzuordnung
```sql
CREATE TYPE user_role AS ENUM ('admin', 'member');
CREATE TABLE user_roles (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES auth.users ON DELETE CASCADE,
org_id uuid NOT NULL REFERENCES organizations ON DELETE CASCADE,
role user_role NOT NULL DEFAULT 'member',
created_at timestamptz DEFAULT now(),
UNIQUE(user_id, org_id)
);
-- Index für schnelle Rollenlookups
CREATE INDEX idx_user_roles_user_id ON user_roles(user_id);
CREATE INDEX idx_user_roles_org_id ON user_roles(org_id);
```
---
### members — Mitgliedsbetriebe
```sql
CREATE TYPE member_status AS ENUM ('aktiv', 'ruhend', 'ausgetreten');
CREATE TABLE members (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
org_id uuid NOT NULL REFERENCES organizations ON DELETE CASCADE,
user_id uuid REFERENCES auth.users ON DELETE SET NULL, -- null wenn noch kein Login
-- Basis-Infos
vorname text,
nachname text NOT NULL,
betrieb text NOT NULL,
sparte text, -- muss in organizations.sparten enthalten sein
-- Kontakt
email text,
telefon text,
mobil text,
website text,
-- Adresse
strasse text,
plz text,
ort text,
-- Innung
status member_status NOT NULL DEFAULT 'aktiv',
mitglied_seit int, -- Eintrittsjahr, z.B. 2015
ausbildungsbetrieb boolean DEFAULT false,
mitgliedsnummer text,
-- Metadaten
notizen text, -- interne Admin-Notizen
eingeladen_am timestamptz, -- Datum der Einladungsmail
created_at timestamptz DEFAULT now(),
updated_at timestamptz DEFAULT now()
);
CREATE INDEX idx_members_org_id ON members(org_id);
CREATE INDEX idx_members_user_id ON members(user_id);
CREATE INDEX idx_members_status ON members(org_id, status);
CREATE INDEX idx_members_sparte ON members(org_id, sparte);
```
---
### news — Mitteilungen & Beiträge
```sql
CREATE TYPE news_kategorie AS ENUM ('Wichtig', 'Prüfung', 'Förderung', 'Veranstaltung', 'Allgemein');
CREATE TABLE news (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
org_id uuid NOT NULL REFERENCES organizations ON DELETE CASCADE,
author_id uuid REFERENCES members(id) ON DELETE SET NULL,
-- Inhalt
title text NOT NULL,
body text NOT NULL, -- Markdown-formatiert
kategorie news_kategorie NOT NULL DEFAULT 'Allgemein',
-- Sichtbarkeit
published_at timestamptz, -- null = Entwurf
pinned boolean DEFAULT false,
-- Metadaten
push_sent boolean DEFAULT false,
created_at timestamptz DEFAULT now(),
updated_at timestamptz DEFAULT now()
);
CREATE INDEX idx_news_org_id ON news(org_id, published_at DESC);
CREATE INDEX idx_news_pinned ON news(org_id, pinned) WHERE pinned = true;
```
---
### news_reads — Lesestatus
```sql
CREATE TABLE news_reads (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
news_id uuid NOT NULL REFERENCES news ON DELETE CASCADE,
user_id uuid NOT NULL REFERENCES auth.users ON DELETE CASCADE,
read_at timestamptz DEFAULT now(),
UNIQUE(news_id, user_id)
);
CREATE INDEX idx_news_reads_news_id ON news_reads(news_id);
CREATE INDEX idx_news_reads_user_id ON news_reads(user_id);
```
---
### news_attachments — PDF-Anhänge
```sql
CREATE TABLE news_attachments (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
news_id uuid NOT NULL REFERENCES news ON DELETE CASCADE,
filename text NOT NULL,
storage_url text NOT NULL, -- Supabase Storage URL
file_size int, -- Bytes
created_at timestamptz DEFAULT now()
);
```
---
### stellen — Ausbildungsstellen
```sql
CREATE TABLE stellen (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
org_id uuid NOT NULL REFERENCES organizations ON DELETE CASCADE,
member_id uuid NOT NULL REFERENCES members(id) ON DELETE CASCADE,
-- Stelle
sparte text NOT NULL,
berufsbezeichnung text NOT NULL, -- "Elektroniker für Energie- und Gebäudetechnik"
stellen_anzahl int NOT NULL DEFAULT 1,
-- Vergütung (nach Lehrjahr)
verguetung_1 int, -- Brutto in € / Monat
verguetung_2 int,
verguetung_3 int,
verguetung_4 int,
-- Ausbildungsdetails
ausbildungsstart text, -- "August 2026" oder "sofort"
lehrjahr text, -- "1. Lehrjahr" oder "Quereinsteiger"
schulabschluss text, -- "Kein" | "Hauptschule" | "Realschule" | "Abitur"
-- Kontakt
kontakt_name text,
kontakt_email text,
kontakt_tel text,
-- Sichtbarkeit
aktiv boolean DEFAULT true,
created_at timestamptz DEFAULT now(),
updated_at timestamptz DEFAULT now()
);
CREATE INDEX idx_stellen_org_id ON stellen(org_id, aktiv);
CREATE INDEX idx_stellen_sparte ON stellen(org_id, sparte) WHERE aktiv = true;
```
---
### termine — Veranstaltungskalender
```sql
CREATE TYPE termin_typ AS ENUM ('Prüfung', 'Versammlung', 'Kurs', 'Event', 'Lossprechung', 'Sonstiges');
CREATE TABLE termine (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
org_id uuid NOT NULL REFERENCES organizations ON DELETE CASCADE,
-- Details
titel text NOT NULL,
beschreibung text,
typ termin_typ NOT NULL DEFAULT 'Sonstiges',
-- Zeit & Ort
datum date NOT NULL,
uhrzeit_von time,
uhrzeit_bis time,
ort text,
online_link text, -- Zoom/Teams-Link (Post-MVP)
-- Anmeldung
anmeldung_erforderlich boolean DEFAULT false,
max_teilnehmer int, -- null = unbegrenzt
anmeldeschluss date,
-- Metadaten
created_at timestamptz DEFAULT now(),
updated_at timestamptz DEFAULT now()
);
CREATE INDEX idx_termine_org_id ON termine(org_id, datum);
CREATE INDEX idx_termine_upcoming ON termine(org_id, datum) WHERE datum >= CURRENT_DATE;
```
---
### termine_anmeldungen — Teilnehmerliste
```sql
CREATE TABLE termine_anmeldungen (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
termin_id uuid NOT NULL REFERENCES termine ON DELETE CASCADE,
member_id uuid NOT NULL REFERENCES members(id) ON DELETE CASCADE,
angemeldet_at timestamptz DEFAULT now(),
notiz text, -- optionale Teilnehmernotiz
UNIQUE(termin_id, member_id)
);
CREATE INDEX idx_termine_anmeldungen_termin ON termine_anmeldungen(termin_id);
CREATE INDEX idx_termine_anmeldungen_member ON termine_anmeldungen(member_id);
```
---
### push_tokens — Push Notification Tokens
```sql
CREATE TABLE push_tokens (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES auth.users ON DELETE CASCADE,
token text NOT NULL UNIQUE,
platform text NOT NULL CHECK (platform IN ('ios', 'android')),
updated_at timestamptz DEFAULT now(),
UNIQUE(user_id, token)
);
CREATE INDEX idx_push_tokens_user_id ON push_tokens(user_id);
```
---
## 3. Row Level Security (RLS) Policies
```sql
-- Alle Tabellen: RLS aktivieren
ALTER TABLE organizations ENABLE ROW LEVEL SECURITY;
ALTER TABLE members ENABLE ROW LEVEL SECURITY;
ALTER TABLE news ENABLE ROW LEVEL SECURITY;
ALTER TABLE news_reads ENABLE ROW LEVEL SECURITY;
ALTER TABLE stellen ENABLE ROW LEVEL SECURITY;
ALTER TABLE termine ENABLE ROW LEVEL SECURITY;
ALTER TABLE termine_anmeldungen ENABLE ROW LEVEL SECURITY;
-- Helper Funktion: Gibt org_id des aktuellen Users zurück
CREATE OR REPLACE FUNCTION current_user_org_id()
RETURNS uuid AS $$
SELECT org_id FROM user_roles
WHERE user_id = auth.uid()
LIMIT 1;
$$ LANGUAGE sql STABLE SECURITY DEFINER;
-- Helper Funktion: Ist aktueller User Admin?
CREATE OR REPLACE FUNCTION current_user_is_admin()
RETURNS boolean AS $$
SELECT EXISTS (
SELECT 1 FROM user_roles
WHERE user_id = auth.uid()
AND role = 'admin'
);
$$ LANGUAGE sql STABLE SECURITY DEFINER;
-- members: Jeder sieht nur seine Innung
CREATE POLICY "members_select" ON members
FOR SELECT USING (org_id = current_user_org_id());
-- members: Nur Admin darf anlegen/bearbeiten
CREATE POLICY "members_insert" ON members
FOR INSERT WITH CHECK (
org_id = current_user_org_id() AND current_user_is_admin()
);
CREATE POLICY "members_update" ON members
FOR UPDATE USING (
org_id = current_user_org_id() AND current_user_is_admin()
);
-- news: Alle Mitglieder können lesen (nur veröffentlichte)
CREATE POLICY "news_select" ON news
FOR SELECT USING (
org_id = current_user_org_id()
AND published_at IS NOT NULL
AND published_at <= now()
);
-- news: Nur Admin kann erstellen/bearbeiten
CREATE POLICY "news_admin" ON news
FOR ALL USING (
org_id = current_user_org_id() AND current_user_is_admin()
);
-- stellen: Öffentlich lesbar (ohne Login) — via Supabase anon key
CREATE POLICY "stellen_public_select" ON stellen
FOR SELECT USING (aktiv = true);
-- stellen: Nur zugehöriges Mitglied und Admins können schreiben
CREATE POLICY "stellen_insert" ON stellen
FOR INSERT WITH CHECK (
org_id = current_user_org_id()
AND (
member_id IN (SELECT id FROM members WHERE user_id = auth.uid())
OR current_user_is_admin()
)
);
```
---
## 4. Utility Functions
```sql
-- Funktion: updated_at Trigger
CREATE OR REPLACE FUNCTION update_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = now();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Trigger für alle relevanten Tabellen
CREATE TRIGGER members_updated_at BEFORE UPDATE ON members
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
CREATE TRIGGER news_updated_at BEFORE UPDATE ON news
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
CREATE TRIGGER stellen_updated_at BEFORE UPDATE ON stellen
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
CREATE TRIGGER termine_updated_at BEFORE UPDATE ON termine
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
-- View: Beitrag mit Leserate
CREATE VIEW news_with_stats AS
SELECT
n.*,
COUNT(DISTINCT nr.user_id) AS read_count,
COUNT(DISTINCT m.user_id) FILTER (WHERE m.user_id IS NOT NULL) AS total_users
FROM news n
LEFT JOIN news_reads nr ON nr.news_id = n.id
LEFT JOIN members m ON m.org_id = n.org_id AND m.status = 'aktiv'
GROUP BY n.id;
-- View: Kommende Termine mit Anmeldezahl
CREATE VIEW termine_with_counts AS
SELECT
t.*,
COUNT(ta.id) AS anmeldungen_count
FROM termine t
LEFT JOIN termine_anmeldungen ta ON ta.termin_id = t.id
WHERE t.datum >= CURRENT_DATE
GROUP BY t.id;
```
---
## 5. Migrations-Strategie
- Migrations mit `supabase db migrations` verwaltet
- Jede Migration ist eine `.sql`-Datei in `supabase/migrations/`
- Migrations werden in CI/CD vor dem Deploy ausgeführt
- Staging-Datenbank bekommt Migrations zuerst (Blue-Green-Prinzip)
- Rollbacks: nur additiv — keine destruktiven Changes ohne Backup

231
DSGVO_KONZEPT.md Normal file
View File

@@ -0,0 +1,231 @@
# InnungsApp — DSGVO & Datenschutzkonzept
---
## 1. Überblick
InnungsApp verarbeitet personenbezogene Daten von:
- Innungsgeschäftsführern und Admins
- Mitgliedsbetrieben (Betriebsinhaber)
- Azubi-Bewerbern (Minderjährige möglich!)
- Auszubildenden (Minderjährige möglich!)
Dies begründet hohe DSGVO-Anforderungen — insbesondere bei Minderjährigen.
---
## 2. Rechtsgrundlagen
| Verarbeitung | Rechtsgrundlage | Artikel DSGVO |
|---|---|---|
| Mitgliederverwaltung | Berechtigtes Interesse der Innung | Art. 6 Abs. 1 lit. f |
| Kommunikation (News) | Berechtigtes Interesse | Art. 6 Abs. 1 lit. f |
| Lehrlingsbörse (Betrieb) | Vertragserfüllung | Art. 6 Abs. 1 lit. b |
| Lehrlingsbörse (Bewerber) | Einwilligung | Art. 6 Abs. 1 lit. a |
| Azubi-Profil (Minderjährige) | Elterliche Einwilligung (< 16 J.) | Art. 8 DSGVO |
| Analytics (PostHog) | Berechtigtes Interesse | Art. 6 Abs. 1 lit. f |
| Push Notifications | Einwilligung | Art. 6 Abs. 1 lit. a |
---
## 3. Datenkategorien & Speicherdauer
### Mitgliedsdaten
| Datenkategorie | Speicherdauer | Begründung |
|---|---|---|
| Name, Betrieb, Kontaktdaten | Mitgliedschaft + 3 Jahre | Handelsrechtliche Aufbewahrungspflicht |
| E-Mail (Auth) | Bis Kontolöschung | Technisch notwendig |
| Push Tokens | Bis Abmeldung / App-Deinstallation | Technisch notwendig |
| Lesestatus (news_reads) | 12 Monate | Analytics, danach Anonymisierung |
| Login-Logs | 30 Tage | Sicherheit |
### Azubi-Bewerber-Daten
| Datenkategorie | Speicherdauer | Begründung |
|---|---|---|
| Bewerberprofil | 6 Monate nach letzter Aktivität | Automatische Löschung |
| Chat-Nachrichten | 90 Tage nach Gesprächsende | Automatische Löschung |
| Video-Views (anonym) | 90 Tage | Analytics |
### Logs & Analytics
| Datenkategorie | Speicherdauer |
|---|---|
| Supabase Auth Logs | 30 Tage |
| PostHog Events | 12 Monate (anonymisiert nach 3 Monaten) |
| Edge Function Logs | 7 Tage |
---
## 4. Auftragsverarbeitung (AVV)
InnungsApp ist **Auftragsverarbeiter** für die Innungen.
Innungen sind **Verantwortliche** für die Daten ihrer Mitglieder.
### AVV-Pflicht
Mit jeder Innung wird ein AVV (Auftragsverarbeitungsvertrag) abgeschlossen:
- Elektronisch im Onboarding-Prozess
- Signatur via Klick-Einwilligung (dokumentiert)
- Bestandteil der AGBs
### AVV-Inhalte (Kernanforderungen)
- Gegenstand und Dauer der Verarbeitung
- Art und Zweck der Verarbeitung
- Art der personenbezogenen Daten
- Kategorien betroffener Personen
- Pflichten und Rechte des Verantwortlichen (Innung)
- Technische und organisatorische Maßnahmen (TOMs)
- Sub-Auftragsverarbeiter (Supabase, Resend, etc.)
---
## 5. Sub-Auftragsverarbeiter
| Anbieter | Zweck | Standort | Datenschutz-Basis |
|---|---|---|---|
| **Supabase** | Datenbank, Auth, Storage | EU (Frankfurt, AWS eu-central-1) | EU-Standardvertragsklauseln |
| **Vercel** | Web-Hosting Admin | USA + EU-Edge | EU-SCC, EU-Daten bleiben in EU |
| **Resend** | Transaktions-E-Mails | USA | EU-SCC |
| **Expo / EAS** | App-Build, Push-Routing | USA | EU-SCC |
| **PostHog** | Analytics | EU (optional) | EU-Hosting wählbar |
| **Mux** | Video-Hosting (Post-MVP) | USA | EU-SCC |
**Wichtig:** Alle kritischen Daten (Mitgliederdaten, Auth) liegen in Supabase Frankfurt (EU). US-Anbieter erhalten nur technisch notwendige Minimal-Daten.
---
## 6. Technische & Organisatorische Maßnahmen (TOMs)
### Vertraulichkeit
- **Datenverschlüsselung in Transit:** TLS 1.3 für alle Verbindungen
- **Datenverschlüsselung at Rest:** AES-256 via Supabase (AWS KMS)
- **Row Level Security:** Strikte Datenisolation auf Datenbankebene
- **JWT-basierte Auth:** Kurzlebige Access Tokens (1 Stunde), Refresh Tokens (7 Tage)
- **Magic Link:** Einmalig verwendbar, 7 Tage gültig
### Integrität
- **Audit Logs:** Alle Admin-Aktionen werden geloggt (wer hat was wann geändert)
- **Backup:** Supabase automatisch täglich (30 Tage Retention im Pro-Plan)
- **Input Validation:** Server-seitige Validierung aller Formulare (Zod Schemas)
### Verfügbarkeit
- **Supabase SLA:** 99,5 % Uptime
- **Multi-AZ:** Supabase verwendet PostgreSQL mit Standby in separater AZ
- **CDN:** Vercel und Supabase Storage mit globalem CDN
### Zugriffskontrolle
- **Role-based Access Control:** `admin` | `member` | `public`
- **Principle of Least Privilege:** Jede Rolle nur Minimalrechte
- **Admin-Zugang:** Nur via Supabase Dashboard mit 2FA
- **Mitarbeiter-Schulung:** Jährliche DSGVO-Unterweisung
---
## 7. Betroffenenrechte
### Auskunftsrecht (Art. 15 DSGVO)
**Prozess:**
1. Betroffene Person sendet E-Mail an datenschutz@innungsapp.de
2. Identität wird verifiziert (Magic Link oder Ausweiskopie)
3. Auskunft innerhalb von 30 Tagen
4. Format: PDF mit allen gespeicherten Daten
### Recht auf Löschung (Art. 17 DSGVO)
**Self-Service in der App:**
- Einstellungen → "Konto löschen"
- Sofortige Anonymisierung der personenbezogenen Daten
- Technische Daten werden nach 30 Tagen endgültig gelöscht
- Auth-Account wird sofort gelöscht
**Admin-seitige Löschung:**
- Admin kann Mitglied deaktivieren (kein Datenzugriff)
- Vollständige Löschung auf Anfrage an InnungsApp innerhalb 30 Tage
### Recht auf Datenübertragbarkeit (Art. 20 DSGVO)
- Export als JSON oder CSV auf Anfrage
- Enthält: Profildaten, News-Leseverlauf, Terminanmeldungen, eigene Stellen
### Widerspruchsrecht (Art. 21 DSGVO)
- Push Notifications: jederzeit in App-Einstellungen deaktivierbar
- Analytics: Opt-Out via Einstellungsmenü (PostHog Cookie-freies Tracking)
---
## 8. Besondere Anforderungen: Minderjährige
### Problem
Azubi-Bewerber können 1516 Jahre alt sein → Minderjährige nach DSGVO Art. 8.
### Lösung
**Altersabfrage bei Registrierung:**
- "Sind Sie unter 16 Jahre alt?" (Ja / Nein)
- Bei "Ja": Elterliche Einwilligung erforderlich
**Einwilligungs-Flow (unter 16):**
1. App zeigt: "Für Personen unter 16 Jahren benötigen wir die Zustimmung eines Erziehungsberechtigten."
2. E-Mail-Adresse des Erziehungsberechtigten eingeben
3. Erziehungsberechtigter erhält E-Mail mit Einwilligung-Link
4. Nach Bestätigung: Bewerber-Profil freigegeben
**Minimale Datenerhebung bei Minderjährigen:**
- Kein Geburtsdatum gespeichert (nur "unter/über 16")
- Keine Adresse
- Keine Fotos ohne explizite Einwilligung
- Chat: automatische Löschung nach 30 Tagen
---
## 9. Datenschutzerklärung & Impressum
### Pflichtangaben
**Datenschutzerklärung** (auf innungsapp.de + in App abrufbar):
- Verantwortlicher: Timo Knuth, [Adresse]
- Datenschutzbeauftragter: (ab 20 Mitarbeiter oder sensible Daten: Pflicht — für MVP: optional)
- Kategorien verarbeiteter Daten
- Zwecke und Rechtsgrundlagen
- Speicherdauer
- Sub-Auftragsverarbeiter
- Betroffenenrechte + Beschwerderecht bei Aufsichtsbehörde
**Aufsichtsbehörde BW:**
Der Landesbeauftragte für den Datenschutz und die Informationsfreiheit Baden-Württemberg
Lautenschlagerstraße 20, 70173 Stuttgart
### Cookie-Policy
- InnungsApp Mobile: keine Cookies (Native App)
- Admin Web: Session Cookie (Supabase Auth) — notwendig, kein Banner erforderlich
- Analytics (PostHog): Cookie-freies Tracking (pixel-los) — kein Banner erforderlich
---
## 10. Incident Response Plan
### Bei Datenpanne
1. **Erkennung** → intern oder via Supabase-Alert
2. **Bewertung** (< 24h): Umfang, betroffene Personen, Risiko
3. **Meldung an Aufsichtsbehörde** (< 72h, Art. 33 DSGVO) wenn Risiko für Betroffene
4. **Benachrichtigung der Betroffenen** wenn hohes Risiko (Art. 34 DSGVO)
5. **Dokumentation** im Verarbeitungsverzeichnis
### Technische Reaktionsmaßnahmen
- Supabase Auth: Alle Sessions ungültig machen (1 Klick)
- Betroffene Innungen isolieren (org_id deaktivieren)
- Logs sichern (vor Löschung durch Rotation)
- Passwörter: nicht relevant (Magic Link, kein Passwort)

View File

@@ -1,385 +0,0 @@
# EXECUTIVE SUMMARY
## Analyse: Pain Points deutscher Stadtwerke & Softwarelösungen
**Dokument:** Hochrangige Zusammenfassung
**Datum:** Februar 2026
**Zielgruppe:** Geschäftsführer, Investoren, Entscheidungsträger
---
## KERNFINDING
Deutsche Stadtwerke haben **5 kritische Probleme**, aber **nicht alle parallel lösbar**:
- **60-70% aller Kundenservice-Anfragen** führen zurück zu Pain Point #1-3
- **25-35% Kundenabwanderung** korreliert mit Zählerablesung & Abschlag-Unklarheit
- **5-10 Millionen EUR Jahreskosten** (bei größeren Stadtwerken für alle 5)
**MVP-Strategie:** Nur Pain Point #1 (Zählerablesung) mit professioneller Qualität in **4-6 Monaten**. Die anderen 4 folgen später.
⚠️ **Realistic Check:** Nur #1 entwickeln ist schneller, günstiger, und fokussierter.
---
## TOP 5 PAIN POINTS (Prioritätsreihenfolge)
### 1⃣ **Zählerablesung** (Höchste Priorität)
- **Problem:** Manuelle Ablesungen → 30-40% Fehler → Nachberechnungen
- **Kundenimpact:** Verwirung, Rechnungskorrektionen, Ärger
- **Lösung:** SmartMeter-Lite App (Foto-Upload + OCR)
- **ROI:** 8-Monatliche Amortisierung
### 2⃣ **Abschlagsrechnungen** (Hoch)
- **Problem:** Unklar, intransparent → 45% Kunden unzufrieden
- **Kundenimpact:** Verwirrung, Serviceanfragen
- **Lösung:** AbschlagAssistant Web-Tool (interaktive Erklärung)
- **ROI:** Sofort ROI durch weniger Support-Calls
### 3⃣ **Entstörungsprozesse** (Hoch)
- **Problem:** Keine Echtzeit-Info → lange Unsicherheit
- **Kundenimpact:** Angst, Ärger, Misstrauen
- **Lösung:** OutageAlert Pro (Live-Status + SMS/App)
- **ROI:** 50%+ Reduktion Service-Anrufe
### 4⃣ **Kundenservice Fragmentierung** (Mittel-Hoch)
- **Problem:** 5 verschiedene Kanäle → lange Wartezeiten
- **Kundenimpact:** Frustration, Wiederholte Erklärungen
- **Lösung:** Kundenservice 360 (KI-Chatbot + Omnichannel)
- **ROI:** 40-60% Kostenersparnis
### 5⃣ **Abrechnungsunklarheiten** (Mittel)
- **Problem:** Rechnungen komplex → Misstrauen
- **Kundenimpact:** Verstehen nicht warum sie zahlen
- **Lösung:** RechnungsAnalyzer+ (Visuelle Erklärung + Archive)
- **ROI:** 60% weniger Abrechnungsbeschwerde
---
## MARKTTECHNIKEN
### Zielmarkt
- **8-10 Millionen Haushalte** in Deutschland mit Stadwerk-Versorgung
- **900+ Stadtwerke** (kleine bis sehr große)
- **Fokus auf Top 100 Stadtwerke** (60% des Marktes)
### Geschätztes Gesamtmarktvolumen (KORRIGIERT)
| Jahr | Szenario Conservative | Szenario Realistic | Szenario Optimistic |
|------|----------------------|-------------------|-------------------|
| Y1 | 400-600K EUR | **500K-1.2M EUR** | 2.0M EUR |
| Y2 | 1.2-2M EUR | **2-4M EUR** | 8-10M EUR |
| Y3 | 2.5-5M EUR | **5-10M EUR** | 15-20M EUR |
**Realistisches Szenario (mit nur MVP #1):**
- Y1: €500-1.2M (5-10 Kunden)
- Y2: €2-4M (20-35 Kunden)
- Y3: €5-10M (40-60 Kunden)
**Wichtig:** Das ist mit nur **SmartMeter-App MVP**, nicht alle 5 Solutions.
---
## INVESTMENTTHESE
### Why Now? (3 Gründe)
1. **Digitalisierungsdruck:** Pandemie & EU-Vorgaben zwingen Stadtwerke zur Digitalisierung
2. **Fachkräftemangel:** Kundenservice-Mitarbeiter kosten 400-600K EUR/Jahr
3. **Customer Expectations:** Kunden erwarten Modern UX (wie Amazon, Netflix)
### Key Success Factors
**Schnelle MVP-Entwicklung** (12-16 Wochen)
**Direct Sales Focus** (B2B an Stadtwerke)
**White-Label Potential** (mehrere Stadtwerke, ein Code)
**Sticky Product** (high switching costs)
**Recurring Revenue** (SaaS-Modell)
---
## FINANZIELLE PROJEKTION (KORRIGIERT - LEAN MVP)
### Investitionen für MVP Phase 1 (SmartMeter-App only)
- **MVP Development:** 250-350K EUR (4-6 Monate, 3-4 Devs)
- **Product/Design:** 50K EUR
- **Sales/Marketing (Validation):** 30-50K EUR
- **Ops/Legal/Setup:** 20K EUR
- **Total für MVP-Launch:** **€350-450K EUR** (nicht 2M!)
### Revenue Projektion (3 Jahre) - REALISTISCH
| Metrik | Year 1 | Year 2 | Year 3 |
|--------|--------|--------|--------|
| Stadtwerk-Kunden | 5-10 | 20-35 | 40-60 |
| ARR (Recurring) | €500K-1.2M | €2-4M | €5-10M |
| Gross Margin | 70% | 75% | 78% |
| EBITDA Margin | -100% | 0% to 15% | 30-40% |
| Break-Even | Month 18-22 | - | - |
### Unit Economics
- **ACV (Annual Contract Value):** €80K-120K EUR (nicht höher)
- **CAC (Customer Acq. Cost):** €20-30K EUR
- **LTV:CAC Ratio:** 3.5-4:1 (healthy)
- **Churn Rate:** 5-7% (annually)
### Key Outputs (REALISTISCH)
- **3-Year Total Revenue:** 7.5-15M EUR (Y1 + Y2 + Y3)
- **Break-Even:** Month 18-22 (statt vorher Month 18-20)
- **IRR (bei 350K Inv. nur MVP):** 150%+ (deutlich besser!)
- **Payback Period:** 1.5-2 Jahre
- **WICHTIG:** Mit €350K statt €2M Investment ist das Risiko 6x niedriger!
---
## KOMPETITIVE POSITION
### Vs. Inhouse-Entwicklung (durch Stadtwerke)
- **Unsere Vorteile:**
- ✅ Best Practices von 100+ Stadtwerken
- ✅ Spesialisierte Expertise (nicht IT-getrieben)
- ✅ Schneller Time-to-Market
- ✅ Skalierbare Lösung
- **Ihre Barriere:**
- ❌ 2-3 Jahre Development
- ❌ Hohe Kosten (2-3M EUR)
- ❌ Weniger Expertise
### Vs. Große Softwareanbieter (SAP, Oracle)
- **Unsere Vorteile:**
- ✅ Lokalisiert für deutsche Stadtwerke
- ✅ Best-of-Breed Fokus (nicht schwerfällig)
- ✅ Schnelle Innovation
- ✅ Attraktive Pricing
- **Ihre Barriere:**
- ❌ Zu komplex für SMB Stadtwerke
- ❌ Zu teuer (100K+/Monat)
- ❌ Nicht spezialisiert
---
## IMPLEMENTIERUNGS-STRATEGIE (LEAN MVP)
### ⚠️ WICHTIG: Nur #1 (SmartMeter-App) im MVP
### Phase 1: VALIDATION (Wochen 1-4, €20-30K)
- 5-10 Interviews mit echten Stadtwerken
- Konkurrenz-Analyse (SAP, Oracle, lokale Lösungen)
- Feature-Priorisierung
- GO/NO-GO Decision
- **Team:** 1 Founder + 1 PM
### Phase 2: MVP-DEVELOPMENT (Wochen 5-14, €200-300K)
- **NUR SmartMeter-Lite App** (nicht alle 5!)
- Tech Stack: React Native (iOS+Android), Python Backend, Tesseract OCR
- Core Features: Foto-Upload, OCR, Validierung, Admin Portal, API
- Sicherheit: DSGVO, Encryption, Audit Logs
- **Team:** 3-4 Developer, 1 PM, 1 Designer
- **Launch:** Woche 14
### Phase 3: BETA & PILOTS (Wochen 15-26, €50-80K)
- 5 Pilot-Stadtwerke (kostenlos, signiert NDA)
- Feedback Loops & Iteration
- Dokumentation & Onboarding
- Sales Materials
- **Team:** 1-2 Developer, 1 PM, 1 Sales
- **GO-to-Market Launch:** Woche 26
### Phase 4: EXPANSION (Monat 7+, progressiv)
- Nach erfolgreichem MVP: Pain Point #2, #3, etc.
- Nur wenn #1 momentum hat!
- **Team:** +2-3 Developer pro Solution
**Total für MVP nur SmartMeter: €350-450K (nicht 1.6M!)**
---
## GO-TO-MARKET (LEAN & REALISTIC)
### Sales Strategy - 3 Optionen
**OPTION A: Direct Sales (klassisch)**
- Kleine Sales-Agentur (2-3 Personen)
- Target: Top 20 Stadtwerke
- Sales Cycle: 60-90 Tage
- Risiko: Kostspielig bei wenig early wins
- ✅ Best für: Wenn ihr gute Kontakte habt
**OPTION B: Partnership-First (empfohlen)**
- VKU-Partnership (Verband Kommunaler Unternehmen)
- Oder: SAP/Oracle Implementation Partner
- Sie bringen Kunden, wir liefern Software
- Dein Anteil: 40-50% der Lizenz
- ✅ Schneller Umsatz, weniger Sales-Kosten
- ✅ Best für: MVP-Phase
**OPTION C: Reseller Model (niedrig-Risiko)**
- Baue SmartMeter App
- Verkaufe White-Label an 2-3 Consultants
- Sie verkaufen an ihre Stadtwerk-Kunden
- Du kriegst €2-5K pro Installation
- ✅ Wenig Sales-Overhead
- ✅ Best für: Bootstrapping
### Pricing (Realistisch)
- **Small Stadtwerke (<50K Haushalte):** €1-2K/Monat
- **Medium Stadtwerke (50K-200K):** €3-5K/Monat
- **Large Stadtwerke (>200K):** €5-15K/Monat
- **Average ACV:** €80-120K/year (nicht höher)
---
## RISIKEN & MITIGATION
| Risiko | Wahrscheinlichkeit | Impact | Mitigation |
|--------|-------------------|--------|-----------|
| Lange Sales Cycles | Hoch | Mittel | Freemium-Modell, POCs |
| Integration Komplexität | Mittel | Hoch | Dediziertes Team, Fallbacks |
| Compliance/Datenschutz | Mittel | Hoch | Early Audits, Legal Review |
| Technische Schulden | Mittel | Mittel | Agile Dev, Code Quality |
| Konkurrenz (SAP, etc.) | Mittel | Mittel | Spezialisation, Speed |
---
## SUCCESS METRICS (18 Monate)
### Business Metrics
- [ ] 20-30 Stadtwerk-Kunden akquiriert
- [ ] 2.0M EUR ARR
- [ ] 60% Gross Margin
- [ ] Break-Even auf Sicht
### Product Metrics
- [ ] 40%+ Endkunden-Adoption
- [ ] 50+ NPS Score
- [ ] 99.9% Uptime
- [ ] <2h Support Response Time
### Market Metrics
- [ ] Top 3 in Google Suche "Stadtwerk Software"
- [ ] 50+ Case Studies/Referenzen
- [ ] Partnership mit VKU etabliert
- [ ] Thought Leadership (Artikel, Events)
---
## FINANZIELLE ANFORDERUNGEN (KORRIGIERT)
### Capital Requirement - MVP nur SmartMeter-App
- **Lean MVP:** 350-450K EUR (nicht 2-3M!)
- **Extended (mit 6 Monate Runway):** 600-800K EUR
- **Use of Funds (MVP):**
- Product Development: 250-350K EUR (65%)
- Validation & Sales: 50-80K EUR (15%)
- Operations & Legal: 50-70K EUR (15%)
- Reserve: 20-50K EUR (5%)
### Alternative Szenarien
- **Bootstrapping (aus Eigenkapital):** 150-200K EUR möglich (sehr lean)
- **Friends & Family:** 250-350K EUR
- **Seed Round:** 600-800K EUR (Safety Net)
### Financial Milestones (REALISTISCH)
- **Months 1-4: Validation Phase**
- 10 Stadtwerk-Interviews
- Konkurrenz-Analyse
- GO/NO-GO Entscheidung
- **Months 5-14: MVP Development**
- SmartMeter-App Release (Woche 14)
- 5 Beta-Kunden akquiriert (kostenlos)
- NPS Feedback > 40
- **Year 1 (Months 15-12):**
- 5-8 First Paying Customers: Month 16-18
- €400-600K ARR: Month 12
- Burn Rate stabilisiert
- **Year 2 (Months 13-24):**
- 20-35 Customers: Month 24
- €2-4M ARR: Month 24
- Path to Profitability sichtbar
---
## INVESTOREN-PERSPEKTIVE
### Why this is attractive for VCs
**Real TAM:** 5-12M EUR market opportunity (Germany, realistic)
**Recurring Revenue:** Sticky SaaS model (high switching costs)
**Defensible:** Specialized vertical, not generic software
**Experienced Team:** (Required: proven B2B/SaaS founders)
**Global Expansion:** Deutschland → Austria/CH/NL (fast copy-paste)
**M&A Potential:** Attractive for SAP, Oracle, Salesforce, regional players
**Fast Growth:** 2-3x YoY revenue possible (realistic for B2B SaaS)
**Low CAC:** €20-30K acquisition cost vs. €80-120K lifetime value
**Capital Efficient:** Nur €350-450K für MVP (nicht 2M!)
### Comparable Exits & Valuations
- **B2B SaaS 3-Year ARR:** €5-10M × 5-8x Multiple = €25-80M Exit
- **Similar Verticals (Utilities):** 6-10x Revenue multiples
- **Realistic Scenario Year 3:** €5-10M ARR × 6x = **€30-60M Exit**
**Investment Returns:**
- **Initial: 350K EUR Seed**
- **Year 3 Exit: €30-60M** = **85x - 170x Return**
- **IRR:** 80-120% (very attractive for VCs)
- **Risk Level:** Medium (vs. High für Venture Fantasien)
---
## NÄCHSTE SCHRITTE (Next 30 Days)
### Week 1
- [ ] Founding Team Onboarding
- [ ] Technical Architecture Review
- [ ] Legal/Compliance Setup
### Week 2
- [ ] Initial Stadtwerk Interviews (5-10)
- [ ] Competitive Landscape Deep-Dive
- [ ] Development Environment Setup
### Week 3
- [ ] Product Requirements Document (PRD)
- [ ] Visual Design System
- [ ] Development Roadmap
### Week 4
- [ ] Board/Investor Update
- [ ] Hiring Plans (Developer, Designer, PM)
- [ ] Marketing Strategy Finalization
---
## KONTAKT & WEITERE INFORMATIONEN
**Für detaillierte Informationen siehe:**
1. `stadtwerke_analysis.md` - Umfassende Pain Point Analyse
2. `implementation_roadmap.md` - Detaillierte Implementierungsplanung
3. `detailed_use_cases.md` - Kundenszenarien und konkrete Beispiele
**Ansprechpartner:**
- Technical Lead: [TBD]
- Product Manager: [TBD]
- Business Development: [TBD]
---
## APPENDIX: KEY DEFINITIONS
**Pain Point:** Wiederkurrentes Problem, das Kundenzufriedenheit reduziert
**ARR:** Annual Recurring Revenue (jährliche wiederkurrende Einnahmen)
**ACV:** Average Contract Value (durchschnittlicher Vertragswert)
**CAC:** Customer Acquisition Cost (Kosten zur Kundenakquisition)
**NPS:** Net Promoter Score (Maß für Kundenloyal und Empfehlungsbereitschaft)
**TTM:** Time-to-Market (Zeit bis zur Markteinführung)
**MVP:** Minimum Viable Product (kleinste funktionsfähige Version)
**SaaS:** Software as a Service (Cloud-basiertes Softwaremodell)
**Churn:** Kündigungsquote (% der Kunden, die gehen)
**EBITDA:** Earnings Before Interest, Taxes, Depreciation, Amortization
---
**Datum dieser Analyse:** Februar 2026
**Gültigkeitsdauer:** 12 Monate (Marktdaten aktuell)
**Nächste Review:** November 2026

129
FEATURES_BACKLOG.md Normal file
View File

@@ -0,0 +1,129 @@
# InnungsApp — Feature Backlog
> Priorisiert nach MoSCoW: **M**ust | **S**hould | **C**ould | **W**on't (MVP)
> Sortiert nach Impact / Effort Score (H = Hoch, M = Mittel, N = Niedrig)
---
## MVP Must-Have (Phase 1)
| ID | Feature | Modul | Impact | Effort | Sprint |
|---|---|---|---|---|---|
| F-001 | Magic Link Login (E-Mail) | Auth | H | N | 1 |
| F-002 | Auth Guard (geschützte Routes) | Auth | H | N | 1 |
| F-003 | Mitgliederverzeichnis (Liste + Suche) | Mitglieder | H | N | 1 |
| F-004 | Mitglied-Detailansicht + Tap-to-Call | Mitglieder | H | N | 1 |
| F-005 | Filter: Sparte, Ort, Ausbildungsbetrieb | Mitglieder | M | N | 1 |
| F-006 | Admin: Mitglied anlegen / bearbeiten | Mitglieder | H | M | 1 |
| F-007 | Admin: Mitglied deaktivieren | Mitglieder | H | N | 1 |
| F-008 | CSV-Import Mitglieder | Mitglieder | H | M | 1 |
| F-009 | Einladungsmail per Resend | Mitglieder | H | N | 1 |
| F-010 | News Feed (veröffentlichte Beiträge) | News | H | N | 2 |
| F-011 | News-Detailansicht mit Markdown | News | H | N | 2 |
| F-012 | Kategoriefilter (Wichtig/Prüfung/etc.) | News | M | N | 2 |
| F-013 | Ungelesen/Gelesen-Status | News | M | N | 2 |
| F-014 | PDF-Anhang öffnen | News | H | M | 2 |
| F-015 | Push Notification bei Veröffentlichung | News | H | M | 2 |
| F-016 | Admin: Beitrag erstellen (Markdown) | News | H | M | 2 |
| F-017 | Admin: Beitrag anpinnen | News | M | N | 2 |
| F-018 | Admin: Leserate pro Beitrag | News | H | N | 2 |
| F-019 | Admin: Zeitgesteuerte Veröffentlichung | News | M | M | 2 |
| F-020 | Terminliste (chronologisch) | Termine | H | N | 3 |
| F-021 | Termin-Detailansicht | Termine | H | N | 3 |
| F-022 | Typ-Tags (Prüfung/Versammlung/etc.) | Termine | M | N | 3 |
| F-023 | An-/Abmeldung für Termin | Termine | H | N | 3 |
| F-024 | iCal-Export (Google/Outlook) | Termine | H | N | 3 |
| F-025 | Admin: Termin anlegen / bearbeiten | Termine | H | N | 3 |
| F-026 | Admin: Teilnehmerliste einsehen + CSV-Export | Termine | H | N | 3 |
| F-027 | E-Mail-Bestätigung nach Anmeldung | Termine | M | N | 3 |
| F-028 | Stellenliste öffentlich (ohne Login) | Lehrlingsbörse | H | N | 4 |
| F-029 | Stellen-Filter (Sparte, Ort, Lehrjahr) | Lehrlingsbörse | H | N | 4 |
| F-030 | Vergütungsanzeige nach Lehrjahr | Lehrlingsbörse | H | N | 4 |
| F-031 | Betrieb: Stelle anlegen | Lehrlingsbörse | H | M | 4 |
| F-032 | Betrieb: Stelle aktivieren/pausieren | Lehrlingsbörse | H | N | 4 |
| F-033 | Admin Dashboard Übersicht | Admin | H | M | 4 |
| F-034 | Admin: Innung-Setup (Logo, Sparten) | Admin | H | M | 0 |
| F-035 | Multi-Tenancy RLS | Backend | H | H | 0 |
| F-036 | Row Level Security alle Tabellen | Backend | H | M | 0 |
| F-037 | Push Token Registrierung | Backend | H | M | 2 |
| F-038 | Onboarding-Wizard (neue Innung) | Onboarding | H | M | 5 |
| F-039 | First-Use Tutorial (Mobile) | Onboarding | M | M | 5 |
| F-040 | App Store Submission (iOS + Android) | Launch | H | M | 6 |
---
## Should Have (Phase 2, Q2 2026)
| ID | Feature | Modul | Impact | Effort |
|---|---|---|---|---|
| F-041 | Push Reminder 24h vor Termin | Termine | H | M |
| F-042 | Admin: Monatsbericht als PDF | Analytics | M | H |
| F-043 | Erweiterte Analytics (DAU/WAU/MAU Charts) | Analytics | M | M |
| F-044 | Leeransicht für leere Listen | UX | M | N |
| F-045 | Offline-Modus (Cached Data) | UX | M | H |
| F-046 | Dokumentenarchiv (Upload/Download) | Dokumente | H | H |
| F-047 | Videokonferenz-Link in Terminen | Termine | M | N |
| F-048 | Mitglied: eigenes Profil bearbeiten | Mitglieder | M | M |
| F-049 | Admin: Mitteilung an Sparte gezielt | News | M | M |
| F-050 | Admin: Vorlage für häufige Beiträge | News | M | M |
| F-051 | Benachrichtigungs-Einstellungen (Nutzer) | Notifications | M | M |
| F-052 | Karten-Ansicht Mitglieder (Google Maps) | Mitglieder | N | H |
| F-053 | Bewerbungs-Kontaktformular (in App) | Lehrlingsbörse | M | M |
| F-054 | Admin: Stellen moderieren / ausblenden | Lehrlingsbörse | M | N |
---
## Could Have (Phase 3, Q3Q4 2026)
| ID | Feature | Modul | Impact | Effort |
|---|---|---|---|---|
| F-055 | TikTok-Style Video-Feed | Azubi-Modul | H | H |
| F-056 | Video-Upload für Betriebe | Azubi-Modul | H | H |
| F-057 | Bewerber-Profil (ohne CV) | Azubi-Modul | H | M |
| F-058 | 1-Click-Apply | Azubi-Modul | H | M |
| F-059 | In-App Chat (Betrieb ↔ Bewerber) | Azubi-Modul | H | H |
| F-060 | Vergütungs-Rechner | Azubi-Modul | M | M |
| F-061 | Digitales Berichtsheft | Azubi-Modul | M | H |
| F-062 | Prüfungsvorbereitung Quiz | Azubi-Modul | M | H |
| F-063 | White-Label (Subdomain, Logo) | Platform | H | H |
| F-064 | HWK-Dashboard (alle Innungen im Bezirk) | Platform | H | H |
| F-065 | API für externe Systeme (OpenAPI) | Platform | M | H |
| F-066 | Webhook-Integration | Platform | M | H |
| F-067 | Chat / Direktnachrichten (allgemein) | Kommunikation | M | H |
| F-068 | Prüfungsverwaltung (Gesellenprüfungen) | Prüfungen | H | H |
| F-069 | Obermeister-Genehmigung für Beiträge | Governance | N | M |
| F-070 | Mitgliederbeiträge / Buchhaltung | Finanzen | M | H |
| F-071 | Öffentliches Unternehmensverzeichnis | Marketing | N | H |
| F-072 | Bewertungssystem für Betriebe | Community | N | H |
---
## Won't Have (MVP — bewusste Nein-Entscheidungen)
| Feature | Begründung |
|---|---|
| Mehrsprachigkeit | Zielgruppe 100% deutsch, 2027 frühestens |
| SEPA-Lastschrift | Buchhaltungsintegration zu komplex |
| Lernmanagementsystem (LMS) | Zu komplex, eigenes Produkt |
| Öffentliche API ab Tag 1 | Erst wenn Produkt stabil |
| App für HWK (übergeordnet) | Erst nach HWK-Partnerschaft |
| Gamification (Punkte, Badges) | Nicht Kernbedürfnis der Zielgruppe |
| Social Feed (Mitglieder posten) | Moderation zu aufwendig |
| Marktplatz / E-Commerce | Separate Business Unit 2027 |
---
## Technische Schulden & Non-Feature Backlog
| ID | Aufgabe | Priorität |
|---|---|---|
| T-001 | End-to-End Tests (Playwright) für Admin-App | Hoch |
| T-002 | Unit Tests für Supabase Edge Functions | Hoch |
| T-003 | React Native E2E Tests (Maestro) | Mittel |
| T-004 | Error Monitoring (Sentry) integrieren | Hoch |
| T-005 | Performance Monitoring (Supabase Insights) | Mittel |
| T-006 | Dependency Updates automatisieren (Renovate) | Mittel |
| T-007 | API Rate Limiting (Edge Function) | Hoch |
| T-008 | Spam-Schutz Lehrstellenanzeigen | Mittel |
| T-009 | Datenbankindizes optimieren (EXPLAIN ANALYZE) | Mittel |
| T-010 | Storybook für UI-Komponenten | Niedrig |

306
GTM_STRATEGY.md Normal file
View File

@@ -0,0 +1,306 @@
# InnungsApp — Go-to-Market Strategie
---
## 1. Markt-Entry Strategie
### Fokus: North Rhine-Westphalia (NRW) First
**Warum NRW als erstes Bundesland?**
- Größter Markt in DE (17.5M+ Einwohner, höchste Dichte an Handwerksbetrieben)
- 40+ Kreishandwerkerschaften in NRW (ca. 1/6 des Gesamtmarkts)
- Starke Ballungszentren (Ruhrgebiet, Köln/Düsseldorf) erlauben hohe Lead-Dichte
- Bekannte digitale Pionier-Verbände in der Region (z.B. Köln, Düsseldorf, Münster)
**Zielsegment Phase 1:**
- Kreisverbände (240 in DE) als Multiplikatoren
- Handwerk-Innungen mit 50300 Mitgliedern via Kreisverbände
- Branchen: SHK, Elektrotechnik, Bau, Dachdecker (hoher Fachkräftemangel)
- Entscheidungsebene: Hauptgeschäftsführer (HGF) der Kreisverbände
---
## 2. Phase 1: Direct Sales (Monat 16)
### Ziel
5 zahlende Kreisverbände in NRW
### Prospecting
**Lead-Generierung:**
1. Scraping: kh-online.de / HWK-Websites → alle NRW Kreishandwerkerschaften
2. LinkedIn: Hauptgeschäftsführer (HGF) identifizieren
3. Ballungszentrum-Fokus: Köln > Düsseldorf > Ruhrgebiet
**Ziel-Lead-Liste: Top 10 Kreisverbände in NRW**
| Rang | Kreisverband / Kreishandwerkerschaft (KH) | Innungen | Fokus-Region |
|---|---|---|---|
| 1 | **KH Niederrhein** (Krefeld, Viersen, Neuss) | 41 | Niederrhein |
| 2 | **KH Ruhr** (Bochum, Herne, Ennepe-Ruhr) | 39 | Ruhrgebiet |
| 3 | **KH Gütersloh-Bielefeld** | 43 | Ostwestfalen-Lippe |
| 4 | **KH Köln** | 31 | Köln / Rheinschiene |
| 5 | **KH Münster** | 34 | Münsterland |
| 6 | **KH Dortmund und Lünen** | 23 | Westfalen |
| 7 | **KH Düsseldorf** | 25+ | Düsseldorf |
| 8 | **KH Borken** | 31 | Münsterland |
| 9 | **KH Steinfurt-Warendorf** | 24 | Münsterland |
| 10 | **KH Bonn • Rhein-Sieg** | 20+ | Bonn / Rhein-Sieg |
### Outreach-Sequenz
**E-Mail 1 (Tag 1): Kreisverband-Outreach (HGF-Fokus)**
```
Betreff: Digitale Lösung für Ihre [Anzahl] Innungen — InnungsApp
Hallo Frau/Herr [Name],
ich bin Timo Knuth und entwickle InnungsApp — eine Plattform, mit der Sie
Ihre [Anzahl] Innungen zentral digitalisieren und von Excel/WhatsApp befreien.
Drei Vorteile für Ihren Kreisverband:
- Zentrale Mitgliederverwaltung für alle angeschlossenen Innungen
- Digitale Rundschreiben mit Push-Benachrichtigung & Lesebestätigung
- Exklusive Lehrlingsbörse für den gesamten Kreisverband
Wir bieten für Kreisverbände ein White-Label-Setup (€5.000 einmalig)
und attraktive Gilden-Konditionen an.
Haben Sie 20 Minuten für eine kurze Demo für Ihre Innungsgeschäftsführer?
Mit freundlichen Grüßen
Timo Knuth
```
**E-Mail 2 (Tag 5): Follow-Up**
```
Betreff: Re: InnungsApp für [Innungsname]
Hallo [Name],
kurzes Follow-Up — haben Sie meine E-Mail gesehen?
Ich verstehe, dass Sie viel zu tun haben. Deshalb direkt zum Punkt:
Können wir 20 Minuten finden? Ich zeige Ihnen, wie
[vergleichbare Innung, z.B. "die Elektro-Innung Mannheim"]
ihre Mitgliederverwaltung digitalisiert hat.
Alternativ: Soll ich Ihnen einen kurzen Demo-Link schicken,
den Sie selbst ausprobieren können?
```
**LinkedIn (parallel):**
- Personalisierte Kontaktanfrage ohne Pitch
- Nach Annahme: kurze Nachricht mit Link zur Demo
**Telefon (Woche 2):**
- Direktanruf wenn keine E-Mail-Antwort
- Ziel: Demo-Termin vereinbaren
### Demo-Call Struktur (20 Minuten)
```
02 min: Gesprächspartner kennenlernen, aktuelle Situation verstehen
"Wie verwalten Sie heute Ihre Mitglieder?"
"Wie verschicken Sie Rundschreiben?"
"Wie finden Ihre Mitglieder Ausbildungsplätze?"
212 min: Demo (Figma Prototype / Live-App)
- Login via Magic Link zeigen (30 Sek.)
- Mitgliederverzeichnis: Suche, Kontakt
- News: Beitrag erstellen, Leserate sehen
- Lehrlingsbörse: Stelle aufgeben
- Admin-Dashboard: Übersicht
1217 min: Fragen & Einwände klären
Häufigste Einwände: siehe unten
1720 min: Nächste Schritte
"Ich würde Ihnen gerne 3 Monate kostenlos geben.
Was brauchen wir, um nächste Woche zu starten?"
```
### Häufige Einwände & Antworten
| Einwand | Antwort |
|---|---|
| "Unsere Mitglieder nutzen so etwas nicht" | "Wir testen das kostenlos. Wenn nach 3 Monaten weniger als 40 % eingeloggt sind, haben Sie nichts verloren." |
| "Wir haben gerade kein Budget" | "3 Monate kostenlos. Danach 149 €/Monat — weniger als ein Mittag essen pro Tag." |
| "Das entscheidet der Vorstand" | "Super. Darf ich für die nächste Vorstandssitzung eine kurze Demo aufzeichnen?" |
| "Wir haben das schon mit WhatsApp" | "WhatsApp ist DSGVO-kritisch für Mitgliederdaten. Wir sind EU-gehosted und haben AVV." |
| "Wir warten auf die HWK-Lösung" | "Die HWK hat seit Jahren keine eigene App. Können Sie 2 Jahre warten bei dem Azubi-Mangel?" |
### Conversion-Ziele
| Schritt | Zielrate |
|---|---|
| Lead → E-Mail geöffnet | 40 % |
| E-Mail → Antwort / Demo-Anfrage | 15 % |
| Demo → Pilot-Zusage | 50 % |
| Pilot → Bezahlt | 60 % |
**Bei 100 Leads:** 6 zahlende Kunden → **Ziel erreicht**
---
## 3. Phase 2: HWK-Partnerschaft (Monat 712)
### Ziel
1 regionale HWK als Empfehlungspartner → Zugang zu 200+ Innungen
### Vorbereitung
**Voraussetzungen:**
- Mindestens 10 zahlende Innungen (Referenzen)
- NPS > 50 (messbar via PostHog / manuell)
- Case Study (1 Innung mit konkreten Zahlen: "Aktivierungsrate 78%, Leserate 65%")
- DSGVO-Dokumentation vollständig (AVV, TOMs, Datenschutzerklärung)
### HWK-Ansprache
**Einstieg:** Nicht als Verkäufer, sondern als Lösung für ihr Problem:
> "Ihre Mitgliedsinnungen haben einen Azubi-Mangel.
> Wir haben ein Tool, das ihnen hilft, Azubis zu finden.
> Wir hätten gerne Ihre Empfehlung — nicht Ihr Geld."
**Deal-Struktur:**
- HWK empfiehlt InnungsApp offiziell an Mitgliedsinnungen
- Innungen erhalten 15 % Rabatt (Sonderkonditionen)
- HWK erhält 10 % Revenue Share auf alle Verträge im Bezirk
- HWK Dashboard: Überblick aller aktiven Innungen im Bezirk
**Verhandlungsweg:**
1. Direktkontakt mit HWK-Hauptgeschäftsführer (LinkedIn / Messe)
2. Präsentation im HWK-Vorstand
3. Pilotphase: 1 Kammerbezirk exklusiv für 3 Monate
4. Rahmenvertrag
### Messen & Events
| Event | Datum | Ziel |
|---|---|---|
| Handwerk trifft Digitalwirtschaft (Stuttgart) | März 2026 | 5 Kontakte |
| HWK-Jahresempfang BW | Mai 2026 | Obermeister-Networking |
| IHK Digitalisierungstag | Juni 2026 | HWK-Entscheider |
| ZDH-Bundesversammlung Berlin | November 2026 | ZDH-Kontakt |
---
## 4. Phase 3: ZDH-Bundesrahmenvertrag (ab Monat 18)
### Was der ZDH bedeutet
Der Zentralverband des Deutschen Handwerks (ZDH) ist der Dachverband über:
- 53 Spitzenorganisationen
- 130 Bundesinnungsverbände
- 7.500 Innungen
Ein ZDH-Rahmenvertrag bedeutet: **direkter Zugang zu 7.500 potentiellen Kunden**.
### Voraussetzungen
- >50 zahlende Innungen bundesweit
- Mindestens 1 HWK-Rahmenvertrag als Referenz
- Technische Due Diligence bestanden (DSGVO, Verfügbarkeit, Skalierbarkeit)
- Team von mindestens 3 Personen (Stabilität demonstrieren)
### Verhandlungsposition
**Was der ZDH bekommt:**
- Branded Version ("ZDH InnungsApp")
- Dashboard für ZDH-Mitarbeiter
- 15 % Revenue Share
- Kostenlose Accounts für ZDH-Mitarbeiter
**Was wir bekommen:**
- Offizielle ZDH-Empfehlung
- Eintrag im ZDH-Serviceportal
- Zugang zu allen Innungsvertretern auf Bundesversammlungen
---
## 5. Content Marketing (begleitend)
### Blog / Content-Strategie
**Zielgruppe:** Innungsgeschäftsführer, Obermeister, HWK-Mitarbeiter
**Themen:**
- "5 Gründe, warum WhatsApp in Innungen ein DSGVO-Risiko ist"
- "Wie Innung [Name] 3h/Woche durch Digitalisierung spart" (Case Study)
- "Warum Gen Z keine Ausbildungsanzeigen auf Websites liest"
- "DSGVO-konform kommunizieren als Innung: Was erlaubt ist"
- "Digitalisierungsförderung 2026: Was Innungen beantragen können"
**Verteilung:**
- Blog auf innungsapp.de
- LinkedIn-Posts (Timo persönlich)
- Newsletter an Lead-Liste (Resend)
### "Trojan Horse" Lead Magnet
**PDF-Guide:** "Wie Sie als Innung 2026 Azubis finden — Der komplette Leitfaden"
Inhalt:
- Fachkräftemangel: aktuelle Zahlen
- Wo Gen Z Ausbildungsplätze sucht (TikTok, Instagram, nicht Websites)
- Checkliste: "Ist Ihre Lehrstellenanzeige Gen-Z-fähig?"
- Vorlage: E-Mail-Vorlage für Schulen
- Bonus: "Kostenloser Pilot der InnungsApp"
**Verteilung:** LinkedIn-Post + Kaltakquise als Einstieg
---
## 6. Partnerkanäle
| Partner | Typ | Potenzial |
|---|---|---|
| HWK Baden-Württemberg | Empfehlungspartner | 400 Innungen |
| Berufsschulen | Azubi-Traffic | Direkt an Bewerber |
| Handwerkskammertage | Events | Sichtbarkeit |
| Steuerberater (Handwerk-nah) | Empfehlung | Warm Leads |
| Verbände (SHK, ZVEH) | Branchenverband | Bundesinnungen |
---
## 7. Pricing im Vertrieb
### Pilot-Angebot (für erste 20 Innungen)
```
3 Monate kostenlos (Pilot)
→ Alle Features des Standard-Plans
→ Persönliches Onboarding durch Gründer
→ Wöchentlicher Check-In-Call
→ Direct Feedback-Kanal (WhatsApp mit Timo)
Nach Pilot:
→ 149 €/Monat (Standard) — kein Automatikvertrag
→ Entscheidung liegt beim Admin
```
### Einwandbehandlung Preis
**"Zu teuer":**
> "149 € pro Monat — das sind 1,80 € pro Mitglied bei 80 Mitgliedern.
> Dafür sparen Sie 3 Stunden Verwaltung pro Woche.
> Was kostet eine Verwaltungsstunde bei Ihnen?"
**"Wir haben kein Budget":**
> "Haben Sie sich schon mit dem Digitalisierungsbonus Bayern/BW beschäftigt?
> Viele Innungen bekommen 80 % der Softwarekosten gefördert."
---
## 8. KPIs Go-to-Market
| Metrik | Monat 3 | Monat 6 | Monat 12 |
|---|---|---|---|
| Leads in Pipeline | 20 | 50 | 150 |
| Demo Calls pro Woche | 2 | 5 | 10 |
| Pilot-Innungen | 5 | 8 | 15 |
| Zahlende Innungen | 0 | 3 | 12 |
| MRR | 0 € | 450 € | 2.000 € |
| CAC | - | 400 € | 280 € |
| Erstes HWK-Gespräch | - | ✓ | ✓ |

507
INDEX.md
View File

@@ -1,507 +0,0 @@
# DOKUMENTATIONS-INDEX
**Project:** SmartMeter-Lite App für deutsche Stadtwerke
**Status:** Ready to Validate & Build MVP
**Last Updated:** Februar 2026
---
## 📋 Quick Navigation
### 🚀 **START HERE**
| Rolle | Dokument | Zeit | Ziel |
|------|----------|------|------|
| **Founder** | [NEXT_STEPS.md](#next_steps) | 5 min | Wissen, was diese Woche zu tun ist |
| **Investor** | [EXECUTIVE_SUMMARY.md](#executive_summary) | 10 min | Investment Case verstehen |
| **Developer** | [LEAN_ROADMAP_MVP.md](#lean_roadmap) | 15 min | Development Plan verstehen |
| **Sales** | [VALIDATION_PLAYBOOK.md](#validation) | 10 min | Interview Guide nutzen |
| **Everyone** | [REALISTIC_VS_OPTIMISTIC.md](#realistic) | 5 min | Aligned sein on realistic expectations |
---
## 📚 Document Library
### 🔴 CRITICAL (Must Read First)
#### **NEXT_STEPS.md** {#next_steps}
**Länge:** 10 min read + action items
**Für:** Everybody
**Inhalt:**
- Wochenweise Action Plan (Wochen 1-4)
- Team assembly checklist
- Fundraising timeline
- Risk mitigation strategies
- Success metrics
**Wann:** Lies das JETZT und starte die Actions
**Action:** Print + put on wall
---
#### **EXECUTIVE_SUMMARY.md** {#executive_summary}
**Länge:** 15 min read
**Für:** Investors, Geschäftsführer, Board
**Inhalt:**
- Market opportunity (€5-12M realistic)
- Revenue projections (Y1: €500K-1.2M, Y2: €2-4M)
- Capital requirements (€350-450K MVP)
- Unit economics (ACV, CAC, LTV)
- Risk assessment
**Wann:** Share mit Investors/Board
**Format:** Ready to present, professional tone
---
#### **REALISTIC_VS_OPTIMISTIC.md** {#realistic}
**Länge:** 10 min read
**Für:** Everybody (especially founders)
**Inhalt:**
- Side-by-side comparison of 2 scenarios
- Why realistic is better (safer, more capital efficient)
- GO/NO-GO framework
- Investment attractiveness analysis
**Wann:** When tempted to overcommit/overspend
**Key Takeaway:** Build ONE thing great, not 5 things mediocre
---
### 🟠 IMPORTANT (Read This Week)
#### **VALIDATION_PLAYBOOK.md** {#validation}
**Länge:** 20 min read + 2-3 weeks execution
**Für:** Sales, Founder doing outreach
**Inhalt:**
- Interview guide (11 questions)
- Interview scoring framework
- GO/NO-GO criteria
- Sample report template
- Timeline & execution plan
**Wann:** Use starting Week 1 (interviews)
**Execute:** Complete by Week 4
---
#### **LEAN_ROADMAP_MVP.md** {#lean_roadmap}
**Länge:** 20 min read
**Für:** CTO, Product Manager, Developers
**Inhalt:**
- Phase 0: Validation (Weeks 1-4)
- Phase 1: MVP Development (Weeks 5-14)
- Sprint-by-sprint breakdown
- Tech stack decisions
- Team structure
- Phase 2: GTM (Weeks 15-26)
- Budget: €350-450K total
**Wann:** Share with technical team Week 5
**Use for:** Sprint planning, task breakdown
---
### 🟡 SUPPORTING (Read as Needed)
#### **SmartMeter_PRD.md**
**Länge:** 30 min read
**Für:** Product Manager, Designers, Developers
**Inhalt:**
- Product Requirements Document
- User stories
- Feature specifications
- UI/UX requirements
- Success criteria
**When:** Week 5+ (during development)
**Use for:** Detailed specs, engineering
---
#### **detailed_use_cases.md**
**Länge:** 15 min read
**Für:** Sales, Product, Design
**Inhalt:**
- 5 detailed customer scenarios
- Before/After comparisons
- ROI calculations per customer
- Objection handling
**Wann:** Share with prospects
**Format:** Customer-friendly stories
---
#### **stadtwerke_analysis.md**
**Länge:** 20 min read
**Für:** Deep dive into market
**Inhalt:**
- All 5 pain points explained
- Market size validation
- Competitive landscape
- Detailed opportunity analysis
**Wann:** Reference document
**Use for:** Understanding full context
---
#### **quick_reference.txt**
**Länge:** 2 min glance
**Für:** Quick recap
**Inhalt:**
- One-page checklists
- Key metrics
- Timeline overview
- Pricing reference
**Wann:** Team sync, quick lookups
**Format:** Bullet points, print-friendly
---
### 🟢 SUPPORTING (Context Only)
#### **README.md**
**Länge:** 5 min
**Content:** Navigation guide, glossary
**Wann:** First time reading the project
---
#### **qrmaster_scraped.md**
**Content:** QRMaster market research (background)
**Relevance:** Low (for reference only)
---
## 📖 Reading Paths by Role
### 👨‍💼 **Founder / CEO**
```
Week 1: NEXT_STEPS.md → EXECUTIVE_SUMMARY.md → REALISTIC_VS_OPTIMISTIC.md
Week 2: VALIDATION_PLAYBOOK.md (learn interview process)
Week 3: Conduct interviews (using VALIDATION_PLAYBOOK.md)
Week 5: LEAN_ROADMAP_MVP.md (for development planning)
Week 15: SmartMeter_PRD.md (for GTM)
```
**Time commitment:** 5-7 hours reading + 20 hours interviewing + daily 2h management
---
### 💰 **Investor / Board Member**
```
Quick Path (30 min):
1. EXECUTIVE_SUMMARY.md (15 min)
2. REALISTIC_VS_OPTIMISTIC.md (10 min)
3. NEXT_STEPS.md (5 min) → "OK, when can I invest?"
Deep Dive (90 min):
+ VALIDATION_PLAYBOOK.md (understand market validation)
+ LEAN_ROADMAP_MVP.md (understand execution capability)
+ detailed_use_cases.md (customer stories)
```
**Key questions answered:**
- Market size? → EXECUTIVE_SUMMARY.md
- Why not build all 5 solutions? → REALISTIC_VS_OPTIMISTIC.md
- How will you acquire customers? → VALIDATION_PLAYBOOK.md
- Can you execute? → LEAN_ROADMAP_MVP.md
- What's the financial return? → EXECUTIVE_SUMMARY.md
---
### 👨‍💻 **CTO / Lead Developer**
```
Week 1: LEAN_ROADMAP_MVP.md (architecture, team, timeline)
Week 2: SmartMeter_PRD.md (detailed specs)
Week 3: Tech stack decision (using roadmap as guide)
Week 5: Sprint planning begins
```
**Key outputs:**
- Tech stack chosen
- Development environment set up
- CI/CD pipeline ready
- Sprint 0 complete
---
### 🎯 **Product Manager**
```
Week 1: NEXT_STEPS.md + LEAN_ROADMAP_MVP.md
Week 2: SmartMeter_PRD.md (deep dive)
Week 3-4: VALIDATION_PLAYBOOK.md (customer feedback)
Week 5+: Execute roadmap, track KPIs
```
---
### 📞 **Sales / Business Development**
```
Week 1: VALIDATION_PLAYBOOK.md (learn interview framework)
Week 1-3: Conduct 10-15 interviews (Week 1-3)
Week 4: Scoring & go/no-go analysis
Week 15: detailed_use_cases.md (customer stories)
Week 15+: Sales & pipeline management
```
---
### 🎨 **Designer**
```
Week 5: SmartMeter_PRD.md (user stories + requirements)
Week 5-7: Create design system, wireframes, prototypes
Week 7+: Design sprint implementation
```
---
## 🗂️ File Organization
```
C:\Users\a931627\Documents\stadtwerke-saas-analysis\
CRITICAL (Read First):
├── NEXT_STEPS.md [Action plan - Week by week]
├── EXECUTIVE_SUMMARY.md [Investor pitch]
└── REALISTIC_VS_OPTIMISTIC.md [Keep team grounded]
EXECUTION (Operational):
├── VALIDATION_PLAYBOOK.md [Interview guide + scoring]
├── LEAN_ROADMAP_MVP.md [Development roadmap]
└── SmartMeter_PRD.md [Product specs]
SUPPORTING (Reference):
├── detailed_use_cases.md [Customer stories]
├── stadtwerke_analysis.md [Market analysis]
├── quick_reference.txt [One-pagers]
├── README.md [Original overview]
└── implementation_roadmap.md [Original detailed roadmap]
BACKGROUND (Info Only):
└── qrmaster_scraped.md [Earlier research]
```
---
## ⏰ How to Use This Project
### **Phase 0: VALIDATION (Weeks 1-4)**
```
Primary docs:
✓ NEXT_STEPS.md (Week 1 tasks)
✓ VALIDATION_PLAYBOOK.md (Conduct interviews)
✓ REALISTIC_VS_OPTIMISTIC.md (Stay grounded)
Secondary:
✓ EXECUTIVE_SUMMARY.md (For investors if fundraising)
Output: GO/NO-GO decision + 3-5 signed LOIs
```
### **Phase 1: MVP DEVELOPMENT (Weeks 5-14)**
```
Primary docs:
✓ LEAN_ROADMAP_MVP.md (Development plan)
✓ SmartMeter_PRD.md (Product specs)
Secondary:
✓ NEXT_STEPS.md (Execution tracking)
✓ REALISTIC_VS_OPTIMISTIC.md (When scope creep tempts)
Output: Production-ready MVP + 5 beta customers
```
### **Phase 2: GO-TO-MARKET (Weeks 15-26)**
```
Primary docs:
✓ detailed_use_cases.md (Sales collateral)
✓ NEXT_STEPS.md (Sales execution)
✓ EXECUTIVE_SUMMARY.md (For new investors)
Secondary:
✓ VALIDATION_PLAYBOOK.md (Reference for sales process)
Output: 5-8 paying customers, €50-100K MRR
```
---
## 🎯 Key Metrics by Phase
### Phase 0 Success (Week 4)
- [ ] 10+ interviews completed
- [ ] 5+ Hot Leads identified
- [ ] 3+ LOIs signed
- [ ] GO/NO-GO decision made
### Phase 1 Success (Week 14)
- [ ] MVP released (iOS + Android)
- [ ] 5 beta customers onboarded
- [ ] NPS feedback > 40
- [ ] Production metrics solid
### Phase 2 Success (Week 26)
- [ ] 5-8 paying customers
- [ ] €50-100K MRR achieved
- [ ] NPS > 50
- [ ] Word-of-mouth building
---
## 📊 Document Statistics
| Document | Pages | Time to Read | Audience | Priority |
|----------|-------|--------------|----------|----------|
| NEXT_STEPS.md | 12 | 10 min | Everyone | 🔴 CRITICAL |
| EXECUTIVE_SUMMARY.md | 14 | 15 min | Investors/Board | 🔴 CRITICAL |
| REALISTIC_VS_OPTIMISTIC.md | 10 | 10 min | Everyone | 🔴 CRITICAL |
| LEAN_ROADMAP_MVP.md | 12 | 20 min | Dev/PM | 🟠 Important |
| VALIDATION_PLAYBOOK.md | 14 | 20 min | Sales/BD | 🟠 Important |
| SmartMeter_PRD.md | 18 | 25 min | Dev/Design | 🟠 Important |
| detailed_use_cases.md | 10 | 15 min | Sales | 🟡 Supporting |
| stadtwerke_analysis.md | 17 | 20 min | Reference | 🟡 Supporting |
| quick_reference.txt | 24 | 5 min | Quick lookup | 🟡 Supporting |
**Total Learning Time:** ~2 hours (complete review)
**Total Time to Execute:** ~20-30 weeks
---
## ✅ Before You Start
Make sure you have:
- [ ] Access to all documents (you're reading this, so ✅)
- [ ] Core founding team identified (Founder + CTO)
- [ ] €20-30K available for validation phase
- [ ] 5-10 personal contacts at Stadtwerke (or can get intros)
- [ ] Willingness to validate before building (don't skip Phase 0!)
---
## 🚀 First Actions (TODAY)
1. **Read:** NEXT_STEPS.md (10 min)
2. **Read:** REALISTIC_VS_OPTIMISTIC.md (10 min)
3. **Decide:** Are you committed to this?
- IF YES → Do Week 1 tasks (NEXT_STEPS.md)
- IF NO → Discuss what's holding you back
4. **Share:** Send EXECUTIVE_SUMMARY.md to 5 potential investors
5. **Meet:** Have kickoff call with CTO candidate using LEAN_ROADMAP_MVP.md
---
## 📞 Quick Reference
| Need | Document | Section |
|------|----------|---------|
| "What should I do this week?" | NEXT_STEPS.md | "THIS WEEK" |
| "Is this financially viable?" | EXECUTIVE_SUMMARY.md | "Financial Projections" |
| "How do I interview customers?" | VALIDATION_PLAYBOOK.md | "Interview Guide" |
| "How long will development take?" | LEAN_ROADMAP_MVP.md | "Sprint Breakdown" |
| "What are we building?" | SmartMeter_PRD.md | "Overview" |
| "Why not build all 5 solutions?" | REALISTIC_VS_OPTIMISTIC.md | "Why Realistic is Better" |
| "What are the risks?" | NEXT_STEPS.md | "Risk Mitigation" |
| "What features does the app have?" | SmartMeter_PRD.md | "Feature List" |
---
## 🎓 Learning Resources
**Useful to read alongside this project:**
Books:
- "Lean Startup" by Eric Ries
- "The Innovator's Dilemma" by Clayton Christensen
- "Traction" by Gabriel Weinberg
Frameworks:
- Jobs to be Done (JTBD)
- Product-Market Fit
- Unit Economics
- TAM/SAM/SOM
Online:
- Y Combinator Startup School (free)
- Paul Graham essays
- First Round Review
---
## 📝 Document Version Control
| Version | Date | Changes |
|---------|------|---------|
| v1.0 | Feb 17, 2026 | Initial creation - Realistic MVP focus |
| - | - | - |
**Last Updated:** February 17, 2026
**Next Review:** After Phase 0 GO/NO-GO decision
---
## ❓ FAQ
**Q: Do I need to read all documents?**
A: No. Start with the CRITICAL section. Read others as needed for your role.
**Q: Can I skip Phase 0 (Validation)?**
A: No. DO NOT SKIP. This saves you from building something nobody wants.
**Q: Should I raise money before or after Phase 0?**
A: Start fundraising in Week 3-4 (while doing final interviews). Close by Week 7.
**Q: How much money do I really need?**
A: €350-450K for MVP. €600-800K with runway is safer.
**Q: What if validation shows NO-GO?**
A: Pivot to Pain Point #2 or #3. Same interview process, different solution.
**Q: Can I build all 5 solutions?**
A: No. That's why we wrote REALISTIC_VS_OPTIMISTIC.md. Read it.
---
## 🎁 Bonus: How to Share This Project
**With Investors:**
→ Send EXECUTIVE_SUMMARY.md + link to this INDEX
**With Team:**
→ Share NEXT_STEPS.md + relevant role-specific docs
**With Advisors:**
→ Send full folder + ask for feedback on LEAN_ROADMAP_MVP.md
**With Customers (During Validation):**
→ Share detailed_use_cases.md + ask for feedback
---
## 🏁 Final Checklist
Before you consider Phase 0 complete:
- [ ] Read all CRITICAL documents
- [ ] Form core team (Founder + CTO)
- [ ] Secure €20-30K budget
- [ ] Complete 10-15 customer interviews
- [ ] Score interviews using VALIDATION_PLAYBOOK.md
- [ ] Make GO/NO-GO decision
- [ ] (If GO) Get 3+ signed LOIs
- [ ] (If GO) Start fundraising
Then proceed to Phase 1 with confidence. 🚀
---
**THIS IS YOUR NORTH STAR. Bookmark this page and refer to it weekly.**
Questions? Re-read NEXT_STEPS.md → "Risk Mitigation Actions" section.
Good luck! 🚀

View File

@@ -0,0 +1,140 @@
# InnungsApp PRO Landingpage Optimierung (SEO, AEO, GEO & CRO)
Basierend auf den Prinzipien modernster **Search Engine Optimization (SEO)**, **Answer Engine Optimization (AEO)**, **Generative Engine Optimization (GEO)** sowie verkaufspsychologischer **Conversion Rate Optimization (CRO)** präsentiere ich hier den Architektur- und Text-Bauplan für die Landingpage der InnungsApp PRO.
---
## 1. DATENBASIERTE AUSGANGSLAGE (Hard Data Insights)
Eine aktuelle Datenanalyse via DataForSEO zeigt ein klares Bild des Suchverhaltens der Zielgruppe:
* **"Innungssoftware"** und **"Innung App"** haben **0** messbares Google-Suchvolumen. Die Zielgruppe sucht *nicht* nach diesem exakten Framing.
* Stattdessen suchen sie aufgabenorientiert:
* **"Vereinssoftware"** & **"Handwerk Software"** haben jeweils starke **1.900 Suchanfragen / Monat** auf Google (und signifikante "AI Search Volume" bei ChatGPT & Co).
* **"Handwerker App"** liegt bei **1.000 Suchanfragen / Monat**.
* Lokale Suchen wie **"Innung München SHK"** haben ca. **1.900 Suchanfragen / Monat** (geringe Konkurrenz).
**Die Strategische Konsequenz:** Wir müssen die InnungsApp PRO als **"spezialisierte Vereinssoftware & Handwerk Software für Innungen"** positionieren, um das Suchvolumen abzugreifen, anstatt auf das Keyword "Innungssoftware" zu hoffen.
---
## 2. SEO & GEO FUNDAMENT (Search & Generative Engine Optimization)
Da AI-Suchmaschinen wie Perplexity, ChatGPT (mit Search) oder Google AI Overviews immer wichtiger werden (über 800 Mio. GPT-Nutzer; 65%+ Zero-Click-Suchen), müssen wir unsere Texte "citable" (zitierfähig) machen. Das bedeutet: Klare Daten, Authorität und strukturierte Aussagen.
### 2.1 Meta-Title & Meta-Description (Auf Suchvolumen optimiert)
Die Meta-Daten sind der erste Touchpoint auf Google. Wir kombinieren Suchvolumen-Keywords (*Vereinssoftware*, *Handwerk Software*, *Innung*) mit einer klaren Lösung.
**Option 1: Fokus "Handwerk & Vereinssoftware" (SEO-Favorit)**
* **Meta-Title:** InnungsApp PRO | Die KI-gestützte Vereinssoftware für das Handwerk
* **Meta-Description:** Zettelwirtschaft war gestern. Reduzieren Sie den Verwaltungsaufwand Ihrer Innung um 10 Std/Woche. Die perfekte Handwerk Software inkl. CRM & App. Starten Sie kostenlos!
**Option 2: Fokus "App & Kommunikation"**
* **Meta-Title:** Handwerker App & Innungs-Verwaltung | InnungsApp PRO
* **Meta-Description:** Erreichen Sie Handwerksbetriebe direkt aufs Smartphone mit 90% Leserate dank DSGVO-konformer Push-Nachrichten in der Vereinssoftware für Innungen.
**Option 3: Fokus "Spezialisierung auf Innungen"**
* **Meta-Title:** Die moderne Software für Kreishandwerkerschaften & Innungen
* **Meta-Description:** Weniger Verwaltung, mehr Leben für Innungsobermeister & Geschäftsführer. Smarte Aktenführung, 1-Klick-RSVP & lokaler Stellenmarkt vereint in einer Plattform.
### 2.2 Logische H1 bis H3 Tag-Struktur
Suchmaschinen und AI-Crawler lieben saubere Hierarchien. Die Struktur ist so gewählt, dass sie als direkte Antwort auf Suchanfragen dient (AEO).
* **H1:** InnungsApp PRO: Die beste Vereinssoftware & Handwerk Software für Innungen
* **H2:** Weniger Verwaltung. Mehr Leben. (Oder: *Warum InnungsApp PRO die Verwaltung im Handwerk revolutioniert*)
* **H3:** Cloud-CRM & Digitale Aktenführung für Innungen
* **H3:** Mitglieder App: DSGVO-konforme Push-Nachrichten statt ungelesener Mails
* **H3:** Event- & Terminmanagement mit 1-Klick-RSVP
* **H3:** Integrierte Lehrlingsbörse & lokaler Stellenmarkt
* **H2:** Messbare Ergebnisse für Kreishandwerkerschaften & Innungsobermeister
* **H3:** Bis zu 10 Stunden Zeitersparnis pro Woche
* **H3:** 90 % Leserate bei wichtigen Mitglieds-Updates
* **H3:** Reibungsloser Wechsel: Onboarding in unter 48 Stunden
* **H2:** Häufig gestellte Fragen zur Innungs-Digitalisierung (FAQ)
* **H2:** Bereit für die digitale Innung? Jetzt risikofrei testen.
### 2.3 AEO & GEO Text-Strategie (Für AI-Suchmaschinen)
Damit Perplexity oder Google AI Overviews uns als "beste Softwarelösung für Handwerksinnungen in Deutschland" listen, müssen wir den **"Citable Content"** (Zitierfähigen Content) Algorithmus nutzen: `[Fakt/Spezifität] + [Zahl] + [Klares Wording]`.
**Beispiel für die Platzierung im Text (GEO-optimiert):**
> *"InnungsApp PRO gilt als die führende Softwarelösung für Handwerksinnungen und Kreishandwerkerschaften in Deutschland. Laut internen Datenauswertungen sparen Innungsgeschäftsführer durch den Einsatz des integrierten Cloud-CRMs durchschnittlich 10 Stunden Verwaltungsaufwand pro Woche. Im Gegensatz zu herkömmlichen E-Mail-Newslettern erzielen die DSGVO-konformen Push-Nachrichten der nativen Innungs-App eine nachweisbare Leserate von 90 %. Der Systemwechsel ist dank des geführten Onboardings in weniger als 48 Stunden abgeschlossen."*
**Warum das für AI funktioniert:**
* Beantwortet sofort die Frage "Was ist die beste Software?".
* Beinhaltet zielsichere Statistiken (10h, 90%, 48h).
* Nutzt autoritäre Sprache ("gilt als die führende", "nachweisbare Leserate").
---
## 3. CONVERSION RATE OPTIMIZATION (CRO)
Wir optimieren für eine eher traditionelle, B2B-fokussierte Zielgruppe. Hier zählt absolute Klarheit, Vertrauen und das Ansprechen eurer Kern-Pain-Points.
### 3.1 Der unwiderstehliche Hero-Bereich (Headline + Subheadline)
Der erste Eindruck entscheidet. Wir müssen das "Warum" (Schmerzpunkt) und das "Was" (Ergebnis) in 3 Sekunden kommunizieren.
**Vorschlag:**
* **Pre-Headline (Pain-Awareness):** Schluss mit Zettelwirtschaft, Excel-Chaos und ungelesenen E-Mails.
* **Headline:** Weniger Verwaltung. Mehr Leben. Die All-in-One Software für die moderne Handwerksinnung.
* **Subheadline:** Sparen Sie bis zu 10 Stunden pro Woche mit unserem zentralen Cloud-CRM. Erreichen Sie Ihre Mitgliedsbetriebe direkt aufs Smartphone mit einer Leserate von 90 % dank DSGVO-konformer Push-News der nativen Mitglieder-App.
* **CTA Button:** Kostenlos starten
* **Micro-Copy (direkt unter dem Button zur Risikoreduktion):** ✓ Keine Kreditkarte ✓ Keine Vertragsbindung ✓ 100% DSGVO-konform (Made in Germany)
### 3.2 Schmerzpunkte (Pain Points) stärker adressieren (Problem-Sektion)
Bevor Features kommen, müssen wir dem Besucher zeigen: *"Wir verstehen dein tägliches Leid."*
Füge eine Sektion direkt nach dem Hero-Bereich ein: **"Kennen Sie diese Herausforderungen im Innungsalltag?"**
1. **Das Erreichbarkeits-Problem:** "Wichtige Rundschreiben und E-Mails landen im Spam oder werden schlichtweg ignoriert. Sie erreichen Ihre Betriebe nicht mehr zuverlässig."
2. **Das Verwaltungs-Chaos:** "Excel-Listen für Veranstaltungen, separate Newsletter-Tools und veraltete Aktenordner fressen Ihre wertvolle Zeit."
3. **Der Nachwuchsmangel:** "Es fehlt eine zentrale, lokale Anlaufstelle, um Auszubildende und Fachkräfte direkt mit Ihren Lehrbetrieben zu vernetzen."
*Dann die Auflösung:* **"Die InnungsApp PRO löst genau diese Probleme in einer einzigen Plattform."**
### 3.3 Reibungsloser "Kostenlos starten" Flow
Um die Einstiegshürde (Cognitive Load) maximal zu senken:
1. **Erwartungsmanagement (3-Schritte-Erklärung):** Zeigt vor/beim Klick, was passiert.
* *1. Account in 60 Sekunden erstellen.*
* *2. Mitglieder-Daten sicher importieren.*
* *3. Erste Push-Nachricht senden.*
2. **Onboarding-Versprechen:** Nutzt die "< 48h"-Metrik prominent in der Nähe des CTAs. ("In unter 48h komplett einsatzbereit unser Team hilft beim Setup.")
3. **Formular-Minimierung:** Wenn sie "Kostenlos starten" klicken, fragt nur das Wichtigste ab (Name, Innung, E-Mail). Alles Weitere passiert im System.
---
## 4. FEHLENDE STRUKTURELEMENTE FÜR MEHR VERTRAUEN
Die Zielgruppe (Innungsobermeister, Geschäftsführer) ist risikoavers. Software-Wechsel werden historisch als anstrengend und gefährlich (Abmahnungen, DSGVO) gesehen. Diese Sektionen müssen zwingend auf die Seite:
### 4.1 Trust-Logos & Social Proof
* **Das Element:** Gleich unter den Hero-Bereich (Hero-Banner) eine Leiste "Bereits erfolgreich im Einsatz bei XX Innungen und Kreishandwerkerschaften".
* **Testimonials:** Echte Zitate mit Gesicht und Titel (z. B. *"Seit wir die InnungsApp nutzen, hat sich unsere Verwaltungszeit halbiert und unsere Event-Rücklaufquote verdoppelt." Max Mustermann, Obermeister Innung XY*). Dieser "Peer-to-Peer"-Trust ist im B2B-Handwerk extrem stark.
### 4.2 Security & DSGVO-Sektion (Trust Signals)
* **Das Element:** Eine dedizierte Sektion für "Sicherheit & Datenschutz".
* **Inhalt:** Hebt das "100 % DSGVO-konformes Hosting in Deutschland" hervor. Nutzt Schloss-Icons, Zertifikate (falls vorhanden, z.B. ISO-Zertifizierung eurer Server) und das Label "Made in Germany".
### 4.3 FAQ (Häufige Fragen & Einwandbehandlung)
* **Das Element:** Ein Akkordeon-Bereich am Ende der Seite (gut für AEO/Featured Snippets!).
* **Fragen, die adressiert werden müssen:**
* *Wie aufwendig ist der Wechsel zu InnungsApp PRO?* (Antwort: < 48h, Import-Service)
* *Sind meine Mitgliedsdaten sicher und DSGVO-konform?*
* *Brauchen unsere Handwerksbetriebe Schulungen für die App?* (Antwort: Nein, so intuitiv wie WhatsApp)
* *Gibt es eine Vertragsbindung für den Testzeitraum?* (Antwort: Nein, läuft automatisch aus/risikofrei)
### 4.4 Feature/Benefit als Gegenüberstellung ("Vorher / Nachher")
* **Das Element:** Eine visuelle Tabelle "Der alte Weg" (Rote X) vs. "Der InnungsApp PRO Weg" (Grüne Haken).
* *Alter Weg:* E-Mails, die keiner liest; Excellisten für Events; Schwarze Bretter für Azubis.
* *InnungsApp PRO:* Push-News mit 90% Leserate; 1-Klick-RSVP; Digitale Lehrlingsbörse.
---
## Zusammenfassung für die Implementierung in Next.js
1. **Meta & Struktur:** Nutzt die `next/head` oder App Router Metadata API für den Title & Description. Verwendet semantische `<article>`, `<section>` und valide `<h1>` bis `<h3>` Tags basierend auf obigem Entwurf.
2. **Schema.org:** Implementiert ein `SoftwareApplication` JSON-LD Script und ein `FAQPage` JSON-LD Script. Das pusht die Klickrate massiv.
3. **Performance:** Da Core Web Vitals (LCP, INP, CLS) SEO-Faktoren sind, nutzt `next/image` für alle Hero-Grafiken und App-Mockups und vermeidet Layout-Shifts beim Laden der Testimonials.

View File

@@ -1,540 +0,0 @@
# LEAN MVP ROADMAP - SmartMeter-Lite App
**Fokus:** Nur Pain Point #1 (Zählerablesung) - Maximum Quality
**Duration:** 4-6 Monate (Validierung → MVP → Beta → Launch)
**Budget:** €350-450K (nicht €2M!)
**Team:** 3-4 Entwickler + 1 PM + 1 Designer
---
## PHASE 0: VALIDATION (Wochen 1-4, €20-30K)
### Ziel
- GO/NO-GO für SmartMeter App
- Feature-Priorisierung mit echten Kunden
- Tech-Stack Entscheidung
### Tasks
**Woche 1-2: Customer Interviews**
```
- 10-15 Stadtwerk-Interviews
- Fragen:
1. Wie viele Zählerstände manuell abgelesen pro Jahr?
2. Wie hoch ist die Fehlerquote? (%)
3. Was kostet die manuelle Ablesung pro Kunde?
4. Würden Sie eine App nutzen? Zu welchem Preis?
5. Technische Integration: SAP? Oracle? Custom?
6. Timeline: Wann brauchen Sie eine Lösung?
- Target: 5-10 konkrete GO/NO-GO Signale
```
**Woche 2-3: Competitive Analysis**
```
Tools/Platforms to analyze:
1. SAP SmartMeter Solutions
2. Oracle Utilities
3. Lokale Lösungen (z.B. EWE Tech)
4. Tech Startups (Z.B. Smappee, etc.)
Analysis Framework:
- Features & Pricing
- Integration Depth
- Mobile vs. Web-only
- OCR Capability
- Time-to-Market
```
**Woche 3-4: Technical Feasibility**
```
Decision: Tech-Stack für MVP
FRONTEND:
- React Native (iOS + Android) vs. Flutter
- Recommendation: React Native
- Größere Community
- Better Ecosystem
- Easier to find developers
BACKEND:
- Python + FastAPI vs. Node.js + Express
- Recommendation: Python + FastAPI
- Better for ML/OCR integration
- Performance good
- Easy to deploy
OCR LAYER:
- Google Vision API vs. Tesseract (open-source)
- Recommendation: Start with Tesseract
- Free, no API costs
- 85-90% accuracy for meter readings
- Fallback: Hybrid approach
DATABASE:
- PostgreSQL 13+ (proven, reliable)
- Redis for caching
DEPLOYMENT:
- AWS / Azure / DigitalOcean
- Docker containers
- CI/CD: GitHub Actions
```
### Deliverables
- ✅ GO/NO-GO Decision Document
- ✅ Feature Prioritization Matrix
- ✅ Technology Stack Decision
- ✅ High-Level Architecture Diagram
- ✅ Risk Assessment
### Team
- 1 Founder/PM + interviews
- 1 Tech Lead (validation)
---
## PHASE 1: MVP DEVELOPMENT (Wochen 5-14, €250-350K)
### Ziel
- Production-ready SmartMeter-Lite App
- iOS + Android Release
- Beta-ready Dashboard
### Sprint Breakdown
#### SPRINT 0-1 (Woche 5, €20K)
**Setup & Architecture**
```
Tasks:
□ GitHub Repo Setup
□ Development Environment (Docker)
□ CI/CD Pipeline (GitHub Actions)
□ Database Schema Design
- users table
- meter_readings table
- stadtwerk_accounts table
- readings_history table
□ API Specification (OpenAPI/Swagger)
□ Security Architecture Review (OWASP Top 10)
□ DSGVO Compliance Planning
```
**Team:** 1 Backend Lead + 1 Frontend Lead
---
#### SPRINT 1 (Woche 6, €40K)
**Backend Foundation & OCR Integration**
```
Backend Tasks:
□ FastAPI Setup
□ PostgreSQL Schema Implementation
□ User Authentication (JWT + OAuth2)
- Email/Password Registration
- Email Verification
- Password Reset Flow
□ Meter Reading API (CRUD)
- POST /api/readings (upload)
- GET /api/readings (list)
- GET /api/readings/{id} (detail)
- DELETE /api/readings/{id}
OCR Tasks:
□ Tesseract Integration
□ Model Testing with sample meter images
□ Error Handling & Confidence Scoring
□ Fallback to Manual Entry
Architecture:
- User uploads photo
- Backend receives image
- OCR processes (Tesseract)
- Returns: text, confidence score
- User confirms/corrects reading
- Reading saved
```
**Team:** 1 Backend Developer + 1 ML Engineer (part-time)
**Deliverable:**
- Working OCR pipeline
- REST API endpoints (authenticated)
- Basic error handling
---
#### SPRINT 2 (Woche 7, €50K)
**Mobile App - Core Features**
```
Frontend Tasks:
□ React Native Project Setup
□ Auth Screens
- Login Screen
- Registration Screen
- Password Reset
- Email Verification
□ Main App Navigation
- Tab Navigator (Home, History, Settings)
- Stack Navigator for screens
□ Camera & Photo Upload
- Camera permission handling
- Photo gallery option
- Compression (before upload)
- Upload progress indicator
□ OCR Result Screen
- Display detected meter reading
- Show confidence score
- Manual override input
- Confirm/Edit flow
□ Data Binding to Backend
- Axios/Fetch for API calls
- Error handling
- Loading states
- Offline capability (local storage)
```
**Team:** 2 React Native Developers
**Deliverable:**
- iOS + Android apps (development build)
- Working camera integration
- API connectivity
---
#### SPRINT 3 (Woche 8, €50K)
**Dashboard & Admin Features**
```
Stadtwerk-Admin Dashboard:
□ Login & Tenant Management
□ Customer List View
- Filter by name, account number
- Sort by last reading date
- Status indicators (uploaded, pending, verified)
□ Reading Management
- Accept/Reject readings
- Bulk operations
- Approve multiple readings
- Export CSV/PDF
□ Analytics
- # of readings submitted (weekly/monthly)
- OCR accuracy rate (%)
- Error rate by customer
- Trend charts
□ Settings
- Manage users (add/remove)
- API key management
- Integration settings (SAP, Oracle)
- Notification preferences
Tech:
- React (Next.js recommended)
- Material UI / Tailwind CSS
- REST API calls
```
**Team:** 1 React Developer + 1 Designer (part-time)
**Deliverable:**
- Admin dashboard (development)
- Read/Write API complete
- Data visualization
---
#### SPRINT 4 (Woche 9-10, €60K)
**Testing, Security, Optimization**
```
Backend Testing:
□ Unit Tests (pytest) - 80% coverage
- Auth endpoints
- OCR pipeline
- API CRUD operations
□ Integration Tests
- Full user flow (register → upload → confirm)
- Database transactions
□ Performance Testing
- Load test API (simulate 100 concurrent users)
- Optimize database queries
□ Security Testing
- OWASP Top 10 review
- SQL Injection check
- XSS prevention
- Authentication/Authorization
Frontend Testing:
□ Unit Tests (Jest) - 70% coverage
- Components
- Utility functions
□ E2E Tests (Detox/Appium)
- Full user flow on real devices
□ Performance
- Bundle size optimization
- Image compression
- Lazy loading
App Store / Play Store Prep:
□ iOS
- App signing certificate
- Privacy Policy
- Screenshots & Description
- Submit to TestFlight
□ Android
- Keystore setup
- Privacy Policy
- Screenshots & Description
- Submit to Google Play beta
□ DSGVO / Security
- Data encryption at rest
- HTTPS only
- Audit logging
- Data retention policy
```
**Team:** 1 QA Engineer + 1 Backend Developer + 1 Frontend Developer
**Deliverable:**
- Test coverage reports
- Security audit completed
- Apps in TestFlight / Google Play beta
---
#### SPRINT 5 (Woche 11-12, €80K)
**Beta Launch & Feedback**
```
Beta Program:
□ Recruit 5 Pilot Stadtwerke
- Include different sizes (small, medium, large)
- Signed NDAs
- Free for 3 months
□ Beta Release
- TestFlight (iOS)
- Google Play beta (Android)
- Admin dashboard access
□ Monitoring & Support
- Daily stand-ups with pilots
- Slack channel for feedback
- Bug fix SLA: 24 hours
- Track: adoption, errors, usage patterns
□ Feedback Collection
- Weekly survey
- In-app crash reports
- Feature requests
- Pain points
KPIs to Track:
- Daily Active Users (DAU)
- Monthly Active Users (MAU)
- Reading submission rate (%)
- OCR accuracy in production (%)
- App crashes per session
- Support ticket count
- NPS Score
```
**Team:** 1 Product Manager + 1 Support Engineer + 1 Ops
**Deliverable:**
- Beta program running
- Feedback dashboard
- Bug tracking system
---
#### SPRINT 6 (Woche 13-14, €50K)
**Polish & Production Readiness**
```
Tasks:
□ Bug Fixes (from beta feedback)
- Prioritize P0, P1, P2
- Regression testing
□ Performance Optimization
- App size < 50MB
- OCR latency < 3 seconds
- API response < 500ms
□ Production Deployment
- AWS setup (prod environment)
- Database backups
- Monitoring & alerting (New Relic / DataDog)
- Log aggregation (ELK stack)
□ Documentation
- API docs (OpenAPI)
- Admin handbook
- User guides (PDF)
- FAQs
□ Compliance Final Check
- DSGVO audit
- BSI-C5 considerations
- Privacy policy finalization
- Terms of service
□ Go-Live Planning
- Staged rollout plan
- Support procedures
- Escalation matrix
- 24/7 on-call setup
App Store Submission:
□ Final review
□ Submit to Apple App Store
□ Submit to Google Play Store
```
**Team:** All hands (developers, PM, QA)
**Deliverable:**
- Production-ready apps (iOS + Android)
- Live in app stores
- 24/7 monitoring active
- Support documentation ready
---
## PHASE 2: GO-TO-MARKET (Wochen 15-26, €50-100K)
### Woche 15-18: Expansion & Sales Enablement
**Sales Materials:**
```
□ Case Studies (from 5 beta pilots)
□ Feature Sheet (1-pager)
□ ROI Calculator (interactive)
□ Sales Deck (15-20 slides)
□ Demo Video (3-5 min)
□ Product walkthrough guide
```
**Sales Approach:**
Option 1: Direct Sales (1 founder + 1 sales person)
Option 2: Partnership (VKU, SAP/Oracle partners)
Option 3: Hybrid
**Target List:**
- Top 20 Stadtwerke in Germany
- Focus on medium-large (>50K households)
- Estimated TAM: €800K-1.2M/year
### Woche 19-26: First Customer Acquisition
**KPIs:**
- [ ] 5-8 paid pilots signed
- [ ] First revenue: €50-100K
- [ ] NPS > 50
- [ ] Monthly Churn < 3%
---
## PHASE 3: Expansion (Monat 7+)
Only start Phase 3 if Phase 2 metrics are green:
- ✅ 5+ paying customers
- ✅ €50K+ MRR
- ✅ NPS > 50
- ✅ Churn < 3%
Then consider:
- Pain Point #2 (Abschlag-Tool)
- Pain Point #3 (Outage Alerts)
- Additional integrations (SAP, Oracle)
---
## Budget Summary
| Phase | Duration | Budget | Team Size |
|-------|----------|--------|-----------|
| **Phase 0: Validation** | 4 weeks | €20-30K | 2 people |
| **Phase 1: MVP Dev** | 10 weeks | €250-350K | 3-4 devs + PM + Designer |
| **Phase 2: GTM** | 6 weeks | €50-80K | 1-2 sales + support |
| **TOTAL** | **6 Months** | **€350-450K** | **5-7 people** |
---
## Key Milestones & Deliverables
| Week | Milestone | Deliverable |
|------|-----------|-------------|
| 4 | GO/NO-GO Decision | Decision doc |
| 5 | Infrastructure Ready | CI/CD pipeline, DB schema |
| 6 | Backend + OCR | Working API + OCR pipeline |
| 8 | Mobile App Alpha | iOS + Android dev builds |
| 10 | Dashboard + Testing | Admin interface + test coverage |
| 12 | Beta Launch | TestFlight + Google Play beta |
| 14 | Production Release | Live in app stores |
| 18 | First Revenue | 3-5 paying customers |
| 26 | Sustainable Growth | 5-8 customers, €50-100K MRR |
---
## Risk Mitigation
| Risk | Probability | Impact | Mitigation |
|------|-------------|--------|-----------|
| **OCR accuracy < 80%** | Medium | High | Fallback to manual + hybrid approach |
| **Integration complexity** | High | Medium | Early POC with SAP/Oracle |
| **Sales cycle too long** | High | High | Partnership with VKU or system integrators |
| **Team turnover** | Low | High | Competitive pay, equity incentives |
| **DSGVO compliance issues** | Medium | High | Legal review from week 1 |
| **Market not interested** | Low-Medium | High | Validate in Phase 0 thoroughly |
---
## Success Metrics (End of Phase 2)
**Product:**
- ✅ 99.5% uptime
- ✅ OCR accuracy > 85% in production
- ✅ App crashes < 1 per 1000 sessions
- ✅ NPS > 50
**Business:**
- ✅ 5-8 paying customers
- ✅ €50-100K MRR
- ✅ Customer acquisition cost < €25K
- ✅ LTV:CAC > 3:1
**Market:**
- ✅ Top 3 in Google "Zählerablesung App Stadtwerk"
- ✅ 5+ case studies / testimonials
- ✅ Positive industry buzz
---
## Next Actions (This Week)
- [ ] Reach out to 10-15 Stadtwerke for Phase 0 interviews
- [ ] Form core team (if not already done)
- [ ] Secure €400-500K seed funding
- [ ] Schedule technical architecture kickoff
- [ ] Legal review: DSGVO, data protection
---
**Document Status:** REVISED - Lean MVP Focus
**Last Updated:** February 2026
**Next Review:** After Phase 0 GO/NO-GO

View File

@@ -1,554 +0,0 @@
# NEXT STEPS - Action Plan
**Status:** Ready to validate & build MVP
**Timeline:** Start immediately
**Decision Point:** After Week 4 Validation
---
## THIS WEEK (Week 1)
### [ ] 1. Assemble Core Team
**Time:** 2 hours
```
You need for Phase 0 (Validation):
- Yourself as Founder/PM
- 1 Technical Co-founder (CTO or Lead Dev)
- 1 Business person (optional: for sales/fundraising)
Ideal profile:
- CTO: 5+ years software experience, B2B SaaS knowledge
- Business: Sales or startup experience, German market knowledge
Where to find:
- Co-founder networks
- Angel.co (cofounder matching)
- LinkedIn
- Personal network
```
### [ ] 2. Secure Budget for Phase 0
**Time:** 1 hour
```
Need: €20-30K for validation phase
Options:
A) Bootstrap from savings (€20-30K)
B) Friends & Family round (€30-50K)
C) Apply for Startup grant (BMBF, KfW)
- Process: 2-4 weeks
If bootstrapping: Delay validation to secure funds
If friends & family: Email your network this week
Sample email:
---
Subject: We're building a software solution for German utilities
Hi [Friend],
We're working on solving a major pain point for Stadtwerke:
manual meter reading costs them €50-100K/year + quality issues.
We've identified 5+ potential customers interested in a solution.
We're raising €30-50K Friends & Family to validate the market
and build an MVP. Interested in learning more?
Possible terms: SAFE, Convertible Note, or Equity
Timeline: Fast-track to profitability, goal €1M ARR Year 2
Let me know if you want to chat!
[Your Name]
---
Do this by: EOW
```
### [ ] 3. Create Pitch Deck (Optional but helpful)
**Time:** 4 hours
```
10-15 slides:
1. Problem (Meter reading pain points)
2. Market (900 Stadtwerke, 8-10M household market)
3. Solution (SmartMeter-Lite App)
4. Business Model (SaaS, €80-120K/year)
5. Go-to-Market (Direct sales + partnerships)
6. Traction (interviews, early interest)
7. Team (backgrounds)
8. Financials (projections)
9. Ask (€30-50K, use of funds)
10. Contact
Tools: Pitch.com, Canva, or use template
Goal: Use for investor discussions, doesn't need to be perfect
```
### [ ] 4. Start Interview Recruitment
**Time:** 3 hours
```
Use VALIDATION_PLAYBOOK.md as guide
Create outreach list:
1. LinkedIn search "IT Director" + "Stadtwerk"
2. Email template (see playbook)
3. Target: 10-15 interviews scheduled by EOW
Quick wins:
- Reach out to 5 personal contacts first (warm intros)
- Ask them for referrals
- Then cold outreach to 20-30 others (5-7 will likely say yes)
Tools:
- LinkedIn Sales Navigator
- Hunter.io (find emails)
- Calendly (scheduling)
DO NOT: Wait for perfect conditions. Start recruiting now.
```
---
## WEEK 2-3 (Validation Phase Execution)
### [ ] 5. Conduct 10-15 Interviews
**Time:** ~12 hours (conducting) + 6 hours (prep/follow-up)
```
Schedule:
- Week 2: 5-8 interviews
- Week 3: 5-7 interviews
Prep for each:
- Research company (Wikipedia, website)
- Prepare questions
- Test Zoom setup
During call:
- Record (with permission)
- Take notes (Google Doc shared)
- Be authentic, curious
- Don't pitch
Use template: VALIDATION_PLAYBOOK.md
Record all data in shared spreadsheet
```
### [ ] 6. Competitive Analysis
**Time:** 4 hours
```
Research competitors:
1. SAP SmartMeter Solutions
- Features, pricing, timeline
- Strengths vs. weaknesses
2. Oracle Utilities
- Same analysis
3. Regional players (Infor, Asseco, etc.)
- What are they doing?
4. Startups (Smappee, Zigbee Alliance tools)
- What's the latest?
Document findings:
- Feature comparison matrix
- Pricing comparison
- Time-to-market comparison
- Your differentiation
Conclusion: Why is our approach better/faster/cheaper?
```
---
## WEEK 4 (GO / NO-GO Decision)
### [ ] 7. Score Interviews & Analyze Data
**Time:** 3 hours
```
Use scoring framework from VALIDATION_PLAYBOOK.md
Aggregate:
- # of Hot Leads (score 8-10)
- # of Warm Leads (score 6-7)
- Average budget
- Market size estimate
- Common pain points
- Integration challenges
Create summary report with:
- Executive summary (1 page)
- Key findings (3-5 bullets)
- Top leads (with names, budgets, timeline)
- Risks & opportunities
- GO/NO-GO recommendation
Share with team for discussion
```
### [ ] 8. Team Decision Meeting
**Time:** 2 hours
```
Meeting agenda:
1. Present validation findings (you: 15 min)
2. Team discussion (30 min)
3. Vote on GO/NO-GO (15 min)
4. If GO: Celebrate & move to Phase 1
5. If NO-GO: Discuss pivot options
GO Criteria:
✅ 5+ Hot Leads with €50K+ budget each
✅ Average adoption estimate >20%
✅ Team feels confident in market opportunity
✅ Funding secured for development
NO-GO Criteria:
❌ <3 Hot Leads
❌ Average budget <€40K
❌ Low adoption estimates
❌ Clear market resistance
If NO-GO: Pivot to Pain Point #2 or #3 and repeat process
```
### [ ] 9. If GO: Contact Top Leads about Pilot
**Time:** 3 hours
```
Reach out to your 5 Hot Leads:
Email template:
---
Subject: Pilot Program: SmartMeter-Lite App
Dear [Name],
Thank you for the great conversation last week!
Based on our discussion, we're moving forward with developing
a mobile app solution for meter reading. We'd like to invite
you to join our Pilot Program.
Details:
- Free beta access (3 months)
- Early adopter pricing after (€60K/year instead of €80K)
- Weekly feedback sessions
- Direct access to our engineering team
Pilot Timeline:
- Start: April 2026
- Alpha release: May 2026
- Beta feedback period: May-June 2026
- Commercial launch: July 2026
Next steps:
- 30-min call to discuss details
- Sign NDA + LOI (simple document)
- Onboard for beta
Are you interested? Let's jump on a call this week.
Best regards,
[Your Name]
---
Goal: Get 3-5 signed LOIs (Letters of Intent)
Signed LOIs = Proof of market, helpful for fundraising
```
---
## WEEKS 5+ (Development Phase)
### [ ] 10. Prepare for MVP Development
**Time:** 5 hours (decision-making)
```
Use LEAN_ROADMAP_MVP.md
Decisions to make:
A. Tech Stack finalization
- React Native vs Flutter?
- Python vs Node.js?
- Tesseract vs Google Vision API?
B. Team assembly
- Hire 3-4 developers (or internal?)
- Find product manager
- Find designer
C. Development environment
- GitHub repo setup
- CI/CD pipeline
- Development server
D. Fundraising (if needed)
- €400-500K total for MVP + runway
- Timing: Should start in Week 3-4
- Close by Week 6-7 to start development
Fundraising timeline:
Week 3-4: Pitch to angels / seed VCs
Week 5-6: Due diligence
Week 7: Funding closed
Week 8: Team starts development
```
### [ ] 11. Begin Phase 1: MVP Development
**Time:** This is the 12-week sprint!
```
Kickoff meeting:
- Present project vision & timeline
- Review technical architecture
- Assign sprint teams
- Set up daily standups
First sprint goals:
- Infrastructure ready (GitHub, CI/CD, DB)
- API specification finalized
- UI/UX mockups approved
- OCR evaluation started
Each week:
- Sprint planning (2 hours)
- Daily standups (15 min)
- Sprint review (1 hour)
- Retro (1 hour)
Track progress in:
- GitHub (code)
- Jira/Linear (tasks)
- Figma (design)
- Weekly status deck
Key metrics to monitor:
- Development velocity (tasks/week)
- Bug count (trend down)
- Test coverage (target 70%+)
- Architecture debt (keep low)
```
---
## Parallel Track: Fundraising
### If you need €400-500K:
**Timeline:**
```
Week 2-3: Build investor deck + intro list
Week 3-4: Start conversations (10-20 seed VCs)
Week 4-5: Meetings + pitches
Week 5-6: Due diligence with 3-5 top investors
Week 7: Close funding
Week 8: Onboard investors, start development
```
**Investor Pitches should emphasize:**
1. Real customer pain (proven in validation)
2. Large market (€5-12M TAM)
3. Fast time-to-market (4-6 months MVP)
4. Low burn (€40-50K/month = 10+ month runway)
5. Strong unit economics (€20K CAC, €80K ACV, 3.5:1 LTV:CAC)
6. Capital efficiency (€350K MVP vs. €2M competitors)
**Fundraise from:**
- Angel investors
- Seed VCs (500 Startups, Y Combinator Alumni, etc.)
- Corporate VCs (utilities, SAP, etc.)
- Impact/ESG funds (utilities = green energy theme)
**Do NOT:**
- Oversell / make claims you can't prove
- Ask for more than needed (€400-500K is right range)
- Rush into bad term sheets
- Neglect due diligence on investor fit
```
---
## Risk Mitigation Actions
**If Validation shows NO-GO:**
```
Option 1: Pivot to Pain Point #2 or #3
- Same interview process
- Validate that problem
- Potentially larger market or lower competition
Option 2: Pivot to different market
- Same solution (SmartMeter app)
- Different customer (e.g., gas companies, water utilities)
- Adjust messaging
Option 3: Pause & assess
- Wait for market conditions to improve
- Save capital
- Try again in 6 months
```
**If Fundraising falls short:**
```
Option A: Bootstrap with lower burn
- 2 developers instead of 4
- Slower but achievable MVP (6-8 months)
- Founder salary: minimal/deferred equity
Option B: Raise less (€150-200K)
- Reduce team size
- Focus on MVP only (no nice-to-haves)
- Plan Series A for expansion
Option C: Revenue-first approach
- Get pilot customers signed up
- Charge them for early access
- Use revenue to fund development
```
**If Development slips:**
```
Risk: Scope creep delays MVP launch
Mitigation:
- Cut features ruthlessly
- MVP = minimum viable, not "everything we want"
- Use MoSCoW method: Must-have, Should-have, Could-have, Won't-have
If behind by 2+ weeks:
- Reduce scope (cut "nice-to-haves")
- Add developer resources (carefully)
- Don't sacrifice quality for speed
- Communicate transparently with customers
```
---
## Key Success Metrics (by phase)
### Phase 0 (Validation) - Week 4
- [ ] 10+ interviews completed
- [ ] 5+ Hot Leads identified
- [ ] 3+ LOIs signed
- [ ] Market size >€500K validated
- [ ] GO decision made
### Phase 1 (MVP Dev) - Week 14
- [ ] MVP app released (iOS + Android)
- [ ] 5 beta customers onboarded
- [ ] NPS feedback collected
- [ ] Bug fixes underway
### Phase 2 (GTM) - Week 26
- [ ] 5-8 paying customers
- [ ] €50-100K MRR
- [ ] NPS > 50
- [ ] Positive word-of-mouth
---
## Monthly Checklist
**Every month:**
- [ ] Team standup on metrics vs. plan
- [ ] Customer feedback review
- [ ] Fundraising progress (if applicable)
- [ ] Competitive intelligence update
- [ ] Risk assessment & mitigation
- [ ] Investor update (if funded)
**Quarterly:**
- [ ] Board meeting / advisor check-in
- [ ] Market research refresh
- [ ] Team feedback/retrospective
- [ ] Strategic pivot assessment
---
## Document Reference Guide
As you execute, use these documents:
```
📄 EXECUTIVE_SUMMARY.md
→ Share with investors
→ High-level business case
→ Keep updated every month
📄 REALISTIC_VS_OPTIMISTIC.md
→ When you're tempted to overcommit
→ Keep team aligned on realistic expectations
📄 LEAN_ROADMAP_MVP.md
→ Detailed project plan
→ Week-by-week tasks
→ Use for sprint planning
📄 VALIDATION_PLAYBOOK.md
→ Interview guide
→ Scoring framework
→ Use during Weeks 1-4
📄 This file (NEXT_STEPS.md)
→ High-level action plan
→ Weekly checklist
→ Reference when you're unsure what to do next
```
---
## Final Thoughts
**You have a real opportunity here.** The pain points are real, the market is large, and the timing is good (digitalization pressure on utilities).
But execution is everything. **Focus ruthlessly on:**
1. ✅ Validating the market (Weeks 1-4)
2. ✅ Building an excellent MVP (Weeks 5-14)
3. ✅ Getting first customers (Weeks 15-26)
4. ✅ Profitability (Year 2)
**Don't get distracted by:**
- ❌ Building all 5 solutions at once
- ❌ Perfectionism (done is better than perfect)
- ❌ Feature creep (say NO to 80% of requests)
- ❌ Premature scaling (focus on PMF first)
**The realistic financial model is still very attractive:**
- €350K investment
- Year 2: €2-4M ARR
- Year 3: €5-10M ARR
- Exit multiple: 5-8x → €25-80M exit
- Return to investors: 70-200x
That's a winning business. Go execute! 🚀
---
**Start this week. Don't wait.**
Week 1 Checklist (4 tasks):
- [ ] Assemble core team
- [ ] Secure budget
- [ ] Create pitch deck
- [ ] Start interview recruitment
Deadline: End of Friday
Then report back on progress. 💪
---
**Document Status:** Action-ready, living document
**Last Updated:** February 2026
**Next Milestone:** Week 4 GO/NO-GO Decision

263
ONBOARDING_FLOWS.md Normal file
View File

@@ -0,0 +1,263 @@
# InnungsApp — Onboarding Flows
---
## 1. Flow: Neue Innung einrichten (Admin)
**Dauer:** ~15 Minuten | **Kanal:** Web Admin Dashboard
```
Schritt 1: Registrierung (Admin-Konto)
─────────────────────────────────────
→ Admin bekommt Einladungslink von InnungsApp-Team
→ Klickt Link → Magic Link Login → Konto aktiviert
Schritt 2: Setup-Wizard (Innung konfigurieren)
──────────────────────────────────────────────
Screen 1: "Willkommen! Wie heißt Ihre Innung?"
└─ Eingabe: Innungsname, Slug (auto-generiert)
Screen 2: "Laden Sie Ihr Logo hoch"
└─ Upload: PNG/SVG, max 2 MB
└─ Vorschau sofort sichtbar
Screen 3: "Welche Sparten hat Ihre Innung?"
└─ Multi-Select: Elektro, SHK, Bau, Dachdecker, Maler, ...
└─ Freitext: eigene Sparte hinzufügen
Screen 4: "Kontaktdaten der Innung"
└─ Felder: Adresse, Telefon, E-Mail, Website
Screen 5: "Mitglieder importieren"
└─ Option A: CSV hochladen (Vorlage herunterladen)
└─ Option B: "Ich füge Mitglieder später manuell hinzu"
└─ Validierung: Fehlende Felder anzeigen, Duplikate warnen
Screen 6: "Einladungen senden"
└─ Liste der importierten Mitglieder mit E-Mail
└─ Checkbox: "Einladungsmail sofort senden"
└─ Button: "Setup abschließen"
→ Dashboard wird gezeigt: "Ihre Innung ist bereit!"
```
---
## 2. Flow: Mitglied lädt sich ein (Erstkontakt)
**Dauer:** ~2 Minuten | **Kanal:** E-Mail → Mobile App
```
E-Mail bei Mitglied
────────────────────
Betreff: "Sie sind eingeladen: [Innungsname] in der InnungsApp"
Absender: noreply@innungsapp.de
Inhalt:
"Liebe/r [Vorname],
[Innungsname] hat Sie zur InnungsApp eingeladen.
Klicken Sie den Button, um Ihren kostenlosen Zugang zu aktivieren."
[Jetzt beitreten] ← Magic Link (7 Tage gültig)
"Kein Konto anlegen, kein Passwort nötig."
──────────────────────────────────────────────
Mitglied klickt Link (Mobile/Desktop)
──────────────────────────────────────
→ Deep Link öffnet InnungsApp (falls installiert)
ODER → Browser: "App herunterladen?" Popup
→ Account wird automatisch erstellt
→ Redirect zu: First-Use-Onboarding
──────────────────────────────────────────────
First-Use Onboarding (Mobile App)
──────────────────────────────────
Screen 1: "Willkommen bei [Innungsname]!"
└─ Logo der Innung wird angezeigt
└─ "Sie sind jetzt Mitglied der [Innungsname] in der App"
[Weiter]
Screen 2: "Push-Benachrichtigungen erlauben?"
└─ Erklärung: "Erhalten Sie wichtige Mitteilungen sofort"
└─ System-Dialog für Erlaubnis
[Erlauben] / [Später]
Screen 3: "Was finden Sie in der App?"
└─ Quick Tour: 4 Slides mit Icons
1. "Mitglieder — Alle Betriebe auf einen Blick"
2. "Mitteilungen — Keine wichtige Info mehr verpassen"
3. "Termine — Anmelden mit einem Klick"
4. "Lehrlingsbörse — Azubis finden oder werden"
[Los geht's]
→ Redirect zu: Dashboard (Tab Bar)
```
---
## 3. Flow: Admin lädt Mitglied manuell ein
**Kanal:** Admin Web Dashboard
```
1. Admin navigiert zu: Mitglieder → Neues Mitglied
2. Formular ausfüllen:
┌─────────────────────────────────────────┐
│ Vorname: [Max ] │
│ Nachname: [Müller ] │
│ Betrieb: [Müller Sanitär GmbH] │
│ Sparte: [SHK ▼ ] │
│ E-Mail: [max@mueller.de ] │
│ Telefon: [0711-98765 ] │
│ Ort: [Stuttgart ] │
│ Status: [Aktiv ▼ ] │
│ ☑ Ausbildungsbetrieb │
└─────────────────────────────────────────┘
[Speichern & Einladung senden]
[Nur Speichern]
3. Einladungsmail wird sofort gesendet (oder bei Button "Einladung senden" im Mitglied-Profil)
4. Admin sieht in der Mitgliederliste:
- "Eingeladen am: 15.03.2026" (solange noch kein Login)
- Wechselt zu "Aktiv seit: 15.03.2026" nach erstem Login
```
---
## 4. Flow: Passwortloser Login (Wiederkehrender Nutzer)
```
App öffnen
────────────
→ Gespeicherte Session vorhanden?
JA → Direkt zu Dashboard (kein Login nötig, Supabase Session ~7 Tage)
NEIN → Login Screen
Login Screen
────────────
┌────────────────────────────────────────┐
│ [Innung Logo] │
│ │
│ Melden Sie sich an │
│ │
│ E-Mail: [________________] │
│ │
│ [Link anfordern] │
│ │
│ "Wir schicken Ihnen einen Login-Link." │
└────────────────────────────────────────┘
→ E-Mail erscheint im Postfach innerhalb ~10 Sekunden
→ Klickt Link → App öffnet sich → Eingeloggt
Fallback: "Kein Link erhalten?"
→ "Link erneut anfordern" (nach 60 Sekunden)
→ "Wenden Sie sich an Ihren Innungsgeschäftsführer"
```
---
## 5. Flow: Lehrstellenanzeige aufgeben (Mitglied)
```
Voraussetzung: Mitglied hat "ausbildungsbetrieb = true"
1. Tab "Lehrlingsbörse" öffnen
2. Button: "Stelle anbieten" (nur sichtbar wenn ausbildungsbetrieb)
3. Formular:
┌──────────────────────────────────────────┐
│ Beruf: [Elektroniker für Energie- u...] │
│ Sparte: [Elektrotechnik ▼ ] │
│ Anzahl Stellen: [2 ▼ ] │
│ Ausbildungsstart: [August 2026 ] │
│ Lehrjahr: [1. Lehrjahr ▼ ] │
│ │
│ Vergütung: │
│ 1. Lehrjahr: [800 ] €/Monat │
│ 2. Lehrjahr: [920 ] €/Monat │
│ 3. Lehrjahr: [1020 ] €/Monat │
│ │
│ Schulabschluss: [Kein ▼ ] │
│ │
│ Kontakt: │
│ Name: [Max Müller ] │
│ E-Mail: [ausbildung@mueller.de ] │
│ Telefon: [0711-98765 ] │
└──────────────────────────────────────────┘
[Stelle veröffentlichen]
4. Stelle erscheint sofort in der öffentlichen Lehrlingsbörse
5. Push-Notification an Admin: "Neue Lehrstellenanzeige von Müller Sanitär"
```
---
## 6. Flow: Azubi-Bewerber (ohne Login)
```
Einstieg: Browser oder App (öffentlicher Bereich)
───────────────────────────────────────────────────
Landing Page: "Ausbildungsplätze bei [Innung]"
→ Suchzeile: [Beruf oder Ort suchen]
→ Filter: Sparte | Ort | Ausbildungsstart | Lehrjahr
Suchergebnis:
┌──────────────────────────────────────────────────┐
│ 🔧 Elektroniker/in │
│ Müller Elektro GmbH · Stuttgart │
│ 2 Stellen · ab August 2026 │
│ Vergütung: 800 € → 920 € → 1.020 € │
│ [Mehr erfahren] [Jetzt bewerben] │
└──────────────────────────────────────────────────┘
Detailansicht:
→ Vollständige Berufsbeschreibung
→ Vergütungstabelle alle Lehrjahre
→ Anforderungen
→ Betriebsprofil
Kontaktaufnahme (ohne Login):
Option A: [Anrufen] → tel: Link
Option B: [E-Mail] → mailto: Link
Option C: [Bewerben] → (Post-MVP: In-App-Profil + Chat)
```
---
## 7. Fehler-Flows
### Magic Link abgelaufen (7 Tage)
```
Nutzer klickt alten Link
─────────────────────────
→ App/Browser zeigt: "Dieser Link ist abgelaufen."
→ "Neuen Link anfordern" Button
→ Gibt E-Mail ein → Neuer Link wird gesendet
```
### Nutzer nicht in der Innung (falsche E-Mail)
```
→ Login erfolgreich ABER kein user_roles-Eintrag gefunden
→ Screen: "Kein Innungszugang gefunden"
→ "Wenden Sie sich an Ihren Innungsgeschäftsführer"
→ E-Mail-Adresse Ihres Ansprechpartners anzeigen
```
### Keine App installiert (Link-Klick im Browser)
```
→ Smart App Banner / Universal Link Fallback
→ Browser zeigt: "Öffnen Sie den Link in der InnungsApp"
→ [App im App Store herunterladen] Button
→ Nach Installation: Deep Link wird verarbeitet (Token im Clipboard)
```

165
PERSONAS.md Normal file
View File

@@ -0,0 +1,165 @@
# InnungsApp — User Personas
---
## Persona 1: "Sabine" — Innungsgeschäftsführerin
**Alter:** 47 | **Ort:** Stuttgart | **Innung:** Innung Elektrotechnik Region Stuttgart (180 Mitglieder)
### Biografie
Sabine arbeitet seit 12 Jahren als Geschäftsführerin der Elektrotechnik-Innung. Sie hat die Stelle damals von ihrem Vorgänger übernommen und sich alles selbst beigebracht. Sie ist die zentrale Ansprechperson für alle 180 Mitgliedsbetriebe — von der Beitragserhebung bis zur Organisation der Gesellenprüfungen.
### Ein typischer Tag
- 07:30 Uhr: E-Mails checken — 3 Anmeldungen für den nächsten Kurs per E-Mail, 2 Telefonanfragen wegen Mitgliedsbeiträgen
- 09:00 Uhr: Excel aktualisieren — neues Mitglied, altes ausgetreten
- 11:00 Uhr: Rundschreiben als PDF erstellen, an E-Mail-Verteiler schicken, hoffen dass alle es sehen
- 14:00 Uhr: Teilnehmerliste für die Innungsversammlung — verschiedene WhatsApp-Nachrichten zählen
### Ziele
- Verwaltungsaufwand reduzieren
- Sicherstellen, dass alle Mitglieder wichtige Infos erhalten
- Mehr Zeit für echte Aufgaben statt Koordination
### Frustrationen
- **"Ich weiß nie, ob das Rundschreiben gelesen wurde."**
- WhatsApp-Gruppen sind DSGVO-Problem, aber Mitglieder wollen es trotzdem
- Excel ist auf ihrem Laptop — wenn sie krank ist, kommt niemand ran
- Mitglieder rufen sie für Infos an, die sie selbst nachschlagen könnten
- Lehrstellenliste auf der Website ist immer veraltet
### Tech-Affinität
Mittel. Nutzt täglich Office, E-Mail, WhatsApp. Hat kein Interesse an komplexen Tools. Will klare, einfache Lösungen.
### Zitat
> "Wenn ich eine neue Software einführe, muss ich auch noch die 180 Mitglieder beibringen, wie sie funktioniert. Das ist das Eigentliche."
### Was sie von InnungsApp erwartet
- Zeitersparnis bei Routinetätigkeiten (Min. 3h/Woche)
- Endlich wissen, wer Mitteilungen gelesen hat
- Mitglieder können selbst nachschauen statt anzurufen
- DSGVO-konforme Alternative zu WhatsApp
---
## Persona 2: "Markus" — Innungsmitglied (Betriebsinhaber)
**Alter:** 52 | **Ort:** Esslingen | **Betrieb:** Müller Sanitär GmbH (8 Mitarbeiter, 2 Azubis)
### Biografie
Markus hat seinen Betrieb vor 18 Jahren von seinem Vater übernommen. Handwerk ist für ihn Leidenschaft. Er ist Mitglied der SHK-Innung seit Anfang an. Die Innung schätzt er für die Geselligkeit und die gemeinsame Interessenvertretung — nicht für ihre Digitalisierung.
### Ein typischer Tag
- 06:00 Uhr: Auf der Baustelle. Kein Laptop, nur Smartphone
- 10:00 Uhr: Kurze Pause — WhatsApp-Nachrichten checken
- 12:00 Uhr: Mittagspause im Büro. E-Mails. Meist zu viele, überfliegt sie
- 14:00 Uhr: Zurück auf Baustelle
- 17:00 Uhr: Bürokram, Rechnungen, nächsten Tag planen
### Ziele
- Seinen Betrieb und seine Azubis sauber managen
- Neue Azubis finden — er hat seit 2 Jahren eine unbesetzte Stelle
- Informationen von der Innung schnell und einfach bekommen
### Frustrationen
- **"Ich vergesse Innungstermine, weil die E-Mail im Spam landet."**
- Azubi-Suche über die Innung ist kompliziert — Formular ausfüllen, auf Website warten
- Hat keine Zeit für langsame digitale Prozesse
- Will keine zehn verschiedenen Apps
### Tech-Affinität
Niedrig bis mittel. Nutzt Smartphone intensiv (WhatsApp, Google Maps, Bankapp). Hat keinen Laptop auf der Baustelle. Hates Apps die mehr als 2 Klicks brauchen.
### Zitat
> "Ich brauche das auf dem Handy, einfach. Wenn es kompliziert ist, mache ich es nicht."
### Was er von InnungsApp erwartet
- Push-Notifications für wirklich wichtige Neuigkeiten
- Einen Azubi finden über die Innung (Lehrlingsbörse)
- Schnell sehen, wann der nächste Innungstermin ist und sich anmelden
---
## Persona 3: "Lukas" — Azubi-Bewerber (Gen Z)
**Alter:** 16 | **Ort:** Heilbronn | **Schulform:** Realschule (Klasse 10)
### Biografie
Lukas interessiert sich fürs Handwerk — sein Onkel ist Elektriker, und Lukas hat ihm manchmal geholfen. Er weiß aber nicht genau, was er machen will. Er googelt manchmal "Ausbildung Elektriker", landet auf veralteten Websites der Innung mit kaputten Links.
### Verhalten
- 5+ Stunden täglich auf dem Smartphone
- TikTok, Instagram, YouTube, Snapchat
- Hat noch nie einen "formellen" Bewerbungsprozess erlebt
- Würde keinen CV auf Papier schicken — kennt das Konzept kaum
- Entscheidet visuell und emotional, nicht rational
### Ziele
- Einen coolen Ausbildungsplatz finden, der zu ihm passt
- Herausfinden, was Handwerk-Berufe wirklich bedeuten (nicht Theorie, sondern Praxis)
- Möglichst wenig Aufwand für die Bewerbung
### Frustrationen
- Websites der Innungen / Betriebe sind hässlich und unübersichtlich
- **"Bewerbung per Post? Wer macht das noch?"**
- Keine echten Bilder oder Videos vom Arbeitsalltag
- Tabellarischer Lebenslauf fühlt sich wie Chinesisch an
### Tech-Affinität
Sehr hoch (Konsument). Erwartet mobile-first, visuell, schnell.
### Zitat
> "Wenn ich auf einer Website surfe und es sieht aus wie 2005, gehe ich sofort weg."
### Was er von InnungsApp erwartet (Azubi-Modul)
- Videos von echten Azubis, die ihren Job zeigen
- Transparente Vergütungsinfo ("Was verdiene ich wirklich?")
- 1-Click Bewerbung: kein PDF, kein Lebenslauf — ein Profil reicht
- Antwort vom Betrieb per Chat
---
## Persona 4: "Fatima" — Azubine (im 2. Lehrjahr)
**Alter:** 18 | **Ort:** Mannheim | **Beruf:** Anlagenmechanikerin SHK
### Biografie
Fatima ist im 2. Lehrjahr ihrer SHK-Ausbildung. Sie mag ihren Job, aber das Berichtsheft nervt sie massiv. Jeden Abend schreibt sie mit der Hand rein, was sie tagsüber gemacht hat. Manchmal vergisst sie es.
### Ziele
- Ihre Ausbildung erfolgreich abschließen
- Den Berufsschulstoff nachvollziehen
- Weniger Papierkram
### Frustrationen
- **"Das Berichtsheft ist so ein Aufwand. Ich mache das spät nachts."**
- Prüfungsvorbereitung: Es gibt keine gute App für SHK-Fachwissen
- Termine der Innung (Lossprechung, Zwischenprüfung) erfährt sie vom Betrieb, nicht direkt
### Was sie von InnungsApp erwartet (Post-MVP)
- Digitales Berichtsheft: Fotos machen, Sprachnotiz, fertig
- Prüfungsfragen als tägliches Quiz
- Push-Notification bei ihren Innungsterminen
---
## Persona 5: "Georg" — Obermeister
**Alter:** 64 | **Ort:** Freiburg | **Funktion:** Obermeister Dachdecker-Innung Südbaden
### Biografie
Georg ist seit 8 Jahren Obermeister. Er führt den Vorsitz ehrenamtlich neben seinem eigenen Betrieb (15 Mitarbeiter). Er repräsentiert die Innung nach außen, hält Reden bei Freisprechungen, verhandelt mit der HWK.
### Ziele
- Die Innung modernisieren, ohne die Tradition zu verlieren
- Dem Vorstand regelmäßig Rechenschaft über den Mitgliederservice ablegen
- Ein attraktives Bild der Innung nach außen zeigen
### Frustrationen
- Keine Übersicht, wie viele Mitglieder aktiv sind
- Muss für Statistiken die Geschäftsführerin anrufen
- **"Ich will zeigen, dass unsere Innung digital ist. Aber womit?"**
### Was er von InnungsApp erwartet
- Repräsentatives Auftreten der Innung mit Logo und Farben
- Einfaches Reporting für den Vorstand
- Lehrlingsbörse als Aushängeschild nach außen

253
PRD.md Normal file
View File

@@ -0,0 +1,253 @@
# InnungsApp — Product Requirements Document (PRD)
> **Version:** 2.0 | **Status:** In Review | **Owner:** Timo Knuth
> **Zielmarkt:** Innungen & Kreishandwerkerschaften in Deutschland
---
## 1. Problemdefinition
### 1.1 Status Quo bei Innungen
Deutsche Handwerksinnungen verwalten ihre Mitglieder und kommunizieren heute mit:
- **Excel-Tabellen** für Mitgliederverwaltung (lokal, nicht synchronisiert)
- **WhatsApp-Gruppen** für interne Kommunikation (DSGVO-Problem)
- **E-Mail-Verteiler** für Rundschreiben (kein Tracking, kein Self-Service)
- **PDF-Formulare** per Post für Anmeldungen (langsam, fehleranfällig)
- **Vereinzelte Websites** für die Lehrstellenbörse (veraltet, schlecht mobil)
### 1.2 Konkrete Pain Points
**Geschäftsführer / Admin:**
- Mitgliederdaten pflegen ist zeitaufwendig (manuelle Excel-Updates)
- Keine Ahnung, ob Rundschreiben gelesen wurden
- Terminanmeldungen kommen per Telefon und E-Mail — chaotisch
- Lehrstelleninserate manuell auf Website pflegen
**Innungsmitglied (Betrieb):**
- Verpasst wichtige Informationen, weil E-Mails übersehen werden
- Keine Möglichkeit, Ausbildungsstellen einfach zu veröffentlichen
- Muss für jede Kleinigkeit den Geschäftsführer anrufen
**Azubi-Bewerber (Gen Z):**
- Stellenangebote auf Websites sind veraltet und langweilig
- Muss PDF-Bewerbungen per E-Mail schicken (antiquiert)
- Kein Einblick in den Berufsalltag
### 1.3 Warum jetzt?
1. **Fachkräftemangel als Treiber:** ~250.000 unbesetzte Ausbildungsplätze in Deutschland (2025)
2. **Digitalisierungsdruck:** Post-Corona haben Innungen erkannt, dass digitale Tools fehlen
3. **Generationenwechsel:** Jüngere Obermeister und Geschäftsführer sind technikaffin
4. **Fördergelder:** BMWi und HWKs haben Digitalisierungsprogramme mit Budget
---
## 2. Produktvision
**Vision Statement:**
> InnungsApp wird die Standard-Infrastruktur des deutschen Handwerks — die Plattform, auf der Innungen verwalten, kommunizieren, Azubis finden und ihre Mitglieder vernetzen.
**Mission:**
> Das deutsche Handwerk digital und zukunftsfähig machen — beginnend mit dem direkten Werkzeug jeder Innung.
---
## 3. Zielgruppe & Rollen
### Primäre Käufer (B2B)
| Rolle | Beschreibung | Hauptmotivation |
|---|---|---|
| **Innungsgeschäftsführer** | Hauptadmin, verwaltet alles | Zeitersparnis, Übersicht |
| **Obermeister** | Vorsitzender der Innung | Reputation, Mitgliederservice |
### Primäre Nutzer (Enduser)
| Rolle | Beschreibung | Hauptbedürfnis |
|---|---|---|
| **Mitglied (Betrieb)** | Inhaberin/Inhaber des Mitgliedsbetriebs | Information, Azubi finden |
| **Azubi-Bewerber** | 1520 Jahre, sucht Ausbildungsplatz | Einfache, moderne Bewerbung |
| **Lernender (Azubi)** | In der Ausbildung | Berichtsheft, Prüfungsvorbereitung |
### Sekundäre Nutzer (Post-MVP)
- Prüfungsausschüsse (Gesellenprüfungen)
- HWK-Mitarbeiter (übergeordnete Verwaltung)
- Lieferanten / Hersteller (Marktplatz-Modul)
---
## 4. Feature-Scope MVP
### 4.1 Modul: Mitgliederverzeichnis
**Ziel:** Zentrales, durchsuchbares Verzeichnis aller Mitgliedsbetriebe.
**User Stories:**
- Als Mitglied möchte ich andere Betriebe in meiner Innung finden, um mich zu vernetzen
- Als Admin möchte ich Mitglieder anlegen, bearbeiten und deaktivieren
**Funktionen:**
- Liste aller Mitgliedsbetriebe mit Suche und Filter
- Filter: Sparte, Ort, Ausbildungsbetrieb ja/nein, Status
- Detailansicht: Name, Betrieb, Sparte, Ort, Telefon, E-Mail, Website, Ausbildungsbetrieb
- Direktkontakt: Tap-to-Call, Tap-to-Mail
- Admin-CRUD: Mitglied anlegen, bearbeiten, deaktivieren, Einladungs-E-Mail senden
- Status-Management: aktiv / ruhend / ausgetreten
**Nicht im MVP:**
- Öffentliches Unternehmensverzeichnis (für Endkunden)
- Bewertungssystem
- Karten-/Geo-Ansicht
- CRM-Integration
---
### 4.2 Modul: Mitteilungen & News
**Ziel:** Gesteuerter Kommunikationskanal von der Innung an ihre Mitglieder.
**User Stories:**
- Als Admin möchte ich Mitteilungen mit Push-Notification versenden, um sicherzustellen, dass alle erreicht werden
- Als Mitglied möchte ich wichtige Mitteilungen einfach finden und nicht in E-Mails suchen müssen
**Funktionen:**
- Feed aller Beiträge, chronologisch sortiert
- Kategorien: Wichtig / Prüfung / Förderung / Veranstaltung / Allgemein
- Push-Notification bei neuem Beitrag (optional konfigurierbar)
- Ungelesen/Gelesen-Status pro Nutzer und Beitrag
- PDF-Anhänge (Rundschreiben, Formulare)
- Admin: Beitrag erstellen, bearbeiten, löschen, zeitgesteuert veröffentlichen
- Admin: Leserate pro Beitrag einsehen
**Nicht im MVP:**
- Kommentarfunktion (erhöht Moderationsaufwand)
- Mitglieder-Posts (asymmetrischer Kanal gewollt)
- Direktnachrichten / Chat
---
### 4.3 Modul: Lehrlingsbörse (Basic)
**Ziel:** Ausbildungsplätze der Mitgliedsbetriebe einfach veröffentlichen und finden.
**User Stories:**
- Als Betrieb möchte ich meine Ausbildungsstellen einfach einstellen, ohne die Innung fragen zu müssen
- Als Bewerber möchte ich Stellen ohne Registrierung durchsuchen
**Funktionen (Betrieb):**
- Stelle anlegen: Sparte, Anzahl, Vergütung nach Lehrjahr, Ausbildungsstart, Kontakt
- Stelle aktivieren / pausieren / löschen
- Bewerbungskontakt: E-Mail oder Telefon
**Funktionen (Bewerber, öffentlich ohne Login):**
- Stellenliste durchsuchen
- Filter: Sparte, Ort, Lehrjahr, Ausbildungsstart
- Direktkontakt zum Betrieb (E-Mail / Telefon)
**Nicht im MVP:**
- In-App Bewerbungsformular
- Matching-Algorithmus
- Video-Feed (→ Azubi-Modul Advanced, Q2)
- Berichtsheft
---
### 4.4 Modul: Termine & Veranstaltungen
**Ziel:** Veranstaltungskalender mit An-/Abmeldefunktion und Teilnehmermanagement.
**User Stories:**
- Als Admin möchte ich Termine anlegen und die Teilnehmerliste einsehen
- Als Mitglied möchte ich mich für Veranstaltungen anmelden und den Termin in meinen Kalender exportieren
**Funktionen:**
- Terminliste mit Typ-Tags: Prüfung / Versammlung / Kurs / Event / Lossprechung
- Termindetail: Titel, Datum, Uhrzeit, Ort, Beschreibung, Typ
- An-/Abmeldung mit E-Mail-Bestätigung
- Kalenderexport: iCal / Google Calendar / Outlook
- Admin: Termin anlegen, Teilnehmerliste exportieren (CSV)
- Push-Notification 24h vor Termin
**Nicht im MVP:**
- Bezahlte Events / Ticketing
- Wartelisten-Management
- Videokonferenz-Links (Q2)
- Saalplan / Sitzordnung
---
### 4.5 Modul: Admin-Dashboard (Web)
**Ziel:** Webbasiertes Backend für Innungsgeschäftsführer.
**Funktionen:**
- Übersicht: Mitgliederzahl, aktive Nutzer, ungelesene Beiträge
- Mitgliederverwaltung: CRUD, Massenimport via CSV, Einladungsversand
- Newsroom: Beiträge erstellen, planen, Leserate sehen
- Terminverwaltung: CRUD, Teilnehmerliste
- Lehrstellenverwaltung: Alle Stellen aller Betriebe einsehen und moderieren
- Einstellungen: Innungslogo, Farbe, Kontaktdaten, Spartenverzeichnis
- Einfache Analytics: DAU/MAU, Aktivierungsrate, meistgelesene Beiträge
**Tech:** Next.js Web-App auf `app.innungsapp.de/admin`
---
## 5. Out of Scope — MVP
| Feature | Begründung | Roadmap |
|---|---|---|
| Chat / Direktnachrichten | Moderation, DSGVO-Komplexität | Q3 2026 |
| Dokumentenarchiv | Fokus zuerst auf Kernkommunikation | Q2 2026 |
| Prüfungsverwaltung | Hochreguliert, eigenes Produkt | Q4 2026 |
| Mitgliederbeiträge / Buchhaltung | Buchhaltungsintegration nötig | 2027 |
| Video-Feed (TikTok-Style) | Eigenes Azubi-Modul | Q2 2026 |
| Berichtsheft digital | Komplex, eigenes Feature | Q3 2026 |
| Mehrsprachigkeit | Zielgruppe DE-only | 2027 |
| White-Label für HWK | Post Product-Market-Fit | Q4 2026 |
| Marktplatz / Lieferantenportal | Separate Business Unit | 2027 |
---
## 6. Nicht-funktionale Anforderungen
### Performance
- App-Ladezeit (Cold Start): < 3 Sekunden
- Feed-Ladezeit: < 1 Sekunde
- Push-Notification-Delivery: > 95 % innerhalb 30 Sekunden
### Sicherheit & Datenschutz
- DSGVO-konform: Datenverarbeitung nur in EU (Frankfurt)
- AVV (Auftragsverarbeitungsvertrag) mit jeder Innung
- Row Level Security: Jede Innung sieht ausschließlich ihre eigenen Daten
- Keine Weitergabe von Mitgliederdaten an Dritte
- Datenlöschung auf Anfrage innerhalb 30 Tage
### Verfügbarkeit
- Uptime-Ziel: 99,5 % (Supabase SLA)
- Geplante Wartungsfenster: nachts 02:0004:00 Uhr
### Skalierbarkeit
- MVP-Kapazität: 100 Innungen, je bis 500 Mitglieder
- Skalierung auf 10.000 Innungen ohne Architekturänderung (Supabase managed)
### Barrierefreiheit
- iOS: Unterstützung für Dynamic Type und VoiceOver
- Mindest-Textgröße: 14pt
- Kontrastverhältnis: WCAG AA
---
## 7. Akzeptanzkriterien MVP
| Feature | Kriterium |
|---|---|
| Login | Nutzer kann sich via Magic Link in < 60 Sek. einloggen |
| Mitgliederverzeichnis | Admin kann Mitglied in < 2 Minuten anlegen |
| News | Beitrag ist nach Veröffentlichung in < 10 Sek. für alle sichtbar |
| Push-Notification | 95 % der Nutzer empfangen Push innerhalb 30 Sek. |
| Lehrlingsbörse | Stelle ist öffentlich sichtbar ohne Login |
| Termine | Anmeldung + Kalenderexport in < 3 Klicks |
| Multi-Tenancy | Kein Mitglied sieht Daten einer anderen Innung |

331
README.md
View File

@@ -1,297 +1,90 @@
# Analyse: Pain Points deutscher Stadtwerke und Softwarelösungen
# InnungsApp — Projektübersicht
**Gesamtes Projekt-Archiv für Geschäftsidee-Evaluierung**
> **SaaS-Plattform für Innungen & Kreishandwerkerschaften in Deutschland**
> **Version:** 2.0 | **Status:** Pre-MVP | **Stand:** Februar 2026
---
## 📋 Überblick
## Technisches Setup
Diese Analyse untersucht die **5 größten operativen Probleme** deutscher Stadtwerke und präsentiert **konkrete, schnell umsetzbare Softwarelösungen**. Der Gesamtmarkt wird auf **10-25 Millionen EUR** in den nächsten 3 Jahren geschätzt.
Die aktuelle und verbindliche Anleitung fuer Start, Docker-Deployment, Migrationen und Seeding liegt in:
**Zielgruppe:** Geschäftsführer, Investoren, Softwareentwickler, Stadtwerk-Manager
- `innungsapp/README.md`
---
Dieses Root-README ist eine Produkt- und Strategieuebersicht.
## 📁 Dokumentstruktur
## Was ist InnungsApp?
### 1. **EXECUTIVE_SUMMARY.md** (5-10 Min Lesedauer)
- Für: C-Level, Investoren, schnelle Übersicht
- Inhalt:
- Kernfinding & Top 5 Pain Points
- Marktgröße & Geschäftsmodell
- Finanzielle Projektion (Revenue, Break-Even, Exit Multiple)
- Investment Thesis
- Nächste Schritte
InnungsApp ist eine mobile-first SaaS-Plattform, die Innungen und Kreishandwerkerschaften digitalisiert. Sie löst zwei akute Probleme gleichzeitig:
**Wann lesen:** Zuerst! (Executive Overview)
1. **Innungsverwaltung:** Mitglieder, News, Termine und interne Kommunikation aus Excel und WhatsApp-Gruppen heraus in eine professionelle App
2. **Azubi-Recruiting:** Fachkräftemangel bekämpfen — durch eine TikTok-inspirierte Lehrstellenbörse, die Gen Z dort abholt, wo sie ist
---
## Marktgröße
### 2. **quick_reference.txt** (10-15 Min Lesedauer)
- Für: Quick Lookup, Meeting Vorbereitung
- Inhalt:
- One-Page Übersicht aller 5 Pain Points
- Timing, Pricing, TTM
- Implementierungs-Timeline
- GO-to-Market Strategie
- Finanzielle Projekte
| Segment | Anzahl | Adressierbar |
|---|---|---|
| Innungen in Deutschland | ~7.500 | Ja |
| Kreishandwerkerschaften | ~500 | Ja |
| Mitgliedsbetriebe | ~500.000 | Indirekt |
| Offene Ausbildungsplätze | ~250.000 | Ja |
**Wann lesen:** Zweites! (Für schnelle Referenz während Meetings)
## Kern-These
---
> 7.500 Innungen verwalten ihre Mitglieder heute mit Excel und WhatsApp.
> Eine branchenspezifische App mit 200 €/Monat × 500 Kunden = **1,2 Mio. € ARR**.
> Der Azubi-Mangel macht dieses Tool zum **Überlebenswerkzeug** — kein Nice-to-have.
### 3. **stadtwerke_analysis.md** (30-40 Min Lesedauer)
- Für: Detaillierte Analyse, Product Manager, Technische Teams
- Inhalt:
- Umfassende Analyse aller 5 Pain Points:
1. Zählerablesung (SmartMeter-Lite)
2. Abschlagsrechnung (AbschlagAssistant)
3. Entstörung (OutageAlert Pro)
4. Kundenservice (Kundenservice 360)
5. Abrechnung (RechnungsAnalyzer+)
- Für jeden Pain Point:
- Problem Beschreibung
- Konkrete Lösung
- Tech-Stack
- Time-to-Market
- Verkaufspotenzial & ROI
- Implementierungs-Roadmap (Phase 1-3)
- Geschäftsmodell-Optionen
- Critical Success Factors
## Dokumente
**Wann lesen:** Für tiefes Verständnis und Konzept-Validation
| Datei | Inhalt |
|---|---|
| `PRD.md` | Vollständige Produktspezifikation |
| `ARCHITECTURE.md` | Technische Architektur |
| `DATABASE_SCHEMA.md` | Datenbankschema (SQL) |
| `USER_STORIES.md` | User Stories nach Rolle |
| `PERSONAS.md` | Nutzerprofile |
| `BUSINESS_MODEL.md` | Preismodell & Unit Economics |
| `ROADMAP.md` | Entwicklungsplan |
| `COMPETITIVE_ANALYSIS.md` | Wettbewerbsanalyse |
| `FEATURES_BACKLOG.md` | Feature Backlog |
| `API_DESIGN.md` | API-Endpunkte |
| `ONBOARDING_FLOWS.md` | Onboarding-Flows |
| `GTM_STRATEGY.md` | Go-to-Market Strategie |
| `VALIDATION_PLAYBOOK.md` | Validierungsstrategie |
| `AZUBI_MODULE.md` | Azubi-Recruiting Modul |
| `DSGVO_KONZEPT.md` | Datenschutz & Compliance |
---
## Tech Stack (Überblick)
### 4. **implementation_roadmap.md** (40-50 Min Lesedauer)
- Für: Entwicklungsteams, Projektmanager, CTO
- Inhalt:
- Detaillierte 6-Monats-Timeline
- Sprint-by-Sprint Breakdown für jede Lösung
- Team-Struktur & Org Design
- Tech Stack Empfehlungen
- Sicherheits-Anforderungen
- Success Metrics & KPIs
- Risk & Mitigation Strategies
- Budget Breakdown
- Next Immediate Steps
- **Mobile:** React Native + Expo
- **Web Admin:** Next.js
- **Backend:** Supabase (PostgreSQL, Auth, Storage, Realtime)
- **Video:** Mux / Cloudflare Stream
- **Hosting:** Vercel
- **Analytics:** PostHog
**Wann lesen:** Für Implementierungsplanung und Team-Onboarding
## Quickstart (Legacy, nicht der aktuelle Betriebsweg)
---
```bash
# Repo klonen
git clone https://gitea.bizmatch.net/tknuth/stadtwerke.git
cd stadtwerke
### 5. **detailed_use_cases.md** (20-30 Min Lesedauer)
- Für: Sales, Marketing, Product Owner, Investoren
- Inhalt:
- 5 Detaillierte Kundenszenarien:
* Anna Mueller (Zählerablesung Problem)
* Klaus Schmidt (Abschlag Verwirrung)
* Familie Bergmann (Stromausfall Angst)
* Sabine Weber (Customer Service Frustration)
* Hans Mueller (Abrechnungs-Unklarheit)
- Vorher/Nachher Vergleiche
- Emotionale Impact & Metriken
- Business Outcomes mit konkreten Zahlen
# Supabase lokal starten
npx supabase init
npx supabase start
**Wann lesen:** Für Empathie-Building und Sales Pitch Entwicklung
---
## 🎯 Lesepfade nach Rolle
### 👔 **Geschäftsführer / Investor**
1. EXECUTIVE_SUMMARY.md (5 min)
2. quick_reference.txt - Finanzen Section (3 min)
3. detailed_use_cases.md - Executive Summary (5 min)
**Total: 13 Minuten**
---
### 💻 **CTO / Technischer Leiter**
1. EXECUTIVE_SUMMARY.md (10 min)
2. stadtwerke_analysis.md - Tech Stack Sections (15 min)
3. implementation_roadmap.md - Tech Stack & Architecture (20 min)
4. quick_reference.txt - Timeline Section (10 min)
**Total: 55 Minuten**
---
### 📊 **Product Manager**
1. EXECUTIVE_SUMMARY.md (10 min)
2. stadtwerke_analysis.md - Alle Pain Points (35 min)
3. detailed_use_cases.md - Alle Use Cases (25 min)
4. implementation_roadmap.md - Success Metrics (10 min)
**Total: 80 Minuten (Deep Dive)**
---
### 🎤 **Sales / Business Development**
1. EXECUTIVE_SUMMARY.md (10 min)
2. detailed_use_cases.md - Alle Szenarien (25 min)
3. quick_reference.txt - GO-to-Market Section (8 min)
4. stadtwerke_analysis.md - Pain Points Overview (15 min)
**Total: 58 Minuten**
---
### 👨‍💼 **Stadtwerk-Manager (Prospect)**
1. EXECUTIVE_SUMMARY.md (10 min)
2. detailed_use_cases.md - Relevant pain points (10 min)
3. stadtwerke_analysis.md - Your Problem + Solution (15 min)
4. implementation_roadmap.md - Implementation Plan (10 min)
**Total: 45 Minuten**
---
## 🔑 Key Takeaways
### Top 5 Pain Points (Priorität)
| # | Problem | Lösung | TTM | Marktpotenzial |
|---|---------|--------|-----|-----------------|
| 1 | **Zählerablesung** Manuelle Prozesse, 30-40% Fehler | SmartMeter-Lite App | 8-12 Wo | 4-8M EUR/Y2-3 |
| 2 | **Abschlag** Unklar, 45% unzufrieden | AbschlagAssistant Tool | 6-10 Wo | 3-6M EUR/Y1 |
| 3 | **Entstörung** Keine Info, 30-60 min Wartezeit | OutageAlert Pro | 10-14 Wo | 2-4M EUR/Y2-3 |
| 4 | **Support** Fragmentiert, lange Wartezeit | Kundenservice 360 | 12-16 Wo | 3-6M EUR/Y2-3 |
| 5 | **Abrechnung** Komplex, 25-30% Beschwerde | RechnungsAnalyzer+ | 10-14 Wo | 1-3M EUR/Y2-3 |
---
## 💰 Finanzielle Highlights
### Geschäftsmodell: B2B SaaS
- **Basis-Preis:** 1.000 - 20.000 EUR/Monat (je nach Stadtwerk-Größe)
- **Average ACV:** 120.000 EUR/Jahr
- **CAC (Customer Acq. Cost):** 40K EUR
- **LTV:CAC Ratio:** 3:1 (healthy)
### Revenue Projektion
- **Year 1:** 2.0M EUR (880K EUR Conservative)
- **Year 2:** 8.0M EUR (3.6M EUR Conservative)
- **Year 3:** 18.0M EUR (7.2M EUR Conservative)
### Investment & Return
- **Required Capital:** 2.0 - 3.0M EUR
- **Break-Even:** Month 18-20
- **3-Year Exit Multiple:** 45x+ Return (bei 5-8x Revenue Multiple)
- **Expected Exit Value:** 90-144M EUR
---
## 🚀 Nächste Schritte
1. **Diese Woche:**
- [ ] EXECUTIVE_SUMMARY.md lesen (5 min)
- [ ] Quick Reference speichern als mobile Reference (5 min)
- [ ] Team zusammenstellen (1-2 Std)
2. **Nächste Woche:**
- [ ] Stakeholder Interviews vorbereiten (3 Stadtwerke)
- [ ] Technical Architecture Review
- [ ] Competitive Landscape Deep-Dive
3. **In 2 Wochen:**
- [ ] Product Requirements Document (PRD) verfassen
- [ ] MVP Development starten (Pain Point #1)
- [ ] Investor Pitch finalisieren
---
## 📊 Statistiken auf einen Blick
```
Gesamtmarktgröße (3 Jahre): 10-25 Millionen EUR
Zielmarkt Haushalte: 8-10 Millionen
Zielmarkt Stadtwerke: Top 100 von 900+
Durchschnitt Support-Anfragen: 60-70% vermeidbar
Durchschn. NPS-Improvement: +70 Punkte
Durchschn. Support-Kostensparnis: -40% bis -70%
Customer Satisfaction: +35% bis +75%
# Expo App starten
cd apps/mobile
npx expo start
```
---
## 🛠️ Verwendete Methoden
pnpm --filter @innungsapp/admin dev -- --port 3032
Diese Analyse basiert auf:
-**Branchenkenntnisse** zu deutschen Stadtwerken
-**Recherchen** bei Top 20 Stadtwerken (München, Berlin, Hamburg, Köln, etc.)
-**Customer Pain Point Analysis** (Support-Kanäle, FAQ, Beschwerde-Datenbanken)
-**Competitive Intelligence** (SAP, Oracle, Zendesk, etc.)
-**Financial Modeling** (Unit Economics, Revenue Projections)
-**Technical Feasibility** (Tech Stack, TTM Estimation)
npx expo start --clear
---
Demo: admin@demo.de / demo1234
## ⚠️ Disclaimer
Diese Analyse basiert auf öffentlich verfügbaren Informationen und Branchenkenntnissen. Die Zahlen sind Schätzungen und können variieren je nach:
- Spezifischen Stadtwerk-Anforderungen
- Marktdynamiken
- Technologischen Änderungen
- Regulatory Landscape
Für Investitionsentscheidungen wird empfohlen:
- Detaillierte Due Diligence mit Stadtwerken
- Technical Proof-of-Concept
- Unabhängige Finanzprüfung
- Legal Review
---
## 📞 Kontakt & Support
Für Fragen zu dieser Analyse:
- **Technische Fragen:** [Technical Lead]
- **Geschäftsfragen:** [Product Manager]
- **Sales/Partnership:** [Business Development]
- **Finanzielle Modelle:** [CFO/Finance]
---
## 📅 Dokumentversionierung
| Version | Datum | Änderungen |
|---------|-------|-----------|
| 1.0 | Feb 2026 | Initial Analysis |
**Nächste Review:** November 2026
---
## 🎓 Bildungs-Ressourcen
Falls Sie mehr über diese Themen lernen möchten:
### Deutsches Stadtwerk-System
- VKU (Verband Kommunaler Unternehmen): www.vku.de
- WAGA (Wasser Abwasser Energie): www.waga-fachtagung.de
### SaaS & Business Model
- "The SaaS Playbook" (Blake Bartlett)
- "Crossing the Chasm" (Geoffrey Moore)
### Kundenservice Excellence
- "The Service Profit Chain" (Harvard Business Review)
- "Net Promoter System" (Fred Reichheld)
---
## 🏁 Schlusswort
Diese Analyse identifiziert eine **attraktive Marktgelegenheit** im deutschen Stadtwerk-Sektor. Mit den richtigen Teams, Technologie und Geduld (Sales Cycle: 60-90 Tage) ist ein **Multi-Million Euro Geschäft** innerhalb von 3 Jahren aufzubauen.
**Kernerfolgsfaktor:** Schnelle MVP-Entwicklung, starke Product-Market-Fit durch direkte Kunden-Zusammenarbeit, und konsequente Fokussierung auf konkrete Pain Points.
---
**Last Updated:** Februar 2026
**Document Status:** FINAL für Sharing
# stadtwerke
Hinweis: Fuer den aktuellen lokalen/produktiven Betrieb bitte `innungsapp/README.md` verwenden.

View File

@@ -1,290 +0,0 @@
# Realistic vs. Optimistic Scenario
**Datum:** Februar 2026
**Status:** REVISED - Focused on MVP-only approach
---
## 🎯 The Fundamental Question
**Scenario 1 (OPTIMISTIC):**
- Build all 5 Solutions at once
- €2M Investment
- Y1: 2M EUR ARR
- Y3: 18M EUR ARR
**Scenario 2 (REALISTIC):**
- Build SmartMeter-App (#1 only)
- €350-450K Investment
- Y1: 500K-1.2M EUR ARR
- Y3: 5-10M EUR ARR
---
## Side-by-Side Comparison
### Development & Product
| Aspekt | Optimistic (❌ NICHT EMPFOHLEN) | Realistic (✅ EMPFOHLEN) |
|--------|----------------------------------|-------------------------|
| **Scope** | All 5 Pain Points parallel | Only #1 (SmartMeter) MVP |
| **Development Time** | 6 Monate (alle 5) | 4-6 Wochen (nur #1) |
| **Team Size** | 10+ Developer | 3-4 Developer |
| **Tech Stack** | Multiple (Web, Mobile, OCR, KI) | Focused (React Native, OCR) |
| **Complexity** | Very High | Medium |
| **Time-to-Market** | 6 Monate | 4-6 Wochen ✅ |
| **Quality** | Schnell & shallow | Tiefgehendes & poliert ✅ |
### Investment & Burn
| Aspekt | Optimistic | Realistic ✅ |
|--------|-----------|-----------|
| **MVP Investment** | €700K - 1.1M | €250-350K |
| **Total Seed** | €2.0-3.0M | €600-800K |
| **Monthly Burn** | €120-150K | €40-50K |
| **Runway (mit 600K)** | 4-5 Monate | 12-15 Monate |
| **Runway (mit 1M)** | 6-7 Monate | 20+ Monate |
**Kritik:** Mit €2M für MVP ist man unter Druck, schnell Umsatz zu machen. Mit €350K ist der Druck niedriger, aber fokussierter.
### Sales & GTM
| Aspekt | Optimistic | Realistic ✅ |
|--------|-----------|-----------|
| **Sales Approach** | Direct + Inbound | Partnership-First oder Direct |
| **Sales Team Y1** | 2-3 Personen | 1 Founder + 1 Partner |
| **Pilot Kunden** | 10+ | 5 (kostenlos) |
| **First Paying Customer** | Month 6-9 | Month 6-8 |
| **Sales Cycle** | 60-90 Tage | 60-90 Tage (same) |
| **Kunden Y1 Ende** | 15-20 | 5-10 |
**Kritik:** Die optimistische Version braucht 2-3 Sales Personen. Das ist teuer in der Früh-Phase.
### Revenue & Profitability
| Metrik | Optimistic | Realistic ✅ |
|--------|-----------|-----------|
| **Y1 ARR** | €2.0M | €500K-1.2M |
| **Y2 ARR** | €8.0M | €2-4M |
| **Y3 ARR** | €18.0M | €5-10M |
| **Y1 Customers** | 15-20 | 5-10 |
| **Y2 Customers** | 40+ | 20-35 |
| **Break-Even** | Month 18-20 | Month 18-22 |
| **EBITDA Y2** | 20%+ | 0-15% |
**Kritik:** Optimistic ist zu aggressive. Realistic ist mehr defensiv.
---
## Why Realistic is Better
### 1. **Lower Burn Rate = More Time**
- Optimistic: €120-150K/Monat → Need revenue in 6 months
- Realistic: €40-50K/Monat → Can grow more deliberately
### 2. **Product Quality > Speed**
- Optimistic: 5 features, all decent
- Realistic: 1 feature, absolutely excellent ✅
**Impact:** A great SmartMeter app sells itself. A mediocre 5-in-1 platform doesn't.
### 3. **Lower Risk for Investors**
- Optimistic: €2M → Need massive ROI to justify risk
- Realistic: €350K → Even 20x ROI (€7M exit) is great
### 4. **Easier to Pivot**
- Optimistic: Built 5 solutions, now stuck
- Realistic: If #1 doesn't work, pivot to #2/#3 easily
### 5. **Better Unit Economics**
- Optimistic: High burn, need high prices, harder to sell
- Realistic: Low burn, can afford to be more patient with sales
---
## Timeline Comparison
### Optimistic (5 Solutions)
```
Month 1-4: Requirements, Design, Setup
Month 5-12: Parallel development (all 5)
- Web team (2)
- Mobile team (2)
- Backend team (2)
- KI/ML team (2)
- QA (1)
- PM (1)
Month 13: Beta launch with 2-3 solutions
Month 14-16: Piloting
Month 17: Commercial launch
Month 18+: Sales
```
**Risk:** Any delay cascades to everything
### Realistic (SmartMeter Only)
```
Month 1-4: Interviews, Validation, GO/NO-GO
Month 5-10: MVP Development (3-4 Devs only)
- Frontend (React Native)
- Backend (Python)
- OCR Integration
- Security/Testing
Month 11: Beta with 5 pilots (free)
Month 12: Commercial launch
Month 13-18: Sales & first revenue
Month 19+: Expansion to #2, #3
```
**Advantage:** Each milestone is clear, achievable, deriskable
---
## Key Metrics: When to Scale
**Realistic Approach suggests:**
Scale to Solution #2 when:
- ✅ 10+ paying customers for SmartMeter
- ✅ NPS > 50
- ✅ Positive unit economics (LTV > 3x CAC)
- ✅ Monthly Churn < 3%
- ✅ ARR > €800K
Only THEN invest in #2 + #3.
---
## Worst Case: What If #1 Doesn't Work?
### Optimistic Scenario
- €2M spent, 5 solutions built
- None of them acquired traction
- **Loss: €2M EUR**
- Runway: 1-2 months to shut down
### Realistic Scenario
- €350K spent, SmartMeter built
- No traction after 4 months of sales
- Pivot to Abschlag (#2) or Outage (#3)
- **Loss: €350K EUR** (manageable)
- Runway: 15+ months to try new approaches
**Clear winner:** Realistic scenario is 5x safer!
---
## Best Case: What If #1 Works Great?
### Optimistic Scenario
- 5 solutions built, SmartMeter works best
- Customers mostly use #1
- Other 4 are "nice to have"
- **Wasted:** €1.2M on unnecessary features
- Result: €2M ARR is achievable, but expensive to get there
### Realistic Scenario
- SmartMeter works great
- €500K spent, high profitability
- Runway to build #2, #3 with cash flow
- **Efficient:** Every EUR was well-spent ✅
- Result: Scale organically from €500K → €1M → €2M+
**Clear winner:** Realistic scenario = more profitable!
---
## Investment Attractiveness
### For Seed/VC Investors
| Kriterium | Optimistic | Realistic ✅ |
|-----------|-----------|-----------|
| **Capital Efficiency** | Low (€2M for MVP) | High (€350K) |
| **Risk Adjusted Return** | Medium | High |
| **Time to Profitability** | 18-20 months | 18-22 months (similar) |
| **Probability of Success** | Medium (40%) | High (65%) |
| **Exit Multiples** | 5-8x | 5-8x (same) |
| **Overall Attractiveness** | ❌ OK | ✅✅ GREAT |
**Investor Perspective:**
- €350K with 65% success = Expected Value: €2.3M
- €2M with 40% success = Expected Value: €6.4M
Actually, on paper Optimistic seems better. But in reality:
- €350K Realistic with 65% = More likely to win
- €2M Optimistic with 40% = More likely to fail spectacularly
**Smart investors prefer:** Lower risk, capital-efficient bets.
---
## The Decision Framework
### Choose OPTIMISTIC if:
- ❌ You have €3-5M in funding already
- ❌ You have a killer sales team ready
- ❌ You have all 5 pain points validated with paying customers
- ❌ You want to be "one-stop-shop"
- **Reality:** Rare. Don't do this.
### Choose REALISTIC if:
- ✅ You have €350-800K seed funding
- ✅ You have 1-2 good founders
- ✅ You want to validate before scaling
- ✅ You can't afford massive burn
- ✅ You want to maximize learning & optionality
- **Reality:** This is 90% of successful startups.
---
## Recommended Path Forward
**Phase 0: NOW (Next 2 weeks)**
1. Get clear GO/NO-GO from 5-10 Stadtwerk contacts
2. Validate that #1 (SmartMeter) is truly their #1 pain
3. If YES → Move to Phase 1
**Phase 1: VALIDATION (Month 1-4, €20-30K)**
- Deep-dive interviews (10-15 Stadtwerke)
- Competitive analysis
- Business model validation
- Feature prioritization
**Phase 2: MVP BUILD (Month 5-10, €250-350K)**
- Build SmartMeter-App with extreme focus
- 3-4 amazing engineers
- 1 great product manager
- 1 talented designer
- Ship by Month 10
**Phase 3: MARKET TEST (Month 11-18, €50-100K)**
- 5 Beta customers (free, learning)
- Iterate based on feedback
- First paying customers Month 13-16
- Measure product-market fit signals
**Phase 4: EXPANSION (Month 19+, progressive)**
- Only if KPIs show strong momentum
- Then build #2, #3
- Each builds on PMF from previous
---
## Bottom Line
**Optimistic:** "Build everything, hope it works"
**Realistic:** "Build one thing great, then expand"
**Winner:** Realistic, because:
- ✅ Lower risk
- ✅ Better capital efficiency
- ✅ More focused product
- ✅ Easier to sell
- ✅ Easier to pivot
- ✅ Similar upside potential
**Recommendation:** Go with Realistic MVP approach. It's smarter.
---
**Status:** This document should guide all strategic decisions moving forward.
**Next Review:** After Phase 0 GO/NO-GO decision (Week 2)

249
ROADMAP.md Normal file
View File

@@ -0,0 +1,249 @@
# InnungsApp — Entwicklungs-Roadmap
> **Methodik:** Agile, 2-Wochen-Sprints | **Start:** März 2026
---
## Phase 0 — Setup & Foundation (Woche 12)
**Ziel:** Fundament für schnelle Entwicklung legen
### Woche 1: Technisches Setup
- [ ] Supabase-Projekt aufsetzen (Production + Staging)
- [ ] Datenbankschema deployen (alle Tabellen, Indizes)
- [ ] Row Level Security Policies schreiben und testen
- [ ] Supabase Auth konfigurieren (Magic Link, E-Mail-Templates)
- [ ] Expo-Projekt initialisieren (React Native + TypeScript)
- [ ] Expo Router Grundstruktur anlegen
- [ ] Next.js Admin-App initialisieren
- [ ] CI/CD: Gitea → Vercel (Admin) + EAS (Mobile)
- [ ] Environments: development / staging / production
### Woche 2: Design & Pilot-Gespräch
- [ ] Design-Tokens definieren: Farben, Typography, Spacing, Radius
- [ ] Figma-Mockups: Login, Mitgliederverzeichnis, News Feed, Terminliste
- [ ] Erste Innung als Design-Partner ansprechen (Kaltakquise BW)
- [ ] Demo-Call vereinbaren für Woche 3
- [ ] Supabase TypeScript-Types generieren
**Deliverables:**
- Supabase-Projekt live (Staging)
- Expo App startet auf Simulator
- Figma-Mockups für alle MVP-Screens
- Demo-Call mit erster Innung geplant
---
## Phase 1 — Core MVP (Woche 310)
### Sprint 1 (Woche 34): Auth + Mitgliederverzeichnis
**Mobile:**
- [ ] Login-Screen mit Magic Link (E-Mail-Eingabe)
- [ ] Verify-Screen (Token-Verarbeitung nach Link-Klick)
- [ ] Auth Guard (Redirect bei nicht eingeloggt)
- [ ] Mitgliederverzeichnis: Liste mit Suche
- [ ] Filter-Bottom-Sheet: Sparte, Ort, Ausbildungsbetrieb
- [ ] Mitglied-Detailansicht: alle Infos, Tap-to-Call, Tap-to-Mail
**Admin:**
- [ ] Login-Page
- [ ] Mitgliederliste mit Tabelle und Suche
- [ ] Mitglied anlegen / bearbeiten (Formular)
- [ ] Mitglied deaktivieren
**Backend:**
- [ ] RLS Policies für members testen
- [ ] CSV-Import Endpoint (Supabase Edge Function)
- [ ] Einladungs-E-Mail via Resend
---
### Sprint 2 (Woche 56): News & Push Notifications
**Mobile:**
- [ ] News Feed: Liste, Kategoriefilter, Ungelesen-Badge
- [ ] Beitrag-Detailansicht: Text, Anhänge (PDF-Viewer)
- [ ] Push-Token-Registrierung beim Login
- [ ] Gelesen-Tracking (mark as read on open)
**Admin:**
- [ ] Beitrag erstellen: Rich-Text-Editor, Kategorie, PDF-Anhang
- [ ] Beitrag zeitgesteuert veröffentlichen
- [ ] Leserate-Anzeige pro Beitrag
- [ ] Beitrag anpinnen
**Backend:**
- [ ] Supabase Database Webhook → Edge Function → Expo Push API
- [ ] news_reads Tracking
- [ ] Supabase Storage für PDF-Anhänge
---
### Sprint 3 (Woche 78): Termine & Anmeldungen
**Mobile:**
- [ ] Terminliste chronologisch
- [ ] Filter nach Typ (Prüfung, Kurs, etc.)
- [ ] Termin-Detailansicht
- [ ] An-/Abmeldebutton
- [ ] iCal-Export
- [ ] Bestätigungs-E-Mail nach Anmeldung
**Admin:**
- [ ] Termin anlegen / bearbeiten
- [ ] Teilnehmerliste einsehen
- [ ] Teilnehmerliste CSV-Export
**Backend:**
- [ ] termine_anmeldungen mit UNIQUE constraint
- [ ] E-Mail bei Anmeldung (Resend)
- [ ] Push Reminder 24h vorher (geplante Edge Function)
---
### Sprint 4 (Woche 910): Lehrlingsbörse & Admin Dashboard
**Mobile (öffentlich, kein Login):**
- [ ] Stellenliste: Suche, Filter (Sparte, Ort, Lehrjahr)
- [ ] Stellen-Detailansicht: alle Infos, Vergütung, Kontakt
- [ ] Direktkontakt: Tap-to-Call / Tap-to-Mail
**Mobile (Mitglied mit Login):**
- [ ] Eigene Stelle anlegen (wenn ausbildungsbetrieb = true)
- [ ] Stelle aktivieren / pausieren / löschen
**Admin:**
- [ ] Alle Stellen der Innung einsehen
- [ ] Stelle moderieren (ausblenden)
**Admin Dashboard Home:**
- [ ] Übersichtszahlen: Mitglieder, aktive Nutzer (7 Tage), WAU
- [ ] Letzte 5 Beiträge mit Leserate
- [ ] Nächste 5 Termine mit Anmeldezahl
- [ ] Quick Actions: Mitglied einladen, Beitrag erstellen
---
## Phase 2 — Polish & Launch (Woche 1114)
### Woche 1112: Onboarding & UX
- [ ] Onboarding-Wizard für neue Innungen (Admin-Setup: Name, Logo, Sparten)
- [ ] First-Use-Tutorial in der Mobile App (Overlay / Tooltips)
- [ ] Leeransicht für leere Listen (Mitglieder, News, Termine)
- [ ] Error States und Retry-Logik
- [ ] Offline-Handling (Cached Data anzeigen)
- [ ] App Icon + Splash Screen
- [ ] Push Notification Permission-Prompt (optimierter Zeitpunkt)
### Woche 1314: App Store Submission & Pilot Launch
- [ ] iOS: App Store Connect, Screenshots, App-Beschreibung
- [ ] Android: Google Play Console, Screenshots
- [ ] EAS Submit für beide Stores
- [ ] TestFlight Beta mit Pilot-Innung
- [ ] 5 Pilotinnungen live schalten
- [ ] PostHog Events in alle kritischen Flows einbauen
- [ ] Feedback-Formular in der App (NPS-Umfrage nach 2 Wochen)
- [ ] Support-Prozess definieren (E-Mail + FAQ-Seite)
**Launch-Kriterien:**
- [ ] Alle 5 MVP-Module funktionieren ohne kritische Bugs
- [ ] Push Notifications ankommen
- [ ] Login-Flow < 60 Sekunden
- [ ] Keine DSGVO-Lücken
- [ ] 1 Pilot-Innung hat App bestätigt
---
## Phase 3 — Post-MVP Q2 2026
### Modul: Dokumentenarchiv
- [ ] Kategorien: Formulare, Satzungen, Prüfungsunterlagen, Protokolle
- [ ] Upload (PDF, Word, Excel) via Admin
- [ ] Download in Mobile App
- [ ] Versionsverwaltung (letzte 3 Versionen behalten)
- [ ] Suche über Dokumententitel
### Modul: Statistik-Dashboard (Admin)
- [ ] DAU / WAU / MAU Grafiken (letzte 90 Tage)
- [ ] Aktivierungsrate: Eingeladene vs. Eingeloggte
- [ ] News-Performance: Top 10 meistgelesene Beiträge
- [ ] Termin-Auslastung: Anmeldequote pro Termin
- [ ] Lehrstellenaktivität: Views, Kontaktanfragen
- [ ] Monatsbericht als PDF-Export
### Modul: Videokonferenz-Integration
- [ ] Zoom / Teams-Link in Terminen hinterlegen
- [ ] "Jetzt beitreten"-Button in Termin-Detailansicht
- [ ] Erinnerung mit Link in Push Notification
---
## Phase 4 — Post-MVP Q3 2026
### Modul: Azubi Video-Feed (Kernstück Azubi-Modul)
- [ ] TikTok-Style vertikaler Video-Feed pro Beruf
- [ ] Video-Upload für Mitgliedsbetriebe (Mux-Integration)
- [ ] Berufs-Tags und Filter
- [ ] Like / Speichern
- [ ] "Mehr erfahren" → Direktkontakt zum Betrieb
### Modul: 1-Click-Bewerbung
- [ ] Bewerber-Profil anlegen (Name, Schulabschluss, Interessen, Wohnort)
- [ ] "Bewerben" Button bei Lehrstellenanzeige
- [ ] Chat-Initiierung: Bewerber ↔ Betrieb (In-App Messaging)
- [ ] Benachrichtigung an Betrieb
### Modul: Digitales Berichtsheft (Azubis)
- [ ] Täglicher Eintrag: Text, Foto, Sprachnotiz
- [ ] Wochenansicht
- [ ] Betrieb kann Einträge bestätigen / kommentieren
- [ ] Export als PDF (für Prüfer)
---
## Phase 5 — Post-MVP Q4 2026
### Modul: Prüfungsvorbereitung
- [ ] Fragenkatalog pro Gewerk (manuell gepflegt)
- [ ] Tägliches Quiz (5 Fragen, Push-Reminder)
- [ ] Lernfortschritt-Tracking
- [ ] Schwächen-Analyse: "Diese Themen solltest du wiederholen"
### White-Label für HWK
- [ ] Eigene Domain pro Innung: `innung-elektro-stuttgart.de`
- [ ] Vollständige Logo/Farb-Anpassung
- [ ] HWK-Dashboard: Alle Innungen im Bezirk auf einen Blick
- [ ] HWK kann Nachrichten an alle Innungen senden
### Verbands-API
- [ ] REST API für externe Systeme (HWK-eigene Software)
- [ ] Webhook-Integration (neue Mitglieder, neue Stellen)
- [ ] Dokumentierte API (OpenAPI/Swagger)
---
## Meilensteine & KPIs
| Meilenstein | Datum | KPI |
|---|---|---|
| Supabase + Expo Setup | KW 10/2026 | Technisches Fundament bereit |
| Erster Pilot live | KW 15/2026 | 1 Innung aktiv |
| App Store Launch | KW 16/2026 | iOS + Android verfügbar |
| 5 Piloten aktiv | KW 20/2026 | WAU > 40%, NPS > 50 |
| Erster zahlender Kunde | KW 22/2026 | MRR > 0 € |
| 10 zahlende Innungen | KW 32/2026 | MRR > 1.500 € |
| Erste HWK-Partnerschaft | KW 40/2026 | Distributionskanal validiert |
| 50 Innungen | KW 10/2027 | MRR > 8.500 €, Profitabilität |
| 100 Innungen | KW 26/2027 | MRR > 18.000 €, Seed-Bereit |

File diff suppressed because it is too large Load Diff

107
USER_STORIES.md Normal file
View File

@@ -0,0 +1,107 @@
# InnungsApp — User Stories
> **Format:** Als [Rolle] möchte ich [Aktion], damit [Nutzen].
> **Status:** MVP = im ersten Release | Post-MVP = spätere Version
---
## Rolle: Innungsgeschäftsführer (Admin)
### Onboarding & Setup
- [ ] Als Admin möchte ich die Innung in 15 Minuten einrichten (Name, Logo, Sparten, Kontaktdaten), damit wir schnell starten können. **[MVP]**
- [ ] Als Admin möchte ich Mitglieder per CSV-Import massenweise anlegen, damit ich nicht jeden einzeln eingeben muss. **[MVP]**
- [ ] Als Admin möchte ich Einladungs-E-Mails an alle Mitglieder mit einem Klick versenden, damit sie ihre Accounts aktivieren. **[MVP]**
- [ ] Als Admin möchte ich sehen, wer die Einladung angenommen hat und wer nicht, damit ich nachfassen kann. **[MVP]**
### Mitgliederverwaltung
- [ ] Als Admin möchte ich ein neues Mitglied anlegen (Name, Betrieb, Sparte, E-Mail, Telefon), damit es in der App erscheint. **[MVP]**
- [ ] Als Admin möchte ich ein Mitglied auf "ausgetreten" setzen, damit es keinen Zugriff mehr hat. **[MVP]**
- [ ] Als Admin möchte ich die Mitgliederliste nach Sparte, Ort und Status filtern, damit ich gezielt suchen kann. **[MVP]**
- [ ] Als Admin möchte ich die gesamte Mitgliederliste als CSV exportieren, damit ich sie für andere Zwecke nutzen kann. **[Post-MVP]**
- [ ] Als Admin möchte ich ein Notizfeld pro Mitglied haben, damit ich interne Informationen festhalten kann. **[MVP]**
### Kommunikation
- [ ] Als Admin möchte ich einen Beitrag mit Text, Kategorie und optionalem PDF-Anhang erstellen, damit Mitglieder informiert werden. **[MVP]**
- [ ] Als Admin möchte ich einen Beitrag zeitgesteuert veröffentlichen (z.B. morgen um 8 Uhr), damit ich Mitteilungen vorbereiten kann. **[MVP]**
- [ ] Als Admin möchte ich nach der Veröffentlichung sehen, wie viele Mitglieder den Beitrag gelesen haben, damit ich die Reichweite kenne. **[MVP]**
- [ ] Als Admin möchte ich eine Push-Notification an alle Mitglieder senden, wenn ein wichtiger Beitrag erscheint. **[MVP]**
- [ ] Als Admin möchte ich einen Beitrag als "Wichtig" anpinnen, damit er oben im Feed erscheint. **[MVP]**
### Terminverwaltung
- [ ] Als Admin möchte ich Termine anlegen (Titel, Datum, Uhrzeit, Ort, Typ), damit Mitglieder informiert sind. **[MVP]**
- [ ] Als Admin möchte ich die Teilnehmerliste eines Termins einsehen und als CSV exportieren, damit ich planen kann. **[MVP]**
- [ ] Als Admin möchte ich für Termine eine maximale Teilnehmerzahl setzen, damit ich die Kapazität steuere. **[Post-MVP]**
### Lehrstellenverwaltung
- [ ] Als Admin möchte ich alle Lehrstellenangebote meiner Mitgliedsbetriebe einsehen und moderieren, damit keine ungeeigneten Inhalte erscheinen. **[MVP]**
- [ ] Als Admin möchte ich Stellen im Namen von Betrieben anlegen, die das nicht selbst können, damit alle vertreten sind. **[MVP]**
### Analytics
- [ ] Als Admin möchte ich auf dem Dashboard sehen: Mitgliederanzahl, aktive Nutzer diese Woche, ungelesene Beiträge, kommende Termine. **[MVP]**
- [ ] Als Admin möchte ich einen Monatsbericht (PDF) über Aktivität und Nutzung erhalten, damit ich dem Vorstand berichten kann. **[Post-MVP]**
---
## Rolle: Innungsmitglied (Betrieb)
### Erste Nutzung
- [ ] Als Mitglied möchte ich mich via Magic Link (E-Mail-Link) einloggen, ohne ein Passwort erstellen zu müssen, damit der Einstieg einfach ist. **[MVP]**
- [ ] Als Mitglied möchte ich beim ersten Login sehen, was die App kann, damit ich sie sofort sinnvoll nutzen kann. **[MVP]**
### Mitgliederverzeichnis
- [ ] Als Mitglied möchte ich alle anderen Betriebe meiner Innung sehen, damit ich mich vernetzen kann. **[MVP]**
- [ ] Als Mitglied möchte ich nach Betrieb, Name, Sparte oder Ort suchen, damit ich schnell finde, was ich suche. **[MVP]**
- [ ] Als Mitglied möchte ich einen anderen Betrieb direkt anrufen (Tap-to-Call), damit ich keine Nummer abtippen muss. **[MVP]**
- [ ] Als Mitglied möchte ich mein eigenes Profil (Kontaktdaten, Website) bearbeiten, damit meine Daten aktuell sind. **[Post-MVP]**
### News & Mitteilungen
- [ ] Als Mitglied möchte ich alle Mitteilungen der Innung in einer übersichtlichen Liste sehen, damit ich nichts verpasse. **[MVP]**
- [ ] Als Mitglied möchte ich Push-Benachrichtigungen bei wichtigen Mitteilungen erhalten, damit ich sofort informiert bin. **[MVP]**
- [ ] Als Mitglied möchte ich PDF-Anhänge direkt in der App öffnen, damit ich keine E-Mail suchen muss. **[MVP]**
- [ ] Als Mitglied möchte ich einstellen können, welche Kategorien mir Push-Benachrichtigungen senden, damit ich nicht überflutet werde. **[Post-MVP]**
### Termine
- [ ] Als Mitglied möchte ich alle kommenden Termine sehen, damit ich planen kann. **[MVP]**
- [ ] Als Mitglied möchte ich mich für einen Termin an-/abmelden, damit der Admin die Planung kennt. **[MVP]**
- [ ] Als Mitglied möchte ich einen Termin in meinen Kalender exportieren (iCal/Google), damit er in meinem Kalender erscheint. **[MVP]**
- [ ] Als Mitglied möchte ich 24h vor einem Termin, für den ich angemeldet bin, eine Erinnerung erhalten. **[MVP]**
### Lehrlingsbörse
- [ ] Als Mitglied (Ausbildungsbetrieb) möchte ich meine Ausbildungsstellen selbst einstellen und verwalten, damit ich Bewerber finde. **[MVP]**
- [ ] Als Mitglied möchte ich meine Stellen schnell aktivieren oder pausieren, wenn die Position besetzt ist. **[MVP]**
- [ ] Als Mitglied möchte ich sehen, wie viele Bewerber meine Stelle kontaktiert haben. **[Post-MVP]**
---
## Rolle: Azubi-Bewerber (Gen Z, öffentlich)
- [ ] Als Bewerber möchte ich Ausbildungsstellen einer Innung ohne Login durchsuchen, damit ich keine Hürde habe. **[MVP]**
- [ ] Als Bewerber möchte ich nach Sparte, Ort und Ausbildungsstart filtern, damit ich relevante Stellen finde. **[MVP]**
- [ ] Als Bewerber möchte ich einen Betrieb direkt per Telefon oder E-Mail kontaktieren, ohne ein Formular auszufüllen. **[MVP]**
- [ ] Als Bewerber möchte ich sehen, was ein Azubi in diesem Beruf verdient (Vergütung nach Lehrjahr), damit ich einen Vergleich habe. **[MVP]**
- [ ] Als Bewerber möchte ich Videos von Azubis sehen, die ihren Berufsalltag zeigen, damit ich einen echten Eindruck bekomme. **[Post-MVP / Azubi-Modul]**
- [ ] Als Bewerber möchte ich mich mit einem Profil (ohne CV) bewerben ("Swipe to Apply"), damit die Hürde niedrig ist. **[Post-MVP / Azubi-Modul]**
- [ ] Als Bewerber möchte ich den Lohn-Rechner nutzen ("Was verdiene ich wirklich?"), damit ich informiert entscheide. **[Post-MVP / Azubi-Modul]**
---
## Rolle: Azubi (in der Ausbildung)
- [ ] Als Azubi möchte ich mein digitales Berichtsheft über die App führen (Fotos, Sprachnotizen), damit ich keinen Papierkram brauche. **[Post-MVP Q3]**
- [ ] Als Azubi möchte ich Prüfungsfragen für meinen Beruf als tägliches Quiz erhalten, damit ich mich vorbereite. **[Post-MVP Q3]**
- [ ] Als Azubi möchte ich Innungs-News und Termine sehen, damit ich auf dem Laufenden bleibe. **[Post-MVP]**
- [ ] Als Azubi möchte ich meinen Lernfortschritt verfolgen, damit ich weiß, wo ich stehe. **[Post-MVP Q4]**
---
## Rolle: Obermeister (Vereinsvorsitzender)
- [ ] Als Obermeister möchte ich Beiträge vor der Veröffentlichung freigeben, damit nur geprüfte Inhalte erscheinen. **[Post-MVP]**
- [ ] Als Obermeister möchte ich Statistiken (Nutzerzahlen, Aktivität) auf einem Dashboard sehen, damit ich den Digitalisierungsfortschritt sehe. **[Post-MVP]**
---
## Rolle: HWK-Mitarbeiter (Post-MVP)
- [ ] Als HWK-Mitarbeiter möchte ich eine Übersicht aller Innungen in meinem Kammerbezirk sehen, damit ich Informationen zentral verwalten kann. **[Post-MVP Q4]**
- [ ] Als HWK-Mitarbeiter möchte ich Beiträge an alle Innungen gleichzeitig senden, damit wichtige Informationen alle erreichen. **[Post-MVP Q4]**

View File

@@ -1,560 +1,250 @@
# VALIDATION PLAYBOOK
# InnungsApp — Validation Playbook
**Ziel:** Schnell validieren, ob SmartMeter-App ein echtes Problem löst
**Dauer:** 2-3 Wochen
**Output:** GO/NO-GO Entscheidung mit Daten
> **Ziel:** Hypothesen vor großen Investitionen mit echten Nutzern validieren
---
## PHASE 0: Interviews mit Entscheidungsträgern
## 1. Kern-Hypothesen
### Zielgruppe
| # | Hypothese | Validierungsmethode | Zielwert |
|---|---|---|---|
| H1 | Innungen zahlen 99199 €/Monat für eine bessere App | Pilot-Conversion | ≥ 60 % Pilot → Paid |
| H2 | Mitglieder loggen sich aktiv ein (kein "Geisterprojekt") | WAU-Rate | ≥ 40 % der eingeladenen Mitglieder |
| H3 | Push-Notifications erhöhen Leserate vs. E-Mail | A/B-Test (Piloten) | Push-Leserate ≥ 2x E-Mail |
| H4 | Azubis nutzen die Lehrlingsbörse ohne Login | Analytics | ≥ 50 % der Aufrufe ohne Auth |
| H5 | Betriebe stellen Stellen selbst ein (kein Admin-Overhead) | Feature-Nutzung | ≥ 70 % Stellen vom Betrieb selbst |
| H6 | HWK sind bereit, InnungsApp zu empfehlen | HWK-Gespräche | 1 LOI innerhalb 6 Monate |
```
Pro Stadtwerk: 2-3 Personen
1. IT-Leiter (Technical, Integration-Fragen)
2. Kundenservice-Leiter oder Betriebsleiter (Use Case, ROI)
3. CFO/Geschäftsführer (Budget, Priority)
Total Target: 10-15 Stadtwerke
= 20-45 Interviews
```
### Kontakt-Strategie
**Tiered Approach:**
```
Tier 1 (Known): Bestehende Kontakte
- LinkedIn Outreach zu IT Directors
- Existierende Geschäftspartner
- Networking Events (VKU)
Tier 2 (Warm): Via Referrals
- Ask friends/advisors for intros
- Chamber of Commerce
- Industry associations
Tier 3 (Cold): Direct Outreach
- LinkedIn Messages
- Email to general contacts
- VKU member database
Template Email:
---
Subject: 15-Min Interview: Smart Meter Lösung für Stadtwerke
Hallo [Name],
## 2. Phase 0: Pre-Product Validation (vor der ersten Zeile Code)
wir entwickeln eine mobile App zur automatisierten Zählerablesung
für Stadtwerke. [Your Company] hat Expertise in diesem Bereich und
wir möchten von dir lernen.
### Customer Discovery Interviews
Hätte du Zeit für ein 15-Min Call diese Woche? Gerne telefonisch
oder per Zoom.
**Ziel:** Verstehen ob das Problem real und schmerzhaft genug ist.
**Zielgruppe:** 10 Innungsgeschäftsführer, 5 Obermeister, 5 Mitglieder
**Interview-Struktur (30 Minuten):**
```
Teil 1: Kontext verstehen (10 Min.)
──────────────────────────────────
"Beschreiben Sie mir, wie ein typischer Montag für Sie aussieht."
"Wie verwalten Sie aktuell die Mitglieder Ihrer Innung?"
"Wie kommunizieren Sie mit Mitgliedern — welche Tools nutzen Sie?"
"Wie oft haben Sie Kontakt zu einzelnen Mitgliedern?"
"Erzählen Sie mir von einem konkreten Moment, wo die Kommunikation schiefgelaufen ist."
Teil 2: Pain Points herausarbeiten (10 Min.)
────────────────────────────────────────────
"Was kostet Sie die meiste Zeit bei der Innungsarbeit?"
"Wenn Sie eine Sache sofort verbessern könnten, was wäre das?"
"Wie viele Stunden pro Woche verbringen Sie mit Verwaltungsaufgaben?"
"Was passiert, wenn ein Mitglied Sie wegen etwas anruft, das es selbst nachschlagen könnte?"
"Haben Sie schon andere Tools probiert? Was hat nicht funktioniert?"
Teil 3: Lösung sondieren (10 Min.)
───────────────────────────────────
[Figma Prototype / Mockup zeigen]
"Wenn ich Ihnen eine App zeige, die X und Y macht — was würden Sie als erstes tippen?"
"Was fehlt hier, was Sie erwartet hätten?"
"Was würden Sie daran anders machen?"
"Wenn diese App heute verfügbar wäre — würden Sie sie testen wollen?"
"Was würden Sie für diese Lösung bezahlen?"
```
**Auswertung:**
- Probleme die 3+ Personen unaufgefordert nennen = validiert
- Zahlungsbereitschaft > 100 €/Monat bei 3+ Personen = validiert
**Interview-Aufzeichnung:** Notion-Template mit Zitaten, Audio-Mitschnitt (mit Erlaubnis)
Danke!
[Your Name]
---
## 3. Phase 1: MVP Pilot Validation (Monat 13)
### Pilot-Setup
| Parameter | Wert |
|---|---|
| Anzahl Pilotinnungen | 5 |
| Pilot-Dauer | 3 Monate |
| Preis | kostenlos |
| Erwartung an Innung | Aktive Nutzung + wöchentlicher Feedback-Call |
| Erwartung an uns | Schnelle Bugfixes (< 24h), persönlicher Support |
### Aktivierungsmetriken (Woche 1 nach Go-Live)
| KPI | Definition | Zielwert | Alarmsignal |
|---|---|---|---|
| Einladungsrate | Eingeladene Mitglieder / Gesamt-Mitglieder | ≥ 80 % | < 50 % |
| Aktivierungsrate | Eingeloggte Nutzer / Eingeladene | ≥ 60 % | < 30 % |
| Time to First Action | Zeit bis erster Aktion nach Login | < 2 Min. | > 5 Min. |
| Feature Discovery | Nutzer die ≥ 3 Features genutzt haben | ≥ 50 % | < 20 % |
### Engagement-Metriken (Monat 13)
| KPI | Definition | Zielwert |
|---|---|---|
| WAU Rate | Wöchentl. aktive Nutzer / Gesamt-Nutzer | ≥ 40 % |
| News Leserate | Gelesen / Empfangen pro Beitrag | ≥ 50 % |
| Termin-Anmeldequote | Angemeldete / Eingeladene pro Termin | ≥ 40 % |
| Lehrlingsbörse Views | Öffentliche Aufrufe der Stellenliste | > 0 Bewerberkontakte |
| NPS (Admin) | Net Promoter Score der Geschäftsführer | ≥ 50 |
| NPS (Mitglied) | Net Promoter Score der Mitglieder | ≥ 40 |
| Support-Tickets | Kritische Bugs gemeldet | < 2 pro Innung |
### Wöchentlicher Check-In mit Pilot-Innungen
**Format:** 20-Minuten-Video-Call, jede Woche
```
Agenda:
1. "Was hat gut funktioniert diese Woche?" (5 Min.)
2. "Was hat nicht funktioniert / gefehlt?" (5 Min.)
3. "Zeigen Sie mir, wie Sie X benutzt haben" (5 Min.)
4. "Nächste Woche freischalten wir Feature Y — was erwarten Sie?" (5 Min.)
```
**Dokumentation:** Call-Notizen in Notion → Feature-Feedback → Backlog-Update
### NPS-Umfrage (nach 6 Wochen)
**Zeitpunkt:** 6 Wochen nach Go-Live, via In-App-Banner
**Fragen:**
1. "Wie wahrscheinlich ist es, dass Sie InnungsApp einer anderen Innung empfehlen?" (010)
2. "Was würden Sie sofort verbessern?" (Freitext)
3. "Welches Feature ist für Sie am wichtigsten?" (Auswahl + Ranking)
---
## 4. Phase 2: Conversion Validation (Monat 3)
### Pilot → Bezahlt Gespräch
**Zeitpunkt:** 2 Wochen vor Pilot-Ende
**Script:**
```
"Wir sind jetzt 3 Monate dabei. Ich möchte offen mit Ihnen sprechen.
Was hat die App für Ihre Innung verändert?
[Zuhören, konkrete Beispiele erfragen]
Basierend auf dem, was ich gesehen habe, glaube ich, dass
[konkrete Zahl: z.B. "60 % Ihrer Mitglieder sind wöchentlich aktiv"].
Wir werden die App nach dem Piloten für 149 €/Monat anbieten.
Was braucht es von unserer Seite, damit Sie dabei bleiben?"
```
**Conversion-Trigger:**
- Intern: WAU > 40 % UND Leserate > 50 % → hohe Conversion-Wahrscheinlichkeit
- Zeige dem Admin seine eigenen Metriken im Gespräch
**Ziel:** ≥ 3 von 5 Piloten konvertieren
### Preis-Sensitivität testen
**Methode:** Van Westendorp Price Sensitivity Meter im Gespräch:
```
"Zu welchem Preis würden Sie sagen, die App ist..."
1. "...zu günstig, sodass Sie an der Qualität zweifeln würden?" [€]
2. "...günstig — ein gutes Preis-Leistungs-Verhältnis?" [€]
3. "...teuer, aber Sie würden es noch überlegen?" [€]
4. "...zu teuer — Sie würden es nicht kaufen?" [€]
```
---
## Interview Guide
## 5. Phase 3: Kanal-Validation (Monat 46)
### PART A: Problem Discovery (5 Min)
### Hypothese: "Direktakquise funktioniert"
**Q1: Current State**
```
"Wie funktioniert eure Zählerablesung heute?"
**Test:**
- 50 Kaltakquise-E-Mails an Innungen in BW
- Tracking: Öffnungsrate, Antwortrate, Demo-Rate, Pilot-Rate
Expected Answers (flag if they say):
- Manual reading by customer
- Reading by technician
- Automated meter (smart meter)
- Mix of the above
**Erfolgskriterium:** Pilot-Rate > 10 % (5 von 50)
Red Flag: If 100% automated already → No problem to solve
Green Flag: If manual + high error rate → Good fit
```
### Hypothese: "LinkedIn-Outreach funktioniert besser als E-Mail"
**Q2: Problem Validation**
```
"Wie viel Zeit/Kosten investiert ihr jährlich in Zählerablesung?"
**Test:**
- 20 E-Mail-Kaltakquise (Kontrollgruppe)
- 20 LinkedIn-Direktnachrichten (Testgruppe)
- Messen: Antwortrate, Demo-Rate
Deep Dive:
- "Wie viele Zählerstände werden abgelesen pro Jahr?"
- "Wer macht das - Mitarbeiter oder Kunden?"
- "Wie hoch ist die Fehlerquote?" (Ziel: >20% = pain point)
- "Was kostet euch jede manuelle Ablesung?"
**Erfolgskriterium:** Einer der Kanäle > 15 % Demo-Rate
Example Math:
- 100K Kunden × 1 reading/year = 100K readings
- 30% need manual follow-up = 30K manual readings
- @€2 per reading = €60K/year cost
### Hypothese: "Zielgruppe sucht aktiv nach Lösung (SEO-Potenzial)"
Red Flag: Low error rate (<5%) or low cost (<€20K) → not a pain point
Green Flag: >20% error rate or >€50K/year cost → worth solving
```
**Q3: Current Frustrations**
```
"Welche sind eure Top 3 Probleme mit dem aktuellen Process?"
Listen for:
- Customer disputes over readings
- Staff effort (time-consuming)
- Data quality issues
- Customer complaints (feedback)
- Billing inaccuracies
Probe deeper on each:
- "How often does this happen?"
- "What's the impact?" (Cost? Customer satisfaction?)
- "Who feels the pain most?" (Support team? Finance? Customers?)
```
**Test:**
- Google Search Console nach 3 Monaten Blog-Content
- Keywords: "Innungsverwaltung Software", "App für Innungen", "Mitgliederverwaltung Handwerk"
- Ziel: > 100 organische Besucher/Monat nach 3 Monaten
---
### PART B: Solution Fit (5 Min)
## 6. Kill Criteria
**Q4: Appetite for Change**
```
"Würdet ihr eine Mobile App für die Zählerablesung einführen?"
**Wir stoppen das Projekt wenn:**
Expected Answers:
A) "Yes, if it solves [specific problem]"
B) "Maybe, but we need to think about it"
C) "No, not interested"
D) "We're already evaluating solutions"
| Kriterium | Wert |
|---|---|
| Aktivierungsrate nach 3 Monaten Pilot | < 20 % |
| Pilot → Paid Conversion | < 2 von 5 (40 %) |
| NPS Admin | < 20 |
| Zahlungsbereitschaft | < 99 €/Monat bei Mehrheit |
| Support-Aufwand | > 5h/Woche pro Innung (nicht skalierbar) |
Green Flag: Answer A or D
Red Flag: Answer C or B (lukewarm interest)
Follow-up:
- "What would make this compelling for you?"
- "What would be blockers?"
- "Timeline - when would you need this?"
```
**Q5: Customer Adoption Likelihood**
```
"Glaubst du, dass deine Kunden eine App nutzen würden
zur Zählerablesung?"
Dig deeper:
- "What % of your customers would use it?" (Target: >20% = viable)
- "What incentives would help adoption?"
- Free (most customers don't care)
- Small discount (€1-2/year per reading)
- Instant feedback (meter consumption insights)
- "Would you push it to customers or wait for demand?"
Red Flag: <10% adoption estimate
Green Flag: >20% adoption estimate
```
**Q6: Integration Feasibility**
```
"Welche Systeme nutzt ihr für Kundenmanagement?"
Record:
- Billing system (SAP, Oracle, Infor, etc.)
- CRM (Salesforce, etc.)
- Meter database (own, external, etc.)
- IT infrastructure (on-prem, cloud, hybrid)
Follow-up:
- "How open is your IT for external integrations?"
- "Who would need to approve this?"
- "Do you have an API layer?"
Red Flag: Very legacy, no API access, slow approval
Green Flag: Modern stack, willing to integrate
```
**Pivots bei Kill-Criteria:**
- WAU niedrig, aber Lehrlingsbörse-Traffic hoch → Pivot zu reiner Azubi-Plattform
- Admins mögen es, Mitglieder nicht → Pivot zu reinem Admin-Tool (Web-only)
- Innungen zahlen nicht → HWK direkt als zahlender Kunde angehen
---
### PART C: Commercial Viability (3 Min)
## 7. Tracking Setup
**Q7: Budget & Pricing**
```
"Wie viel würdet ihr für eine Lösung budgetieren?"
### Analytics Events (PostHog)
Open-ended first:
- Listen to their thinking
- Don't anchor them to your price
Then probe:
- "Is that annual or one-time?"
- "Would you pay monthly/yearly/license?"
- "What's the ROI threshold?" (Typical: payback in 1-2 years)
Red Flag: Budget <€20K/year or willing to pay only if risk-free
Green Flag: Budget >€50K/year, willing to invest for ROI
```typescript
// Alle kritischen User Actions tracken
posthog.capture('member_invited', { org_id, member_count: 1 });
posthog.capture('member_activated', { org_id, time_since_invite_hours: 24 });
posthog.capture('news_opened', { org_id, news_id, kategorie });
posthog.capture('news_attachment_downloaded', { org_id, news_id });
posthog.capture('termin_angemeldet', { org_id, termin_id, termin_typ });
posthog.capture('ical_exported', { org_id });
posthog.capture('stelle_created', { org_id });
posthog.capture('stelle_viewed', { org_id, stelle_id, sparte, is_logged_in: false });
posthog.capture('stelle_contact_tapped', { org_id, stelle_id, contact_method: 'call' });
```
**Q8: Decision Making Process**
```
"Wie würde so eine Entscheidung ablaufen?"
### Funnels in PostHog
Map:
- Who needs to approve? (Budget owner, IT, Board?)
- Timeline? (Fast-track vs. normal process)
- RFP required? (Or can you do handshake deal?)
- Contract terms? (1-year, 3-year, multi-year discount?)
1. **Aktivierungs-Funnel:** Eingeladen → E-Mail geöffnet → Link geklickt → Login → Erste Aktion
2. **News-Funnel:** Push erhalten → App geöffnet → Beitrag gelesen → Anhang geöffnet
3. **Stellen-Funnel:** Stellenliste aufgerufen → Stelle geöffnet → Kontakt aufgenommen
4. **Admin-Funnel:** Login → Beitrag erstellt → Veröffentlicht → Push gesendet
Red Flag: Requires months-long RFP, many approvers, slow
Green Flag: Can decide quickly, fewer stakeholders
```
**Q9: Switching Costs**
```
"Wenn die App nicht funktioniert, wie schnell könnt ihr
rückwärts gehen?"
This matters because:
- High switching costs = stickier customer
- Easy exit = customer feels safer trying
Answer:
- "We'd just stop recommending it to customers"
- "We'd need to ramp down over X months"
- etc.
Green Flag: Easy to exit → customer feels safe to try
```
---
### PART D: Follow-Up / Qualification (2 Min)
**Q10: Next Steps**
```
"Wäre es interessant, wenn wir euch eine Demo/Beta
im nächsten Monat zeigen?"
Clear YES:
- "Yes, I want to see this"
- "Yes, let's do a pilot"
Soft YES:
- "Maybe, depends on what I see"
- "Let me talk to my team first"
NO:
- "Not right now"
- "We're happy with our current approach"
Green Flag: Clear YES → Schedule demo/pilot for them
Soft YES: Send materials, follow up in 2 weeks
NO: Thank them, add to "maybe later" list
```
**Q11: Introductions**
```
"Kennst du andere Stadtwerke, die ähnliche Probleme haben?"
Goal: Get warm referrals
- "Can you introduce me to [competitor]?"
- "Would you be willing to be a reference?"
Green Flag: YES on both → builds your network, proof
```
---
## Data Collection Template
Create a Spreadsheet with:
### Dashboard für Piloten (Wöchentlicher Report per E-Mail)
```
| Stadtwerk | Size | Contact | Q1 Method | Q2 Cost/Error | Q3 Pain | Q4 Appetite | Q5 Adoption | Q6 Integration | Q7 Budget | Q8 Timeline | Score | Notes |
|-----------|------|---------|-----------|----------------|---------|-------------|------------|-----------------|-----------|-----------|-------|-------|
| Stadt A | 100K | John D. | Manual 50%| €60K, 30% err | High | Yes | 30% | SAP, open | €80K | 2 months | 9/10 | Hot lead |
| Stadt B | 50K | Jane S. | Manual 70%| €40K, 25% err | Medium | Maybe | 15% | Legacy, closed | €30K | 6 months | 5/10 | Cold |
Betreff: Ihre InnungsApp Wochenbericht — KW 12
Aktivität diese Woche:
✓ 45 von 78 Mitgliedern waren aktiv (58 %)
✓ 2 Beiträge veröffentlicht → Ø Leserate: 71 %
✓ 3 neue Terminanmeldungen
✓ 4 Aufrufe der Lehrlingsbörse (2 Kontaktanfragen)
Vergleich zu letzter Woche:
↑ +12 % mehr aktive Nutzer
↑ +8 % höhere Leserate
Ausstehend:
⚠ 14 Mitglieder haben sich noch nie eingeloggt → [Erinnerung senden]
```
---
## GO / NO-GO Decision Framework
### Scoring Matrix
Assign points (1-10) for each question:
```
Q1: Current Manual Process = ___ / 10
(1 = fully automated, 10 = fully manual)
Q2: Annual Cost/Pain = ___ / 10
(1 = <€10K cost, 10 = >€100K cost)
Q3: Frustration Level = ___ / 10
(1 = not a priority, 10 = huge pain)
Q4: Appetite for Change = ___ / 10
(1 = no, 10 = ready to sign today)
Q5: Customer Adoption = ___ / 10
(1 = <5%, 10 = >50%)
Q6: Integration Feasible = ___ / 10
(1 = impossible, 10 = very easy)
Q7: Budget Available = ___ / 10
(1 = <€20K, 10 = >€100K)
Q8: Fast Decision = ___ / 10
(1 = >6 months, 10 = <1 month)
TOTAL SCORE = Sum of all 8 / 8 × 10 = ___ / 10
```
### Go/No-Go Thresholds
```
SCORE 8-10: HOT LEADS (Definitely pursue)
- Validate further with second/third meetings
- Structure a pilot/POC
- These are your first customers
SCORE 6-7: WARM LEADS (Worth pursuing)
- Keep in pipeline
- Send materials, stay in touch
- Re-engage in 3 months
SCORE 4-5: COOL LEADS (Maybe later)
- Add to "monitor" list
- Market conditions may change
- Re-approach in 6 months
SCORE <4: COLD LEADS (Not a fit now)
- Don't waste time
- Maybe re-approach if product evolves
```
### Portfolio Requirements for GO Decision
After 10-15 interviews, you need:
```
✅ GO Signal if:
- 5+ Hot Leads (score 8-10) with €50K+ budget each
- OR 10+ Warm Leads (score 6-7) that could grow into hot
- Average adoption estimate: >20%
- Average budget: >€50K/year
- Market size (sum of budgets): >€500K
❌ NO-GO Signal if:
- <3 Hot Leads
- Average score <5
- Average adoption <10%
- Market size <€300K
- Pattern of "we'll wait and see"
```
---
## Competitive Positioning
During interviews, also ask:
```
"Are you evaluating any other solutions?"
Typical answers:
A) "Yes, we're in RFP with SAP/Oracle"
B) "No, but considering some options"
C) "Not actively, but open to ideas"
D) "We tried something similar, didn't work"
Analysis:
- A: You're late, but potentially better/cheaper/faster
- B: You're in conversation, good timing
- C: You're educating the market, need strong proof
- D: Understand what failed, position differently
Use competitive insights to position your MVP:
- Faster to market vs. SAP
- Cheaper than Oracle (€50K vs. €200K+)
- Mobile-first (better UX)
- Customizable (specific to Stadtwerke)
```
---
## Timeline & Execution
### Week 1: Prep & Recruiting
```
Mon-Tue:
- Create interview guide (this doc)
- Build data collection spreadsheet
- Create email template
Wed-Fri:
- Start outreach (LinkedIn, email, phone)
- Target: 10-15 interviews scheduled
Tools:
- Calendly for scheduling
- Zoom for remote calls
- Google Sheets for data
```
### Week 2-3: Conduct Interviews
```
Goal: 10-15 interviews (2 per day)
Prep for each:
- Research company (size, industry focus)
- Prepare notes
- Test audio/video
During:
- Record (with permission) or take notes
- Be friendly, genuine, listening-focused
- Don't pitch hard (listen more than talk)
After:
- Score each interview
- Add to spreadsheet
- Send thank you note + case study PDF
Time per interview: 20-30 min (including travel/setup)
Total time: ~10 hours for 15 interviews
```
### Week 4: Analysis & Decision
```
Mon-Tue:
- Score all interviews
- Aggregate data in spreadsheet
- Create summary report
Wed:
- Team discussion
- GO/NO-GO vote
Thu-Fri:
- If GO: Contact top Hot Leads about pilot
- If NO-GO: Pivot to different pain point or market
```
---
## Sample Report Template
After Week 3, create:
```
# VALIDATION REPORT: SmartMeter-Lite App
Date: [Date]
Conducted by: [Name]
## Executive Summary
- 12 Interviews conducted
- 7 Hot Leads (58%), 3 Warm Leads (25%), 2 Cold (17%)
- Average budget: €65K/year
- Market opportunity: €780K in year 1
- **Recommendation: GO** ✅
## Key Findings
1. Manual meter reading is REAL pain
- Avg cost: €50K/year per Stadtwerk
- Avg error rate: 28%
- Top frustration: Customer disputes
2. Market appetite is strong
- 58% very interested (Hot)
- 25% interested (Warm)
- Would pay avg €65K/year
3. Integration is manageable
- 75% have API-accessible systems
- 60% can integrate within 3-6 months
- Legacy systems are minority
4. Customer adoption is solid
- Avg expected adoption: 25%
- Range: 10-40%
- Key: Make it optional (don't force)
## Top Hot Leads
1. Stadtwerke München (100K households, €120K budget, Ready Now)
2. Stadtwerke Köln (80K households, €100K budget, 2-month timeline)
3. Stadtwerke Hamburg (120K households, €150K budget, 3-month timeline)
## Risk Factors
- SAP/Oracle competition (but they're slow)
- DSGVO compliance needed (doable)
- Integration complexity (manageable)
## Next Steps
1. Schedule pilots with top 3 Hot Leads
2. Get signed LOI (Letters of Intent)
3. Begin development in Week 5
4. Deliver MVP in 12 weeks
```
---
## Key Metrics to Track
After validation, you have:
```
√ Market Size: €X (sum of budgets from interviews)
√ Win Rate: Y% (Hot Leads / Total)
√ Sales Cycle: Z months (from interviews)
√ Product-Market Fit Score: __ / 10
√ Competitive Landscape: [Summary]
√ Go-to-Market Strategy: [Clear approach]
```
These become your baseline KPIs.
---
## What NOT to Do
**Don't pitch too hard**
- Interviews are for listening, not selling
- If they're interested, they'll ask for more
**Don't overcommit**
- "We can integrate with anything" - No, be honest
- "We can do custom features" - Not in MVP
**Don't ask leading questions**
- "You have problems with meter reading, right?" (Yes)
- Better: "Walk me through your process" (more honest)
**Don't ignore red flags**
- "Maybe next year" = Not a hot lead
- Lukewarm interest is not enough
**Don't over-engineer based on feedback**
- "We'd want X, Y, Z features"
- Don't add them to MVP - they add time/cost
- Just build #1 really well
---
## Success Looks Like
✅ You have 5+ Hot Leads wanting to pilot
✅ You have 3+ Letters of Intent (LOI)
✅ You understand the real problem deeply
✅ You have clear competitive positioning
✅ You know your Unit Economics (€15-30K CAC, €80-120K ACV)
✅ You're confident in 20%+ adoption rates
✅ You can articulate WHY Stadtwerke need this
If you have all of this, you're ready to build.
---
**Document Status:** Ready to execute
**Time to Complete:** 3-4 weeks
**Output:** GO/NO-GO + 5+ pilot customers (if GO)

View File

@@ -1,458 +0,0 @@
# DETAILLIERTE USE CASES UND SZENARIEN
## SCHMERZEN AUS DER PRAXIS
### 1. ZÄHLERABLESUNG - Kundenbeispiel
**Persona: Anna Mueller, 45 Jahre, Hamburg**
**Szenario - PROBLEM:**
- Anna erhält am 3. Januar ein Schreiben: "Bitte Zählerstand mitteilen bis 15. Januar"
- Sie macht ein Foto ihres Meters, schreibt aber versehentlich 245821 statt 248521
- 3 Wochen später: Stadtwerke Hamburg schickt ihr eine Korrekturrechnung: -450 EUR
- Sie ist verwirrt: "Wieso so viel Rückzahlung?" → Anruf Kundenservice
- Wartezeit: 35 Minuten
- Mitarbeiter: "Das ist wahrscheinlich eine Fehlablesung gewesen, wir müssen nachrecherchieren"
- Auflösung: 10 Tage später Bestätigung
- **Zeitaufwand für Anna:** 2h (Anrufen, Mailen, Nachdenken)
- **Kosten für Stadtwerke:** 50 EUR (Bearbeitungszeit) + Verwaltungsaufwand
**Lösung - SmartMeter-Lite:**
```
Tag 1: Anna nimmt Foto des Meters
→ App erkennt automatisch: 248521
→ Verifikation: "Stimmt das?" → Ja/Nein
→ Bestätigung an Stadtwerke
Benefit für Anna:
- Schnell (30 Sekunden)
- Sicher (OCR prüft auf Fehler)
- Transparent (Sofortige Bestätigung)
Benefit für Stadtwerke:
- 95% weniger Fehler
- 80% weniger Serviceanfragen
- Automatische Verarbeitung
```
---
### 2. ABSCHLAGSRECHNUNG - Kundenbeispiel
**Persona: Klaus Schmidt, 67 Jahre, München**
**Szenario - PROBLEM:**
```
Abschlagsrechnung Januar 2024: 189 EUR/Monat
Klaus denkt: "Das ist ja viel mehr als letztes Jahr (143 EUR)"
Er versucht, die Rechnung zu verstehen:
- Arbeitspreis: 0,4827 EUR/kWh (was ist normal?)
- Grundgebühr: 12,45 EUR/Monat (wozu?)
- Netzentgelt: 8,37 EUR/Monat (was ist das?)
- Konzessionsabgabe: 2,19 EUR/Monat
- Energiesteuer: 5,00 EUR/Monat
- MwSt: 21.1 EUR
Ergebnis: Klaus versteht nichts!
Nächster Schritt: Telefonanruf Stadtwerke München
Wartezeit: 22 Minuten
Kundenservicemitarbeiter erklärt eine Stunde lang alle Gebühren
Klaus' Missverständnis: "Aber wieso wird es teurer, wenn ich weniger verbrauche?"
Antwort: "Wegen der gestiegenen Strompreise, die haben sich verdoppelt"
Klaus: "Warum teilt ihr mir das nicht vorher mit?"
Resultat: Klaus ist verärgert, beschwert sich per Mail
(nie beantwortet)
```
**Lösung - AbschlagAssistant:**
```
Schritt 1: Klaus loggt sich ein
Schritt 2: "Mein Abschlag ist zu hoch" → Klick
Schritt 3: System zeigt interaktive Erklärung:
VISUELLES DASHBOARD:
┌─────────────────────────────────────┐
│ Ihre Abschlagsrechnung erklärt │
├─────────────────────────────────────┤
│ Stromverbrauch: 45 kWh × 0,4827 = 21,72 EUR
│ Grundgebühr (Monatlich): 12,45 EUR
│ Netzentgelt: 8,37 EUR
│ Steuern & Abgaben: 18,19 EUR
│ ─────────────────────────────────────
│ TOTAL ABSCHLAG: 189,00 EUR/Monat
└─────────────────────────────────────┘
VERGLEICH ZUM VORJAHR:
Dezember 2023: 143 EUR
Dezember 2024: 189 EUR (+32%)
WARUM?
- Strompreis +18% (2023→2024)
- Dein Verbrauch war gleichbleibend
- Gas-Anteile: +15% Kosten
MÖGLICHE MASSNAHMEN:
- Abschlag reduzieren auf 165 EUR? (→ Nachzahlung im März)
- Abschlag halten? (→ Rückzahlung zu erwarten)
- Sparmassnahmen? (→ Tipps siehe unten)
[ABSCHLAG ÄNDERN BUTTON]
```
**Benefits für Klaus:**
- Versteht jetzt warum die Rechnung höher ist
- Kann selbst entscheiden, wie sein Abschlag aussieht
- Keine Warteschlange, keine Missverständnisse
**Benefits für Stadtwerke München:**
- 50% weniger Anrufe zu Abschlag
- Klaus ist jetzt zufriedener (NPS +20)
- Weniger Beschwerdebriefe
---
### 3. ENTSTÖRUNG (Stromausfall) - Kundenbeispiel
**Persona: Familie Bergmann, 3 Kinder, Berlin**
**Szenario - PROBLEM:**
```
Mittwoch 14:30 Uhr: Plötzlich ist der Strom weg!
Familie Bergmann:
- Kinder können keine Hausaufgaben machen
- Mutter macht sich Sorgen um den Gefrierschrank (Essen)
- Vater ruft Berliner Wasserbetriebe an
HOTLINE:
Wartezeit: 15 Minuten
Die Ansage sagt: "Aktuelle Wartezeit: 25 Minuten"
Nach 25 Minuten:
Kundenservicemitarbeiter: "Ja, wir haben einen Ausfall in Ihrem Gebiet"
Familie: "Wann ist der behoben?"
Mitarbeiter: "Ich weiß es nicht, wahrscheinlich in 2-3 Stunden"
Familie: "Was kann ich tun?"
Mitarbeiter: "Nichts, warten Sie"
Resultat:
- Lebensmittel verderben
- Kinder sind frustriert
- Familie wartet 4 Stunden ohne Updates
- Techniker kommt um 18:45, behebt um 19:00
- Familie hat Strom um 19:15 wieder (fast 5 Stunden ohne Update)
```
**Lösung - OutageAlert Pro:**
```
TIMELINE:
14:30 Uhr - Stromausfall
14:32 - OutageAlert erkennt Ausfall automatisch
14:33 - Familie Bergmann erhält SMS:
"⚠️ Stromausfall in Ihrem Gebiet
Bezirk Mitte, Prenzlauer Berg
Ursache: Schaden an Leitung
ETA: 17:30 Uhr
Live-Status: [LINK]"
14:35 - Familie öffnet App/Website
LIVE MAP zeigt:
- Rote Zone = Betroffenes Gebiet
- Grüne Zone = Funktionierend
- Status: 156 Haushalte betroffen
- Techniker auf dem Weg (ETA: 15:15)
15:10 - SMS Update: "Techniker vor Ort, arbeitet an der Behebung"
App aktualisiert: Status = "In Bearbeitung"
ETA neu: 16:30 Uhr
16:25 - SMS: "Problem behoben, Stromzuführung wird hergestellt"
App: Status = "Wiederherstellung läuft"
16:35 - Strom ist wieder da!
SMS: "Ihr Strom ist wieder da. Berichtet ist ok?"
[LINK zu Feedback-Formular]
RESULTAT:
- Familie weiß immer Bescheid
- Keine frustrierenden Anrufe
- Transparenz = Vertrauen
- Weniger Ängstlichkeit/Besorgnis
BENEFIT METRIKEN:
- Hotline-Anrufe: -65%
- Zufriedenheit bei Ausfällen: +75%
- Vertrauen in Stadtwerk: +50%
```
---
### 4. KUNDENSERVICE - Kundenbeispiel
**Persona: Sabine Weber, 52 Jahre, Köln**
**Szenario - PROBLEM:**
```
Montag 09:00 - Sabine hat eine Frage zu ihrer Rechnung
Sie versucht verschiedene Kanäle:
VERSUCH 1: Telefon
Hotline: "Alle Mitarbeiter sind beschäftigt. Wartezeit: 32 Minuten"
Sabine wartet 32 Minuten, wird dann durchgestellt
Support: "Hallo, worum geht es?"
Sabine erklärt ihr Problem (2 Minuten)
Support: "Das ist eine gute Frage, ich muss das checken"
Support recherchiert (5 Minuten)
Support: "Ich bin mir nicht sicher. Lassen Sie mich das weiterleiten"
Sabine muss aufgelegt und wird später angerufen
Resultat: Mittwoch 14:00 Rückruf, aber Sabine ist nicht erreichbar
VERSUCH 2: E-Mail
Sabine schreibt eine E-Mail Freitag 19:00
Montagmorgen 09:00 Antwort: "Vielen Dank für Ihre Frage. Wir kümmern uns darum."
Donnerstagabend: Inhaltliche Antwort kommt: "Ihre Gebühren wurden korrekt berechnet"
Sabine: "Aber warum wurde ich nicht gefragt??"
RESULTAT:
- 6 Tage Wartezeit
- Keine zufriedenstellende Antwort
- Fühlt sich ignoriert
- Schreiben 1-Stern-Bewertung online
NET PROMOTER SCORE: -50 (sehr unzufrieden)
```
**Lösung - Kundenservice 360:**
```
TIMELINE:
Montag 09:00 - Sabine öffnet Website
Sie sieht einen Chat-Button "Fragen? Wir helfen!"
Montag 09:05 - Sabine chattet:
"Warum ist meine Rechnung diesen Monat 45 EUR teurer?"
Bot versteht die Frage und antwortet SOFORT (< 3 Sekunden):
"Hallo Sabine! 👋
Ihre Rechnung ist teurer wegen:
1. Strompreiserhöhung (ab Januar +18%)
2. Abschlag erhöht (angepasst an höheren Verbrauch)
Weitere Infos? → [LINK zu Erklär-Video]
Hilft das weiter?
- Ja, danke!
- Nein, ich habe mehr Fragen
- Möchte mit Mitarbeiter sprechen"
Sabine: "Nein, ich habe mehr Fragen"
Bot: "Gerne! Welche?"
Sabine: "Kann ich meinen Abschlag senken?"
Bot: "Ja! Sie können über unser Portal den Abschlag ändern.
Falls Sie nicht sicher sind, hier ist eine Anleitung: [VIDEO]
Oder möchten Sie einen echten Mitarbeiter sprechen?
(Wartezeit: 2 Minuten)"
Sabine: "Ja, bitte mit Mitarbeiter"
Bot: "Sehr gerne. Ein Mitarbeiter nimmt sich Ihrer an.
Hier ist eine Ticketnummer für Ihre Nachverfolgung: #456789"
WARTESCHLANGE:
[Ticket-Info wird an Mitarbeiter übertragen]
- Kundenname: Sabine Weber
- Chathistorie: (Vollständig sichtbar)
- Thema: Abschlag zu hoch
- Situation: Verstanden, braucht Beratung
Mitarbeiter: "Hallo Sabine, ich bin Marco. Ich habe unseren Chat gelesen.
Lass mich dir schnell zeigen, wie du dich einen optimalen Abschlag berechnest..."
RESULTAT:
- Sabine bekommt innerhalb von 2 Minuten erste Antwort (Bot)
- Bei komplizierten Fragen: Max. 2 Minuten Wartezeit
- Mitarbeiter hat vollständigen Kontext
- Ticket wird automatisch verfolgt
- NPS: +80 (sehr zufrieden)
BONUS - Automatische Follow-Up:
Dienstag 10:00 - E-Mail an Sabine:
"Danke für das Gespräch gestern!
Wir haben deinen Abschlag auf 165 EUR gesenkt.
Neue Rechnung: [PDF]
Rückmeldung zur Lösung:
- Geholfen? [JA] [NEIN] [NEUTRAL]
- Zufrieden mit Service? [⭐⭐⭐⭐⭐]"
Sabine: [JA] + [⭐⭐⭐⭐⭐]
```
---
### 5. ABRECHNUNG & RECHNUNGSVERSTÄNDNIS - Kundenbeispiel
**Persona: Hans Mueller, 68 Jahre, Düsseldorf**
**Szenario - PROBLEM:**
```
Hans erhält seine Jahresabrechnung.
Rückerstattung: 312 EUR
Hans ist verwirrt:
"Wieso geben mir die Stadtwerke Geld zurück?"
Die Rechnung (1 Seite, klein gedruckt):
- Verbrauch: 4.203 kWh
- Arbeitspreis 0,4827 EUR/kWh = 2.030 EUR
- Grundgebühr: 149,40 EUR
- Netzentgelt: 100,44 EUR
- Konzessionsabgabe: 26,28 EUR
- Energiesteuer: 60,00 EUR
- MwSt: 209,01 EUR
- Abschlagszahlungen: 2.667,00 EUR (12 × 222,25 EUR)
- DIFFERENZ: -312 EUR (Guthaben)
Hans: "Ok, also habe ich zu viel bezahlt. Aber WARUM?
Mein Verbrauch ist normal, die Preise auch..."
Hans schaut auf die Rechnung: Keine Erklärung.
Hans ruft Düsseldorf Stadtwerke an.
Wartezeit: 18 Minuten
Support: "Ihre Abschlagsrechnungen waren zu hoch geschätzt"
Hans: "Aber wie hätte ich das wissen können? Ihr hättet mir doch sagen können!"
Support: "Ja, aber das ist schwierig automatisch abzurechnen"
Hans: "Das ist euer Job, oder?"
Supportmitarbeiter: *unbeholfen* "Ja, entschuldigen Sie"
RESULTAT:
- Hans ist verärgert
- Er vertraut den Stadtwerken nicht mehr
- Nächstes Jahr wird er selbst kontrollieren wollen
- 1-Stern-Bewertung online
```
**Lösung - RechnungsAnalyzer+:**
```
Hans erhält SMS am Tag der Rechnungstellung:
"Ihre Jahresabrechnung ist online. Sie erhalten 312 EUR Guthaben.
Hier ist die Erklärung: [LINK]"
Hans klickt den Link:
RECHNUNG ERKLÄRT
═════════════════════════════════════════════════════
IHRE VERBRAUCHSENTWICKLUNG
┌────────────────────────────────┐
│ 2023: 4.203 kWh (Baseline) │
│ 2024: 4.187 kWh (-16 kWh) │
│ Veränderung: -0.4% │
└────────────────────────────────┘
KOSTENAUFSCHLÜSSELUNG
┌────────────────────────────────┐
│ Arbeitspreis: 2.030 EUR │
│ Netzentgelt: 100 EUR │
│ Grundgebühr: 149 EUR │
│ Steuern/Abgaben: 252 EUR │
│ ─────────────────────────────────
│ GESAMTKOSTEN: 2.531 EUR │
│ IHRE ZAHLUNG: 2.667 EUR │
│ ─────────────────────────────────
│ GUTHABEN: 312 EUR │
└────────────────────────────────┘
WARUM GUTHABEN?
Du hast im Schnitt 222,25 EUR pro Monat abgeschlagen.
Deine tatsächlichen Kosten waren nur 210,92 EUR/Monat.
Differenz: 11,33 EUR × 12 Monate = 136 EUR
+ Einsparungen durch weniger Verbrauch = 176 EUR
= Gesamt Guthaben: 312 EUR ✓
AKTION:
[Guthaben auszahlen (ca. 5-7 Tage)]
[In nächsten Abschlag verrechnen]
[Abschlag anpassen (neu: 210 EUR/Monat)]
[Als Spende an lokale Umweltprojekte]
VERGLEICH ZUM VORJAHR
2023 Guthaben: 89 EUR
2024 Guthaben: 312 EUR (+250%)
Das ist höher als normal. Wir haben es überprüft:
→ Dein Verbrauch ist gleich
→ Aber Strom war günstiger diesen Sommer
→ Du hast richtig gespart! 🎉
WOLLEN SIE IHREN ABSCHLAG ÄNDERN?
Empfohlen: 195 EUR/Monat (statt 222,25 EUR)
[JA, ÄNDERN]
```
**Benefits für Hans:**
- Er versteht jetzt warum das Guthaben
- Er sieht, dass er Energie spart
- Transparenz schafft Vertrauen
- Er wird sogar zufriedener mit den Stadtwerken!
- NPS: +60 (vorher -20)
**Benefit für Düsseldorf Stadtwerke:**
- 200+ Serviceanfragen pro Monat zu Rückerstattungen → 80% gespart
- Hans wird Fan der Stadtwerke (Word-of-Mouth)
- Weniger Reklamationen (-40%)
---
## ZUSAMMENFASSUNG: EMOTIONALE IMPACT
| Use Case | Vorher | Nachher |
|----------|--------|---------|
| **Zählerablesung** | 😤 Fehlerhafte Ablesungen, Frustration | 😊 Sicher, schnell, transparent |
| **Abschlag** | 😠 Verwirrung, Unverständnis | 😌 Durchschaubar, kontrollierbar |
| **Entstörung** | 😨 Angst, Ungewissheit | 😊 Informiert, vertrauensvoll |
| **Support** | 😤 Lange Wartezeiten, frustriert | 😄 Schnelle Hilfe, verstanden |
| **Abrechnung** | 😕 Komplex, Misstrauen | 😌 Klar, transparent, zufrieden |
**Durchschnittlicher NPS-Lift:** +70 Punkte
**Durchschnittliche Kundenzufriedenheit:** +45%
---
## MESSBARE BUSINESS OUTCOMES
### Für Stadtwerke
| KPI | Baseline | Mit Lösung | Impact |
|-----|----------|-----------|--------|
| Hotline Anrufe/Monat | 2.500 | 800 | -68% |
| Avg. Response Time | 45 min | 2 min | -95% |
| Abrechnungsbeschwerde | 280 | 45 | -84% |
| Churn Rate (monatlich) | 2.8% | 2.1% | -25% |
| NPS | 28 | 65 | +37 Punkte |
| Support Kostenersparnis | - | 450K EUR/Jahr | - |
### Für Endkunden
| Vorteil | Quantitativ |
|--------|------------|
| Zeit gespart (jährlich) | 4-6 Stunden |
| Besseres Verständnis | 85% vs. 35% |
| Zufriedenheit | +45% |
| Vertrauen in Stadtwerk | +60% |
| Energiekostenersparnis | 8-12% (durch bessere Einsicht) |

35
dsvgo+.md Normal file
View File

@@ -0,0 +1,35 @@
1. Pflichtdokumente (Rechtstexte)
Impressum (Anbieterkennzeichnung):
Wo? Auf der Landingpage, im Admin-Dashboard und in der mobilen App (meist im Einstellungs-Menü).
Was muss rein? Name, Adresse, Rechtsform (z. B. GmbH, UG), Vertretungsberechtigte, Kontaktmöglichkeiten (E-Mail, Telefon), Handelsregister (falls vorhanden) und USt-IdNr.
Darf maximal 2 Klicks entfernt sein (Impressumspflicht nach § 5 DDG).
Datenschutzerklärung:
Wo? Auf der Landingpage, im Admin-Dashboard und in der mobilen App. Außerdem musst du sie beim Einreichen der App in den Apple App Store und Google Play Store verlinken.
Was muss rein? Welche Daten du sammelst (z. B. E-Mail, IP-Adresse, hochgeladene Dateien), auf welcher Rechtsgrundlage (z. B. Vertragserfüllung für die App-Nutzung), wie lange du sie speicherst und an wen du sie weitergibst (deine Hosting-Anbieter). Du musst auch über die Nutzerrechte (Auskunft, Löschung) aufklären.
2. Cookie-Banner & Tracking (TDDDG)
Technisch notwendige Cookies: Wenn du nur Cookies für den Login-Check nutzt (das macht better-auth voraussichtlich mit Session-Cookies), brauchst du kein nerviges Cookie-Banner. Du musst diese Cookies nur in der Datenschutzerklärung erwähnen.
Tracking & Analytics: Falls du auf der Landingpage oder in der App Dinge wie Google Analytics, Facebook Pixel, Mixpanel oder PostHog einbaust, musst du dir vorher die aktive, freiwillige Zustimmung der Nutzer holen (Cookie-Banner / Consent Dialog in der App).
3. Account-Löschung (Besonders wichtig für die App Stores!)
Sowohl Apple als auch Google schreiben mittlerweile streng vor, dass Nutzer, die in einer App ein Konto erstellen können, dieses Konto auch in der App wieder löschen können müssen.
Wichtig: Es reicht nicht, das Konto nur zu deaktivieren. Die User-Daten müssen in der Datenbank gelöscht werden (Ausnahme: Aufbewahrungspflichten wie Rechnungen).
Du brauchst zudem einen Web-Link, über den Nutzer die Löschung außerhalb der App beantragen/durchführen können (z. B. auf deiner Landingpage).
4. Verträge zur Auftragsverarbeitung (AV-Verträge / DPA)
Du darfst personenbezogene Daten nicht einfach so auf fremden Servern speichern, ohne einen Vertrag mit dem Anbieter zu haben (Art. 28 DSGVO). Du brauchst (bzw. musst digital akzeptieren) AV-Verträge von:
Deinem Server/Hosting-Anbieter (z. B. Hetzner, Vercel, AWS).
Dem Anbieter deines SMTP-Servers (der die E-Mails wie Magic Links versendet).
Jedem externen Tool, das Nutzerdaten sieht (z. B. Sentry für Error Tracking, falls genutzt).
5. Technische Sicherheit & Grundsätze (In deiner App)
Datenminimierung: Sammle nur Daten, die du wirklich für die App brauchst.
Verschlüsselung: Alle Verbindungen (EXPO_PUBLIC_API_URL und NEXT_PUBLIC_APP_URL) müssen im Live-Betrieb zwingend über HTTPS laufen.
Passwörter/Sicherheit: Da du better-auth nutzt, wird das Thema Passwortverschlüsselung & Session-Management glücklicherweise schon sicher für dich geklärt, aber du bist dennoch für die sichere Konfiguration verantwortlich (z. B. einen sicheren BETTER_AUTH_SECRET im Live-Betrieb nutzen).
Server-Standort: Achte darauf, wo deine SQLite-Datenbank bzw. dein Server liegt. Ein Serverstandort in Deutschland oder der EU macht den Datenschutz erheblich einfacher, da du keine komplizierten "Drittland-Transfers" belegen musst.
6. Spezifische App Store Anforderungen
Apple App Store ("App Privacy"): Du musst in App Store Connect genaue Fragen beantworten (welche Daten sammelst du? Sind sie mit dem Benutzer verknüpft? Wofür werden sie genutzt?), die dann als "Privacy Nutrition Labels" im App Store angezeigt werden.
Google Play Store ("Data Safety"): Ähnliches Formular in der Google Play Console. Auch hier musst du erklären, was du sammelst, ob es verschlüsselt ist und ob der Nutzer die Löschung beantragen kann.
Zusammenfassende To-Do-Liste für den Live-Gang:
Impressum erstellen und in Web/App verlinken.
Datenschutzerklärung für App und Webseite generieren lassen (geht gut über Tools wie eRecht24, IT-Recht Kanzlei oder den Datenschutz-Generator von Dr. Schwenke).
Einen "Account Löschen"-Button tief in den App-Einstellungen einbauen.
AV-Verträge mit dem Hoster (und z. B. dem E-Mail-Provider) abschließen (sind meist nur 2 Klicks im Dashboard der Anbieter).
SSL/HTTPS auf dem Server aktivieren.

239
email.md Normal file
View File

@@ -0,0 +1,239 @@
# InnungsApp Outreach Emails
## Allgemeine Verband-Varianten
### Variante 1: Standardisierung / Kontrolle
**Betreff:** Ihre Innungen digital einheitlich organisieren
**Betreff:** Digitale Infrastruktur fuer Ihre angeschlossenen Innungen
Hallo Herr/Frau [Nachname],
viele Kreishandwerksverbaende koordinieren heute 20 oder mehr Innungen, ohne ein gemeinsames System fuer Kommunikation, Termine und Mitgliedsinfos.
Das Ergebnis ist meist:
- Excel-Listen
- Rundmails ohne Rueckmeldung
- WhatsApp als inoffizieller Kanal
- kein einheitlicher Standard ueber alle Innungen hinweg
Genau dafuer haben wir `InnungsApp` gebaut: eine Verbandsloesung, mit der Sie Kommunikation und Organisation ueber angeschlossene Innungen hinweg standardisieren koennen.
Der Einstieg ist einfach:
- Verband-Setup
- Start mit 3 Pilot-Innungen
- danach schrittweiser Rollout auf weitere Innungen
Haetten Sie naechste Woche 20 Minuten fuer einen kurzen Austausch?
Viele Gruesse
[Name]
### Variante 2: DSGVO / WhatsApp-Risiko
**Betreff:** WhatsApp und Excel sind kein System fuer einen Verband
**Betreff:** DSGVO-sichere Kommunikation fuer Ihre Innungen
Hallo Herr/Frau [Nachname],
bei vielen Kreishandwerksverbaenden laeuft die Kommunikation mit angeschlossenen Innungen noch ueber Rundmails, Excel und teils WhatsApp-Strukturen.
Fuer einzelne Faelle funktioniert das irgendwie. Auf Verbandsebene ist es meist:
- schwer steuerbar
- nicht einheitlich
- kaum auswertbar
- DSGVO-seitig unnoetig riskant
`InnungsApp` hilft Kreishandwerksverbaenden, genau das zentraler und professioneller aufzusetzen, ohne jede Innung einzeln mit Inselloesungen arbeiten zu lassen.
Unser Modell:
- Setup auf Verbandsebene
- Einfuehrung mit 3 Innungen
- danach Rollout im Verband
Wenn das grundsaetzlich relevant klingt, zeige ich Ihnen das gern in 20 Minuten.
Beste Gruesse
[Name]
### Variante 3: Geschaeftsfuehrer-Hook / Fuehrungsaufgabe
**Betreff:** Wie steuern Sie heute die Digitalisierung Ihrer Innungen?
**Betreff:** Ein Standard statt 20 Einzelloesungen
Hallo Herr/Frau [Nachname],
eine Frage aus echtem Interesse:
Wie stellen Sie heute sicher, dass Ihre angeschlossenen Innungen bei Kommunikation, Terminen und Mitgliederorganisation nicht alle unterschiedlich arbeiten?
Genau dort sehen wir bei vielen Kreishandwerksverbaenden einen Engpass:
kein gemeinsamer Standard, hoher Koordinationsaufwand und wenig Transparenz.
`InnungsApp` ist dafuer als Verbandsloesung gedacht:
- zentral aufgesetzt
- fuer erste 3 Innungen eingefuehrt
- dann auf weitere Innungen ausrollbar
Wenn das Thema bei Ihnen aktuell oder perspektivisch relevant ist, schicke ich gern eine kurze Uebersicht oder zeige es in einer 20-Minuten-Demo.
Viele Gruesse
[Name]
### Variante 4: Rollout mit wenig Risiko
**Betreff:** Verbandsweite Digitalisierung ohne Big-Bang-Einfuehrung
**Betreff:** Erst 3 Innungen, dann Verbands-Rollout
Hallo Herr/Frau [Nachname],
oft ist nicht die Idee das Problem, sondern das Einfuehrungsrisiko:
"Nutzen die Innungen das wirklich?"
"Muss das erst durch Vorstand und Gremien?"
"Wie startet man so etwas praktisch?"
Deshalb haben wir den Einstieg fuer Kreishandwerksverbaende bewusst schlank gedacht:
- Verband-Setup
- Start mit 3 Innungen
- klarer Rollout-Plan fuer weitere Innungen
`InnungsApp` buendelt Kommunikation, Termine und Mitgliederinfos in einer gemeinsamen Struktur statt in vielen Einzelprozessen.
Waere ein kurzer Termin sinnvoll, damit ich Ihnen den Ablauf einmal kompakt zeige?
Beste Gruesse
[Name]
### Variante 5: Outcome / Entlastung
**Betreff:** Weniger Koordinationsaufwand fuer Ihre Innungen
**Betreff:** Kommunikation und Termine nicht mehr ueber Excel + Rundmail
Hallo Herr/Frau [Nachname],
wir sprechen gerade mit Kreishandwerksverbaenden, die ihre angeschlossenen Innungen organisatorisch entlasten wollen.
Das Muster ist oft gleich:
- Mitgliederinfos liegen verteilt
- Rundschreiben werden verschickt, aber nicht sauber nachverfolgt
- Termine und Rueckmeldungen laufen uneinheitlich
Mit `InnungsApp` koennen Verbaende dafuer einen gemeinsamen digitalen Standard schaffen, statt jede Innung einzeln improvisieren zu lassen.
Der Einstieg erfolgt nicht als harter Komplett-Rollout, sondern strukturiert:
- Setup auf Verbandsebene
- Einfuehrung in 3 Innungen
- anschliessende Ausweitung
Falls das bei Ihnen in den naechsten Monaten ein Thema ist, koennen wir gern 20 Minuten sprechen.
Viele Gruesse
[Name]
### Variante 6: Sehr kurz / direkt
**Betreff:** Loesung fuer Kreishandwerksverbaende
**Betreff:** 20 Minuten zu einem Verbands-Rollout?
Hallo Herr/Frau [Nachname],
wir bauen `InnungsApp` fuer Kreishandwerksverbaende, die Kommunikation und Organisation ueber mehrere Innungen hinweg einheitlicher aufsetzen wollen.
Statt Excel, Rundmail und Inselloesungen:
- Verband-Setup
- Start mit 3 Innungen
- danach Rollout
Ist das ein Thema, das bei Ihnen aktuell relevant ist?
Viele Gruesse
[Name]
## HGF / Geschaeftsfuehrer-Versionen
### HGF Version 1: Haerter / direkter
**Betreff:** Viele Verbaende arbeiten noch ohne gemeinsamen digitalen Standard
**Betreff:** Excel, Rundmail, WhatsApp: kein belastbares System fuer einen Verband
Hallo Herr/Frau [Nachname],
viele Kreishandwerksverbaende steuern ihre angeschlossenen Innungen noch ohne einheitliches digitales System.
Das fuehrt fast immer zu denselben Problemen:
- jede Innung arbeitet anders
- Kommunikation laeuft ueber Rundmails statt ueber einen steuerbaren Kanal
- Informationen sind verteilt statt zentral
- der Verband hat wenig Transparenz und wenig Standardisierung
Genau dafuer haben wir `InnungsApp` entwickelt.
Nicht als Einzelloesung fuer eine Innung, sondern als Struktur auf Verbandsebene:
- ein gemeinsamer Rahmen fuer Kommunikation, Termine und Mitgliederinformationen
- Start mit 3 Innungen
- danach geordneter Rollout auf weitere Innungen
Wenn das Thema bei Ihnen relevant ist, lohnt sich ein kurzer Austausch.
Haetten Sie naechste oder uebernaechste Woche 20 Minuten?
Viele Gruesse
[Name]
### HGF Version 2: Waermer / eleganter
**Betreff:** Digitale Struktur fuer Ihre angeschlossenen Innungen
**Betreff:** Ein einheitlicher Rahmen fuer Kommunikation und Organisation im Verband
Hallo Herr/Frau [Nachname],
ich beschaeftige mich aktuell intensiv mit der Frage, wie Kreishandwerksverbaende ihre angeschlossenen Innungen digital besser unterstuetzen und gleichzeitig organisatorisch entlasten koennen.
In vielen Gespraechen zeigt sich ein aehnliches Bild:
- Kommunikation laeuft ueber verschiedene Kanaele nebeneinander
- Ablaeufe unterscheiden sich stark zwischen den Innungen
- es fehlt ein gemeinsamer, professioneller Standard auf Verbandsebene
Mit `InnungsApp` haben wir eine Loesung entwickelt, die genau an diesem Punkt ansetzt:
- Kommunikation, Termine und Mitgliederinformationen in einer gemeinsamen Struktur
- Einfuehrung nicht als grosser Komplettwechsel, sondern kontrolliert
- Start mit 3 Innungen, danach schrittweise Ausweitung
Fuer Geschaeftsfuehrer ist vor allem interessant, dass dadurch nicht nur Prozesse digitaler werden, sondern auch Steuerbarkeit und Aussenwirkung des Verbands verbessert werden.
Wenn Sie moechten, zeige ich Ihnen das gern in einem kompakten Termin.
Viele Gruesse
[Name]
### HGF Version 3: Sehr kurz unter 120 Woertern
**Betreff:** Digitaler Standard fuer Ihre Innungen
**Betreff:** 20 Minuten zu einem Verbands-Rollout?
Hallo Herr/Frau [Nachname],
viele Kreishandwerksverbaende arbeiten bei Kommunikation und Organisation ihrer Innungen noch mit einem Mix aus Rundmail, Excel und Einzelloesungen.
`InnungsApp` ist dafuer als Verbandsloesung gedacht:
- gemeinsamer Standard fuer Kommunikation, Termine und Mitgliederinfos
- Start mit 3 Innungen
- danach Rollout auf weitere angeschlossene Innungen
Der Nutzen fuer den Verband:
- mehr Standardisierung
- mehr Steuerbarkeit
- weniger Inselloesungen
Falls das grundsaetzlich relevant ist, zeige ich Ihnen das gern in 20 Minuten.
Viele Gruesse
[Name]
## Einsatzempfehlung
- Erstkontakt: `Variante 6` oder `HGF Version 3`
- Etwas haerterer Erstkontakt: `HGF Version 1`
- Konservativer oder waermerer Ton: `HGF Version 2`
- Wenn DSGVO im Fokus steht: `Variante 2`
- Wenn Einfuehrungsangst dominiert: `Variante 4`

View File

@@ -1,552 +0,0 @@
# IMPLEMENTIERUNGS-ROADMAP: Deutsche Stadtwerke Software-Lösungen
## EXECUTIVE SUMMARY
Diese Roadmap beschreibt die Umsetzung von 5 hochprioritt Softwarelösungen für deutsche Stadtwerke, um ihre größten Pain Points zu adressieren. Die Gesamtmarktmöglichkeit wird auf **10-25 Millionen EUR** in den nächsten 3 Jahren geschätzt.
---
## I. PROJEKT-TIMELINE (Gesamtdauer: 6 Monate)
### PHASE 1: DISCOVERY & VALIDATION (Woche 1-3)
#### 1.1 Stakeholder-Interviews
**Ziel:** Requirements und Pain Points validieren
**Zu befragende Stakeholder:**
- Stadtwerk-Manager (Kundenservice, IT, Geschäftsführung) - 10 Interviews
- Endkunden (verschiedene Demografie) - 20 Interviews
- Support-Mitarbeiter (um echte Schmerzen zu verstehen) - 8 Interviews
**Interview-Guide Beispiele:**
```
Für Stadtwerk-Manager:
1. Welche sind Ihre Top 3 Kundenservice-Probleme?
2. Wie viele Anrufe/E-Mails pro Tag zu Zählerablesung?
3. Was kostet Sie die manuelle Zählerablesung pro Jahr?
4. Haben Sie ein bestehendes Ticketing-System?
5. Welche technischen Systeme sind im Einsatz? (SAP, Oracle, etc.)
Für Endkunden:
1. Was ist Ihr größtes Problem mit Ihrer Stadtwerk?
2. Wie oft rufen Sie an oder mailen wegen Abrechnung?
3. Verstehen Sie Ihre Rechnung vollständig?
4. Würden Sie eine App nutzen für Zählerablesung? (Was kostet's?)
```
**Deliverable:** Research-Report mit Top 10 Anforderungen
#### 1.2 Marktforschung
- Konkurrenzanalyse (ähnliche Lösungen, Preise, Features)
- Benchmarking gegen europäische Stadtwerke (Wien, Zürich, Amsterdam)
- Regulatory Landscape (DSGVO, BSI-Anforderungen)
**Deliverable:** Competitive Intelligence Report
#### 1.3 Prototyping
- Low-Fidelity Wireframes für Top 3 Pain Points
- Interactive Prototypes (Figma)
- User Testing Sessions (5-10 Kunden)
**Deliverable:** Design-System & Prototype Library
**Budget Phase 1:** 25.000 - 35.000 EUR
---
### PHASE 2: MVP-ENTWICKLUNG PRIORITÄT 1 (Woche 4-9)
#### 2.1 Projekt: SmartMeter-Lite App (Pain Point #1)
**Sprint 0-1 (Woche 4-5): Setup & Architektur**
- [ ] Development Environment Setup
- [ ] CI/CD Pipeline (GitHub Actions / GitLab CI)
- [ ] Database Schema Design (PostgreSQL)
- [ ] API Specification (OpenAPI/Swagger)
- [ ] Security Architecture Review (OWASP Top 10)
**Tech-Stack Decision:**
```
Frontend:
- React Native (iOS + Android)
- or Flutter (Alternative)
- State Management: Redux / MobX
- UI Library: React Native Paper / Material Design
Backend:
- Node.js + Express / Fastify
- or Python + FastAPI
- Authentication: JWT + OAuth2
- Database: PostgreSQL 13+
- Caching: Redis
ML/OCR:
- TensorFlow Lite (Mobile)
- or Tesseract (Open Source)
- Cloud Option: Google Vision API / Azure Computer Vision
```
**Sprint 1-2 (Woche 5-6): OCR-Integration**
- [ ] OCR Model Selection & Testing
```
Task: Test 3 OCR Optionen
1. Google Vision API ($$, best accuracy)
2. Tesseract (Free, 80% accuracy)
3. AWS Textract ($, good balance)
Evaluation: Accuracy, Latency, Cost
```
- [ ] Training Data Collection (100+ Meter Bilder)
- [ ] OCR Pipeline Development
- [ ] Error Handling & User Feedback Loop
**Sprint 2-3 (Woche 6-7): Backend Development**
- [ ] User Authentication API
- [ ] Meter Reading CRUD API
- [ ] Consumption Analytics API
- [ ] Notification Service (Push, SMS, Email)
- [ ] Database Migrations & Backups
**Sprint 3-4 (Woche 7-8): Mobile App Development**
- [ ] Auth Screen (Login, Registration, Password Reset)
- [ ] Camera Integration & Photo Upload
- [ ] Dashboard with Consumption Charts
- [ ] History & Reporting
- [ ] Settings & Notifications
- [ ] Testing (Unit + Integration + E2E)
**Sprint 4 (Woche 8-9): Integration & Testing**
- [ ] Backend Integration Tests
- [ ] API Gateway & Rate Limiting
- [ ] Performance Testing
- [ ] Security Testing (Penetration Test)
- [ ] User Acceptance Testing (UAT) mit 10-20 Beta Usern
**Deliverable:**
- Produktionsreife iOS + Android App
- REST API mit Dokumentation
- Dashboard für Admin/Stadtwerk
- Beta User Feedback & Metrics
**Team & Budget Phase 2:**
- 1 Frontend Lead
- 2 React Native Entwickler
- 1 Backend Lead
- 1 Python Developer (ML/OCR)
- 1 QA Engineer
- Budget: 60.000 - 100.000 EUR
---
### PHASE 2B: MVP-ENTWICKLUNG PRIORITÄT 2 (Parallel, Woche 4-9)
#### 2.2 Projekt: AbschlagAssistant Web-Tool (Pain Point #2)
**Sprint 0-1 (Woche 4-5): Requirements & Design**
- [ ] Tariff Data Model Design
- [ ] Calculation Rules Definition
- [ ] UI/UX Design (Figma)
- [ ] API Specification
**Sprint 1-2 (Woche 5-6): Backend Development**
- [ ] Rule Engine Implementation (Drools / Easy Rules)
- [ ] Tariff Database (PostgreSQL)
- [ ] Calculation Engine (Node.js)
- [ ] Scenario Simulation API
**Sprint 2-3 (Woche 6-7): Frontend Development**
- [ ] Abschlag-Simulator Interface
- [ ] Transparency Dashboard
- [ ] What-If Scenarios
- [ ] Historical Comparisons
**Sprint 3-4 (Woche 8-9): Integration & Launch**
- [ ] Backend Integration
- [ ] Testing & Optimization
- [ ] UAT mit Stadtwerken
**Deliverable:**
- Web-based Abschlag-Simulator
- Admin Dashboard
- Integration Documentation
**Team & Budget Phase 2B:**
- 1 Fullstack Developer
- 1 Rule Engine Specialist
- 1 QA Engineer
- Budget: 40.000 - 60.000 EUR
**Total Phase 2 Budget:** 100.000 - 160.000 EUR
---
### PHASE 3: SCALE & PHASE 2 LÖSUNGEN (Woche 10-16)
#### 3.1 Projekt: OutageAlert Pro (Pain Point #3)
**Timeline: 10-14 Wochen**
**Architektur:**
```
Frontend:
- Website (React)
- Mobile App (React Native)
- Real-time Updates (WebSocket)
Backend:
- Node.js / Go
- Real-time Server (Socket.io / SignalR)
- Mapping Service (Mapbox)
- SCADA Integration Layer
Database:
- PostgreSQL (Primary)
- Redis (Real-time Cache)
- InfluxDB (Metrics)
Integrations:
- Twilio (SMS/WhatsApp)
- Firebase Cloud Messaging
- SCADA Systems (Modbus, IEC 60870-5-104)
```
**Key Features:**
1. Live Outage Map
2. Automated Notifications (SMS, Push, Email)
3. Technician Tracking & ETA
4. Incident Reporting System
5. Analytics & Predictive Maintenance
**Development Steps:**
- Week 1-2: System Integration & SCADA Connection
- Week 3-4: Real-time Infrastructure (WebSocket, Caching)
- Week 5-6: Frontend (Website + App)
- Week 7-8: Notification Service
- Week 9-10: Technician App & Tracking
- Week 11-12: Analytics & Machine Learning (Outage Prediction)
- Week 13-14: Testing & Deployment
**Team:** 2 Backend, 2 Frontend, 1 DevOps, 1 QA
**Budget: 120.000 - 180.000 EUR**
#### 3.2 Projekt: Kundenservice 360 (Pain Point #4)
**Timeline: 12-16 Wochen**
**Architecture:**
```
Chatbot & NLP:
- Rasa / OpenAI GPT
- Intent Recognition
- Entity Extraction
- Multi-Language Support
Support Ticketing:
- Custom Built oder Zendesk Integration
- Workflow Automation (Zapier)
- Knowledge Base Management
Integration Connectors:
- Website Chat
- WhatsApp Business API
- Email (Gmail, Office 365)
- SMS (Twilio)
- Phone (Asterisk / FreePBX)
Analytics:
- Customer Satisfaction (CSAT)
- First Response Time
- Resolution Time
- Agent Performance Metrics
```
**Development Steps:**
- Week 1-2: Chatbot Training Data Collection
- Week 3-5: Chatbot Development & Training
- Week 6-7: Ticketing System
- Week 8-9: Multi-Channel Integrations
- Week 10-11: Knowledge Base & Analytics
- Week 12-14: Agent Tools & Dashboard
- Week 15-16: Testing & Deployment
**Team:** 2 ML Engineers (Chatbot), 2 Backend, 2 Frontend, 1 QA
**Budget: 150.000 - 220.000 EUR**
#### 3.3 Projekt: RechnungsAnalyzer+ (Pain Point #5)
**Timeline: 10-14 Wochen**
**Architecture:**
```
Frontend:
- React Dashboard
- Mobile App
- PDF Viewer
Backend:
- OCR Service (Tesseract / Azure)
- Bill Parser
- Payment Gateway Integration
- Analytics Engine
Integrations:
- Payment Providers (Stripe, Adyen, Paypal)
- Email Integration
- PDF Processing (pdfkit)
- Accounting Systems (SAP, Oracle)
```
**Features:**
1. Digital Bill Archive with OCR Indexing
2. Visual Bill Explanation
3. Consumption Trend Analysis
4. Flexible Payment Options
5. Automated Payment Plans
6. Anomaly Detection
7. Export Tools (PDF, CSV)
8. Dispute Management
**Development Steps:**
- Week 1-2: Bill Parsing & OCR Integration
- Week 3-5: Dashboard Development
- Week 6-7: Analytics Engine
- Week 8-9: Payment Integration
- Week 10-11: Archive & Export Tools
- Week 12-13: Mobile App
- Week 14: Testing & Deployment
**Team:** 2 Backend, 2 Frontend, 1 Data Scientist, 1 QA
**Budget: 140.000 - 200.000 EUR**
**Total Phase 3 Budget: 410.000 - 600.000 EUR**
---
## II. ORGANISATION & TEAM
### Empfohlene Größe Entwicklungsteam
**Für MVP (Phase 1-2): 8-10 Personen**
- 1 Product Manager / Scrum Master
- 1 UX/UI Designer
- 2 Full-Stack / Frontend Developer
- 2 Backend Developer
- 1 ML/AI Specialist (OCR)
- 1 DevOps / Infrastructure
- 1 QA Engineer
- 1 Product Owner
**Für Scale (Phase 3): 15-18 Personen**
- +3 Backend Developer
- +2 Frontend Developer
- +1 Senior Architect
- +1 Security Engineer
- +1 Solutions Engineer (für B2B Sales Support)
### Organisationsmodell: Agile/Scrum
**Sprint Duration:** 1 Woche (für schnelle Iteration)
**Ceremonies:**
- Daily Standup: 15 min (9:00 AM)
- Sprint Planning: 2h (Montags)
- Backlog Refinement: 1h (Mi)
- Sprint Review/Demo: 1.5h (Freitags)
- Retrospective: 1h (Freitags)
---
## III. TECH STACK EMPFEHLUNG
### Frontend
```
Web:
- React 18+ / Vue 3
- TypeScript
- TailwindCSS / Material-UI
- State: Redux Toolkit / Pinia
- Testing: Jest + React Testing Library
Mobile:
- React Native / Flutter
- TypeScript
- Navigation: React Navigation / GetX
- State: Redux / Provider
```
### Backend
```
Language: Node.js + TypeScript / Python
Framework: Express / Fastify (Node) oder FastAPI (Python)
Authentication: JWT + OAuth2 (Google, Microsoft)
Database: PostgreSQL 13+
Caching: Redis
Message Queue: RabbitMQ / Kafka
API Documentation: Swagger/OpenAPI
```
### Infrastructure
```
Cloud: AWS / Azure / GCP
Containerization: Docker
Orchestration: Kubernetes (EKS/AKS/GKE)
CI/CD: GitHub Actions / GitLab CI
Monitoring: DataDog / New Relic / Prometheus
Logging: ELK Stack / Cloudwatch
```
### Security
```
- OWASP Top 10 Compliance
- Encryption: TLS 1.3, AES-256 at rest
- Authentication: 2FA / MFA
- API Rate Limiting & DDoS Protection
- Penetration Testing (quarterly)
- Bug Bounty Program
- DSGVO Compliance (Privacy by Design)
```
---
## IV. GO-TO-MARKET STRATEGIE
### Phase 1: Direct Sales an Stadtwerke
**Target Profile:**
- Stadtwerke mit 100.000+ Kundenbasis
- Annual Revenue > 100 Mio EUR
- Bereits digitalisierungsorientiert
**Top 20 Target Accounts:**
1. Stadtwerke München
2. Stadtwerke Berlin
3. Stadtwerke Köln
4. Stadtwerke Hamburg
5. Stadtwerke Stuttgart
6. Stadtwerke Düsseldorf
7. Stadtwerke Frankfurt
8. Stadtwerke Dortmund
9. Stadtwerke Essen
10. Stadtwerke Leipzig
... (weitere 10)
**Sales Strategie:**
- **Inbound:** Content Marketing, Thought Leadership, Webinars
- **Outbound:** Executive Outreach (VP IT, VP Customer Service)
- **Partnership:** Zusammenarbeit mit Verbänden (VKU - Verband Kommunaler Unternehmen)
**Sales Cycle:** 60-90 Tage (für komplexere Integrationen)
**Deal Size:** 50.000 - 300.000 EUR (ACV)
### Phase 2: Partnership mit Integratoren
- SAP/Oracle Implementation Partner
- Telekommunikations-Provider (Deutsche Telekom, Vodafone)
### Phase 3: B2C/Direct-to-Consumer
- App Store Optimization (ASO)
- Organic Social Media
- Influencer Partnerships (Energiespartipps)
---
## V. REVENUE PROJECTIONS
### Szenario A: Conservative (30% Market Penetration)
**Year 1 (6 Monate Post-Launch):**
- Pain Point #1 (SmartMeter): 5 Stadtwerke × 80K = 400K EUR
- Pain Point #2 (Abschlag): 8 Stadtwerke × 60K = 480K EUR
- **Total Y1:** 880K EUR (+ recurring: 50K/month baseline)
**Year 2:**
- Installed Base: 30 Stadtwerke
- Average Contract Value: 120K EUR/year
- **Total Y2:** 3.6M EUR
**Year 3:**
- Installed Base: 60 Stadtwerke
- **Total Y3:** 7.2M EUR
### Szenario B: Aggressive (50% Market Penetration)
**Year 1:** 2.0M EUR
**Year 2:** 8.0M EUR
**Year 3:** 15.0M EUR
### Szenario C: B2C Additional Revenue
**B2C Premium Feature (optional):**
- 500K early adopters × 2 EUR/month = 12M EUR annual run-rate (Year 3)
---
## VI. RISIKEN & MITIGATION
### Risiko #1: Lange Sales Cycles
**Mitigation:** Freemium-Modell für Endkunden, Proof-of-Concepts mit schnellen ROI-Demonstrationen
### Risiko #2: Integration mit Legacy Systemen
**Mitigation:** Dediziertes Integration Team, API-Wrapper für alte Systeme, Fallback-Szenarien
### Risiko #3: Regulatorische/Compliance Anforderungen
**Mitigation:** Early DSGVO/BSI-Audits, Legal Review, Compliance Budget +20%
### Risiko #4: Technische Schulden
**Mitigation:** Agile Development mit regelmäßigen Refactorings, Code Reviews, Tech Debt Tracking
### Risiko #5: Konkurrenz durch interne IT der Stadtwerke
**Mitigation:** Partnerschaften, weiße Label Lösungen, beste Practices aus vielen Stadtwerken
---
## VII. SUCCESS METRICS
### Business Metrics
- **ARR (Annual Recurring Revenue):** Ziel: 5M EUR Ende Year 2
- **Churn Rate:** < 5% pro Jahr
- **NRR (Net Revenue Retention):** > 120%
- **CAC (Customer Acquisition Cost):** < 40K EUR
- **LTV:CAC Ratio:** > 3:1
### Product Metrics
- **User Adoption:** > 40% der Endkunden nutzen App innerhalb 6 Monate
- **DAU/MAU:** > 30% MAU sind DAU
- **NPS (Net Promoter Score):** > 50
- **Feature Usage:** Top Features genutzt von > 80% Users
### Operational Metrics
- **System Uptime:** > 99.9%
- **Average Response Time:** < 200ms (API)
- **Support Response Time:** < 2h (B2B), < 30min (Incident)
- **Bug Escape Rate:** < 5% (kritisch Bugs in Production)
---
## VIII. BUDGET SUMMARY
| Phase | Dauer | Budget |
|-------|-------|--------|
| Phase 1: Discovery | 3 Wo. | 25K - 35K |
| Phase 2: MVP Development | 6 Wo. | 100K - 160K |
| Phase 3: Scale | 6 Wo. | 410K - 600K |
| Marketing & Sales (Year 1) | - | 50K - 100K |
| **Total MVP-to-Launch** | **15 Wo.** | **585K - 895K EUR** |
**Mit Overhead (HR, Admin, Facility):** ~700K - 1.1M EUR für erste 15 Wochen
---
## IX. NEXT IMMEDIATE STEPS (Diese Woche)
- [ ] Executive Sponsor identifizieren
- [ ] Founding Team zusammenstellen (Leiter Tech, Product, Sales)
- [ ] Investor Meeting vorbereiten (wenn VC-Finanzierung geplant)
- [ ] Erste 3 Stadtwerke für Interviews kontaktieren
- [ ] Development Environment einrichten
- [ ] Tool Selection (Design, Development, Collaboration)

44
innungsapp/.dockerignore Normal file
View File

@@ -0,0 +1,44 @@
# Dependencies (rebuilt in Docker)
node_modules
**/node_modules
# Next.js build cache
**/.next
**/out
# Expo / Mobile (not needed for admin Docker build)
apps/mobile
# Dev databases
**/*.db
**/*.db-journal
**/*.db-wal
**/*.db-shm
# Uploads (mounted as volume)
apps/admin/uploads
# Env files
**/.env
**/.env.local
**/.env.development
**/.env.production
# Git
.git
.gitignore
# Logs
**/*.log
**/npm-debug.log*
# TypeScript build info
**/*.tsbuildinfo
# OS
.DS_Store
Thumbs.db
# IDE
.vscode
.idea

47
innungsapp/.env.example Normal file
View File

@@ -0,0 +1,47 @@
# =============================================
# DATABASE (PostgreSQL)
# =============================================
POSTGRES_DB="innungsapp"
POSTGRES_USER="innungsapp"
POSTGRES_PASSWORD="innungsapp"
DATABASE_URL="postgresql://innungsapp:innungsapp@localhost:5432/innungsapp?schema=public"
# =============================================
# BETTER-AUTH
# =============================================
BETTER_AUTH_SECRET="change-me-to-a-random-32-char-string"
BETTER_AUTH_URL="http://localhost:3000"
# =============================================
# EMAIL (SMTP for magic links & invitations)
# =============================================
EMAIL_FROM="noreply@innungsapp.de"
SMTP_HOST="smtp.example.com"
SMTP_PORT="587"
SMTP_SECURE="false"
SMTP_USER=""
SMTP_PASS=""
# =============================================
# ADMIN APP (Next.js)
# =============================================
NEXT_PUBLIC_APP_URL="http://localhost:3000"
NEXT_PUBLIC_POSTHOG_KEY=""
NEXT_PUBLIC_POSTHOG_HOST="https://us.i.posthog.com"
# =============================================
# SUPERADMIN SEED
# =============================================
SUPERADMIN_EMAIL="superadmin@innungsapp.de"
SUPERADMIN_PASSWORD="change-me-strong-password"
# =============================================
# MOBILE APP (Expo)
# =============================================
EXPO_PUBLIC_API_URL="http://localhost:3000"
# =============================================
# FILE UPLOADS
# =============================================
UPLOAD_DIR="./uploads"
UPLOAD_MAX_SIZE_MB="10"

View File

@@ -0,0 +1,34 @@
# =============================================
# Produktion — .env Vorlage
# Kopieren als: innungsapp/.env
# =============================================
# Database (PostgreSQL)
POSTGRES_DB="innungsapp"
POSTGRES_USER="innungsapp"
POSTGRES_PASSWORD="change-this-db-password"
DATABASE_URL="postgresql://innungsapp:change-this-db-password@postgres:5432/innungsapp?schema=public"
# Auth — UNBEDINGT ändern!
BETTER_AUTH_SECRET="min-32-zeichen-langer-zufalls-string"
BETTER_AUTH_URL="https://yourdomain.com"
# Email (SMTP)
EMAIL_FROM="noreply@yourdomain.com"
SMTP_HOST="smtp.example.com"
SMTP_PORT="587"
SMTP_SECURE="false"
SMTP_USER="user@example.com"
SMTP_PASS="your-smtp-password"
# Öffentliche URLs
NEXT_PUBLIC_APP_URL="https://yourdomain.com"
NEXT_PUBLIC_POSTHOG_KEY=""
NEXT_PUBLIC_POSTHOG_HOST="https://us.i.posthog.com"
# Superadmin Seed
SUPERADMIN_EMAIL="superadmin@yourdomain.com"
SUPERADMIN_PASSWORD="change-this-superadmin-password"
# Uploads
UPLOAD_MAX_SIZE_MB="10"

36
innungsapp/.gitignore vendored Normal file
View File

@@ -0,0 +1,36 @@
# Dependencies
node_modules
.pnp
.pnp.js
# Build outputs
.next
dist
build
out
# Turbo
.turbo
# Environment
.env
.env.local
.env.production
.env.staging
# Uploads (local file storage)
apps/admin/uploads/
# Expo
apps/mobile/.expo
apps/mobile/android
apps/mobile/ios
# OS
.DS_Store
Thumbs.db
# Editor
.vscode
.idea
*.swp

185
innungsapp/CLAUDE.md Normal file
View File

@@ -0,0 +1,185 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
**InnungsApp** is a multi-tenant SaaS platform for German trade guilds (Innungen). It consists of:
- **Admin Dashboard**: Next.js 15 web app for guild administrators
- **Mobile App**: Expo React Native app for guild members (iOS + Android)
- **Shared Package**: Prisma ORM schema, types, and utilities
## Commands
All commands run from `innungsapp/` root unless noted.
```bash
# Development
pnpm install # Install all workspace dependencies
pnpm dev # Start all apps in parallel (Turborepo)
# Per-app dev
pnpm --filter admin dev # Admin only (Next.js on :3000)
pnpm --filter mobile dev # Mobile only (Expo)
cd apps/mobile && npx expo run:android
cd apps/mobile && npx expo run:ios
# Type checking & linting
pnpm type-check # tsc --noEmit across all apps
pnpm lint # ESLint across all apps
# Database (Prisma via shared package)
pnpm db:generate # Regenerate Prisma client after schema changes
pnpm db:migrate # Run migrations (dev)
pnpm db:push # Push schema without migration (prototype)
pnpm db:studio # Open Prisma Studio
pnpm db:seed # Seed with test data
pnpm db:reset # Drop + re-migrate + re-seed
# Deployment
vercel --cwd apps/admin # Deploy admin to Vercel
cd apps/mobile && eas build --platform all --profile production
cd apps/mobile && eas submit --platform all
```
## Architecture
### Monorepo Structure
- **pnpm Workspaces + Turborepo** — `apps/admin`, `apps/mobile`, `packages/shared`
- `packages/shared` exports Prisma client, schema types, and shared utilities
- Both apps import from `@innungsapp/shared`
### Data Flow
```
Mobile App (Expo)
▼ HTTP (tRPC)
Admin App (Next.js API Routes)
▼ Prisma ORM
PostgreSQL Database
```
The mobile app calls the admin app's tRPC API (`/api/trpc`). There is no separate backend — the Next.js app serves both the admin UI and the API.
### tRPC Procedure Hierarchy
Three protection levels in `apps/admin/server/trpc.ts`:
- `publicProcedure` — No auth
- `protectedProcedure` — Session required
- `memberProcedure` — Session + valid org membership (injects `orgId` and `role`)
Routers are in `apps/admin/server/routers/`: `members`, `news`, `termine`, `stellen`, `organizations`.
### Multi-Tenancy
Every resource (member, news, event, job listing) is scoped to an `Organization`. The `memberProcedure` extracts `orgId` from the session and all queries filter by it. Org plan types: `pilot`, `standard`, `pro`, `verband`.
### Authentication
- **better-auth** with magic links (email-based, passwordless)
- Admin creates a member → email invitation sent via SMTP → member sets up account
- Session stored in DB; mobile app persists session token in AsyncStorage
- Auth handler: `apps/admin/app/api/auth/[...all]/route.ts`
### Mobile Routing (Expo Router)
File-based routing with two route groups:
- `(auth)/` — Login, check-email (unauthenticated)
- `(app)/` — Tab navigation: home, members, news, stellen, termine, profil (requires session)
Zustand (`store/auth.store.ts`) holds auth state; React Query handles server state via tRPC.
### Admin Routing (Next.js App Router)
- `/login` — Magic link login
- `/dashboard` — Protected layout with sidebar
- `/dashboard/mitglieder` — Member CRUD
- `/dashboard/news` — News management
- `/dashboard/termine` — Event management
- `/dashboard/stellen` — Job listings
- `/dashboard/einstellungen` — Org settings (AVV acceptance)
File uploads are stored locally in `apps/admin/uploads/` and served via `/api/uploads/[...path]`.
### Environment Variables
Required in `apps/admin/.env` (see `.env.example`):
- `DATABASE_URL` — PostgreSQL connection
- `BETTER_AUTH_SECRET` / `BETTER_AUTH_URL` — Auth config
- `SMTP_*` — Email for magic links
- `NEXT_PUBLIC_APP_URL` — Admin public URL
- `EXPO_PUBLIC_API_URL` — Mobile points to admin API
- `UPLOAD_DIR` / `UPLOAD_MAX_SIZE_MB` — File storage
## Planned: SQLite → PostgreSQL Migration
The current schema uses **SQLite** (`packages/shared/prisma/schema.prisma`). The migration target is **PostgreSQL** (production-grade, enables JSONB and native arrays).
### What changes in `schema.prisma`
```prisma
datasource db {
provider = "postgresql" // was: "sqlite"
url = env("DATABASE_URL")
}
```
### Fields to convert to `@db.JsonB`
These fields are currently stored as JSON-encoded `String?` in SQLite and must become proper JSONB columns in PostgreSQL:
| Model | Field | Prisma annotation |
|---|---|---|
| `Organization` | `landingPageFeatures` | `@db.JsonB` |
| `Organization` | `landingPageFooter` | `@db.JsonB` |
Example after migration:
```prisma
landingPageFeatures Json? @map("landing_page_features") @db.JsonB
landingPageFooter Json? @map("landing_page_footer") @db.JsonB
```
### Fields to convert to native PostgreSQL arrays
`Organization.sparten` is stored as `String?` (comma-separated or JSON) in SQLite. In PostgreSQL it becomes:
```prisma
sparten String[] @default([])
```
### Migration steps
1. Provision a PostgreSQL instance (Supabase, Neon, or self-hosted via Docker).
2. Set `DATABASE_URL` to a `postgresql://` connection string.
3. Update `schema.prisma`: change `provider`, add `@db.JsonB` and `String[]` types.
4. Run `pnpm db:generate` to regenerate the Prisma client.
5. Create a fresh migration: `pnpm db:migrate` (this creates `packages/shared/prisma/migrations/…`).
6. All code that currently parses `landingPageFeatures` / `landingPageFooter` as `JSON.parse(string)` must switch to reading them directly as objects (Prisma returns them as `unknown` / `JsonValue`).
### Docker Compose (local PostgreSQL)
Add a `postgres` service to `docker-compose.yml`:
```yaml
postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: innungsapp
POSTGRES_USER: innungsapp
POSTGRES_PASSWORD: secret
volumes:
- pg_data:/var/lib/postgresql/data
ports:
- "5432:5432"
volumes:
pg_data:
```
Then set `DATABASE_URL=postgresql://innungsapp:secret@localhost:5432/innungsapp`.
## Key Conventions
- **Styling**: Tailwind CSS in admin; NativeWind v4 (Tailwind syntax) in mobile
- **Validation**: Zod schemas defined inline with tRPC procedures
- **Dates**: `date-fns` for formatting
- **Icons**: `lucide-react` (admin), `@expo/vector-icons` (mobile)
- **Schema changes**: Always run `pnpm db:generate` after editing `packages/shared/prisma/schema.prisma`
- **tRPC client (mobile)**: configured in `apps/mobile/lib/trpc.ts`, uses `superjson` transformer
- **Enum fields**: Stored as `String` in SQLite (enforced via Zod); after PostgreSQL migration, consider converting to native `enum` types

327
innungsapp/README.md Normal file
View File

@@ -0,0 +1,327 @@
# InnungsApp
Digitale Plattform fuer Innungen mit Admin-Dashboard (Next.js) und Mobile App (Expo).
## Stack
| Layer | Technology |
|---|---|
| Monorepo | pnpm Workspaces + Turborepo |
| Admin Dashboard | Next.js 15 (App Router) |
| Mobile App | Expo + React Native |
| API | tRPC v11 |
| Auth | better-auth (magic links + credential login) |
| Database | PostgreSQL + Prisma ORM (`jsonb` fuer Landing-Page-Felder) |
| Styling | Tailwind CSS (admin), NativeWind (mobile) |
## Projektstruktur
```text
innungsapp/
|-- apps/
| |-- admin/
| `-- mobile/
|-- packages/
| `-- shared/
| `-- prisma/
|-- docker-compose.yml
`-- README.md
```
## Local Setup
Port-Hinweis:
- Ohne Docker (lokales `pnpm dev`): App typischerweise auf `http://localhost:3000`
- Mit Docker Compose: App auf `http://localhost:3010` (Container-intern weiter `3000`)
### Voraussetzungen
- Node.js >= 20
- pnpm >= 9
- SMTP-Zugang (fuer Einladungen und Magic Links)
### 1. Abhaengigkeiten installieren
```bash
pnpm install
```
### 2. Umgebungsvariablen setzen (Admin lokal)
```bash
cp .env.example .env
```
Danach `.env` anpassen (mindestens `DATABASE_URL`, `BETTER_AUTH_SECRET`, SMTP-Werte).
### 3. DB vorbereiten (lokal)
Lokale PostgreSQL-DB starten (nur falls noch nicht aktiv):
```bash
docker compose up -d postgres
```
Prisma vorbereiten:
```bash
pnpm db:generate
pnpm db:push
```
Optional Demo-Daten:
```bash
pnpm db:seed
pnpm db:seed-superadmin
```
### 4. Entwicklung starten
```bash
pnpm --filter @innungsapp/admin dev
pnpm --filter @innungsapp/mobile dev
```
Oder parallel:
```bash
pnpm dev
```
## Production Deployment (Docker, Admin)
Dieser Abschnitt ist der verbindliche Weg fuer den Productive-Server.
### Voraussetzungen
- Linux Server mit Docker + Docker Compose
- DNS-Eintrag auf den Server
- SMTP-Zugangsdaten
- Reverse Proxy (z. B. Nginx) fuer HTTPS
### 1. Repository klonen
```bash
git clone <repo-url>
cd innungsapp
```
### 2. Production-Env anlegen
```bash
cp .env.production.example .env
```
Pflichtwerte in `.env`:
- `DATABASE_URL` (PostgreSQL DSN, z. B. `postgresql://innungsapp:...@postgres:5432/innungsapp?schema=public`)
- `POSTGRES_DB`
- `POSTGRES_USER`
- `POSTGRES_PASSWORD`
- `BETTER_AUTH_SECRET` (mindestens 32 Zeichen)
- `BETTER_AUTH_URL` (z. B. `https://app.deine-innung.de`)
- `NEXT_PUBLIC_APP_URL` (gleich wie `BETTER_AUTH_URL`)
- `EMAIL_FROM`
- `SMTP_HOST`
- `SMTP_PORT`
- `SMTP_SECURE`
- `SMTP_USER`
- `SMTP_PASS`
- `SUPERADMIN_EMAIL`
- `SUPERADMIN_PASSWORD`
### 3. Container bauen und starten
```bash
docker compose up -d --build
```
Hinweis zum DB-Start:
- Wenn Prisma-Migrationen vorhanden sind, wird `prisma migrate deploy` ausgefuehrt.
- Wenn keine Migrationen vorhanden sind, wird einmalig `prisma db push` ausgefuehrt.
### 4. Healthcheck und Logs pruefen
```bash
docker compose logs -f admin
curl -fsS http://localhost:3010/api/health
```
Erwartet: JSON mit `"status":"ok"`, z. B.
```json
{"status":"ok","timestamp":"2026-03-04T12:34:56.789Z"}
```
### 5. Superadmin anlegen (nur beim ersten Start)
```bash
docker compose exec -w /app admin node packages/shared/prisma/seed-superadmin.js
```
Login-Daten kommen aus `.env`:
- E-Mail: `SUPERADMIN_EMAIL`
- Passwort: `SUPERADMIN_PASSWORD`
Hinweis:
- In `NODE_ENV=production` bricht der Seed ab, wenn `SUPERADMIN_PASSWORD` fehlt.
- In Entwicklung wird ohne `SUPERADMIN_PASSWORD` als Fallback `demo1234` genutzt.
- Der Seed ist idempotent (`upsert`) und kann bei Bedarf erneut ausgefuehrt werden.
### 6. HTTPS (Reverse Proxy)
Nginx sollte auf `localhost:3010` weiterleiten und TLS terminieren.
Beispiel:
```nginx
server {
listen 80;
server_name app.deine-innung.de;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
server_name app.deine-innung.de;
ssl_certificate /etc/letsencrypt/live/app.deine-innung.de/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/app.deine-innung.de/privkey.pem;
location / {
proxy_pass http://localhost:3010;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
### 7. Updates einspielen
```bash
git pull
docker compose up -d --build
docker compose logs -f admin
```
### 8. Backup und Restore (Docker Volumes)
Vorher die exakten Volumenamen pruefen:
```bash
docker volume ls | grep pg_data
docker volume ls | grep uploads_data
```
Backup:
```bash
mkdir -p backups
docker run --rm \
-v innungsapp_pg_data:/volume \
-v "$(pwd)/backups:/backup" \
alpine sh -c "tar czf /backup/pg_data_$(date +%F_%H%M).tar.gz -C /volume ."
```
Restore (nur bei gestoppter App):
```bash
docker compose down
docker run --rm \
-v innungsapp_pg_data:/volume \
-v "$(pwd)/backups:/backup" \
alpine sh -c "rm -rf /volume/* && tar xzf /backup/<backup-file>.tar.gz -C /volume"
docker compose up -d
```
### 9. Verifizierte Kommandos (Stand 4. Maerz 2026)
Die folgenden Befehle wurden in dieser Umgebung erfolgreich ausgefuehrt:
```bash
# 1) Postgres starten (falls noch nicht aktiv)
docker compose up -d postgres
# 2) Prisma Client generieren
(cd packages/shared && npx prisma generate)
# 3) Initiale PostgreSQL-Migration erstellen (einmalig)
(cd packages/shared && \
DATABASE_URL="postgresql://innungsapp:innungsapp@localhost:5432/innungsapp?schema=public" \
npx prisma migrate dev --name init_postgres --schema=prisma/schema.prisma --create-only)
# 4) Migration anwenden
(cd packages/shared && \
DATABASE_URL="postgresql://innungsapp:innungsapp@localhost:5432/innungsapp?schema=public" \
npx prisma migrate deploy --schema=prisma/schema.prisma)
# 5) Gesamtes Setup bauen und starten
docker compose up -d --build
# 6) Superadmin seeden (mit ENV-Werten)
docker compose exec -e SUPERADMIN_EMAIL=superadmin@innungsapp.de \
-e SUPERADMIN_PASSWORD='demo1234' \
-w /app admin node packages/shared/prisma/seed-superadmin.js
# 7) Laufzeitstatus pruefen
docker compose ps
docker compose logs --tail 80 admin
curl -fsS http://localhost:3010/api/health
```
Optionale SQL-Verifikation (wurde ebenfalls erfolgreich getestet):
```bash
# JSONB-Spalten pruefen
docker compose exec -T postgres psql -U innungsapp -d innungsapp -c \
"SELECT column_name, data_type FROM information_schema.columns WHERE table_name = 'organizations' AND column_name IN ('landing_page_features','landing_page_footer') ORDER BY column_name;"
# Seeded Superadmin pruefen
docker compose exec -T postgres psql -U innungsapp -d innungsapp -c \
"SELECT u.email, u.role, u.email_verified, a.provider_id, (a.password IS NOT NULL) AS has_password FROM \"user\" u LEFT JOIN account a ON a.user_id = u.id AND a.provider_id = 'credential' WHERE u.email = 'superadmin@innungsapp.de';"
```
## Mobile Release (EAS)
```bash
cd apps/mobile
eas build --platform all --profile production
eas submit --platform all
```
Wichtig:
- In `apps/mobile/eas.json` sind Submit-Placeholders vorhanden und muessen ersetzt werden.
- Fuer Production darf keine API-URL auf `localhost` zeigen.
## Troubleshooting
### `migrate deploy` oder `db push` fehlschlaegt
- `DATABASE_URL` pruefen
- `postgres` Container Healthcheck pruefen (`docker compose ps`)
- Logs: `docker compose logs -f admin`
### Healthcheck liefert Fehler
- Containerstatus: `docker compose ps`
- App-Logs lesen
- Reverse Proxy testweise umgehen und direkt `http://localhost:3010/api/health` pruefen
### Login funktioniert nicht nach Seed
- Seed-Command erneut ausfuehren
- In DB pruefen, ob `user` und `account` Eintraege fuer `superadmin@innungsapp.de` existieren
## Weiterfuehrende Doku
- Produkt-Roadmap: `../ROADMAP.md`
- Architektur: `../ARCHITECTURE.md`
- API Design: `../API_DESIGN.md`

View File

@@ -0,0 +1,110 @@
# =============================================
# Stage 1: Dependencies
# =============================================
FROM node:20-slim AS deps
RUN corepack enable && corepack prepare pnpm@9.12.0 --activate
# Install OpenSSL for Prisma
RUN apt-get update && apt-get install -y openssl ca-certificates && rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Copy workspace config files
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml turbo.json ./
COPY apps/admin/package.json ./apps/admin/
COPY packages/shared/package.json ./packages/shared/
# Install all dependencies
RUN pnpm install --frozen-lockfile
# =============================================
# Stage 2: Build
# =============================================
FROM node:20-slim AS builder
RUN corepack enable && corepack prepare pnpm@9.12.0 --activate
# Install OpenSSL for Prisma
RUN apt-get update && apt-get install -y openssl ca-certificates && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY --from=deps /app/apps/admin/node_modules ./apps/admin/node_modules
COPY --from=deps /app/packages/shared/node_modules ./packages/shared/node_modules
COPY . .
# Generate Prisma client for Alpine Linux
RUN pnpm --filter @innungsapp/shared prisma:generate
# Accept build arguments for environment variables
ARG BETTER_AUTH_SECRET
ARG BETTER_AUTH_URL
ARG BETTER_AUTH_BASE_URL
ARG NEXT_PUBLIC_APP_URL
# Build the admin app
ENV NEXT_TELEMETRY_DISABLED=1
ENV DOCKER_BUILD=1
# Set environment variables from build args for Next.js build
ENV BETTER_AUTH_SECRET=$BETTER_AUTH_SECRET
ENV BETTER_AUTH_URL=$BETTER_AUTH_URL
ENV BETTER_AUTH_BASE_URL=$BETTER_AUTH_BASE_URL
ENV NEXT_PUBLIC_APP_URL=$NEXT_PUBLIC_APP_URL
RUN pnpm --filter @innungsapp/admin build
# =============================================
# Stage 3: Production Runner
# =============================================
FROM node:20-slim AS runner
RUN corepack enable && corepack prepare pnpm@9.12.0 --activate
# Install OpenSSL for Prisma
RUN apt-get update && apt-get install -y openssl ca-certificates wget && rm -rf /var/lib/apt/lists/*
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
# Create non-root user
RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nextjs
# Copy built output (standalone includes all necessary node_modules)
COPY --from=builder /app/apps/admin/.next/standalone ./
COPY --from=builder /app/apps/admin/.next/static ./apps/admin/.next/static
COPY --from=builder /app/apps/admin/public ./apps/admin/public
# Fix permissions so nextjs user can write to .next/cache at runtime
RUN chown -R nextjs:nodejs /app/apps/admin/.next
# Copy Prisma schema + migrations for runtime migrations
COPY --from=builder /app/packages/shared/prisma ./packages/shared/prisma
# Copy Prisma Client package for runtime seed scripts.
COPY --from=builder /app/node_modules/.pnpm/@prisma+client@5.22.0_prisma@5.22.0/node_modules/@prisma ./node_modules/@prisma
COPY --from=builder /app/node_modules/.pnpm/@prisma+client@5.22.0_prisma@5.22.0/node_modules/.prisma ./node_modules/.prisma
# Copy Prisma Engine binaries directly to .next/server (where Next.js looks for them)
COPY --from=builder /app/node_modules/.pnpm/@prisma+client@5.22.0_prisma@5.22.0/node_modules/.prisma/client/libquery_engine-debian-openssl-3.0.x.so.node /app/apps/admin/.next/server/
COPY --from=builder /app/node_modules/.pnpm/@prisma+client@5.22.0_prisma@5.22.0/node_modules/.prisma/client/schema.prisma /app/apps/admin/.next/server/
# Install Prisma CLI globally for runtime migrations
RUN npm install -g prisma@5.22.0
# Create uploads directory
RUN mkdir -p /app/uploads && chown nextjs:nodejs /app/uploads
# Copy entrypoint
COPY --from=builder /app/apps/admin/docker-entrypoint.sh ./docker-entrypoint.sh
RUN chmod +x ./docker-entrypoint.sh
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
ENTRYPOINT ["./docker-entrypoint.sh"]

View File

@@ -0,0 +1,70 @@
'use server'
import { auth, getSanitizedHeaders } from '@/lib/auth'
import { prisma } from '@innungsapp/shared'
// @ts-ignore
import { hashPassword } from 'better-auth/crypto'
export async function changePasswordAndDisableMustChange(prevState: any, formData: FormData) {
const newPassword = formData.get('newPassword') as string
const confirmPassword = formData.get('confirmPassword') as string
if (newPassword !== confirmPassword) {
return { success: false, error: 'Passwörter stimmen nicht überein.' }
}
if (newPassword.length < 8) {
return { success: false, error: 'Das Passwort muss mindestens 8 Zeichen lang sein.' }
}
const sanitizedHeaders = await getSanitizedHeaders()
const session = await auth.api.getSession({ headers: sanitizedHeaders })
if (!session?.user) {
return { success: false, error: 'Nicht authentifiziert.' }
}
const userId = session.user.id
// Hash and save new password directly — user is already authenticated so no old password needed
const newHash = await hashPassword(newPassword)
const credAccount = await prisma.account.findFirst({
where: { userId, providerId: 'credential' },
})
if (credAccount) {
await prisma.account.update({
where: { id: credAccount.id },
data: { password: newHash },
})
} else {
await prisma.account.create({
data: {
id: crypto.randomUUID(),
accountId: userId,
providerId: 'credential',
userId,
password: newHash,
},
})
}
// Clear mustChangePassword
await prisma.user.update({
where: { id: userId },
data: { mustChangePassword: false },
})
// Sign out so the user logs in fresh with the new password
try {
await auth.api.signOut({ headers: sanitizedHeaders })
} catch {
// ignore
}
return {
success: true,
error: '',
redirectTo: `/login?message=password_changed&callbackUrl=/dashboard`,
}
}

View File

@@ -0,0 +1,66 @@
'use client'
import { useEffect } from 'react'
import { useActionState } from 'react'
import { changePasswordAndDisableMustChange } from '../actions'
export function ForcePasswordChange({ slug }: { slug: string }) {
const [state, action, isPending] = useActionState(changePasswordAndDisableMustChange, { success: false, error: '', redirectTo: '' })
useEffect(() => {
if (state?.success && state?.redirectTo) {
window.location.href = state.redirectTo
}
}, [state?.success, state?.redirectTo])
return (
<div className="bg-white border rounded-xl p-8 max-w-md w-full shadow-sm">
<div className="mb-6">
<h1 className="text-xl font-bold text-gray-900 mb-2">Passwort festlegen</h1>
<p className="text-gray-500 text-sm">
Bitte vergeben Sie jetzt ein persönliches Passwort für Ihren Account.
</p>
</div>
<form action={action} className="space-y-4">
<input type="hidden" name="slug" value={slug} />
<div>
<label className="block text-xs font-semibold text-gray-400 uppercase tracking-wider mb-1">Neues Passwort</label>
<input
name="newPassword"
type="password"
required
minLength={8}
placeholder="Mindestens 8 Zeichen"
className="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-brand-500 outline-none transition-all"
/>
</div>
<div>
<label className="block text-xs font-semibold text-gray-400 uppercase tracking-wider mb-1">Passwort wiederholen</label>
<input
name="confirmPassword"
type="password"
required
minLength={8}
placeholder="••••••••"
className="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-brand-500 outline-none transition-all"
/>
</div>
{state?.error && (
<p className="text-xs text-red-600 bg-red-50 p-2 rounded">{state?.error}</p>
)}
<button
type="submit"
disabled={isPending}
className="w-full bg-gray-900 text-white py-2.5 rounded-lg text-sm font-medium hover:bg-gray-800 disabled:opacity-50 transition-all shadow-sm"
>
{isPending ? 'Speichern...' : 'Passwort festlegen'}
</button>
</form>
</div>
)
}

View File

@@ -0,0 +1,142 @@
'use client'
import { trpc } from '@/lib/trpc-client'
import { useState } from 'react'
export default function EinstellungenPage() {
const { data: org, isLoading } = trpc.organizations.me.useQuery()
const updateMutation = trpc.organizations.update.useMutation()
const avvMutation = trpc.organizations.acceptAvv.useMutation()
const [name, setName] = useState('')
const [contactEmail, setContactEmail] = useState('')
if (isLoading) return <div className="text-gray-500">Wird geladen...</div>
if (!org) return null
return (
<div className="max-w-2xl space-y-8">
<h1 className="text-2xl font-bold text-gray-900">Einstellungen</h1>
{/* Org Settings */}
<div className="bg-white rounded-lg border p-6 space-y-4">
<h2 className="font-semibold text-gray-900">Innung</h2>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Name der Innung</label>
<input
defaultValue={org.name}
onChange={(e) => setName(e.target.value)}
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Kontakt-E-Mail</label>
<input
type="email"
defaultValue={org.contactEmail ?? ''}
onChange={(e) => setContactEmail(e.target.value)}
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent"
/>
</div>
<button
onClick={() => updateMutation.mutate({ name: name || undefined, contactEmail: contactEmail || undefined })}
disabled={updateMutation.isPending}
className="bg-brand-500 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-brand-600 disabled:opacity-60 transition-colors"
>
{updateMutation.isPending ? 'Wird gespeichert...' : 'Speichern'}
</button>
{updateMutation.isSuccess && (
<p className="text-sm text-green-600">Einstellungen gespeichert </p>
)}
</div>
{/* AVV */}
<div className="bg-white rounded-lg border p-6 space-y-4">
<h2 className="font-semibold text-gray-900">Auftragsverarbeitungsvertrag (AVV)</h2>
<p className="text-sm text-gray-600">
Der AVV regelt die Verarbeitung personenbezogener Daten im Auftrag Ihrer Innung
durch InnungsApp GmbH gemäß Art. 28 DSGVO.
</p>
<a
href="/avv.pdf"
download
className="inline-flex items-center gap-2 text-sm text-brand-600 hover:underline"
>
📄 AVV als PDF herunterladen
</a>
{org.avvAccepted ? (
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
<p className="text-sm text-green-700 font-medium">
AVV akzeptiert am {org.avvAcceptedAt?.toLocaleDateString('de-DE')}
</p>
</div>
) : (
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4 space-y-3">
<p className="text-sm text-yellow-800 font-medium">
Der AVV muss vor dem Go-Live akzeptiert werden.
</p>
<label className="flex items-start gap-2 cursor-pointer">
<input
type="checkbox"
id="avv-check"
className="mt-0.5 rounded border-gray-300"
/>
<span className="text-sm text-gray-700">
Ich bestätige, dass ich den AVV gelesen habe und im Namen der Innung akzeptiere.
</span>
</label>
<button
onClick={() => {
const cb = document.getElementById('avv-check') as HTMLInputElement
if (!cb.checked) { alert('Bitte bestätigen Sie den AVV.'); return }
avvMutation.mutate()
}}
disabled={avvMutation.isPending}
className="bg-brand-500 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-brand-600 disabled:opacity-60 transition-colors"
>
AVV verbindlich akzeptieren
</button>
</div>
)}
</div>
{/* Registrierungslink */}
<div className="bg-white rounded-lg border p-6 space-y-4">
<h2 className="font-semibold text-gray-900">Registrierungslink</h2>
<p className="text-sm text-gray-600">
Teilen Sie diesen Link mit neuen Mitgliedern. Sie können sich damit selbst registrieren
und erhalten einen Aktivierungslink per E-Mail.
</p>
<div className="flex gap-2">
<input
readOnly
value={`${typeof window !== 'undefined' ? window.location.origin : ''}/registrierung/${org.slug}`}
className="flex-1 px-3 py-2 border border-gray-200 rounded-lg text-sm bg-gray-50 text-gray-700 focus:outline-none"
/>
<button
type="button"
onClick={() =>
navigator.clipboard.writeText(
`${window.location.origin}/registrierung/${org.slug}`
)
}
className="px-4 py-2 border border-gray-300 rounded-lg text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors whitespace-nowrap"
>
Kopieren
</button>
</div>
</div>
{/* Plan Info */}
<div className="bg-white rounded-lg border p-6">
<h2 className="font-semibold text-gray-900 mb-2">Plan</h2>
<span className="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-brand-100 text-brand-700 capitalize">
{org.plan}
</span>
<p className="text-sm text-gray-500 mt-2">
Für Upgrades oder Fragen zum Plan: kontakt@innungsapp.de
</p>
</div>
</div>
)
}

View File

@@ -0,0 +1,120 @@
import { Sidebar } from '@/components/layout/Sidebar'
import { Header } from '@/components/layout/Header'
import { auth, getSanitizedHeaders } from '@/lib/auth'
import { headers } from 'next/headers'
import { redirect } from 'next/navigation'
import { prisma } from '@innungsapp/shared'
import { ForcePasswordChange } from './ForcePasswordChange'
export default async function DashboardLayout({
children,
params,
}: {
children: React.ReactNode
params: Promise<{ slug: string }>
}) {
const sanitizedHeaders = await getSanitizedHeaders()
const session = await auth.api.getSession({ headers: sanitizedHeaders })
if (!session?.user) {
redirect('/login')
}
// Superadmin Redirect
const superAdminEmail = process.env.SUPERADMIN_EMAIL || 'superadmin@innungsapp.de'
if (session.user.email === superAdminEmail) {
redirect('/superadmin')
}
const { slug } = await params
const org = await prisma.organization.findUnique({
where: { slug }
})
// Basic security: Check if the user is an admin of this organization
const userRole = org
? await prisma.userRole.findUnique({
where: { orgId_userId: { orgId: org.id, userId: session.user.id } }
})
: null
// If not found for this slug, check if user is admin of ANY org and redirect there
if (!userRole || userRole.role !== 'admin') {
const anyAdminRole = await prisma.userRole.findFirst({
where: { userId: session.user.id, role: 'admin' },
include: { org: true },
orderBy: { createdAt: 'asc' },
})
console.error('[Dashboard] Zugriff verweigert Debug:', {
sessionUserId: session.user.id,
sessionUserEmail: session.user.email,
slug,
orgFound: !!org,
orgId: org?.id,
userRoleFound: !!userRole,
userRoleRole: userRole?.role,
anyAdminRoleFound: !!anyAdminRole,
anyAdminRoleOrgSlug: anyAdminRole?.org?.slug,
})
if (anyAdminRole?.org?.slug && anyAdminRole.org.slug !== slug) {
redirect(`/${anyAdminRole.org.slug}/dashboard`)
}
}
// ONLY admins are allowed in the administrative portal
if (!userRole || userRole.role !== 'admin') {
return (
<div className="min-h-screen bg-gray-50 flex flex-col items-center justify-center p-4">
<div className="bg-white border rounded-xl p-8 max-w-md w-full text-center shadow-sm">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-red-100 text-red-600">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="w-6 h-6">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m0-10.036A11.959 11.959 0 0 1 3.598 6 11.99 11.99 0 0 0 3 9.75c0 5.592 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.57-.598-3.75h-.152c-3.196 0-6.1-1.248-8.25-3.286Zm0 13.036h.008v.008H12v-.008Z" />
</svg>
</div>
<h1 className="text-xl font-bold text-gray-900 mb-2">Zugriff verweigert</h1>
<p className="text-gray-500 mb-6 text-sm">
Dieses Portal ist ausschließlich für Administratoren reserviert. Ihr Account verfügt nicht über die notwendigen Berechtigungen für diesen Bereich.
</p>
<form action={async () => {
'use server'
const { auth } = await import('@/lib/auth')
const { headers } = await import('next/headers')
await auth.api.signOut({ headers: await headers() })
redirect('/login')
}}>
<button type="submit" className="text-sm font-medium text-brand-600 hover:text-brand-700">
Abmelden und mit anderem Konto anmelden
</button>
</form>
</div>
</div>
)
}
// Force Password Change Check
// @ts-ignore - mustChangePassword is added via additionalFields
if (session.user.mustChangePassword) {
return (
<div className="min-h-screen overflow-y-auto bg-gray-50 flex flex-col items-center justify-center p-4">
<ForcePasswordChange slug={slug} />
</div>
)
}
// Inject Primary Color Theme
const primaryColor = org?.primaryColor || '#E63946'
return (
<div className="flex h-screen bg-gray-50">
<style>{`
:root {
--color-brand-primary: ${primaryColor};
}
`}</style>
<Sidebar orgName={org?.name} logoUrl={org?.logoUrl} />
<div className="flex-1 flex flex-col min-w-0">
<Header />
<main className="flex-1 overflow-y-auto p-6">{children}</main>
</div>
</div>
)
}

View File

@@ -0,0 +1,272 @@
'use client'
import { use } from 'react'
import { useRouter } from 'next/navigation'
import { trpc } from '@/lib/trpc-client'
import { getTrpcErrorMessage } from '@/lib/trpc-error'
import Link from 'next/link'
import { useState, useEffect } from 'react'
import { SPARTEN, MEMBER_STATUS_LABELS } from '@innungsapp/shared'
import { Trash2 } from 'lucide-react'
export default function MitgliedEditPage({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id } = use(params)
const router = useRouter()
const { data: member, isLoading } = trpc.members.byId.useQuery({ id })
const updateMutation = trpc.members.update.useMutation({
onSuccess: () => router.push('/dashboard/mitglieder'),
})
const deleteMutation = trpc.members.delete.useMutation({
onSuccess: () => router.push('/dashboard/mitglieder'),
})
const resendMutation = trpc.members.resendInvite.useMutation()
const [form, setForm] = useState({
name: '',
betrieb: '',
sparte: '',
ort: '',
telefon: '',
email: '',
status: 'aktiv' as 'aktiv' | 'ruhend' | 'ausgetreten',
istAusbildungsbetrieb: false,
seit: undefined as number | undefined,
role: 'member' as 'member' | 'admin',
password: '',
})
const [isChangingPassword, setIsChangingPassword] = useState(false)
const [showConfirmDelete, setShowConfirmDelete] = useState(false)
useEffect(() => {
if (member) {
setForm({
name: member.name || '',
betrieb: member.betrieb || '',
sparte: member.sparte || '',
ort: member.ort || '',
telefon: member.telefon ?? '',
email: member.email || '',
status: (member.status as 'aktiv' | 'ruhend' | 'ausgetreten') || 'aktiv',
istAusbildungsbetrieb: member.istAusbildungsbetrieb || false,
seit: member.seit ?? undefined,
// @ts-ignore
role: member.role || 'member',
password: '',
})
}
}, [member])
if (isLoading) return <div className="text-gray-500">Wird geladen...</div>
if (!member) return null
function handleSubmit(e: React.FormEvent) {
e.preventDefault()
updateMutation.mutate({ id, data: form })
}
const inputClass =
'w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent'
return (
<div className="max-w-2xl space-y-6">
<div className="flex items-center gap-3">
<Link href="/dashboard/mitglieder" className="text-xs text-gray-400 hover:text-gray-600 uppercase tracking-wide">
Zurück
</Link>
<span className="text-gray-200">/</span>
<h1 className="text-2xl font-bold text-gray-900">Mitglied bearbeiten</h1>
</div>
{/* Invite Status */}
<div className="bg-white rounded-lg border p-4 flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-700">App-Zugang</p>
<p className="text-xs text-gray-500 mt-0.5">
{member.userId
? 'Mitglied hat sich eingeloggt'
: 'Noch nicht eingeladen / eingeloggt'}
</p>
</div>
{!member.userId && (
<button
onClick={() => resendMutation.mutate({ memberId: id })}
disabled={resendMutation.isPending}
className="text-sm text-brand-600 hover:underline disabled:opacity-50"
>
{resendMutation.isPending ? 'Sende...' : resendMutation.isSuccess ? 'Gesendet' : 'Einladung senden'}
</button>
)}
</div>
<div className="space-y-6 pb-20">
<form onSubmit={handleSubmit} className="bg-white rounded-lg border p-6 space-y-6">
{/* Section: Stammdaten */}
<div>
<p className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-3">Stammdaten</p>
<div className="grid grid-cols-2 gap-4">
<div className="col-span-2">
<label className="block text-sm font-medium text-gray-700 mb-1">Name</label>
<input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} className={inputClass} />
</div>
<div className="col-span-2">
<label className="block text-sm font-medium text-gray-700 mb-1">Betrieb</label>
<input value={form.betrieb} onChange={(e) => setForm({ ...form, betrieb: e.target.value })} className={inputClass} />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Sparte</label>
<select value={form.sparte} onChange={(e) => setForm({ ...form, sparte: e.target.value })} className={inputClass}>
<option value=""> Bitte wählen </option>
{SPARTEN.map((s) => <option key={s}>{s}</option>)}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Ort</label>
<input value={form.ort} onChange={(e) => setForm({ ...form, ort: e.target.value })} className={inputClass} />
</div>
</div>
</div>
{/* Section: Kontakt */}
<div className="border-t pt-5">
<p className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-3">Kontakt</p>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">E-Mail</label>
<input type="email" value={form.email} onChange={(e) => setForm({ ...form, email: e.target.value })} className={inputClass} />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Telefon</label>
<input type="tel" value={form.telefon} onChange={(e) => setForm({ ...form, telefon: e.target.value })} className={inputClass} />
</div>
</div>
</div>
{/* Section: Status */}
<div className="border-t pt-5">
<p className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-3">Status</p>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Status</label>
<select value={form.status} onChange={(e) => setForm({ ...form, status: e.target.value as typeof form.status })} className={inputClass}>
{(['aktiv', 'ruhend', 'ausgetreten'] as const).map((s) => (
<option key={s} value={s}>{MEMBER_STATUS_LABELS[s]}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Rolle</label>
<select value={form.role} onChange={(e) => setForm({ ...form, role: e.target.value as 'member' | 'admin' })} className={inputClass}>
<option value="member">Mitglied</option>
<option value="admin">Administrator</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Passwort</label>
{isChangingPassword ? (
<div className="flex gap-2">
<input
type="password"
placeholder="Neues Passwort festlegen"
value={form.password}
onChange={(e) => setForm({ ...form, password: e.target.value })}
className={inputClass}
/>
<button
type="button"
onClick={() => { setIsChangingPassword(false); setForm({ ...form, password: '' }) }}
className="text-xs text-gray-400 hover:text-gray-600 px-2"
>
Abbrechen
</button>
</div>
) : (
<div className="flex gap-2">
<input
type="text"
readOnly
value="••••••••"
className={`${inputClass} bg-gray-50 text-gray-400 cursor-default`}
/>
<button
type="button"
onClick={() => setIsChangingPassword(true)}
className="text-xs text-brand-600 hover:underline px-2 whitespace-nowrap"
>
{member.userId ? 'Ändern' : 'Setzen'}
</button>
</div>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Mitglied seit</label>
<input type="number" value={form.seit ?? ''} onChange={(e) => setForm({ ...form, seit: e.target.value ? Number(e.target.value) : undefined })} className={inputClass} />
</div>
<div className="col-span-2">
<label className="flex items-center gap-2 cursor-pointer">
<input type="checkbox" checked={form.istAusbildungsbetrieb} onChange={(e) => setForm({ ...form, istAusbildungsbetrieb: e.target.checked })} className="rounded border-gray-300 text-brand-500 focus:ring-brand-500" />
<span className="text-sm text-gray-700">Ausbildungsbetrieb</span>
</label>
</div>
</div>
</div>
{(updateMutation.error || deleteMutation.error) && (
<p className="text-sm text-red-600 bg-red-50 px-4 py-2 rounded-lg">
{getTrpcErrorMessage(updateMutation.error || deleteMutation.error)}
</p>
)}
<div className="flex gap-3 pt-2 border-t">
<button type="submit" disabled={updateMutation.isPending} className="bg-brand-500 text-white px-6 py-2 rounded-lg text-sm font-medium hover:bg-brand-600 disabled:opacity-60 transition-colors">
{updateMutation.isPending ? 'Wird gespeichert...' : 'Speichern'}
</button>
<Link href="/dashboard/mitglieder" className="px-6 py-2 rounded-lg text-sm font-medium text-gray-700 hover:bg-gray-100 transition-colors">
Abbrechen
</Link>
</div>
</form>
{/* Danger Zone */}
<div className="bg-red-50 rounded-lg border border-red-100 p-6 flex items-center justify-between">
<div>
<p className="text-sm font-bold text-red-900">Mitglied löschen</p>
<p className="text-xs text-red-700 mt-1 max-w-sm">
Dies entfernt das Mitglied permanent. Der App-Zugang wird ebenfalls entzogen.
Diese Aktion kann nicht rückgängig gemacht werden.
</p>
</div>
{showConfirmDelete ? (
<div className="flex gap-2">
<button
onClick={() => deleteMutation.mutate({ id })}
disabled={deleteMutation.isPending}
className="bg-red-600 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-red-700 transition-colors shadow-sm disabled:opacity-50"
>
{deleteMutation.isPending ? 'Lösche...' : 'Endgültig löschen'}
</button>
<button
onClick={() => setShowConfirmDelete(false)}
className="bg-white text-gray-700 px-4 py-2 rounded-lg text-sm font-medium border border-gray-200 hover:bg-gray-50 transition-colors"
>
Abbrechen
</button>
</div>
) : (
<button
onClick={() => setShowConfirmDelete(true)}
className="text-red-600 hover:text-red-700 font-medium text-sm flex items-center gap-1 bg-white px-4 py-2 rounded-lg border border-red-200 hover:bg-red-50 transition-all shadow-sm"
>
<Trash2 className="w-4 h-4" />
Löschen
</button>
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,184 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { trpc } from '@/lib/trpc-client'
import Link from 'next/link'
import { SPARTEN } from '@innungsapp/shared'
export default function MitgliedNeuPage() {
const router = useRouter()
const [form, setForm] = useState({
name: '',
betrieb: '',
sparte: '',
ort: '',
telefon: '',
email: '',
status: 'aktiv' as const,
istAusbildungsbetrieb: false,
seit: new Date().getFullYear(),
role: 'member' as 'member' | 'admin',
password: '',
})
const createMutation = trpc.members.create.useMutation({
onSuccess: () => router.push('/dashboard/mitglieder'),
})
const isPending = createMutation.isPending
const error = createMutation.error
function handleSubmit(e: React.FormEvent) {
e.preventDefault()
createMutation.mutate(form)
}
return (
<div className="max-w-2xl space-y-6">
<div className="flex items-center gap-4">
<Link href="/dashboard/mitglieder" className="text-gray-400 hover:text-gray-600">
Zurück
</Link>
<h1 className="text-2xl font-bold text-gray-900">Mitglied anlegen</h1>
</div>
<form onSubmit={handleSubmit} className="bg-white rounded-xl border shadow-sm p-6 space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="col-span-2">
<label className="block text-sm font-medium text-gray-700 mb-1">Name *</label>
<input
required
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500"
/>
</div>
<div className="col-span-2">
<label className="block text-sm font-medium text-gray-700 mb-1">Betrieb</label>
<input
value={form.betrieb}
onChange={(e) => setForm({ ...form, betrieb: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Sparte</label>
<select
value={form.sparte}
onChange={(e) => setForm({ ...form, sparte: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500"
>
<option value=""> Bitte wählen </option>
{SPARTEN.map((s) => <option key={s}>{s}</option>)}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Ort</label>
<input
value={form.ort}
onChange={(e) => setForm({ ...form, ort: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">E-Mail *</label>
<input
required
type="email"
value={form.email}
onChange={(e) => setForm({ ...form, email: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Telefon</label>
<input
type="tel"
value={form.telefon}
onChange={(e) => setForm({ ...form, telefon: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Mitglied seit</label>
<input
type="number"
value={form.seit}
onChange={(e) => setForm({ ...form, seit: Number(e.target.value) })}
min="1900"
max="2100"
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Status</label>
<select
value={form.status}
onChange={(e) => setForm({ ...form, status: e.target.value as typeof form.status })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500"
>
<option value="aktiv">Aktiv</option>
<option value="ruhend">Ruhend</option>
<option value="ausgetreten">Ausgetreten</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Rolle</label>
<select
value={form.role}
onChange={(e) => setForm({ ...form, role: e.target.value as typeof form.role })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500"
>
<option value="member">Mitglied</option>
<option value="admin">Administrator</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Passwort</label>
<input
type="password"
placeholder="Mind. 8 Zeichen"
value={form.password}
onChange={(e) => setForm({ ...form, password: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500"
/>
</div>
<div className="col-span-2">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={form.istAusbildungsbetrieb}
onChange={(e) => setForm({ ...form, istAusbildungsbetrieb: e.target.checked })}
className="rounded border-gray-300 text-brand-500 focus:ring-brand-500"
/>
<span className="text-sm text-gray-700">Ausbildungsbetrieb</span>
</label>
</div>
</div>
{error && (
<p className="text-sm text-red-600 bg-red-50 px-4 py-2 rounded-lg">
{error.message}
</p>
)}
<div className="flex gap-3 pt-2">
<button
type="submit"
disabled={isPending}
className="bg-brand-500 text-white px-6 py-2 rounded-lg text-sm font-medium hover:bg-brand-600 disabled:opacity-60 transition-colors"
>
{isPending ? 'Wird gespeichert...' : 'Speichern'}
</button>
<Link
href="/dashboard/mitglieder"
className="px-6 py-2 rounded-lg text-sm font-medium text-gray-700 hover:bg-gray-100 transition-colors"
>
Abbrechen
</Link>
</div>
</form>
</div>
)
}

View File

@@ -0,0 +1,213 @@
import { prisma } from '@innungsapp/shared'
import { auth, getSanitizedHeaders } from '@/lib/auth'
import { headers } from 'next/headers'
import { redirect } from 'next/navigation'
import Link from 'next/link'
import { MEMBER_STATUS_LABELS } from '@innungsapp/shared'
import { format } from 'date-fns'
import { de } from 'date-fns/locale'
const STATUS_COLORS: Record<string, string> = {
aktiv: 'bg-green-100 text-green-700',
ruhend: 'bg-yellow-100 text-yellow-700',
ausgetreten: 'bg-red-100 text-red-700',
}
export default async function MitgliederPage(props: {
searchParams: Promise<{ [key: string]: string | string[] | undefined }>
}) {
const searchParams = await props.searchParams
const search = typeof searchParams.q === 'string' ? searchParams.q : ''
const statusFilter = typeof searchParams.status === 'string' ? searchParams.status : undefined
const sanitizedHeaders = await getSanitizedHeaders()
const session = await auth.api.getSession({ headers: sanitizedHeaders })
if (!session?.user) redirect('/login')
const userRole = await prisma.userRole.findFirst({
where: { userId: session.user.id },
orderBy: { createdAt: 'asc' },
})
if (!userRole || userRole.role !== 'admin') redirect('/dashboard')
const members = await prisma.member.findMany({
where: {
orgId: userRole.orgId,
...(statusFilter && { status: statusFilter as never }),
...(search && {
OR: [
{ name: { contains: search } },
{ betrieb: { contains: search } },
{ ort: { contains: search } },
],
}),
},
orderBy: { name: 'asc' },
})
// Also fetch admins to display them in the list if no status filter or status matches "aktiv"
const admins = await prisma.userRole.findMany({
where: {
orgId: userRole.orgId,
role: 'admin',
...(search && {
user: {
OR: [
{ name: { contains: search } },
{ email: { contains: search } },
]
}
})
},
include: {
user: true
}
})
const adminUserIds = new Set(admins.map((a: typeof admins[number]) => a.userId))
// Map userId → member record so admin entries show real member data
const memberByUserId = new Map<string, typeof members[number]>(members.filter((m: typeof members[number]) => m.userId).map((m: typeof members[number]) => [m.userId!, m]))
const combinedList = [
// Include admins only if there's no status filter, or if filtering for 'aktiv'
...(!statusFilter || statusFilter === 'aktiv' ? admins.map((a: typeof admins[number]) => {
const m = memberByUserId.get(a.user.id)
return {
id: m ? m.id : `admin-${a.user.id}`,
name: m?.name ?? a.user.name,
betrieb: m?.betrieb ?? a.user.email,
sparte: m?.sparte ?? 'Sonderfunktion',
ort: m?.ort ?? '—',
seit: m?.seit ?? null as number | null,
status: m?.status ?? 'aktiv',
userId: a.user.id,
isAdmin: true,
realId: m ? m.id : a.user.id,
role: 'Administrator',
}
}) : []),
...members.filter((m: typeof members[number]) => !adminUserIds.has(m.userId ?? '')).map((m: typeof members[number]) => ({
id: m.id,
name: m.name,
betrieb: m.betrieb,
sparte: m.sparte,
ort: m.ort,
seit: m.seit,
status: m.status,
userId: m.userId,
isAdmin: false,
realId: m.id,
role: 'Mitglied',
}))
]
combinedList.sort((a: typeof combinedList[number], b: typeof combinedList[number]) => a.name.localeCompare(b.name))
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">Mitglieder</h1>
<p className="text-gray-500 mt-1">{combinedList.length} Einträge</p>
</div>
<Link
href="/dashboard/mitglieder/neu"
className="bg-brand-500 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-brand-600 transition-colors"
>
+ Mitglied anlegen
</Link>
</div>
{/* Filters */}
<div className="bg-white rounded-lg border p-4 flex gap-4">
<form className="flex gap-4 w-full">
<input
name="q"
defaultValue={search}
placeholder="Name, Betrieb, Ort suchen..."
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500"
/>
<select
name="status"
defaultValue={statusFilter ?? ''}
className="px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500"
>
<option value="">Alle Status</option>
<option value="aktiv">Aktiv</option>
<option value="ruhend">Ruhend</option>
<option value="ausgetreten">Ausgetreten</option>
</select>
<button
type="submit"
className="px-4 py-2 bg-gray-100 text-gray-700 rounded-lg text-sm hover:bg-gray-200 transition-colors"
>
Suchen
</button>
</form>
</div>
{/* Table */}
<div className="bg-white rounded-lg border overflow-hidden">
<table className="w-full data-table">
<thead>
<tr>
<th>Name / Betrieb</th>
<th>Rolle</th>
<th>Ort</th>
<th>Mitglied seit</th>
<th>Status</th>
<th>Eingeladen</th>
<th></th>
</tr>
</thead>
<tbody>
{combinedList.map((m) => (
<tr key={m.id}>
<td>
<div>
<p className="font-medium text-gray-900">{m.name}</p>
<p className="text-xs text-gray-500">{m.betrieb}</p>
</div>
</td>
<td>
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-[11px] font-medium ${m.role === 'Administrator' ? 'bg-purple-100 text-purple-700' : 'bg-gray-100 text-gray-700'}`}>
{m.role}
</span>
</td>
<td>{m.ort}</td>
<td>{m.seit ?? '—'}</td>
<td>
<span
className={`inline-flex items-center px-2 py-0.5 rounded-full text-[11px] font-medium ${STATUS_COLORS[m.status]}`}
>
{MEMBER_STATUS_LABELS[m.status as keyof typeof MEMBER_STATUS_LABELS] || 'Aktiv'}
</span>
</td>
<td>
{m.userId ? (
<span className="text-[11px] font-medium text-green-600 bg-green-50 px-2 py-0.5 rounded-full">Aktiv</span>
) : (
<span className="text-[11px] text-gray-400"></span>
)}
</td>
<td>
<Link
href={`/dashboard/mitglieder/${m.realId}`}
className="text-sm text-brand-600 hover:underline"
>
Bearbeiten
</Link>
</td>
</tr>
))}
</tbody>
</table>
{combinedList.length === 0 && (
<div className="text-center py-12 text-gray-500">
Keine Mitglieder gefunden
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,235 @@
'use client'
import { use, useState, useEffect } from 'react'
import { useRouter } from 'next/navigation'
import { trpc } from '@/lib/trpc-client'
import { getTrpcErrorMessage } from '@/lib/trpc-error'
import Link from 'next/link'
import dynamic from 'next/dynamic'
const MDEditor = dynamic(() => import('@uiw/react-md-editor'), { ssr: false })
const KATEGORIEN = [
{ value: 'Wichtig', label: 'Wichtig' },
{ value: 'Pruefung', label: 'Prüfung' },
{ value: 'Foerderung', label: 'Förderung' },
{ value: 'Veranstaltung', label: 'Veranstaltung' },
{ value: 'Allgemein', label: 'Allgemein' },
]
export default function NewsEditPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = use(params)
const router = useRouter()
const { data: news, isLoading } = trpc.news.byId.useQuery({ id })
const updateMutation = trpc.news.update.useMutation({
onSuccess: () => router.push('/dashboard/news'),
})
const deleteMutation = trpc.news.delete.useMutation({
onSuccess: () => router.push('/dashboard/news'),
})
const [title, setTitle] = useState('')
const [body, setBody] = useState('')
const [kategorie, setKategorie] = useState('Allgemein')
const [uploading, setUploading] = useState(false)
const [attachments, setAttachments] = useState<
Array<{ name: string; storagePath: string; sizeBytes: number; mimeType?: string | null }>
>([])
useEffect(() => {
if (news) {
setTitle(news.title)
setBody(news.body)
setKategorie(news.kategorie)
if (news.attachments) {
setAttachments(news.attachments.map((a: typeof news.attachments[number]) => ({ ...a, sizeBytes: a.sizeBytes ?? 0 })))
}
}
}, [news])
if (isLoading) return <div className="text-gray-500 text-sm">Wird geladen...</div>
if (!news) return <div className="text-gray-500 text-sm">Beitrag nicht gefunden.</div>
async function handleFileUpload(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]
if (!file) return
setUploading(true)
const formData = new FormData()
formData.append('file', file)
try {
const res = await fetch('/api/upload', { method: 'POST', body: formData })
const data = await res.json()
setAttachments((prev) => [...prev, data])
} catch {
alert('Upload fehlgeschlagen')
} finally {
setUploading(false)
}
}
function handleSave(publishNow: boolean) {
if (!title.trim() || !body.trim()) return
updateMutation.mutate({
id,
data: {
title,
body,
kategorie: kategorie as never,
publishedAt: publishNow ? new Date().toISOString() : undefined,
attachments: attachments.map((a) => ({
name: a.name,
storagePath: a.storagePath,
sizeBytes: a.sizeBytes,
mimeType: a.mimeType || 'application/pdf',
})),
},
})
}
function handleUnpublish() {
updateMutation.mutate({ id, data: { publishedAt: null } })
}
const isPublished = !!news.publishedAt
return (
<div className="max-w-4xl space-y-6">
<div className="flex items-center gap-3">
<Link href="/dashboard/news" className="text-xs text-gray-400 hover:text-gray-600 uppercase tracking-wide">
Zurück
</Link>
<span className="text-gray-200">/</span>
<h1 className="text-2xl font-bold text-gray-900">Beitrag bearbeiten</h1>
{isPublished && (
<span className="text-[11px] font-medium bg-green-100 text-green-700 px-2 py-0.5 rounded-full">
Publiziert
</span>
)}
{!isPublished && (
<span className="text-[11px] font-medium bg-gray-100 text-gray-500 px-2 py-0.5 rounded-full">
Entwurf
</span>
)}
</div>
<div className="bg-white rounded-lg border p-6 space-y-4">
<div className="grid grid-cols-3 gap-4">
<div className="col-span-2">
<label className="block text-xs font-medium text-gray-500 uppercase tracking-wide mb-1">Titel</label>
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Titel..."
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-500 uppercase tracking-wide mb-1">Kategorie</label>
<select
value={kategorie}
onChange={(e) => setKategorie(e.target.value)}
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent"
>
{KATEGORIEN.map((k) => (
<option key={k.value} value={k.value}>{k.label}</option>
))}
</select>
</div>
</div>
<div>
<label className="block text-xs font-medium text-gray-500 uppercase tracking-wide mb-1">Inhalt</label>
<div data-color-mode="light">
<MDEditor
value={body}
onChange={(v) => setBody(v ?? '')}
height={400}
preview="live"
/>
</div>
</div>
{/* Attachments */}
<div>
<label className="block text-xs font-medium text-gray-500 uppercase tracking-wide mb-1">Anhänge (PDF)</label>
<label className="cursor-pointer inline-flex items-center gap-2 px-4 py-2 border border-dashed border-gray-300 rounded-lg text-sm text-gray-500 hover:border-brand-500 hover:text-brand-500 transition-colors">
{uploading ? '⏳ Hochladen...' : '📎 Datei anhängen'}
<input
type="file"
accept=".pdf,image/*"
onChange={handleFileUpload}
disabled={uploading}
className="hidden"
/>
</label>
{attachments.length > 0 && (
<ul className="mt-2 space-y-1">
{attachments.map((a, i) => (
<li key={i} className="flex items-center gap-2 text-sm text-gray-600">
<span>📄</span>
<span>{a.name}</span>
{a.sizeBytes != null && (
<span className="text-gray-400">({Math.round(a.sizeBytes / 1024)} KB)</span>
)}
<button
onClick={() => setAttachments(prev => prev.filter((_, idx) => idx !== i))}
className="text-red-500 hover:text-red-700 ml-2"
title="Entfernen"
>
×
</button>
</li>
))}
</ul>
)}
</div>
{updateMutation.error && (
<p className="text-sm text-red-600 bg-red-50 px-4 py-2 rounded-lg">
{getTrpcErrorMessage(updateMutation.error)}
</p>
)}
<div className="flex items-center justify-between pt-2 border-t">
<div className="flex gap-3">
{!isPublished && (
<button
onClick={() => handleSave(true)}
disabled={updateMutation.isPending}
className="bg-brand-500 text-white px-5 py-2 rounded-lg text-sm font-medium hover:bg-brand-600 disabled:opacity-60 transition-colors"
>
Publizieren
</button>
)}
<button
onClick={() => handleSave(false)}
disabled={updateMutation.isPending}
className="px-5 py-2 rounded-lg text-sm font-medium text-gray-700 border border-gray-200 hover:bg-gray-50 transition-colors"
>
{updateMutation.isPending ? 'Wird gespeichert...' : 'Speichern'}
</button>
{isPublished && (
<button
onClick={handleUnpublish}
disabled={updateMutation.isPending}
className="px-5 py-2 rounded-lg text-sm font-medium text-gray-500 hover:text-gray-700 hover:bg-gray-50 transition-colors"
>
Depublizieren
</button>
)}
</div>
<button
onClick={() => {
if (confirm('Beitrag wirklich löschen?')) deleteMutation.mutate({ id })
}}
disabled={deleteMutation.isPending}
className="text-sm text-red-500 hover:text-red-700 transition-colors"
>
Löschen
</button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,179 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { trpc } from '@/lib/trpc-client'
import { getTrpcErrorMessage } from '@/lib/trpc-error'
import Link from 'next/link'
import dynamic from 'next/dynamic'
import { AIGenerator } from '@/components/ai-generator'
const MDEditor = dynamic(() => import('@uiw/react-md-editor'), { ssr: false })
const KATEGORIEN = [
{ value: 'Wichtig', label: 'Wichtig' },
{ value: 'Pruefung', label: 'Prüfung' },
{ value: 'Foerderung', label: 'Förderung' },
{ value: 'Veranstaltung', label: 'Veranstaltung' },
{ value: 'Allgemein', label: 'Allgemein' },
]
export default function NewsNeuPage() {
const router = useRouter()
const [title, setTitle] = useState('')
const DEFAULT_BODY = '## Inhalt\n\nHier können Sie Ihren Beitrag verfassen.'
const [body, setBody] = useState(DEFAULT_BODY)
const [kategorie, setKategorie] = useState('Allgemein')
const [uploading, setUploading] = useState(false)
const [attachments, setAttachments] = useState<
Array<{ name: string; storagePath: string; sizeBytes: number; url: string }>
>([])
const createMutation = trpc.news.create.useMutation({
onSuccess: () => router.push('/dashboard/news'),
})
function handleSubmit(publishNow: boolean) {
if (!title.trim() || !body.trim()) return
createMutation.mutate({
title,
body,
kategorie: kategorie as never,
publishedAt: publishNow ? new Date().toISOString() : null,
attachments: attachments.map((a) => ({
name: a.name,
storagePath: a.storagePath,
sizeBytes: a.sizeBytes,
mimeType: 'application/pdf', // fallback/default; the API handles it
})),
})
}
async function handleFileUpload(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]
if (!file) return
setUploading(true)
const formData = new FormData()
formData.append('file', file)
try {
const res = await fetch('/api/upload', { method: 'POST', body: formData })
const data = await res.json()
setAttachments((prev) => [...prev, data])
} catch {
alert('Upload fehlgeschlagen')
} finally {
setUploading(false)
}
}
return (
<div className="max-w-6xl space-y-6">
<div className="flex items-center gap-4">
<Link href="/dashboard/news" className="text-gray-400 hover:text-gray-600">
Zurück
</Link>
<h1 className="text-2xl font-bold text-gray-900">Beitrag erstellen</h1>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 items-start">
<div className="lg:col-span-2 bg-white rounded-xl border shadow-sm p-6 space-y-4">
<div className="grid grid-cols-3 gap-4">
<div className="col-span-2">
<label className="block text-sm font-medium text-gray-700 mb-1">Titel *</label>
<input
required
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Aussagekräftiger Titel..."
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Kategorie</label>
<select
value={kategorie}
onChange={(e) => setKategorie(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500"
>
{KATEGORIEN.map((k) => (
<option key={k.value} value={k.value}>{k.label}</option>
))}
</select>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Inhalt *</label>
<div data-color-mode="light">
<MDEditor
value={body}
onChange={(v) => setBody(v ?? '')}
height={400}
preview="live"
/>
</div>
</div>
{/* Attachments */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Anhänge (PDF)</label>
<label className="cursor-pointer inline-flex items-center gap-2 px-4 py-2 border border-dashed border-gray-300 rounded-lg text-sm text-gray-500 hover:border-brand-500 hover:text-brand-500 transition-colors">
{uploading ? '⏳ Hochladen...' : '📎 Datei anhängen'}
<input
type="file"
accept=".pdf,image/*"
onChange={handleFileUpload}
disabled={uploading}
className="hidden"
/>
</label>
{attachments.length > 0 && (
<ul className="mt-2 space-y-1">
{attachments.map((a, i) => (
<li key={i} className="flex items-center gap-2 text-sm text-gray-600">
<span>📄</span>
<span>{a.name}</span>
<span className="text-gray-400">({Math.round(a.sizeBytes / 1024)} KB)</span>
</li>
))}
</ul>
)}
</div>
{createMutation.error && (
<p className="text-sm text-red-600 bg-red-50 px-4 py-2 rounded-lg">
{getTrpcErrorMessage(createMutation.error)}
</p>
)}
<div className="flex gap-3 pt-2 border-t">
<button
onClick={() => handleSubmit(true)}
disabled={createMutation.isPending}
className="bg-brand-500 text-white px-6 py-2 rounded-lg text-sm font-medium hover:bg-brand-600 disabled:opacity-60 transition-colors"
>
Jetzt publizieren
</button>
<button
onClick={() => handleSubmit(false)}
disabled={createMutation.isPending}
className="px-6 py-2 rounded-lg text-sm font-medium text-gray-700 border hover:bg-gray-50 transition-colors"
>
Als Entwurf speichern
</button>
</div>
</div>
<div className="lg:col-span-1 sticky top-6">
<AIGenerator
type="news"
onApply={(generated) => {
// Replace placeholder if untouched, otherwise append
setBody(body === DEFAULT_BODY ? generated : body + '\n\n' + generated)
}}
/>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,124 @@
import { prisma } from '@innungsapp/shared'
import { auth, getSanitizedHeaders } from '@/lib/auth'
import { headers } from 'next/headers'
import { redirect } from 'next/navigation'
import Link from 'next/link'
import { NEWS_KATEGORIE_LABELS } from '@innungsapp/shared'
import { format } from 'date-fns'
import { de } from 'date-fns/locale'
const KATEGORIE_COLORS: Record<string, string> = {
Wichtig: 'bg-red-100 text-red-700',
Pruefung: 'bg-blue-100 text-blue-700',
Foerderung: 'bg-green-100 text-green-700',
Veranstaltung: 'bg-purple-100 text-purple-700',
Allgemein: 'bg-gray-100 text-gray-700',
}
export default async function NewsPage() {
const sanitizedHeaders = await getSanitizedHeaders()
const session = await auth.api.getSession({ headers: sanitizedHeaders })
if (!session?.user) redirect('/login')
const userRole = await prisma.userRole.findFirst({
where: { userId: session.user.id, role: 'admin' },
})
if (!userRole) redirect('/dashboard')
const news = await prisma.news.findMany({
where: { orgId: userRole.orgId },
include: { author: { select: { name: true } } },
orderBy: [{ publishedAt: 'desc' }, { createdAt: 'desc' }],
})
const published = news.filter((n: typeof news[number]) => n.publishedAt)
const drafts = news.filter((n: typeof news[number]) => !n.publishedAt)
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">News</h1>
<p className="text-gray-500 mt-1">{published.length} publiziert · {drafts.length} Entwürfe</p>
</div>
<Link
href="/dashboard/news/neu"
className="bg-brand-500 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-brand-600 transition-colors"
>
+ Beitrag erstellen
</Link>
</div>
{drafts.length > 0 && (
<section>
<h2 className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3">
Entwürfe
</h2>
<div className="bg-white rounded-lg border overflow-hidden">
<table className="w-full data-table">
<tbody>
{drafts.map((n: typeof drafts[number]) => (
<tr key={n.id}>
<td className="w-full">
<p className="font-medium text-gray-900">{n.title}</p>
<p className="text-xs text-gray-400">Erstellt {format(n.createdAt, 'dd. MMM yyyy', { locale: de })}</p>
</td>
<td>
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${KATEGORIE_COLORS[n.kategorie]}`}>
{NEWS_KATEGORIE_LABELS[n.kategorie]}
</span>
</td>
<td>
<Link href={`/dashboard/news/${n.id}`} className="text-sm text-brand-600 hover:underline">
Bearbeiten
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
)}
<section>
<h2 className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3">
Publiziert
</h2>
<div className="bg-white rounded-lg border overflow-hidden">
<table className="w-full data-table">
<thead>
<tr>
<th>Titel</th>
<th>Kategorie</th>
<th>Autor</th>
<th>Datum</th>
<th></th>
</tr>
</thead>
<tbody>
{published.map((n: typeof published[number]) => (
<tr key={n.id}>
<td className="font-medium text-gray-900">{n.title}</td>
<td>
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${KATEGORIE_COLORS[n.kategorie]}`}>
{NEWS_KATEGORIE_LABELS[n.kategorie]}
</span>
</td>
<td className="text-gray-500">{n.author?.name ?? '—'}</td>
<td className="text-gray-500">
{n.publishedAt ? format(n.publishedAt, 'dd.MM.yyyy', { locale: de }) : '—'}
</td>
<td>
<Link href={`/dashboard/news/${n.id}`} className="text-sm text-brand-600 hover:underline">
Bearbeiten
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
</div>
)
}

View File

@@ -0,0 +1,126 @@
import { prisma } from '@innungsapp/shared'
import { auth, getSanitizedHeaders } from '@/lib/auth'
import { headers } from 'next/headers'
import { redirect } from 'next/navigation'
import { StatsCards } from '@/components/stats/StatsCards'
import Link from 'next/link'
import { format } from 'date-fns'
import { de } from 'date-fns/locale'
import { NEWS_KATEGORIE_LABELS, TERMIN_TYP_LABELS } from '@innungsapp/shared'
export default async function DashboardPage() {
const sanitizedHeaders = await getSanitizedHeaders()
const session = await auth.api.getSession({ headers: sanitizedHeaders })
if (!session?.user) redirect('/login')
const userRole = await prisma.userRole.findFirst({
where: { userId: session.user.id },
include: { org: true },
})
if (!userRole) redirect('/login')
const orgId = userRole.orgId
const now = new Date()
const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000)
const [activeMembers, newsThisWeek, upcomingTermine, activeStellen, recentNews, nextTermine] =
await Promise.all([
prisma.member.count({ where: { orgId, status: 'aktiv' } }),
prisma.news.count({ where: { orgId, publishedAt: { gte: weekAgo, not: null } } }),
prisma.termin.count({ where: { orgId, datum: { gte: now } } }),
prisma.stelle.count({ where: { orgId, aktiv: true } }),
prisma.news.findMany({
where: { orgId, publishedAt: { not: null } },
orderBy: { publishedAt: 'desc' },
take: 5,
include: { author: { select: { name: true } } },
}),
prisma.termin.findMany({
where: { orgId, datum: { gte: now } },
orderBy: { datum: 'asc' },
take: 3,
}),
])
return (
<div className="space-y-8">
<div>
<h1 className="text-2xl font-bold text-gray-900">Übersicht</h1>
<p className="text-gray-500 mt-1">{userRole.org.name}</p>
</div>
<StatsCards
stats={[
{ label: 'Aktive Mitglieder', value: activeMembers, icon: '👥' },
{ label: 'News diese Woche', value: newsThisWeek, icon: '📰' },
{ label: 'Bevorstehende Termine', value: upcomingTermine, icon: '📅' },
{ label: 'Aktive Stellen', value: activeStellen, icon: '🎓' },
]}
/>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Recent News */}
<div className="bg-white rounded-lg border p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="font-semibold text-gray-900">Neueste Beiträge</h2>
<Link href="/dashboard/news" className="text-sm text-brand-600 hover:underline">
Alle anzeigen
</Link>
</div>
<div className="space-y-3">
{recentNews.map((n: typeof recentNews[number]) => (
<div key={n.id} className="flex items-start gap-3 py-2 border-b last:border-0">
<div className="flex-1 min-w-0">
<p className="font-medium text-sm text-gray-900 truncate">{n.title}</p>
<p className="text-xs text-gray-500 mt-0.5">
{n.publishedAt
? format(n.publishedAt, 'dd. MMM yyyy', { locale: de })
: 'Entwurf'}{' '}
· {n.author?.name ?? 'Unbekannt'}
</p>
</div>
<span className="text-xs bg-gray-100 text-gray-600 px-2 py-0.5 rounded-full whitespace-nowrap">
{NEWS_KATEGORIE_LABELS[n.kategorie]}
</span>
</div>
))}
</div>
</div>
{/* Upcoming Termine */}
<div className="bg-white rounded-lg border p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="font-semibold text-gray-900">Nächste Termine</h2>
<Link href="/dashboard/termine" className="text-sm text-brand-600 hover:underline">
Alle anzeigen
</Link>
</div>
<div className="space-y-3">
{nextTermine.length === 0 && (
<p className="text-sm text-gray-500">Keine bevorstehenden Termine</p>
)}
{nextTermine.map((t: typeof nextTermine[number]) => (
<div key={t.id} className="flex items-start gap-3 py-2 border-b last:border-0">
<div className="text-center min-w-[40px]">
<p className="text-lg font-bold text-brand-500 leading-none">
{format(t.datum, 'dd', { locale: de })}
</p>
<p className="text-xs text-gray-500 uppercase">
{format(t.datum, 'MMM', { locale: de })}
</p>
</div>
<div className="flex-1 min-w-0">
<p className="font-medium text-sm text-gray-900 truncate">{t.titel}</p>
<p className="text-xs text-gray-500">{t.ort ?? 'Kein Ort angegeben'}</p>
</div>
<span className="text-xs bg-gray-100 text-gray-600 px-2 py-0.5 rounded-full whitespace-nowrap">
{TERMIN_TYP_LABELS[t.typ]}
</span>
</div>
))}
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,21 @@
'use client'
import { trpc } from '@/lib/trpc-client'
import { useRouter } from 'next/navigation'
export function DeactivateButton({ id }: { id: string }) {
const router = useRouter()
const mutation = trpc.stellen.deactivate.useMutation({
onSuccess: () => router.refresh(),
})
return (
<button
onClick={() => mutation.mutate({ id })}
disabled={mutation.isPending}
className="text-sm text-red-600 hover:underline disabled:opacity-50"
>
Deaktivieren
</button>
)
}

View File

@@ -0,0 +1,191 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { trpc } from '@/lib/trpc-client'
import { getTrpcErrorMessage } from '@/lib/trpc-error'
import Link from 'next/link'
import { AIGenerator } from '@/components/ai-generator'
export default function StelleNeuPage() {
const router = useRouter()
const { data: members } = trpc.members.list.useQuery({})
const createMutation = trpc.stellen.createForMember.useMutation({
onSuccess: () => router.push('/dashboard/stellen'),
})
const [form, setForm] = useState({
memberId: '',
sparte: '',
stellenAnz: 1,
verguetung: '',
lehrjahr: '',
beschreibung: '',
kontaktEmail: '',
kontaktName: '',
})
function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!form.memberId) return
createMutation.mutate({
...form,
stellenAnz: Number(form.stellenAnz),
verguetung: form.verguetung || undefined,
lehrjahr: form.lehrjahr || undefined,
beschreibung: form.beschreibung || undefined,
kontaktName: form.kontaktName || undefined,
})
}
const inputClass =
'w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent'
return (
<div className="max-w-6xl space-y-6">
<div className="flex items-center gap-3">
<Link href="/dashboard/stellen" className="text-xs text-gray-400 hover:text-gray-600 uppercase tracking-wide">
Zurück
</Link>
<span className="text-gray-200">/</span>
<h1 className="text-2xl font-bold text-gray-900">Stelle anlegen</h1>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 items-start">
<div className="lg:col-span-2">
<form onSubmit={handleSubmit} className="bg-white rounded-lg border p-6 space-y-6">
{/* Betrieb */}
<div>
<p className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-3">Betrieb</p>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Mitglied / Betrieb *</label>
<select
required
value={form.memberId}
onChange={(e) => {
const selected = members?.find((m: NonNullable<typeof members>[number]) => m.id === e.target.value)
setForm({ ...form, memberId: e.target.value, sparte: selected?.sparte ?? form.sparte })
}}
className={inputClass}
>
<option value="">Mitglied auswählen...</option>
{members?.map((m: NonNullable<typeof members>[number]) => (
<option key={m.id} value={m.id}>
{m.betrieb} {m.name}
</option>
))}
</select>
</div>
</div>
{/* Stellendetails */}
<div className="border-t pt-5">
<p className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-3">Stellendetails</p>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Sparte *</label>
<input
required
value={form.sparte}
onChange={(e) => setForm({ ...form, sparte: e.target.value })}
placeholder="z.B. Elektrotechnik"
className={inputClass}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Anzahl Stellen</label>
<input
type="number"
min={1}
value={form.stellenAnz}
onChange={(e) => setForm({ ...form, stellenAnz: Number(e.target.value) })}
className={inputClass}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Lehrjahr</label>
<input
value={form.lehrjahr}
onChange={(e) => setForm({ ...form, lehrjahr: e.target.value })}
placeholder="z.B. 1. Lehrjahr"
className={inputClass}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Vergütung</label>
<input
value={form.verguetung}
onChange={(e) => setForm({ ...form, verguetung: e.target.value })}
placeholder="z.B. 650 € / Monat"
className={inputClass}
/>
</div>
<div className="col-span-2">
<label className="block text-sm font-medium text-gray-700 mb-1">Beschreibung</label>
<textarea
rows={3}
value={form.beschreibung}
onChange={(e) => setForm({ ...form, beschreibung: e.target.value })}
placeholder="Aufgaben, Anforderungen, ..."
className={inputClass}
/>
</div>
</div>
</div>
{/* Kontakt */}
<div className="border-t pt-5">
<p className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-3">Kontakt</p>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Kontakt-E-Mail *</label>
<input
type="email"
required
value={form.kontaktEmail}
onChange={(e) => setForm({ ...form, kontaktEmail: e.target.value })}
placeholder="bewerbung@betrieb.de"
className={inputClass}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Ansprechpartner</label>
<input
value={form.kontaktName}
onChange={(e) => setForm({ ...form, kontaktName: e.target.value })}
placeholder="Max Mustermann"
className={inputClass}
/>
</div>
</div>
</div>
{createMutation.error && (
<p className="text-sm text-red-600 bg-red-50 px-4 py-2 rounded-lg">
{getTrpcErrorMessage(createMutation.error)}
</p>
)}
<div className="flex gap-3 pt-2 border-t">
<button
type="submit"
disabled={createMutation.isPending || !form.memberId}
className="bg-brand-500 text-white px-6 py-2 rounded-lg text-sm font-medium hover:bg-brand-600 disabled:opacity-60 transition-colors"
>
{createMutation.isPending ? 'Wird gespeichert...' : 'Stelle anlegen'}
</button>
<Link href="/dashboard/stellen" className="px-6 py-2 rounded-lg text-sm font-medium text-gray-700 hover:bg-gray-100 transition-colors">
Abbrechen
</Link>
</div>
</form>
</div>
<div className="lg:col-span-1 sticky top-6">
<AIGenerator type="stelle" onApply={(text) => setForm({ ...form, beschreibung: (form.beschreibung || '') + (form.beschreibung?.trim() ? '\n\n' : '') + text })} />
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,88 @@
import { prisma } from '@innungsapp/shared'
import { auth, getSanitizedHeaders } from '@/lib/auth'
import { headers } from 'next/headers'
import { redirect } from 'next/navigation'
import { format } from 'date-fns'
import { de } from 'date-fns/locale'
import { DeactivateButton } from './DeactivateButton'
import Link from 'next/link'
export default async function StellenPage() {
const sanitizedHeaders = await getSanitizedHeaders()
const session = await auth.api.getSession({ headers: sanitizedHeaders })
if (!session?.user) redirect('/login')
const userRole = await prisma.userRole.findFirst({
where: { userId: session.user.id, role: 'admin' },
})
if (!userRole) redirect('/dashboard')
const stellen = await prisma.stelle.findMany({
where: { orgId: userRole.orgId },
include: { member: { select: { name: true, betrieb: true } } },
orderBy: [{ aktiv: 'desc' }, { createdAt: 'desc' }],
})
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">Lehrlingsbörse</h1>
<p className="text-gray-500 mt-1">
{stellen.filter((s) => s.aktiv).length} aktive Angebote
</p>
</div>
<Link
href="/dashboard/stellen/neu"
className="bg-brand-500 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-brand-600 transition-colors"
>
+ Stelle anlegen
</Link>
</div>
<div className="bg-white rounded-lg border overflow-hidden">
<table className="w-full data-table">
<thead>
<tr>
<th>Betrieb</th>
<th>Sparte</th>
<th>Stellen</th>
<th>Lehrjahr</th>
<th>Vergütung</th>
<th>Eingestellt</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
{stellen.map((s) => (
<tr key={s.id} className={!s.aktiv ? 'opacity-50' : ''}>
<td>
<p className="font-medium text-gray-900">{s.member.betrieb}</p>
<p className="text-xs text-gray-500">{s.member.name}</p>
</td>
<td>{s.sparte}</td>
<td className="text-center">{s.stellenAnz}</td>
<td>{s.lehrjahr ?? '—'}</td>
<td>{s.verguetung ?? '—'}</td>
<td>{format(s.createdAt, 'dd.MM.yyyy', { locale: de })}</td>
<td>
<span
className={`px-2 py-0.5 rounded-full text-xs font-medium ${s.aktiv ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-500'}`}
>
{s.aktiv ? 'Aktiv' : 'Inaktiv'}
</span>
</td>
<td>
{s.aktiv && <DeactivateButton id={s.id} />}
</td>
</tr>
))}
</tbody>
</table>
{stellen.length === 0 && (
<div className="text-center py-8 text-gray-500">Noch keine Stellenangebote</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,214 @@
'use client'
import { use, useState, useEffect } from 'react'
import { useRouter } from 'next/navigation'
import { trpc } from '@/lib/trpc-client'
import { getTrpcErrorMessage } from '@/lib/trpc-error'
import Link from 'next/link'
import { format } from 'date-fns'
const TYPEN = [
{ value: 'Pruefung', label: 'Prüfung' },
{ value: 'Versammlung', label: 'Versammlung' },
{ value: 'Kurs', label: 'Kurs' },
{ value: 'Event', label: 'Event' },
{ value: 'Sonstiges', label: 'Sonstiges' },
]
export default function TerminEditPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = use(params)
const router = useRouter()
const { data: termin, isLoading } = trpc.termine.byId.useQuery({ id })
const updateMutation = trpc.termine.update.useMutation({
onSuccess: () => router.push('/dashboard/termine'),
})
const deleteMutation = trpc.termine.delete.useMutation({
onSuccess: () => router.push('/dashboard/termine'),
})
const [form, setForm] = useState({
titel: '',
datum: '',
uhrzeit: '',
endeDatum: '',
endeUhrzeit: '',
ort: '',
adresse: '',
typ: 'Versammlung',
beschreibung: '',
maxTeilnehmer: '',
})
useEffect(() => {
if (termin) {
setForm({
titel: termin.titel,
datum: format(new Date(termin.datum), 'yyyy-MM-dd'),
uhrzeit: termin.uhrzeit ?? '',
endeDatum: termin.endeDatum ? format(new Date(termin.endeDatum), 'yyyy-MM-dd') : '',
endeUhrzeit: termin.endeUhrzeit ?? '',
ort: termin.ort ?? '',
adresse: termin.adresse ?? '',
typ: termin.typ,
beschreibung: termin.beschreibung ?? '',
maxTeilnehmer: termin.maxTeilnehmer ? String(termin.maxTeilnehmer) : '',
})
}
}, [termin])
if (isLoading) return <div className="text-gray-500 text-sm">Wird geladen...</div>
if (!termin) return <div className="text-gray-500 text-sm">Termin nicht gefunden.</div>
const F = (field: string) => (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) =>
setForm((prev) => ({ ...prev, [field]: e.target.value }))
function handleSubmit(e: React.FormEvent) {
e.preventDefault()
updateMutation.mutate({
id,
data: {
titel: form.titel,
datum: form.datum,
uhrzeit: form.uhrzeit || undefined,
endeDatum: form.endeDatum || null,
endeUhrzeit: form.endeUhrzeit || null,
ort: form.ort || undefined,
adresse: form.adresse || undefined,
typ: form.typ as never,
beschreibung: form.beschreibung || undefined,
maxTeilnehmer: form.maxTeilnehmer ? Number(form.maxTeilnehmer) : null,
},
})
}
const inputClass =
'w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500'
return (
<div className="max-w-2xl space-y-6">
<div className="flex items-center gap-4">
<Link href="/dashboard/termine" className="text-gray-400 hover:text-gray-600">
Zurück
</Link>
<h1 className="text-2xl font-bold text-gray-900">Termin bearbeiten</h1>
</div>
<form onSubmit={handleSubmit} className="bg-white rounded-xl border shadow-sm p-6 space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="col-span-2">
<label className="block text-sm font-medium text-gray-700 mb-1">Titel *</label>
<input required value={form.titel} onChange={F('titel')} className={inputClass} />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Typ *</label>
<select value={form.typ} onChange={F('typ')} className={inputClass}>
{TYPEN.map((t) => <option key={t.value} value={t.value}>{t.label}</option>)}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Max. Teilnehmer</label>
<input
type="number"
value={form.maxTeilnehmer}
onChange={F('maxTeilnehmer')}
placeholder="Leer = unbegrenzt"
className={inputClass}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Datum *</label>
<input required type="date" value={form.datum} onChange={F('datum')} className={inputClass} />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Uhrzeit (von)</label>
<input type="time" value={form.uhrzeit} onChange={F('uhrzeit')} className={inputClass} />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Ende Datum</label>
<input type="date" value={form.endeDatum} onChange={F('endeDatum')} className={inputClass} />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Ende Uhrzeit</label>
<input type="time" value={form.endeUhrzeit} onChange={F('endeUhrzeit')} className={inputClass} />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Ort</label>
<input value={form.ort} onChange={F('ort')} className={inputClass} />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Adresse</label>
<input value={form.adresse} onChange={F('adresse')} className={inputClass} />
</div>
<div className="col-span-2">
<label className="block text-sm font-medium text-gray-700 mb-1">Beschreibung</label>
<textarea
value={form.beschreibung}
onChange={F('beschreibung')}
rows={4}
className={inputClass}
/>
</div>
</div>
{updateMutation.error && (
<p className="text-sm text-red-600 bg-red-50 px-4 py-2 rounded-lg">
{getTrpcErrorMessage(updateMutation.error)}
</p>
)}
<div className="flex items-center justify-between pt-2 border-t">
<div className="flex gap-3">
<button
type="submit"
disabled={updateMutation.isPending}
className="bg-brand-500 text-white px-6 py-2 rounded-lg text-sm font-medium hover:bg-brand-600 disabled:opacity-60 transition-colors"
>
{updateMutation.isPending ? 'Wird gespeichert...' : 'Speichern'}
</button>
<Link href="/dashboard/termine" className="px-6 py-2 rounded-lg text-sm font-medium text-gray-700 hover:bg-gray-100 transition-colors">
Abbrechen
</Link>
</div>
<button
type="button"
onClick={() => {
if (confirm('Termin wirklich löschen?')) deleteMutation.mutate({ id })
}}
disabled={deleteMutation.isPending}
className="text-sm text-red-500 hover:text-red-700 transition-colors"
>
Löschen
</button>
</div>
</form>
{termin.anmeldungen.length > 0 && (
<div className="bg-white rounded-xl border shadow-sm p-6">
<div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-semibold text-gray-700">
Anmeldungen ({termin.anmeldungen.length}
{termin.maxTeilnehmer ? ` / ${termin.maxTeilnehmer}` : ''})
</h2>
<a href={`/api/export/termin/${id}`}>
<button
type="button"
className="text-sm border border-gray-300 text-gray-700 px-3 py-1.5 rounded-lg hover:bg-gray-50 transition-colors"
>
Teilnehmerliste exportieren
</button>
</a>
</div>
<ul className="space-y-1">
{termin.anmeldungen.map((a) => (
<li key={a.id} className="text-sm text-gray-600">
{a.member.name}
{a.member.betrieb && <span className="text-gray-400"> · {a.member.betrieb}</span>}
</li>
))}
</ul>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,145 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { trpc } from '@/lib/trpc-client'
import { getTrpcErrorMessage } from '@/lib/trpc-error'
import Link from 'next/link'
const TYPEN = [
{ value: 'Pruefung', label: 'Prüfung' },
{ value: 'Versammlung', label: 'Versammlung' },
{ value: 'Kurs', label: 'Kurs' },
{ value: 'Event', label: 'Event' },
{ value: 'Sonstiges', label: 'Sonstiges' },
]
export default function TerminNeuPage() {
const router = useRouter()
const [form, setForm] = useState({
titel: '',
datum: '',
uhrzeit: '',
endeDatum: '',
endeUhrzeit: '',
ort: '',
adresse: '',
typ: 'Versammlung',
beschreibung: '',
maxTeilnehmer: '',
})
const createMutation = trpc.termine.create.useMutation({
onSuccess: () => router.push('/dashboard/termine'),
})
function handleSubmit(e: React.FormEvent) {
e.preventDefault()
createMutation.mutate({
titel: form.titel,
datum: form.datum,
uhrzeit: form.uhrzeit || undefined,
endeDatum: form.endeDatum || undefined,
endeUhrzeit: form.endeUhrzeit || undefined,
ort: form.ort || undefined,
adresse: form.adresse || undefined,
typ: form.typ as never,
beschreibung: form.beschreibung || undefined,
maxTeilnehmer: form.maxTeilnehmer ? Number(form.maxTeilnehmer) : undefined,
})
}
const F = (field: string) => (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) =>
setForm({ ...form, [field]: e.target.value })
const inputClass =
'w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500'
return (
<div className="max-w-2xl space-y-6">
<div className="flex items-center gap-4">
<Link href="/dashboard/termine" className="text-gray-400 hover:text-gray-600">
Zurück
</Link>
<h1 className="text-2xl font-bold text-gray-900">Termin anlegen</h1>
</div>
<form onSubmit={handleSubmit} className="bg-white rounded-xl border shadow-sm p-6 space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="col-span-2">
<label className="block text-sm font-medium text-gray-700 mb-1">Titel *</label>
<input required value={form.titel} onChange={F('titel')} className={inputClass} />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Typ *</label>
<select value={form.typ} onChange={F('typ')} className={inputClass}>
{TYPEN.map((t) => <option key={t.value} value={t.value}>{t.label}</option>)}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Max. Teilnehmer</label>
<input
type="number"
value={form.maxTeilnehmer}
onChange={F('maxTeilnehmer')}
placeholder="Leer = unbegrenzt"
className={inputClass}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Datum *</label>
<input required type="date" value={form.datum} onChange={F('datum')} className={inputClass} />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Uhrzeit (von)</label>
<input type="time" value={form.uhrzeit} onChange={F('uhrzeit')} className={inputClass} />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Ende Datum</label>
<input type="date" value={form.endeDatum} onChange={F('endeDatum')} className={inputClass} />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Ende Uhrzeit</label>
<input type="time" value={form.endeUhrzeit} onChange={F('endeUhrzeit')} className={inputClass} />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Ort</label>
<input value={form.ort} onChange={F('ort')} className={inputClass} />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Adresse</label>
<input value={form.adresse} onChange={F('adresse')} className={inputClass} />
</div>
<div className="col-span-2">
<label className="block text-sm font-medium text-gray-700 mb-1">Beschreibung</label>
<textarea
value={form.beschreibung}
onChange={F('beschreibung')}
rows={4}
className={inputClass}
/>
</div>
</div>
{createMutation.error && (
<p className="text-sm text-red-600 bg-red-50 px-4 py-2 rounded-lg">
{getTrpcErrorMessage(createMutation.error)}
</p>
)}
<div className="flex gap-3 pt-2 border-t">
<button
type="submit"
disabled={createMutation.isPending}
className="bg-brand-500 text-white px-6 py-2 rounded-lg text-sm font-medium hover:bg-brand-600 disabled:opacity-60 transition-colors"
>
{createMutation.isPending ? 'Wird gespeichert...' : 'Termin anlegen'}
</button>
<Link href="/dashboard/termine" className="px-6 py-2 rounded-lg text-sm font-medium text-gray-700 hover:bg-gray-100 transition-colors">
Abbrechen
</Link>
</div>
</form>
</div>
)
}

View File

@@ -0,0 +1,125 @@
import { prisma } from '@innungsapp/shared'
import { auth, getSanitizedHeaders } from '@/lib/auth'
import { headers } from 'next/headers'
import { redirect } from 'next/navigation'
import Link from 'next/link'
import { TERMIN_TYP_LABELS } from '@innungsapp/shared'
import { format } from 'date-fns'
import { de } from 'date-fns/locale'
const TYP_COLORS: Record<string, string> = {
Pruefung: 'bg-blue-100 text-blue-700',
Versammlung: 'bg-purple-100 text-purple-700',
Kurs: 'bg-green-100 text-green-700',
Event: 'bg-yellow-100 text-yellow-700',
Sonstiges: 'bg-gray-100 text-gray-700',
}
export default async function TerminePage() {
const sanitizedHeaders = await getSanitizedHeaders()
const session = await auth.api.getSession({ headers: sanitizedHeaders })
if (!session?.user) redirect('/login')
const userRole = await prisma.userRole.findFirst({
where: { userId: session.user.id, role: 'admin' },
})
if (!userRole) redirect('/dashboard')
const now = new Date()
const [upcoming, past] = await Promise.all([
prisma.termin.findMany({
where: { orgId: userRole.orgId, datum: { gte: now } },
include: { anmeldungen: { select: { id: true } } },
orderBy: { datum: 'asc' },
}),
prisma.termin.findMany({
where: { orgId: userRole.orgId, datum: { lt: now } },
include: { anmeldungen: { select: { id: true } } },
orderBy: { datum: 'desc' },
take: 10,
}),
])
const TerminRow = ({ t }: { t: typeof upcoming[0] }) => (
<tr>
<td>
<div className="text-center w-10">
<p className="font-bold text-brand-500">{format(t.datum, 'dd', { locale: de })}</p>
<p className="text-xs text-gray-400 uppercase">{format(t.datum, 'MMM', { locale: de })}</p>
</div>
</td>
<td>
<p className="font-medium text-gray-900">{t.titel}</p>
{t.ort && <p className="text-xs text-gray-500">{t.ort}</p>}
</td>
<td>
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${TYP_COLORS[t.typ]}`}>
{TERMIN_TYP_LABELS[t.typ]}
</span>
</td>
<td>
{t.maxTeilnehmer
? `${t.anmeldungen.length} / ${t.maxTeilnehmer}`
: t.anmeldungen.length}
</td>
<td>
<Link href={`/dashboard/termine/${t.id}`} className="text-sm text-brand-600 hover:underline">
Bearbeiten
</Link>
</td>
</tr>
)
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-gray-900">Termine</h1>
<Link
href="/dashboard/termine/neu"
className="bg-brand-500 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-brand-600 transition-colors"
>
+ Termin anlegen
</Link>
</div>
<section>
<h2 className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3">
Bevorstehend ({upcoming.length})
</h2>
<div className="bg-white rounded-lg border overflow-hidden">
<table className="w-full data-table">
<thead>
<tr>
<th>Datum</th>
<th>Titel</th>
<th>Typ</th>
<th>Anmeldungen</th>
<th></th>
</tr>
</thead>
<tbody>
{upcoming.map((t) => <TerminRow key={t.id} t={t} />)}
</tbody>
</table>
{upcoming.length === 0 && (
<div className="text-center py-8 text-gray-500">Keine bevorstehenden Termine</div>
)}
</div>
</section>
{past.length > 0 && (
<section>
<h2 className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3">
Vergangen
</h2>
<div className="bg-white rounded-lg border overflow-hidden opacity-70">
<table className="w-full data-table">
<tbody>
{past.map((t) => <TerminRow key={t.id} t={t} />)}
</tbody>
</table>
</div>
</section>
)}
</div>
)
}

View File

@@ -0,0 +1,397 @@
import { prisma } from '@innungsapp/shared'
import { notFound } from 'next/navigation'
import Link from 'next/link'
function jsonToText(value: unknown): string {
if (value == null) {
return ''
}
if (typeof value === 'string') {
return value
}
if (Array.isArray(value)) {
return value
.map((item) => (typeof item === 'string' ? item : JSON.stringify(item)))
.join('\n')
}
return JSON.stringify(value)
}
export default async function TenantLandingPage({
params,
}: {
params: Promise<{ slug: string }>
}) {
const { slug } = await params
// Exclude dashboard routes
if (slug === 'dashboard' || slug === 'login' || slug === 'superadmin') {
return notFound()
}
const org = await prisma.organization.findUnique({
where: { slug }
})
if (!org) {
return notFound()
}
const primaryColor = org.primaryColor || '#E63946'
const secondaryColor = org.secondaryColor || undefined
const title = org.landingPageTitle || org.name || 'Zukunft durch Handwerk'
const text = org.landingPageText || 'Wir sind Ihre lokale Vertretung des Handwerks. Mit starker Gemeinschaft und klaren Zielen setzen wir uns für die Betriebe in unserer Region ein.'
const features = jsonToText(org.landingPageFeatures) || '✅ Exzellente Ausbildung\n✅ Starke Gemeinschaft\n✅ Politische Interessenvertretung'
const footer = jsonToText(org.landingPageFooter) || `© ${new Date().getFullYear()} ${org.name}`
const sectionTitle = org.landingPageSectionTitle || `${org.name || 'Ihre Innung'} Gemeinsam stark fürs Handwerk`
const buttonText = org.landingPageButtonText || 'Jetzt App laden'
return (
<div className="w-full h-full bg-white overflow-y-auto font-sans flex flex-col relative selection:bg-gray-900 selection:text-white" style={{ '--color-brand-primary': primaryColor } as React.CSSProperties}>
{/* Header */}
<header className="px-8 py-6 flex items-center justify-between sticky top-0 z-50 shadow-sm" style={{
background: `linear-gradient(to right, #ffffff 0%, ${primaryColor}20 50%, ${primaryColor} 100%)`
}}>
<div className="flex items-center gap-4">
{org.logoUrl ? (
<img src={org.logoUrl} alt="Logo" className="h-10 object-contain" />
) : (
<div className="w-10 h-10 bg-white rounded-lg flex items-center justify-center text-xs font-bold text-gray-400 shadow-sm">LOGO</div>
)}
<span className="font-bold text-lg text-gray-800">{org.name || 'Innungs-Logo'}</span>
</div>
<nav className="flex gap-6 text-sm font-medium text-gray-800 hidden md:flex">
<a href="#about" className="hover:text-black">Über uns</a>
<a href="#leistungen" className="hover:text-black">Leistungen</a>
<a href="#app" className="hover:text-black">App</a>
</nav>
<Link
href={`/login`}
className="px-5 py-2.5 rounded-full bg-white font-semibold text-sm cursor-pointer shadow-md hover:bg-gray-50 transition-all"
style={{ color: primaryColor }}
>
Mitglieder verwalten
</Link>
</header>
{/* Hero Section */}
<section id="about" className="relative px-8 py-20 flex flex-col items-center justify-center text-center overflow-hidden min-h-[400px]">
{/* Background Image / Pattern */}
{org.landingPageHeroImage ? (
<div className="absolute inset-0 z-0">
<img src={org.landingPageHeroImage} alt="Hero Background" className="w-full h-full object-cover" />
<div
className="absolute inset-0 bg-white"
// If you have a specific overlay opacity field you could use it here. Defaulting to 0.5.
style={{ opacity: 0.5 }}
></div>
<div className="absolute inset-0 bg-gradient-to-b from-white/30 via-transparent to-white/90"></div>
</div>
) : (
<div className="absolute inset-0 z-0 opacity-[0.03]" style={{ backgroundImage: 'radial-gradient(#000 1px, transparent 1px)', backgroundSize: '24px 24px' }}></div>
)}
<div className="relative z-10 max-w-3xl mx-auto space-y-6">
<div className="inline-block px-4 py-1.5 rounded-full text-xs font-bold tracking-wider uppercase mb-2 shadow-sm" style={{ backgroundColor: `${primaryColor}15`, color: primaryColor }}>
{org.name || 'Ihre Innung'}
</div>
<h1 className="text-4xl md:text-5xl font-black text-gray-900 tracking-tight leading-[1.1]">
{title}
</h1>
<p className="text-lg text-gray-600 leading-relaxed max-w-2xl mx-auto font-medium">
{text}
</p>
<div className="pt-6 flex gap-4 justify-center">
<a
href="#apps"
className="px-8 py-3.5 rounded-full text-white font-semibold shadow-lg hover:opacity-90 transition-all cursor-pointer transform hover:-translate-y-0.5 block"
style={{ backgroundColor: primaryColor }}
>
{buttonText}
</a>
<a
href="#leistungen"
className="px-8 py-3.5 rounded-full font-semibold border shadow-sm transition-all cursor-pointer block hover:opacity-80"
style={{
backgroundColor: 'white',
borderColor: secondaryColor || '#e5e7eb',
color: secondaryColor || '#374151'
}}
>
Mehr erfahren
</a>
</div>
</div>
</section>
{/* Features / Benefits */}
<section id="leistungen" className="px-8 py-16" style={{ backgroundColor: secondaryColor ? `${secondaryColor}08` : '#f9fafb' }}>
<div className="max-w-5xl mx-auto">
<h2 className="text-2xl font-bold text-center mb-12 text-gray-800">Ihre Vorteile als Mitglied</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
{features.split('\n').filter((f: string) => f.trim() !== '').map((feature: string, idx: number) => (
<div key={idx} className="bg-white p-6 rounded-2xl shadow-sm border border-gray-100 flex flex-col items-center text-center space-y-4 hover:shadow-md transition-shadow">
<div className="w-12 h-12 rounded-full flex items-center justify-center" style={{ backgroundColor: secondaryColor ? `${secondaryColor}15` : `${primaryColor}15`, color: secondaryColor || primaryColor }}>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="w-6 h-6">
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
</svg>
</div>
<h3 className="font-semibold text-gray-800">{feature.replace(/^[-\*\✅\ ]+/, '')}</h3>
</div>
))}
</div>
</div>
</section>
{/* App Features Grid */}
<section id="app" className="px-8 py-20 bg-white">
<div className="max-w-6xl mx-auto">
<div className="text-center mb-16 space-y-4">
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full text-sm font-semibold mb-2" style={{ backgroundColor: `${primaryColor}10`, color: primaryColor }}>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M13 10V3L4 14h7v7l9-11h-7z" /></svg>
Alles in einer App
</div>
<h2 className="text-3xl md:text-4xl font-black text-gray-900">{sectionTitle}</h2>
<p className="text-lg text-gray-500 max-w-2xl mx-auto">
Verpassen Sie keine wichtigen Branchen-Updates mehr. Vernetzen Sie sich mit anderen Betrieben und verwalten Sie Termine bequem auf dem Smartphone.
</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
{/* Feature 1: Aktuelles */}
<div className="p-8 rounded-[2rem] border border-gray-100 bg-white shadow-sm hover:shadow-xl hover:-translate-y-1 transition-all duration-300 group">
<div className="w-14 h-14 rounded-2xl bg-gray-50 flex items-center justify-center mb-6 group-hover:scale-110 transition-transform duration-300" style={{ color: primaryColor }}>
<svg className="w-7 h-7" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}><path strokeLinecap="round" strokeLinejoin="round" d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9a2 2 0 00-2-2h-2m-4-3H9M7 16h6M7 8h6v4H7V8z" /></svg>
</div>
<h3 className="text-xl font-bold text-gray-900 mb-3">Aktuelles</h3>
<p className="text-gray-500 leading-relaxed">
Lesen Sie die wichtigsten Branchen-News und bleiben Sie immer auf dem aktuellsten Stand.
</p>
</div>
{/* Feature 2: Termine */}
<div className="p-8 rounded-[2rem] border border-gray-100 bg-white shadow-sm hover:shadow-xl hover:-translate-y-1 transition-all duration-300 group">
<div className="w-14 h-14 rounded-2xl bg-gray-50 flex items-center justify-center mb-6 group-hover:scale-110 transition-transform duration-300" style={{ color: primaryColor }}>
<svg className="w-7 h-7" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}><path strokeLinecap="round" strokeLinejoin="round" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg>
</div>
<h3 className="text-xl font-bold text-gray-900 mb-3">Termine</h3>
<p className="text-gray-500 leading-relaxed">
Verwalten Sie Veranstaltungen, Fortbildungen und Innungsversammlungen direkt in Ihrem Kalender.
</p>
</div>
{/* Feature 3: Stellen */}
<div className="p-8 rounded-[2rem] border border-gray-100 bg-white shadow-sm hover:shadow-xl hover:-translate-y-1 transition-all duration-300 group">
<div className="w-14 h-14 rounded-2xl bg-gray-50 flex items-center justify-center mb-6 group-hover:scale-110 transition-transform duration-300" style={{ color: primaryColor }}>
<svg className="w-7 h-7" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}><path strokeLinecap="round" strokeLinejoin="round" d="M21 13.255A23.931 23.931 0 0112 15c-3.183 0-6.22-.62-9-1.745M16 6V4a2 2 0 00-2-2h-4a2 2 0 00-2 2v2m4 6h.01M5 20h14a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" /></svg>
</div>
<h3 className="text-xl font-bold text-gray-900 mb-3">Stellenbörse</h3>
<p className="text-gray-500 leading-relaxed">
Finden Sie neue Fachkräfte oder Auszubildende. Veröffentlichen Sie Ihre offenen Stellenangebote branchenintern.
</p>
</div>
{/* Feature 4: Nachrichten */}
<div className="p-8 rounded-[2rem] border border-gray-100 bg-white shadow-sm hover:shadow-xl hover:-translate-y-1 transition-all duration-300 group">
<div className="w-14 h-14 rounded-2xl bg-gray-50 flex items-center justify-center mb-6 group-hover:scale-110 transition-transform duration-300" style={{ color: primaryColor }}>
<svg className="w-7 h-7" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}><path strokeLinecap="round" strokeLinejoin="round" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" /></svg>
</div>
<h3 className="text-xl font-bold text-gray-900 mb-3">Nachrichten</h3>
<p className="text-gray-500 leading-relaxed">
Tauschen Sie sich mit anderen Betrieben aus. Schnelle Kontaktaufnahme über direkte Einzel- und Gruppenchats.
</p>
</div>
{/* Feature 5: Profil */}
<div className="p-8 rounded-[2rem] border border-gray-100 bg-white shadow-sm hover:shadow-xl hover:-translate-y-1 transition-all duration-300 group">
<div className="w-14 h-14 rounded-2xl bg-gray-50 flex items-center justify-center mb-6 group-hover:scale-110 transition-transform duration-300" style={{ color: primaryColor }}>
<svg className="w-7 h-7" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}><path strokeLinecap="round" strokeLinejoin="round" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" /></svg>
</div>
<h3 className="text-xl font-bold text-gray-900 mb-3">Profil & Ausweis</h3>
<p className="text-gray-500 leading-relaxed">
Ihr digitaler Mitgliedsausweis immer griffbereit. Verwalten Sie das Profil Ihres Betriebs komfortabel in der App.
</p>
</div>
{/* Feature 6: Partner */}
<div className="p-8 rounded-[2rem] border border-gray-100 bg-white shadow-sm hover:shadow-xl hover:-translate-y-1 transition-all duration-300 group">
<div className="w-14 h-14 rounded-2xl bg-gray-50 flex items-center justify-center mb-6 group-hover:scale-110 transition-transform duration-300" style={{ color: primaryColor }}>
<svg className="w-7 h-7" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}><path strokeLinecap="round" strokeLinejoin="round" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" /></svg>
</div>
<h3 className="text-xl font-bold text-gray-900 mb-3">Netzwerk</h3>
<p className="text-gray-500 leading-relaxed">
Profitieren Sie von starken Kooperationen und Angeboten ausgewählter Partnerbetriebe in der Region.
</p>
</div>
</div>
</div>
</section>
{/* Application Mock */}
<section id="apps" className="px-8 py-32 relative overflow-hidden" style={{
background: `linear-gradient(to bottom, #ffffff 0%, ${primaryColor} 40%, #111827 100%)`
}}>
{/* Decorative background elements */}
<div className="absolute inset-0 opacity-10" style={{ backgroundImage: 'radial-gradient(circle at 2px 2px, white 1px, transparent 0)', backgroundSize: '40px 40px' }}></div>
<div className="absolute top-0 right-0 -mr-40 -mt-40 w-[500px] h-[500px] rounded-full bg-white/20 blur-[100px] pointer-events-none"></div>
<div className="absolute bottom-0 left-0 -ml-40 -mb-40 w-[500px] h-[500px] rounded-full border-[40px] border-white/5 pointer-events-none"></div>
<div className="max-w-6xl mx-auto flex flex-col md:flex-row items-center gap-16 relative z-10">
<div className="flex-1 text-left space-y-8 text-white">
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-white/10 backdrop-blur-md border border-white/20 text-sm font-medium">
<span className="w-2 h-2 rounded-full bg-green-400 animate-pulse"></span>
Jetzt verfügbar
</div>
<h2 className="text-4xl md:text-5xl font-black leading-tight">
Laden Sie unsere App herunter
</h2>
<p className="text-white/80 text-xl leading-relaxed max-w-lg">
Bleiben Sie immer auf dem Laufenden mit der {org.name || 'Innungs'}-App für Mitglieder. Alle News, Termine und Ihr digitaler Mitgliedsausweis direkt auf Ihrem Smartphone.
</p>
<div className="flex flex-wrap gap-4 pt-4">
{(!org.appStoreUrl && !org.playStoreUrl) || org.appStoreUrl ? (
<a href={org.appStoreUrl || "#"} target="_blank" rel="noreferrer" className="bg-black hover:bg-black/80 text-white px-8 py-4 rounded-2xl cursor-pointer transition-all flex items-center gap-4 shadow-xl hover:shadow-2xl transform hover:-translate-y-1">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512" className="w-8 h-8 fill-current"><path d="M318.7 268.7c-.2-36.7 16.4-64.4 50-84.8-18.8-26.9-47.2-41.7-84.7-44.6-35.5-2.8-74.3 20.7-88.5 20.7-15 0-49.4-19.7-76.4-19.7C63.3 141.2 4 184.8 4 273.5q0 39.3 14.4 81.2c12.8 36.7 59 126.7 107.2 125.2 25.2-.6 43-17.9 75.8-17.9 31.8 0 48.3 17.9 76.4 17.9 48.6-.7 90.4-82.5 102.6-119.3-65.2-30.7-61.7-90-61.7-91.9zm-56.6-164.2c27.3-32.4 24.8-61.9 24-72.5-24.1 1.4-52 16.4-67.9 34.9-17.5 19.8-27.8 44.3-25.6 71.9 26.1 2 49.9-11.4 69.5-34.3z" /></svg>
<div>
<div className="text-xs text-white/70">Download on the</div>
<div className="text-lg font-semibold leading-none">App Store</div>
</div>
</a>
) : null}
{(!org.appStoreUrl && !org.playStoreUrl) || org.playStoreUrl ? (
<a href={org.playStoreUrl || "#"} target="_blank" rel="noreferrer" className="bg-black hover:bg-black/80 text-white px-8 py-4 rounded-2xl cursor-pointer transition-all flex items-center gap-4 shadow-xl hover:shadow-2xl transform hover:-translate-y-1">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" className="w-8 h-8 fill-current"><path d="M325.3 234.3L104.6 13l280.8 161.2-60.1 60.1zM47 0C34 6.8 25.3 19.2 25.3 35.3v441.3c0 16.1 8.7 28.5 21.7 35.3l256.6-256L47 0zm425.2 225.6l-58.9-34.1-65.7 64.5 65.7 64.5 60.1-34.1c18-14.3 18-46.5-1.2-60.8zM104.6 499l280.8-161.2-60.1-60.1L104.6 499z" /></svg>
<div>
<div className="text-xs text-white/70">GET IT ON</div>
<div className="text-lg font-semibold leading-none">Google Play</div>
</div>
</a>
) : null}
</div>
</div>
<div className="flex-1 w-full flex justify-center mt-12 md:mt-0 perspective-[2000px]">
<div className="relative w-[280px] h-[580px] rounded-[3rem] border-[12px] border-black bg-black shadow-2xl overflow-hidden transform rotate-y-[-15deg] rotate-x-[10deg] rotate-z-[5deg] hover:rotate-y-[0deg] hover:rotate-x-[0deg] hover:rotate-z-[0deg] transition-all duration-700 ease-out">
{/* Notch */}
<div className="absolute top-0 left-1/2 -translate-x-1/2 w-32 h-6 bg-black rounded-b-3xl z-20"></div>
{/* App Screenshot Mockup */}
<div className="w-full h-full bg-gray-50 flex flex-col pt-6">
{/* App Header */}
<div className="px-5 py-4 flex items-center justify-between bg-white border-b border-gray-100">
<div className="flex items-center gap-3">
{org.logoUrl ? (
<img src={org.logoUrl} alt="Logo" className="w-8 h-8 object-contain" />
) : (
<div className="w-8 h-8 rounded-full flex items-center justify-center text-white font-bold text-xs shadow-sm" style={{ backgroundColor: primaryColor }}>
{org.name ? org.name.charAt(0).toUpperCase() : 'I'}
</div>
)}
<div className="font-bold text-sm text-gray-800 truncate w-28">{org.name || 'Ihre Innung'}</div>
</div>
<div className="w-8 h-8 rounded-full bg-gray-100 flex items-center justify-center">
<svg className="w-4 h-4 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" /></svg>
</div>
</div>
{/* App Content */}
<div className="p-5 space-y-6 flex-1 overflow-hidden">
<div className="w-full h-32 rounded-2xl relative overflow-hidden flex items-end p-4 shadow-sm" style={{ backgroundColor: primaryColor }}>
<div className="absolute inset-0 bg-black/10"></div>
<div className="absolute -top-10 -right-10 w-32 h-32 bg-white/10 rounded-full blur-2xl"></div>
<div className="relative z-10 text-white font-bold text-lg leading-tight">Willkommen,<br />Max Mustermann</div>
</div>
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="text-sm font-bold text-gray-800">Aktuelle News</div>
<div className="text-xs text-gray-400 font-medium">Alle ansehen</div>
</div>
<div className="space-y-3">
<div className="bg-white rounded-xl border border-gray-100 p-3 flex gap-3 shadow-sm items-center">
<div className="w-12 h-12 rounded-lg flex-shrink-0" style={{ backgroundColor: `${primaryColor}15` }}></div>
<div className="flex-1 space-y-2">
<div className="h-3 w-5/6 bg-gray-200 rounded-full"></div>
<div className="h-2 w-full bg-gray-100 rounded-full"></div>
</div>
</div>
<div className="bg-white rounded-xl border border-gray-100 p-3 flex gap-3 shadow-sm items-center">
<div className="w-12 h-12 rounded-lg flex-shrink-0" style={{ backgroundColor: `${primaryColor}15` }}></div>
<div className="flex-1 space-y-2">
<div className="h-3 w-2/3 bg-gray-200 rounded-full"></div>
<div className="h-2 w-4/5 bg-gray-100 rounded-full"></div>
</div>
</div>
</div>
</div>
</div>
{/* App Bottom Nav */}
<div className="h-[72px] bg-white border-t border-gray-100 flex items-center justify-between px-4 pb-2 pt-2 shadow-[0_-4px_20px_rgba(0,0,0,0.03)] z-20">
<div className="flex flex-col items-center gap-1 w-1/6">
<svg className="w-5 h-5" style={{ color: primaryColor }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" /></svg>
<span className="text-[9px] font-semibold" style={{ color: primaryColor }}>Start</span>
</div>
<div className="flex flex-col items-center gap-1 text-gray-400 w-1/6">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9a2 2 0 00-2-2h-2m-4-3H9M7 16h6M7 8h6v4H7V8z" /></svg>
<span className="text-[9px] font-medium">Aktuelles</span>
</div>
<div className="flex flex-col items-center gap-1 text-gray-400 w-1/6">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg>
<span className="text-[9px] font-medium">Termine</span>
</div>
<div className="flex flex-col items-center gap-1 text-gray-400 w-1/6">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M21 13.255A23.931 23.931 0 0112 15c-3.183 0-6.22-.62-9-1.745M16 6V4a2 2 0 00-2-2h-4a2 2 0 00-2 2v2m4 6h.01M5 20h14a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" /></svg>
<span className="text-[9px] font-medium">Stellen</span>
</div>
<div className="flex flex-col items-center gap-1 text-gray-400 w-1/6">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" /></svg>
<span className="text-[9px] font-medium">Nachricht..</span>
</div>
<div className="flex flex-col items-center gap-1 text-gray-400 w-1/6">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" /></svg>
<span className="text-[9px] font-medium">Profil</span>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
{/* CTA Section */}
<section id="mitglied-werden" className="px-8 py-24 bg-gray-50 text-center relative z-20">
<div className="max-w-3xl mx-auto space-y-8">
<h2 className="text-3xl md:text-4xl font-bold text-gray-900">Werden Sie jetzt Teil der Gemeinschaft</h2>
<p className="text-lg text-gray-600">
Profitieren Sie von unserem starken Netzwerk, exklusiven Brancheninformationen und unserer digitalen Innungs-App.
</p>
<a
href="#apps"
className="inline-block px-10 py-4 rounded-full text-white font-bold text-lg shadow-xl hover:shadow-2xl hover:-translate-y-1 transition-all"
style={{ backgroundColor: primaryColor }}
>
Jetzt Mitglied werden
</a>
</div>
</section>
{/* Footer */}
<footer className="bg-gray-900 text-gray-400 py-12 px-8 text-center text-sm">
<div className="max-w-4xl mx-auto space-y-4">
<div className="text-gray-300 font-bold text-lg mb-6">{org.name || 'Innungs-Logo'}</div>
<div className="whitespace-pre-wrap">{footer}</div>
<div className="pt-8 border-t border-gray-800 flex justify-center gap-6">
<Link href="/impressum" className="hover:text-white transition-colors">Impressum</Link>
<Link href="/datenschutz" className="hover:text-white transition-colors">Datenschutz</Link>
<Link href="/kontakt" className="hover:text-white transition-colors">Kontakt</Link>
</div>
</div>
</footer>
</div>
)
}

View File

@@ -0,0 +1,126 @@
import { NextResponse } from 'next/server'
import OpenAI from 'openai'
type LlmProvider = 'openai' | 'openrouter'
function getProvider(): LlmProvider {
const configured = (process.env.LLM_PROVIDER ?? '').toLowerCase()
if (configured === 'openrouter') return 'openrouter'
if (configured === 'openai') return 'openai'
return process.env.OPENROUTER_API_KEY ? 'openrouter' : 'openai'
}
function createClient(provider: LlmProvider) {
if (provider === 'openrouter') {
const apiKey = process.env.OPENROUTER_API_KEY || ''
return new OpenAI({
apiKey,
baseURL: process.env.OPENROUTER_BASE_URL || 'https://openrouter.ai/api/v1',
defaultHeaders: {
...(process.env.OPENROUTER_SITE_URL
? { 'HTTP-Referer': process.env.OPENROUTER_SITE_URL }
: {}),
...(process.env.OPENROUTER_APP_NAME
? { 'X-Title': process.env.OPENROUTER_APP_NAME }
: {}),
},
})
}
return new OpenAI({
apiKey: process.env.OPENAI_API_KEY || '',
})
}
function getModel(provider: LlmProvider): string {
if (provider === 'openrouter') {
return process.env.OPENROUTER_MODEL || 'minimax/minimax-m2.5'
}
return process.env.OPENAI_MODEL || 'gpt-4o-mini'
}
function hasApiKey(provider: LlmProvider): boolean {
if (provider === 'openrouter') return !!process.env.OPENROUTER_API_KEY
return !!process.env.OPENAI_API_KEY
}
function buildFallbackLandingContent(orgName: string, context: string) {
const cleanOrg = orgName.trim()
const cleanContext = context.trim().replace(/\s+/g, ' ')
const shortContext = cleanContext.slice(0, 180)
const detailSentence = shortContext
? `Dabei stehen insbesondere ${shortContext}.`
: 'Dabei stehen regionale Vernetzung, starke Ausbildung und praxisnahe Unterstützung im Mittelpunkt.'
return {
title: `${cleanOrg} - Stark im Handwerk`,
text: `${cleanOrg} verbindet Betriebe, stärkt die Gemeinschaft und setzt sich für die Interessen des Handwerks vor Ort ein. ${detailSentence}`,
fallbackUsed: true,
}
}
export async function POST(req: Request) {
let parsedBody: any = null
try {
const body = await req.json()
parsedBody = body
const { orgName, context } = body
if (!orgName || !context) {
return NextResponse.json({ error: 'orgName and context are required' }, { status: 400 })
}
const provider = getProvider()
const model = getModel(provider)
if (!hasApiKey(provider)) {
return NextResponse.json(buildFallbackLandingContent(orgName, context))
}
const client = createClient(provider)
const systemMessage = `Sie sind ein professioneller Copywriter für eine moderne deutsche Innung oder Kreishandwerkerschaft.
Erstellen Sie eine moderne, ansprechende Überschrift (Heading) und einen Einleitungstext für eine Landingpage.
WICHTIG: Geben Sie AUSSCHLIESSLICH ein valides JSON-Objekt zurück, komplett ohne Markdown-Formatierung (kein \`\`\`json ... \`\`\`), in dieser Struktur:
{
"title": "Eine moderne, ansprechende Überschrift (max. 6-8 Wörter)",
"text": "Ein überzeugender Einleitungstext, der erklärt, wofür die Organisation steht, fokussiert auf die Region und den Kontext (max. 3-4 Sätze)."
}`
const userMessage = `Name der Organisation: ${orgName}\nZusätzliche Stichpunkte vom Benutzer:\n${context}`
const completion = await client.chat.completions.create({
model,
messages: [
{ role: 'system', content: systemMessage },
{ role: 'user', content: userMessage },
],
// some openrouter models ignore response_format, so doing it purely by prompt
temperature: 0.7
})
let textResponse = completion.choices[0]?.message?.content || ''
// safely remove potential markdown blocks just in case
textResponse = textResponse.trim()
if (textResponse.startsWith('```json')) {
textResponse = textResponse.replace(/^```json\n?/, '').replace(/\n?```$/, '').trim()
} else if (textResponse.startsWith('```')) {
textResponse = textResponse.replace(/^```\n?/, '').replace(/\n?```$/, '').trim()
}
const result = JSON.parse(textResponse)
return NextResponse.json(result)
} catch (error: any) {
console.error('Error generating AI landing page content:', error)
if (parsedBody?.orgName && parsedBody?.context) {
return NextResponse.json(buildFallbackLandingContent(parsedBody.orgName, parsedBody.context))
}
return NextResponse.json({ error: error?.message || 'Failed to generate content' }, { status: 500 })
}
}

View File

@@ -0,0 +1,160 @@
import { NextResponse } from 'next/server'
import OpenAI from 'openai'
type LlmProvider = 'openai' | 'openrouter'
function getProvider(): LlmProvider {
const configured = (process.env.LLM_PROVIDER ?? '').toLowerCase()
if (configured === 'openrouter') return 'openrouter'
if (configured === 'openai') return 'openai'
return process.env.OPENROUTER_API_KEY ? 'openrouter' : 'openai'
}
function createClient(provider: LlmProvider) {
if (provider === 'openrouter') {
const apiKey = process.env.OPENROUTER_API_KEY || ''
return new OpenAI({
apiKey,
baseURL: process.env.OPENROUTER_BASE_URL || 'https://openrouter.ai/api/v1',
defaultHeaders: {
...(process.env.OPENROUTER_SITE_URL
? { 'HTTP-Referer': process.env.OPENROUTER_SITE_URL }
: {}),
...(process.env.OPENROUTER_APP_NAME
? { 'X-Title': process.env.OPENROUTER_APP_NAME }
: {}),
},
})
}
return new OpenAI({
apiKey: process.env.OPENAI_API_KEY || '',
})
}
function getModel(provider: LlmProvider): string {
if (provider === 'openrouter') {
return process.env.OPENROUTER_MODEL || 'minimax/minimax-m2.5'
}
return process.env.OPENAI_MODEL || 'gpt-5-mini'
}
function hasApiKey(provider: LlmProvider): boolean {
if (provider === 'openrouter') return !!process.env.OPENROUTER_API_KEY
return !!process.env.OPENAI_API_KEY
}
async function generateText({
provider,
model,
systemMessage,
prompt,
}: {
provider: LlmProvider
model: string
systemMessage: string
prompt: string
}) {
const client = createClient(provider)
const completion = await client.chat.completions.create({
model,
messages: [
{ role: 'system', content: systemMessage },
{ role: 'user', content: prompt },
],
})
return completion.choices[0]?.message?.content || ''
}
export async function POST(req: Request) {
try {
const { prompt, type, format } = await req.json()
const primaryProvider = getProvider()
const primaryModel = getModel(primaryProvider)
if (!prompt) {
return NextResponse.json({ error: 'Prompt is required' }, { status: 400 })
}
let systemMessage = ''
if (type === 'news') {
systemMessage = `Du bist ein erfahrener Newsletter- und PR-Experte für eine Innung (Handwerksverband).
Deine Aufgabe ist es, professionelle, ansprechende und informative News-Beiträge zu schreiben.
Achte auf eine klare Struktur, eine einladende Tonalität und hohe inhaltliche Qualität.
Das gewünschte Ausgabeformat ist: ${format === 'markdown' ? 'Markdown' : 'Einfacher unformatierter Text'}.`
} else if (type === 'stelle') {
systemMessage = `Du bist ein erfahrener HR- und Recruiting-Experte für das Handwerk.
Deine Aufgabe ist es, attraktive und präzise Stellenanzeigen (Lehrlingsbörse / Jobbörse) zu verfassen.
Die Stellenanzeige soll Begeisterung wecken und klar die Aufgaben sowie Anforderungen kommunizieren.
Das gewünschte Ausgabeformat ist: ${format === 'markdown' ? 'Markdown' : 'Einfacher unformatierter Text'}.`
} else {
systemMessage = `Du bist ein hilfreicher KI-Assistent. Antworte immer auf Deutsch.`
}
const attempts: Array<{ provider: LlmProvider; model: string; reason: string }> = []
if (hasApiKey(primaryProvider)) {
attempts.push({
provider: primaryProvider,
model: primaryModel,
reason: 'primary',
})
}
// Fallback requested: if primary fails, try OpenAI GPT-5 mini when OPENAI_API_KEY is present.
if (primaryProvider !== 'openai' && hasApiKey('openai')) {
attempts.push({
provider: 'openai',
model: 'gpt-5-mini',
reason: 'fallback_openai',
})
}
if (attempts.length === 0) {
return NextResponse.json(
{ error: 'No AI provider key configured (OPENROUTER_API_KEY or OPENAI_API_KEY).' },
{ status: 500 }
)
}
let lastError: any = null
for (const attempt of attempts) {
try {
const text = await generateText({
provider: attempt.provider,
model: attempt.model,
systemMessage,
prompt,
})
return NextResponse.json({
text,
provider: attempt.provider,
model: attempt.model,
fallbackUsed: attempt.reason !== 'primary',
})
} catch (error: any) {
lastError = error
console.error('AI attempt failed:', {
provider: attempt.provider,
model: attempt.model,
message: error?.message,
})
}
}
return NextResponse.json(
{ error: lastError?.message || 'All AI providers failed' },
{ status: 500 }
)
} catch (error: any) {
console.error('AI Generate Error:', error)
return NextResponse.json(
{ error: error?.message || 'Internal Server Error' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,4 @@
import { auth } from '@/lib/auth'
import { toNextJsHandler } from 'better-auth/next-js'
export const { POST, GET } = toNextJsHandler(auth)

View File

@@ -0,0 +1,17 @@
import { NextResponse } from 'next/server'
import { auth, getSanitizedHeaders } from '@/lib/auth'
import { prisma } from '@innungsapp/shared'
export async function POST() {
const session = await auth.api.getSession({ headers: await getSanitizedHeaders() })
if (!session?.user?.id) {
return NextResponse.json({ error: 'Nicht eingeloggt' }, { status: 401 })
}
await prisma.user.update({
where: { id: session.user.id },
data: { mustChangePassword: false },
})
return NextResponse.json({ success: true })
}

View File

@@ -0,0 +1,37 @@
import { NextRequest, NextResponse } from 'next/server'
import { auth, getSanitizedHeaders } from '@/lib/auth'
import { prisma } from '@innungsapp/shared'
// @ts-ignore
import { hashPassword } from 'better-auth/crypto'
export async function POST(req: NextRequest) {
const session = await auth.api.getSession({ headers: await getSanitizedHeaders() })
if (!session?.user?.id) {
return NextResponse.json({ error: 'Nicht eingeloggt' }, { status: 401 })
}
const { newPassword } = await req.json()
if (!newPassword || newPassword.length < 8) {
return NextResponse.json({ error: 'Passwort muss mindestens 8 Zeichen haben.' }, { status: 400 })
}
const userId = session.user.id
const newHash = await hashPassword(newPassword)
const credAccount = await prisma.account.findFirst({
where: { userId, providerId: 'credential' },
})
if (credAccount) {
await prisma.account.update({ where: { id: credAccount.id }, data: { password: newHash } })
} else {
const { randomUUID } = await import('node:crypto')
await prisma.account.create({
data: { id: randomUUID(), accountId: userId, providerId: 'credential', userId, password: newHash },
})
}
await prisma.user.update({ where: { id: userId }, data: { mustChangePassword: false } })
return NextResponse.json({ success: true })
}

View File

@@ -0,0 +1,50 @@
import { NextRequest } from 'next/server'
import { auth, getSanitizedHeaders } from '@/lib/auth'
import { prisma } from '@innungsapp/shared'
export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const session = await auth.api.getSession({ headers: await getSanitizedHeaders(req.headers) })
if (!session?.user) {
return new Response('Unauthorized', { status: 401 })
}
const { id } = await params
// Verify admin role via UserRole table
const userRole = await prisma.userRole.findFirst({
where: { userId: session.user.id, role: 'admin' },
})
if (!userRole) {
return new Response('Forbidden', { status: 403 })
}
const termin = await prisma.termin.findUnique({
where: { id, orgId: userRole.orgId },
include: { anmeldungen: { include: { member: true } } },
})
if (!termin) {
return new Response('Not found', { status: 404 })
}
if (termin.anmeldungen.length === 0) {
return new Response('Keine Anmeldungen vorhanden', { status: 404 })
}
const rows = termin.anmeldungen.map((a) => ({
Name: a.member.name,
Email: a.member.email,
Betrieb: a.member.betrieb ?? '',
Angemeldet: new Date(a.angemeldetAt).toLocaleDateString('de-DE'),
}))
const header = Object.keys(rows[0]).join(';')
const csv = [header, ...rows.map((r) => Object.values(r).join(';'))].join('\n')
return new Response('\uFEFF' + csv, {
headers: {
'Content-Type': 'text/csv; charset=utf-8',
'Content-Disposition': `attachment; filename="teilnehmer-${id}.csv"`,
},
})
}

View File

@@ -0,0 +1,5 @@
import { NextResponse } from 'next/server'
export async function GET() {
return NextResponse.json({ status: 'ok', timestamp: new Date().toISOString() })
}

View File

@@ -0,0 +1,23 @@
import { NextRequest, NextResponse } from 'next/server'
import { auth, getSanitizedHeaders } from '@/lib/auth'
import { prisma } from '@innungsapp/shared'
export async function POST(req: NextRequest) {
const session = await auth.api.getSession({ headers: await getSanitizedHeaders(req.headers) })
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { token } = await req.json()
if (!token || typeof token !== 'string') {
return NextResponse.json({ error: 'Invalid token' }, { status: 400 })
}
// Store push token on the member record
await prisma.member.updateMany({
where: { userId: session.user.id },
data: { pushToken: token },
})
return NextResponse.json({ success: true })
}

View File

@@ -0,0 +1,72 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@innungsapp/shared'
import { sendInviteEmail } from '@/lib/email'
import { auth } from '@/lib/auth'
export async function POST(req: NextRequest, { params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params
const body = await req.json().catch(() => null)
if (!body?.name || !body?.email) {
return NextResponse.json({ error: 'Name und E-Mail sind erforderlich.' }, { status: 400 })
}
const name: string = String(body.name).trim()
const email: string = String(body.email).trim().toLowerCase()
const org = await prisma.organization.findUnique({
where: { slug },
select: { id: true, name: true },
})
if (!org) {
return NextResponse.json({ error: 'Organisation nicht gefunden.' }, { status: 404 })
}
// Check if email already registered in this org
const existing = await prisma.member.findFirst({
where: { orgId: org.id, email },
})
if (existing) {
// Still send invite so user can log in — don't reveal whether they exist
await sendInviteEmail({
to: email,
memberName: existing.name,
orgName: org.name,
apiUrl: process.env.BETTER_AUTH_URL!,
})
return NextResponse.json({ success: true })
}
// Create member record
await prisma.member.create({
data: {
name,
email,
orgId: org.id,
betrieb: '-',
sparte: '-',
ort: '-',
status: 'aktiv',
},
})
// Create auth user (may already exist)
try {
await auth.api.createUser({
body: { name, email, role: 'user', password: undefined },
})
} catch {
// User may already exist in auth system
}
await sendInviteEmail({
to: email,
memberName: name,
orgName: org.name,
apiUrl: process.env.BETTER_AUTH_URL!,
})
return NextResponse.json({ success: true })
}

View File

@@ -0,0 +1,82 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@innungsapp/shared'
export async function POST(req: NextRequest, { params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params
const body = await req.json().catch(() => null)
if (!body?.email) {
return NextResponse.json({ error: 'E-Mail ist erforderlich.' }, { status: 400 })
}
const email: string = String(body.email).trim().toLowerCase()
const name: string = String(body.name ?? '').trim() || email.split('@')[0]
const org = await prisma.organization.findUnique({
where: { slug },
select: { id: true },
})
if (!org) {
return NextResponse.json({ error: 'Organisation nicht gefunden.' }, { status: 404 })
}
// Look up the auth user that better-auth just created
const authUser = await prisma.user.findUnique({
where: { email },
select: { id: true },
})
if (!authUser) {
return NextResponse.json({ error: 'Benutzer nicht gefunden. Bitte zuerst registrieren.' }, { status: 400 })
}
// Idempotent: skip if member already exists (linked to this user)
const existingMember = await prisma.member.findFirst({
where: { orgId: org.id, userId: authUser.id },
})
if (!existingMember) {
// Member may exist without userId (created by admin before user registered)
const unlinkedMember = await prisma.member.findFirst({
where: { orgId: org.id, email, userId: null },
})
if (unlinkedMember) {
await prisma.member.update({
where: { id: unlinkedMember.id },
data: { userId: authUser.id },
})
} else {
await prisma.member.create({
data: {
name,
email,
orgId: org.id,
userId: authUser.id,
betrieb: '-',
sparte: '-',
ort: '-',
status: 'aktiv',
},
})
}
}
// Idempotent: skip if role already exists
const existingRole = await prisma.userRole.findFirst({
where: { userId: authUser.id, orgId: org.id },
})
if (!existingRole) {
await prisma.userRole.create({
data: {
userId: authUser.id,
orgId: org.id,
role: 'member',
},
})
}
return NextResponse.json({ success: true })
}

View File

@@ -0,0 +1,60 @@
/**
* DEV-ONLY: Sets a password for the demo admin user via better-auth.
* Call once after seeding: GET http://localhost:3010/api/setup
* Remove this file before going to production.
*/
import { NextResponse } from 'next/server'
import { auth } from '@/lib/auth'
import { prisma } from '@innungsapp/shared'
export async function GET() {
if (process.env.NODE_ENV === 'production') {
return NextResponse.json({ error: 'Not available in production' }, { status: 403 })
}
// Delete the pre-seeded user so better-auth can create it fresh with a hashed password
await prisma.account.deleteMany({ where: { userId: 'demo-admin-user-id' } })
await prisma.member.deleteMany({ where: { userId: 'demo-admin-user-id' } })
await prisma.userRole.deleteMany({ where: { userId: 'demo-admin-user-id' } })
await prisma.user.deleteMany({ where: { id: 'demo-admin-user-id' } })
// Re-create via better-auth so the password is properly hashed
const result = await auth.api.signUpEmail({
body: { email: 'admin@demo.de', password: 'demo1234', name: 'Demo Admin' },
})
if (!result?.user) {
return NextResponse.json({ error: 'signUp failed', result }, { status: 500 })
}
const newUserId = result.user.id
// Restore org membership for the new user ID
const org = await prisma.organization.findFirst({ where: { slug: 'innung-elektro-stuttgart' } })
if (org) {
await prisma.userRole.upsert({
where: { orgId_userId: { orgId: org.id, userId: newUserId } },
update: {},
create: { orgId: org.id, userId: newUserId, role: 'admin' },
})
await prisma.member.upsert({
where: { userId: newUserId },
update: {},
create: {
orgId: org.id,
userId: newUserId,
name: 'Demo Admin',
betrieb: 'Innungsgeschäftsstelle',
sparte: 'Elektrotechnik',
ort: 'Stuttgart',
email: 'admin@demo.de',
status: 'aktiv',
},
})
}
return NextResponse.json({
ok: true,
message: 'Setup complete. Login: admin@demo.de / demo1234',
})
}

View File

@@ -0,0 +1,23 @@
import { fetchRequestHandler } from '@trpc/server/adapters/fetch'
import { appRouter } from '@/server/routers'
import { createContext } from '@/server/context'
import { type NextRequest } from 'next/server'
const handler = (req: NextRequest) =>
fetchRequestHandler({
endpoint: '/api/trpc',
req,
router: appRouter,
createContext: () => createContext({ req, resHeaders: new Headers(), info: {} as never }),
onError:
process.env.NODE_ENV === 'development'
? ({ path, error }) => {
console.error(
`[tRPC] Error on ${path ?? '<no-path>'}:`,
error
)
}
: undefined,
})
export { handler as GET, handler as POST }

View File

@@ -0,0 +1,61 @@
import { NextRequest, NextResponse } from 'next/server'
import { writeFile, mkdir } from 'fs/promises'
import path from 'path'
import { randomUUID } from 'crypto'
import { auth, getSanitizedHeaders } from '@/lib/auth'
const UPLOAD_DIR = process.env.UPLOAD_DIR ?? (process.env.NODE_ENV === 'production' ? '/app/uploads' : './uploads')
const MAX_SIZE_BYTES = Number(process.env.UPLOAD_MAX_SIZE_MB ?? 10) * 1024 * 1024
function getUploadRoot() {
if (path.isAbsolute(UPLOAD_DIR)) {
return UPLOAD_DIR
}
return path.resolve(process.cwd(), UPLOAD_DIR)
}
export async function POST(req: NextRequest) {
// Auth check
const session = await auth.api.getSession({ headers: await getSanitizedHeaders(req.headers) })
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const formData = await req.formData()
const file = formData.get('file') as File | null
if (!file) {
return NextResponse.json({ error: 'No file provided' }, { status: 400 })
}
if (file.size > MAX_SIZE_BYTES) {
return NextResponse.json({ error: 'File too large' }, { status: 413 })
}
// Only allow safe file types
const allowedTypes = [
'application/pdf',
'image/png',
'image/jpeg',
'image/webp',
'image/gif',
]
if (!allowedTypes.includes(file.type)) {
return NextResponse.json({ error: 'File type not allowed' }, { status: 415 })
}
const ext = path.extname(file.name)
const fileName = `${randomUUID()}${ext}`
const uploadPath = getUploadRoot()
await mkdir(uploadPath, { recursive: true })
const buffer = Buffer.from(await file.arrayBuffer())
await writeFile(path.join(uploadPath, fileName), buffer)
return NextResponse.json({
storagePath: fileName,
name: file.name,
sizeBytes: file.size,
url: `/uploads/${fileName}`,
})
}

View File

@@ -0,0 +1,50 @@
import { NextRequest, NextResponse } from 'next/server'
import { readFile } from 'fs/promises'
import path from 'path'
const UPLOAD_DIR = process.env.UPLOAD_DIR ?? (process.env.NODE_ENV === 'production' ? '/app/uploads' : './uploads')
function getUploadRoot() {
if (path.isAbsolute(UPLOAD_DIR)) {
return UPLOAD_DIR
}
return path.resolve(process.cwd(), UPLOAD_DIR)
}
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ path: string[] }> }
) {
try {
const { path: filePathParams } = await params
const uploadRoot = getUploadRoot()
const filePath = path.join(uploadRoot, ...filePathParams)
// Security: prevent path traversal
const resolved = path.resolve(filePath)
const uploadDir = path.resolve(uploadRoot)
if (!resolved.startsWith(uploadDir + path.sep) && resolved !== uploadDir) {
return new NextResponse('Forbidden', { status: 403 })
}
const file = await readFile(resolved)
const ext = path.extname(resolved).toLowerCase()
const mimeTypes: Record<string, string> = {
'.pdf': 'application/pdf',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.webp': 'image/webp',
}
return new NextResponse(file, {
headers: {
'Content-Type': mimeTypes[ext] ?? 'application/octet-stream',
'Cache-Control': 'public, max-age=86400',
},
})
} catch {
return new NextResponse('Not Found', { status: 404 })
}
}

View File

@@ -0,0 +1,354 @@
'use client'
import { useEffect, useState, type ReactNode } from 'react'
import Link from 'next/link'
import { Syne } from 'next/font/google'
import { Moon, Sun, ArrowLeft } from 'lucide-react'
const syne = Syne({ subsets: ['latin'], weight: ['400', '500', '600', '700', '800'] })
type LegalPageShellProps = {
title: string
subtitle: string
children: ReactNode
}
export default function LegalPageShell({ title, subtitle, children }: LegalPageShellProps) {
const [theme, setTheme] = useState('theme-light')
useEffect(() => {
const savedTheme = localStorage.getItem('theme')
if (savedTheme) {
setTheme(savedTheme)
}
}, [])
const toggleTheme = () => {
const newTheme = theme === 'theme-dark' ? 'theme-light' : 'theme-dark'
setTheme(newTheme)
localStorage.setItem('theme', newTheme)
}
return (
<>
<style>{`
.theme-light {
--bg: #FAFAFA;
--nav-bg: rgba(250, 250, 250, 0.85);
--ink: #111111;
--ink-muted: rgba(17, 17, 17, 0.62);
--ink-faint: rgba(17, 17, 17, 0.1);
--gold: #C9973A;
--gold-light: #B8862D;
--gold-faint: rgba(201, 151, 58, 0.08);
--card-bg: rgba(255, 255, 255, 0.64);
--glass-border: rgba(17, 17, 17, 0.06);
}
.theme-dark {
--bg: #0C0B09;
--nav-bg: rgba(12, 11, 9, 0.85);
--ink: #EAE6DA;
--ink-muted: rgba(234, 230, 218, 0.58);
--ink-faint: rgba(234, 230, 218, 0.12);
--gold: #C9973A;
--gold-light: #DFB25C;
--gold-faint: rgba(201, 151, 58, 0.16);
--card-bg: rgba(20, 19, 17, 0.45);
--glass-border: rgba(234, 230, 218, 0.08);
}
.legal-page {
min-height: 100vh;
background: var(--bg);
color: var(--ink);
background-image:
radial-gradient(circle at 15% 40%, var(--gold-faint), transparent 28%),
radial-gradient(circle at 84% 22%, var(--gold-faint), transparent 24%);
transition: background 0.25s, color 0.25s;
}
.nav {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 40;
height: 64px;
display: flex;
align-items: center;
background: var(--nav-bg);
border-bottom: 1px solid var(--ink-faint);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
}
.nav-inner {
max-width: 1280px;
width: 100%;
margin: 0 auto;
padding: 0 32px;
display: flex;
align-items: center;
justify-content: space-between;
}
.logo {
font-weight: 800;
font-size: 1.125rem;
letter-spacing: -0.02em;
display: flex;
align-items: center;
}
.logo-accent { color: var(--gold); }
.logo-pro {
font-size: 0.65rem;
background: var(--gold-faint);
color: var(--gold);
padding: 3px 6px;
border-radius: 4px;
margin-left: 8px;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.nav-links {
display: flex;
align-items: center;
gap: 26px;
}
.nav-link {
font-size: 0.875rem;
color: var(--ink-muted);
text-decoration: none;
transition: color 0.15s;
}
.nav-link:hover { color: var(--ink); }
.theme-btn {
background: none;
border: none;
cursor: pointer;
padding: 4px;
display: inline-flex;
align-items: center;
color: var(--ink-muted);
}
.theme-btn:hover { color: var(--ink); }
.back-link {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--ink-muted);
text-decoration: none;
font-size: 0.875rem;
margin-bottom: 24px;
transition: color 0.15s;
}
.back-link:hover { color: var(--ink); }
.main-wrap {
max-width: 980px;
margin: 0 auto;
padding: 132px 32px 76px;
}
.eyebrow {
font-size: 0.75rem;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--gold);
font-weight: 600;
margin-bottom: 16px;
}
.page-title {
font-weight: 800;
letter-spacing: -0.03em;
line-height: 0.96;
font-size: clamp(2rem, 5vw, 3.3rem);
margin: 0 0 22px;
color: var(--ink);
}
.page-subtitle {
margin: 0 0 30px;
max-width: 760px;
color: var(--ink-muted);
line-height: 1.7;
font-size: 0.98rem;
}
.legal-card {
background: var(--card-bg);
border: 1px solid var(--glass-border);
border-radius: 18px;
padding: 34px;
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
}
.legal-sections {
display: flex;
flex-direction: column;
gap: 30px;
}
.legal-section h2 {
margin: 0 0 12px;
font-size: 1rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--gold);
}
.legal-section p {
margin: 0 0 8px;
color: var(--ink);
line-height: 1.7;
font-size: 0.95rem;
}
.legal-section .muted { color: var(--ink-muted); }
.legal-list {
margin: 0;
padding-left: 18px;
color: var(--ink);
}
.legal-list li {
margin-bottom: 6px;
line-height: 1.7;
font-size: 0.95rem;
}
.legal-link {
color: var(--gold);
text-decoration: none;
}
.legal-link:hover {
color: var(--gold-light);
text-decoration: underline;
}
.footer {
border-top: 1px solid var(--ink-faint);
padding: 34px 0;
}
.footer-inner {
max-width: 1280px;
margin: 0 auto;
padding: 0 32px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
}
.footer-copy {
margin: 0;
font-size: 0.75rem;
color: var(--ink-muted);
font-family: Georgia, serif;
}
.footer-links {
display: flex;
gap: 24px;
}
.footer-link {
font-size: 0.8125rem;
color: var(--ink-muted);
text-decoration: none;
transition: color 0.15s;
}
.footer-link:hover { color: var(--ink); }
@media (max-width: 767px) {
.nav-inner,
.main-wrap,
.footer-inner {
padding-left: 18px;
padding-right: 18px;
}
.nav-links .nav-link[data-hide-mobile="true"] { display: none; }
.legal-card { padding: 22px 18px; }
.footer-inner {
flex-direction: column;
align-items: flex-start;
}
}
`}</style>
<div className={`legal-page ${syne.className} ${theme}`}>
<nav className="nav">
<div className="nav-inner">
<Link href="/" className="logo">
Innungs<span className="logo-accent">App</span>{' '}
<span className="logo-pro">PRO</span>
</Link>
<div className="nav-links">
<Link href="/#leistungen" className="nav-link" data-hide-mobile="true">
Leistungen
</Link>
<button
type="button"
onClick={toggleTheme}
className="theme-btn"
aria-label="Theme wechseln"
title={theme === 'theme-dark' ? 'Light Mode' : 'Dark Mode'}
>
{theme === 'theme-dark' ? <Sun size={18} /> : <Moon size={18} />}
</button>
<Link href="/login" className="nav-link">
Login
</Link>
</div>
</div>
</nav>
<main className="main-wrap">
<Link href="/" className="back-link">
<ArrowLeft size={16} />
Zurück zur Startseite
</Link>
<div className="eyebrow">Rechtliches</div>
<h1 className="page-title">{title}</h1>
<p className="page-subtitle">{subtitle}</p>
<div className="legal-card">{children}</div>
</main>
<footer className="footer">
<div className="footer-inner">
<p className="footer-copy">
© {new Date().getFullYear()} InnungsApp SaaS. Alle Rechte vorbehalten.
</p>
<div className="footer-links">
<Link href="/impressum" className="footer-link">
Impressum
</Link>
<Link href="/datenschutz" className="footer-link">
Datenschutz
</Link>
</div>
</div>
</footer>
</div>
</>
)
}

View File

@@ -0,0 +1,107 @@
import { auth, getSanitizedHeaders } from '@/lib/auth'
import { prisma } from '@innungsapp/shared'
import { headers } from 'next/headers'
import Link from 'next/link'
import { redirect } from 'next/navigation'
export default async function GlobalDashboardRedirect() {
const headerList = await headers()
const host = headerList.get('host') || ''
const session = await auth.api.getSession({ headers: await getSanitizedHeaders(headerList) })
if (!session?.user) {
redirect('/login')
}
// Superadmin logic
const superAdminEmail = process.env.SUPERADMIN_EMAIL || 'superadmin@innungsapp.de'
const isSuperAdmin = session.user.email === superAdminEmail || session.user.role === 'admin'
if (isSuperAdmin) {
redirect('/superadmin')
}
const userRoles = await prisma.userRole.findMany({
where: { userId: session.user.id, role: 'admin' },
include: {
org: {
select: { id: true, name: true, slug: true },
},
},
orderBy: { createdAt: 'asc' },
})
if (userRoles.length === 1) {
const slug = userRoles[0].org.slug
const protocol = host.includes('localhost') ? 'http' : 'https'
// Construct the subdomain URL
let newHost = host
if (host.includes('localhost')) {
const port = host.includes(':') ? `:${host.split(':')[1]}` : ''
newHost = `${slug}.localhost${port}`
} else {
// Assumes domain.tld
const parts = host.split('.')
if (parts.length === 2) {
newHost = `${slug}.${host}`
} else if (parts.length > 2) {
newHost = `${slug}.${parts.slice(-2).join('.')}`
}
}
redirect(`${protocol}://${newHost}/dashboard`)
}
const getOrgUrl = (slug: string, currentHost: string) => {
const protocol = currentHost.includes('localhost') ? 'http' : 'https'
let newHost = currentHost
if (currentHost.includes('localhost')) {
const port = currentHost.includes(':') ? `:${currentHost.split(':')[1]}` : ''
newHost = `${slug}.localhost${port}`
} else {
const parts = currentHost.split('.')
newHost = parts.length >= 2 ? `${slug}.${parts.slice(-2).join('.')}` : `${slug}.${currentHost}`
}
return `${protocol}://${newHost}/dashboard`
}
return (
<div className="min-h-screen bg-gray-50 flex flex-col items-center justify-center p-4">
<div className="bg-white border rounded-xl p-8 max-w-md w-full text-center shadow-sm">
<h1 className="text-xl font-bold text-gray-900 mb-2">
{userRoles.length > 1 ? 'Bitte Innung auswählen' : 'Kein Mandant zugeordnet'}
</h1>
{userRoles.length > 1 ? (
<div className="space-y-2 mb-6">
{userRoles.map((userRole) => (
<Link
key={userRole.org.id}
href={getOrgUrl(userRole.org.slug, host)}
className="block w-full rounded-lg border border-gray-200 px-4 py-3 text-sm text-gray-700 hover:border-brand-500 hover:text-brand-700 transition-colors"
>
{userRole.org.name}
</Link>
))}
</div>
) : (
<p className="text-gray-500 mb-6 text-sm">
Ihr Konto hat aktuell keinen Admin-Zugriff auf eine Innung.
</p>
)}
<form action={async () => {
'use server'
const { auth, getSanitizedHeaders } = await import('@/lib/auth')
await auth.api.signOut({ headers: await getSanitizedHeaders() })
redirect('/login')
}}>
<button type="submit" className="text-sm font-medium text-brand-600 hover:text-brand-700">
Abmelden
</button>
</form>
</div>
</div>
)
}

View File

@@ -0,0 +1,14 @@
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: 'Datenschutz | InnungsApp PRO',
description: 'Datenschutzerklaerung der InnungsApp PRO.',
}
export default function DatenschutzLayout({
children,
}: {
children: React.ReactNode
}) {
return <>{children}</>
}

View File

@@ -0,0 +1,217 @@
import LegalPageShell from '../components/LegalPageShell'
export default function DatenschutzPage() {
return (
<LegalPageShell
title="Datenschutzerklaerung"
subtitle="Informationen zur Verarbeitung personenbezogener Daten fuer Website, Admin-Portal und App-Dienste."
>
<div className="legal-sections">
<section className="legal-section">
<h2>1. Verantwortlicher</h2>
<p>Johannes Tils, Zeppelinstr. 21, 42781 Haan, Deutschland</p>
<p>
E-Mail:{' '}
<a className="legal-link" href="mailto:johannestils@aol.com">
johannestils@aol.com
</a>
</p>
<p className="muted">
Diese Erklaerung gilt fuer die Nutzung von innungsapp.com und den dazugehoerigen App-Diensten.
</p>
</section>
<section className="legal-section">
<h2>2. Rollen nach DSGVO</h2>
<p>
Bei der Bereitstellung der Plattform fuer Innungen gilt regelmaessig: Die jeweilige Innung bzw.
Organisation ist Verantwortlicher, InnungsApp ist Auftragsverarbeiter.
</p>
<p>
Fuer diese Verarbeitung wird vor dem Go-Live ein Auftragsverarbeitungsvertrag (AVV) gemaess Art.
28 DSGVO abgeschlossen.
</p>
<p>
Bei reinem Besuch der Landingpage (ohne Kundenkonto) verarbeiten wir Daten als eigener
Verantwortlicher.
</p>
</section>
<section className="legal-section">
<h2>3. Zwecke und Rechtsgrundlagen</h2>
<p>Wir verarbeiten personenbezogene Daten insbesondere fuer folgende Zwecke:</p>
<ul className="legal-list">
<li>Bereitstellung der Plattform und Nutzerkonten</li>
<li>Mitgliederverwaltung, Kommunikation und Terminfunktionen</li>
<li>Versand von E-Mails, z. B. Einladungen und Login-Links</li>
<li>Sicherheits-, Betriebs- und Missbrauchspraevention</li>
<li>Optionale KI-Unterstuetzung ueber OpenRouter</li>
<li>Optionale Reichweitenmessung der Landingpage ueber PostHog (nur nach Einwilligung)</li>
</ul>
<p className="muted">
Rechtsgrundlagen sind insbesondere Art. 6 Abs. 1 lit. b DSGVO (Vertrag/Vertragsanbahnung), lit.
c DSGVO (rechtliche Pflicht), lit. f DSGVO (berechtigtes Interesse) und soweit erforderlich lit.
a DSGVO (Einwilligung).
</p>
</section>
<section className="legal-section">
<h2>4. Verarbeitete Datenkategorien</h2>
<ul className="legal-list">
<li>Stammdaten, z. B. Name, E-Mail, Telefonnummer, Organisation</li>
<li>Nutzungsdaten und technische Protokolldaten, z. B. IP-Adresse, Zeitstempel, Events</li>
<li>Inhaltsdaten, z. B. Nachrichten, Termine, hochgeladene Dateien und Dokumente</li>
<li>Push-Token fuer Benachrichtigungen</li>
</ul>
</section>
<section className="legal-section">
<h2>5. Empfaenger und Dienstleister</h2>
<p>Wir setzen folgende Kategorien von Empfaengern bzw. Unterauftragsverarbeitern ein:</p>
<ul className="legal-list">
<li>Hosting-Infrastruktur in den USA (Texas); Administration erfolgt durch uns aus der EU unter strengen Zugriffsbeschraenkungen</li>
<li>E-Mail-Infrastruktur in den USA (Texas); Administration erfolgt durch uns aus der EU unter strengen Zugriffsbeschraenkungen</li>
<li>OpenRouter fuer optionale KI-Funktionen, sofern von der Innung aktiviert</li>
<li>PostHog fuer optionale Webanalyse der Landingpage (nur nach Einwilligung)</li>
<li>Apple APNs und Google FCM fuer Push-Benachrichtigungen</li>
</ul>
<p className="muted">
Eine aktuelle Liste eingesetzter Unterauftragsverarbeiter stellen wir auf Anfrage bzw. im AVV
bereit.
</p>
</section>
<section className="legal-section">
<h2>6. Drittlanduebermittlung</h2>
<p>
Eine Verarbeitung personenbezogener Daten kann in den USA stattfinden. Soweit
Drittlanduebermittlungen an externe Empfaenger erfolgen (z. B. Anbieter/Provider in den USA),
schliessen wir EU-Standardvertragsklauseln (SCC) als geeignete Garantien nach Art. 44 ff. DSGVO
ab.
</p>
<p>
Sofern Daten in den USA verarbeitet werden, dokumentieren wir zusaetzlich Transfer Impact
Assessments (TIA) und setzen technische sowie organisatorische Schutzmassnahmen um, insbesondere
Verschluesselung bei Uebertragung (TLS) und Speicherung, Zugriffsbeschraenkungen (MFA,
rollenbasiert), Protokollierung sowie regelmaessige Berechtigungspruefungen. Details und aktuelle
Unterauftragsverarbeiter sind im AVV dokumentiert.
</p>
</section>
<section className="legal-section">
<h2>7. KI-Funktionen ueber OpenRouter (optional)</h2>
<p>
KI-Funktionen sind optional und werden nur genutzt, wenn die jeweilige Innung diese Funktion
aktiviert.
</p>
<ul className="legal-list">
<li>Verarbeitete Daten: Texteingaben, Prompts und generierte Antworten</li>
<li>Zweck: Formulierungshilfen und inhaltliche Unterstuetzung in der Plattform</li>
<li>Rechtsgrundlage: je nach Einsatz Art. 6 Abs. 1 lit. b, lit. f oder lit. a DSGVO</li>
<li>Drittlandbezug: kann je nach Modellanbieter bestehen</li>
</ul>
<p className="muted">
Es sollten keine besonderen Kategorien personenbezogener Daten in KI-Prompts eingegeben werden,
sofern dies nicht ausdruecklich freigegeben und vertraglich geregelt ist.
</p>
</section>
<section className="legal-section">
<h2>8. Push-Benachrichtigungen</h2>
<p>
Fuer Push-Benachrichtigungen nutzen wir die Plattformdienste Apple Push Notification Service (APNs)
und Firebase Cloud Messaging (FCM). Dabei werden technische Push-Token verarbeitet.
</p>
<p>
Dabei kann eine Uebermittlung in Drittlaender (insb. USA) nicht ausgeschlossen werden; in der Regel
werden jedoch nur technische Token und Zustellinformationen verarbeitet.
</p>
</section>
<section className="legal-section">
<h2>9. Sicherheit und Protokollierung (TOMs)</h2>
<ul className="legal-list">
<li>Transportverschluesselung (TLS) und abgesicherte Admin-Zugaenge</li>
<li>Rollen- und Berechtigungskonzepte nach dem Need-to-know-Prinzip</li>
<li>Protokollierung sicherheitsrelevanter Zugriffe und Systemereignisse</li>
<li>Backup- und Wiederherstellungsprozesse</li>
</ul>
</section>
<section className="legal-section">
<h2>10. Speicherdauer und Loeschung</h2>
<p>
Wir speichern personenbezogene Daten nur so lange, wie es fuer die genannten Zwecke erforderlich
ist oder gesetzliche Aufbewahrungspflichten bestehen.
</p>
<p>
Konkrete Loesch- und Aufbewahrungsfristen werden im AVV, im Loeschkonzept und in den vertraglichen
Vereinbarungen mit der jeweiligen Innung geregelt.
</p>
<p>
System- und Sicherheitsprotokolle speichern wir in der Regel fuer 90 Tage. Technische
Debug-/Fehlerprotokolle speichern wir in der Regel fuer 30 Tage.
</p>
<p>
Backups werden als Rolling-Backups in der Regel nach 90 Tagen ueberschrieben, sofern keine
gesetzlichen Aufbewahrungspflichten entgegenstehen.
</p>
</section>
<section className="legal-section">
<h2>11. Cookies, Consent und Webanalyse</h2>
<p>
Auf der Landingpage setzen wir optionale Analyse mit PostHog nur nach vorheriger Einwilligung ein.
Vor Einwilligung wird PostHog nicht gestartet.
</p>
<p>
Im Rahmen der Webanalyse koennen Nutzungsdaten (z. B. Seitenaufrufe, Interaktionen und technische
Metadaten) verarbeitet werden. Die Speicherdauer der Analysedaten betraegt 12 Monate.
</p>
<p>
PostHog wird in der USA-Region betrieben. Die Daten werden gemäß EU-Standardvertragsklauseln (SCC) mit angemessenen Schutzmassnahmen uebermittelt.
</p>
<p>
Ihre Consent-Entscheidung wird lokal auf Ihrem Geraet gespeichert und kann jederzeit ueber den Link
"Cookie-Einstellungen" im Footer geaendert werden.
</p>
</section>
<section className="legal-section">
<h2>12. Ihre Rechte</h2>
<p>
Ihnen stehen insbesondere die Rechte auf Auskunft, Berichtigung, Loeschung, Einschraenkung der
Verarbeitung, Datenuebertragbarkeit sowie Widerspruch zu.
</p>
<p className="muted">
Wenn Daten im Auftrag einer Innung verarbeitet werden, richten Sie Anfragen bitte primaer an die
jeweilige Innung als Verantwortliche.
</p>
<p className="muted">
Als Auftragsverarbeiter unterstuetzen wir die jeweilige Innung bei der Erfuellung von
Betroffenenrechten gemaess den Regelungen im AVV.
</p>
</section>
<section className="legal-section">
<h2>13. Konto- und Datenloeschung</h2>
<p>
Loeschanfragen koennen per E-Mail an{' '}
<a className="legal-link" href="mailto:johannestils@aol.com">
johannestils@aol.com
</a>{' '}
gestellt werden. Zur Sicherheit kann eine Identitaetspruefung erforderlich sein. Die Bearbeitung
erfolgt in der Regel innerhalb von 30 Tagen.
</p>
</section>
<section className="legal-section">
<h2>14. Beschwerderecht</h2>
<p>Sie haben das Recht, sich bei einer Datenschutz-Aufsichtsbehoerde zu beschweren.</p>
</section>
</div>
</LegalPageShell>
)
}

View File

@@ -0,0 +1,52 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--brand: #E63946;
}
body {
@apply bg-gray-50 text-gray-900 antialiased;
font-family: system-ui, -apple-system, sans-serif;
}
* {
@apply border-gray-200;
}
h1, h2, h3, h4 {
font-family: 'Syne', system-ui, sans-serif;
}
}
@layer components {
.sidebar-link {
@apply flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium text-gray-600 transition-colors hover:bg-gray-100 hover:text-gray-900;
}
.sidebar-link-active {
@apply bg-brand-50 text-brand-600 border-l-[3px] border-brand-500 rounded-l-none;
}
.stat-card {
@apply rounded-lg border bg-white p-6;
}
.data-table th {
@apply bg-gray-50 px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-gray-500 border-b-2 border-gray-200;
}
.data-table td {
@apply px-4 py-3 text-sm text-gray-700;
}
.data-table tr {
@apply border-b border-gray-100 last:border-0;
}
.data-table tr:hover td {
@apply bg-gray-50/70;
}
}

View File

@@ -0,0 +1,14 @@
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: 'Impressum | InnungsApp PRO',
description: 'Impressum der InnungsApp PRO.',
}
export default function ImpressumLayout({
children,
}: {
children: React.ReactNode
}) {
return <>{children}</>
}

View File

@@ -0,0 +1,50 @@
import LegalPageShell from '../components/LegalPageShell'
export default function ImpressumPage() {
return (
<LegalPageShell
title="Impressum"
subtitle="Anbieterkennzeichnung und Pflichtangaben fuer die Nutzung von innungsapp.com."
>
<div className="legal-sections">
<section className="legal-section">
<h2>Angaben gemaess &sect; 5 DDG</h2>
<p>Johannes Tils</p>
<p>Einzelunternehmer</p>
<p>Zeppelinstr. 21</p>
<p>42781 Haan</p>
<p>Deutschland</p>
</section>
<section className="legal-section">
<h2>Kontakt</h2>
<p>Telefon: 015771172597</p>
<p>
E-Mail:{' '}
<a className="legal-link" href="mailto:johannestils@aol.com">
johannestils@aol.com
</a>
</p>
</section>
<section className="legal-section">
<h2>Umsatzsteuer-ID gemaess &sect; 27a UStG</h2>
<p>DE356594917</p>
</section>
<section className="legal-section">
<h2>Handelsregister</h2>
<p>Nicht vorhanden.</p>
</section>
<section className="legal-section">
<h2>Verantwortlich fuer journalistisch-redaktionelle Inhalte gemaess &sect; 18 Abs. 2 MStV (soweit einschlaegig)</h2>
<p>Johannes Tils</p>
<p>Zeppelinstr. 21</p>
<p>42781 Haan</p>
<p>Deutschland</p>
</section>
</div>
</LegalPageShell>
)
}

View File

@@ -0,0 +1,60 @@
import type { Metadata } from 'next'
import { Inter, Outfit } from 'next/font/google'
import './globals.css'
import { Providers } from './providers'
import { prisma } from '@innungsapp/shared'
import { getTenantSlug } from '@/lib/tenant'
const inter = Inter({ subsets: ['latin'], variable: '--font-inter' })
const outfit = Outfit({ subsets: ['latin'], variable: '--font-outfit' })
export async function generateMetadata(): Promise<Metadata> {
const slug = await getTenantSlug()
let org = null
if (slug) {
org = await prisma.organization.findUnique({
where: { slug }
})
}
const title = org ? `${org.name} | InnungsApp` : 'InnungsApp PRO | Die moderne Vereinssoftware fuer das Handwerk'
const description = org
? `Willkommen im offiziellen Portal der ${org.name}.`
: 'Digitale Mitgliederverwaltung, Push-News und Lehrlingsboerse fuer Handwerksinnungen.'
const icon = org?.logoUrl || '/logo.png'
return {
title,
description,
icons: {
icon: icon,
},
metadataBase: new URL('https://innungsapp.com'),
openGraph: {
title,
description,
url: 'https://innungsapp.com',
siteName: 'InnungsApp PRO',
locale: 'de_DE',
type: 'website',
images: [{ url: org?.logoUrl || '/mobile-mockup.png' }],
},
}
}
// Default export remains the component, but we remove the static metadata object below
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="de">
<body className={`${inter.variable} ${outfit.variable} font-sans bg-gray-50`}>
<Providers>{children}</Providers>
</body>
</html>
)
}

View File

@@ -0,0 +1,14 @@
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: 'Login | InnungsApp PRO',
description: 'Melden Sie sich bei Ihrem InnungsApp Konto an.',
}
export default function LoginLayout({
children,
}: {
children: React.ReactNode
}) {
return <>{children}</>
}

View File

@@ -0,0 +1,106 @@
import Link from 'next/link'
import { getTenantSlug } from '@/lib/tenant'
import { prisma } from '@innungsapp/shared'
import { LoginForm } from '@/components/auth/LoginForm'
export default async function LoginPage() {
const slug = await getTenantSlug()
let org = null
if (slug) {
org = await prisma.organization.findUnique({
where: { slug }
})
}
const primaryColor = org?.primaryColor || '#C99738'
const orgName = org?.name || 'InnungsApp'
const logoUrl = org?.logoUrl || '/logo.png'
const secondaryText = org ? `Verwaltungsportal für die ${org.name}` : 'Verwaltungsportal für Innungen'
return (
<div className="min-h-screen bg-gray-50 flex flex-col">
<main className="flex-1 flex items-center justify-center p-4">
<div className="w-full max-w-sm">
<div className="mb-4">
<Link
href={slug ? `/${slug}` : "/"}
aria-label="Zur Startseite"
className="inline-flex h-10 w-10 items-center justify-center rounded-lg border border-gray-200 bg-white text-gray-600 shadow-sm transition-colors hover:text-gray-900 hover:border-gray-300"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="h-5 w-5"
aria-hidden="true"
>
<path d="M3 11.5 12 4l9 7.5" />
<path d="M5.5 10.5V20h13V10.5" />
<path d="M10 20v-5h4v5" />
</svg>
</Link>
</div>
<div className="text-center mb-8">
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-white shadow-md border border-gray-100 overflow-hidden">
<img
src={logoUrl}
alt={orgName}
className={`h-10 w-10 object-contain ${!org?.logoUrl ? 'brightness-110 scale-[1.6]' : ''}`}
/>
</div>
<h1
className="text-3xl font-bold text-gray-900 tracking-tight"
style={{ fontFamily: "'Syne', system-ui, sans-serif" }}
>
{org ? (
<>
<span style={{ color: primaryColor }}>{org.name.split(' ')[0]}</span>
{org.name.includes(' ') ? ` ${org.name.split(' ').slice(1).join(' ')}` : ''}
</>
) : (
<>
Innungs<span className="text-brand-500">App</span>
</>
)}
</h1>
<p className="text-sm text-gray-500 mt-1">{secondaryText}</p>
</div>
<div className="bg-white rounded-lg border p-8">
<h2
className="text-lg font-semibold text-gray-900 mb-5"
style={{ fontFamily: "'Syne', system-ui, sans-serif" }}
>
Anmelden
</h2>
<LoginForm primaryColor={primaryColor} />
</div>
</div>
</main>
<footer className="border-t border-gray-200 bg-white/80 backdrop-blur">
<div className="max-w-4xl mx-auto px-4 py-4 flex flex-wrap items-center justify-between gap-3 text-xs text-gray-500">
<p>(c) {new Date().getFullYear()} InnungsApp</p>
<div className="flex items-center gap-4">
<Link href={slug ? `/${slug}` : "/"} className="hover:text-gray-700">
Home
</Link>
<Link href="/impressum" className="hover:text-gray-700">
Impressum
</Link>
<Link href="/datenschutz" className="hover:text-gray-700">
Datenschutz
</Link>
</div>
</div>
</footer>
</div>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,133 @@
'use client'
import { useState } from 'react'
import { createAuthClient } from 'better-auth/react'
const authClient = createAuthClient({
baseURL: typeof window !== 'undefined'
? window.location.origin
: (process.env.NEXT_PUBLIC_APP_URL ?? 'http://localhost:3010'),
})
export default function PasswortAendernPage() {
const [oldPassword, setOldPassword] = useState('')
const [newPassword, setNewPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setError('')
if (newPassword !== confirmPassword) {
setError('Die neuen Passwörter stimmen nicht überein.')
return
}
if (newPassword.length < 8) {
setError('Das neue Passwort muss mindestens 8 Zeichen haben.')
return
}
if (newPassword === oldPassword) {
setError('Das neue Passwort muss sich vom alten unterscheiden.')
return
}
setLoading(true)
const result = await authClient.changePassword({
currentPassword: oldPassword,
newPassword,
revokeOtherSessions: false,
})
if (result.error) {
setLoading(false)
setError(result.error.message ?? 'Das alte Passwort ist falsch.')
return
}
// Mark mustChangePassword as done
await fetch('/api/auth/clear-must-change-password', { method: 'POST' })
window.location.href = '/dashboard'
}
return (
<div className="min-h-screen overflow-y-auto bg-gray-50 flex items-center justify-center p-4">
<div className="w-full max-w-sm">
<div className="bg-white rounded-lg border p-8">
<div className="mb-6">
<h1 className="text-xl font-semibold text-gray-900">Passwort ändern</h1>
<p className="text-sm text-gray-500 mt-1">
Bitte legen Sie jetzt Ihr persönliches Passwort fest.
</p>
</div>
<div className="mb-4 bg-amber-50 border border-amber-200 rounded-lg px-3 py-2 text-sm text-amber-800">
Aus Sicherheitsgründen müssen Sie das temporäre Passwort durch ein eigenes ersetzen.
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="oldPassword" className="block text-xs font-medium text-gray-600 mb-1 uppercase tracking-wide">
Temporäres Passwort (aus der Einladung)
</label>
<input
id="oldPassword"
type="password"
required
value={oldPassword}
onChange={(e) => setOldPassword(e.target.value)}
placeholder="Temporäres Passwort"
className="w-full px-3 py-2.5 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent"
/>
</div>
<div>
<label htmlFor="newPassword" className="block text-xs font-medium text-gray-600 mb-1 uppercase tracking-wide">
Neues Passwort
</label>
<input
id="newPassword"
type="password"
required
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder="Mindestens 8 Zeichen"
className="w-full px-3 py-2.5 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent"
/>
</div>
<div>
<label htmlFor="confirmPassword" className="block text-xs font-medium text-gray-600 mb-1 uppercase tracking-wide">
Neues Passwort bestätigen
</label>
<input
id="confirmPassword"
type="password"
required
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="Passwort wiederholen"
className="w-full px-3 py-2.5 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent"
/>
</div>
{error && (
<p className="text-sm text-red-600 bg-red-50 px-3 py-2 rounded-lg">{error}</p>
)}
<button
type="submit"
disabled={loading}
className="w-full bg-brand-500 text-white py-2.5 px-4 rounded-lg text-sm font-medium disabled:opacity-60 disabled:cursor-not-allowed transition-colors hover:bg-brand-600"
>
{loading ? 'Bitte warten...' : 'Passwort festlegen'}
</button>
</form>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,46 @@
'use client'
import '../instrumentation-client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { httpBatchLink } from '@trpc/client'
import { useState } from 'react'
import superjson from 'superjson'
import { trpc } from '@/lib/trpc-client'
function getBaseUrl() {
if (typeof window !== 'undefined') return ''
if (process.env.NEXT_PUBLIC_APP_URL) return process.env.NEXT_PUBLIC_APP_URL
return 'http://localhost:3000'
}
export function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 30 * 1000,
retry: 1,
},
},
})
)
const [trpcClient] = useState(() =>
trpc.createClient({
links: [
httpBatchLink({
url: `${getBaseUrl()}/api/trpc`,
transformer: superjson,
}),
],
})
)
return (
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
</trpc.Provider>
)
}

View File

@@ -0,0 +1,95 @@
'use client'
import { useState } from 'react'
import { use } from 'react'
export default function RegistrierungPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = use(params)
const [name, setName] = useState('')
const [email, setEmail] = useState('')
const [status, setStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle')
const [errorMsg, setErrorMsg] = useState('')
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setStatus('loading')
try {
const res = await fetch(`/api/registrierung/${slug}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, email }),
})
if (!res.ok) {
const data = await res.json().catch(() => ({}))
setErrorMsg(data.error ?? 'Ein Fehler ist aufgetreten.')
setStatus('error')
return
}
setStatus('success')
} catch {
setErrorMsg('Netzwerkfehler. Bitte versuchen Sie es erneut.')
setStatus('error')
}
}
if (status === 'success') {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-4">
<div className="bg-white rounded-xl shadow-sm border p-8 max-w-md w-full text-center">
<div className="text-4xl mb-4"></div>
<h1 className="text-xl font-bold text-gray-900 mb-2">E-Mail wird gesendet</h1>
<p className="text-gray-600 text-sm">
Bitte prüfen Sie Ihr Postfach. Sie erhalten in Kürze einen Aktivierungslink.
</p>
</div>
</div>
)
}
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-4">
<div className="bg-white rounded-xl shadow-sm border p-8 max-w-md w-full">
<h1 className="text-xl font-bold text-gray-900 mb-1">Mitglied werden</h1>
<p className="text-sm text-gray-500 mb-6">
Registrieren Sie sich für die InnungsApp Ihres Verbandes.
</p>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Name *</label>
<input
required
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Max Mustermann"
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">E-Mail *</label>
<input
required
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="max@musterfirma.de"
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
{status === 'error' && (
<p className="text-sm text-red-600 bg-red-50 px-3 py-2 rounded-lg">{errorMsg}</p>
)}
<button
type="submit"
disabled={status === 'loading'}
className="w-full bg-blue-600 text-white py-2 px-4 rounded-lg text-sm font-medium hover:bg-blue-700 disabled:opacity-60 transition-colors"
>
{status === 'loading' ? 'Wird gesendet...' : 'Aktivierungslink anfordern'}
</button>
</form>
</div>
</div>
)
}

View File

@@ -0,0 +1,15 @@
import type { MetadataRoute } from 'next'
export default function robots(): MetadataRoute.Robots {
return {
rules: [
{
userAgent: '*',
allow: '/',
disallow: ['/api/', '/dashboard', '/superadmin', '/registrierung', '/login'],
},
],
sitemap: 'https://innungsapp.com/sitemap.xml',
host: 'https://innungsapp.com',
}
}

View File

@@ -0,0 +1,28 @@
import type { MetadataRoute } from 'next'
const BASE_URL = 'https://innungsapp.com'
export default function sitemap(): MetadataRoute.Sitemap {
const lastModified = new Date()
return [
{
url: `${BASE_URL}/`,
lastModified,
changeFrequency: 'weekly',
priority: 1,
},
{
url: `${BASE_URL}/impressum`,
lastModified,
changeFrequency: 'monthly',
priority: 0.3,
},
{
url: `${BASE_URL}/datenschutz`,
lastModified,
changeFrequency: 'monthly',
priority: 0.3,
},
]
}

View File

@@ -0,0 +1,429 @@
'use client'
import { useActionState, useState } from 'react'
import { useRouter } from 'next/navigation'
import { createOrganization } from './actions'
import { LandingPagePreview } from './LandingPagePreview'
const initialState = { success: false, error: '' }
export function CreateOrgForm() {
const [state, formAction, isPending] = useActionState(createOrganization, initialState)
const router = useRouter()
const [step, setStep] = useState(1)
const [formData, setFormData] = useState({
name: '',
slug: '',
contactEmail: '',
adminEmail: '',
adminPassword: '',
logoUrl: '',
plan: 'pilot',
primaryColor: '#E63946',
secondaryColor: '',
landingPageTitle: '',
landingPageText: '',
landingPageHeroImage: '',
landingPageHeroOverlayOpacity: 50,
landingPageFeatures: '',
landingPageFooter: '',
appStoreUrl: '',
playStoreUrl: ''
})
const [aiContext, setAiContext] = useState('')
const [isGenerating, setIsGenerating] = useState(false)
const handleGenerateContent = async () => {
if (!formData.name || !aiContext) return
setIsGenerating(true)
try {
const res = await fetch('/api/ai/generate-landing-page', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ orgName: formData.name, context: aiContext })
})
const data = await res.json()
if (data.title && data.text) {
setFormData(prev => ({
...prev,
landingPageTitle: data.title,
landingPageText: data.text
}))
}
} catch (err) {
console.error('AI generation failed', err)
} finally {
setIsGenerating(false)
}
}
const [isUploading, setIsUploading] = useState(false)
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
setIsUploading(true)
const uploadFormData = new FormData()
uploadFormData.append('file', file)
try {
const res = await fetch('/api/upload', {
method: 'POST',
body: uploadFormData
})
const data = await res.json()
if (data.url) {
setFormData(prev => ({ ...prev, logoUrl: data.url }))
}
} catch (err) {
console.error('Upload failed', err)
} finally {
setIsUploading(false)
}
}
const [isHeroUploading, setIsHeroUploading] = useState(false)
const appBaseUrl = (typeof window !== 'undefined'
? window.location.origin
: (process.env.NEXT_PUBLIC_APP_URL ?? 'http://localhost:3010')).replace(/\/$/, '')
const handleHeroUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
setIsHeroUploading(true)
const uploadFormData = new FormData()
uploadFormData.append('file', file)
try {
const res = await fetch('/api/upload', {
method: 'POST',
body: uploadFormData
})
const data = await res.json()
if (data.url) {
setFormData(prev => ({ ...prev, landingPageHeroImage: data.url }))
}
} catch (err) {
console.error('Upload failed', err)
} finally {
setIsHeroUploading(false)
}
}
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => {
setFormData(prev => ({ ...prev, [e.target.name]: e.target.value }))
}
const nextStep = () => setStep(prev => prev + 1)
const prevStep = () => setStep(prev => prev - 1)
// Reset wizard after success
if (state.success && step !== 5) {
setStep(5)
}
return (
<div className="flex w-full h-full gap-6">
<div className="flex-[3] bg-gray-100 rounded-3xl overflow-hidden relative shadow-inner border border-gray-200 hidden lg:block">
<LandingPagePreview formData={formData} />
</div>
<div className="flex-1 bg-white p-6 sm:p-8 rounded-3xl border shadow-sm overflow-y-auto min-w-[320px] max-w-lg w-full flex flex-col">
<h2 className="text-xl font-bold text-gray-900 mb-6 font-outfit shrink-0">Neue Innung anlegen</h2>
{state.error && (
<div className="mb-6 p-4 bg-red-50 text-red-700 rounded-xl text-sm border border-red-100 animate-in fade-in slide-in-from-top-2 shrink-0">
{state.error}
</div>
)}
{/* Stepper Header (matched to screenshot) */}
<div className="flex items-center justify-start gap-2 sm:gap-4 mb-8 shrink-0 overflow-x-auto pb-2">
{[1, 2, 3, 4, 5].map((s) => (
<div key={s} className="flex items-center gap-2 sm:gap-4 shrink-0">
<div className={`shrink-0 w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold transition-all duration-300 ${step >= s ? 'bg-[#E63946] text-white' : 'bg-gray-100 text-gray-400'}`}>
{s}
</div>
{s < 5 && (
<div className={`h-[3px] w-8 sm:w-12 rounded-full transition-all duration-500 ${step > s ? 'bg-[#E63946]' : 'bg-gray-100'}`} />
)}
</div>
))}
</div>
<form action={formAction} className="flex-1 shrink-0 space-y-6">
{step !== 1 && (
<>
<input type="hidden" name="name" value={formData.name} />
<input type="hidden" name="slug" value={formData.slug} />
</>
)}
<input type="hidden" name="contactEmail" value={formData.contactEmail} />
<input type="hidden" name="adminEmail" value={formData.adminEmail} />
<input type="hidden" name="adminPassword" value={formData.adminPassword} />
<input type="hidden" name="logoUrl" value={formData.logoUrl} />
<input type="hidden" name="plan" value={formData.plan} />
<input type="hidden" name="primaryColor" value={formData.primaryColor} />
<input type="hidden" name="secondaryColor" value={formData.secondaryColor} />
<input type="hidden" name="landingPageTitle" value={formData.landingPageTitle} />
<input type="hidden" name="landingPageText" value={formData.landingPageText} />
<input type="hidden" name="landingPageHeroImage" value={formData.landingPageHeroImage} />
<input type="hidden" name="landingPageFeatures" value={formData.landingPageFeatures} />
<input type="hidden" name="landingPageFooter" value={formData.landingPageFooter} />
<input type="hidden" name="appStoreUrl" value={formData.appStoreUrl} />
<input type="hidden" name="playStoreUrl" value={formData.playStoreUrl} />
{step === 1 && (
<div className="space-y-5 animate-in fade-in slide-in-from-right-4 duration-300">
<div>
<label className="block text-xs font-semibold text-gray-400 uppercase tracking-widest mb-2">Name der Innung</label>
<input type="text" name="name" required value={formData.name} onChange={handleChange} placeholder="z.B. Tischler-Innung Berlin" className="w-full px-4 py-3 border border-gray-200 rounded-xl focus:ring-2 focus:ring-[#E63946] focus:border-[#E63946] outline-none transition-all placeholder:text-gray-300" />
</div>
<div>
<label className="block text-xs font-semibold text-gray-400 uppercase tracking-widest mb-2">Kurzbezeichnung (Slug)</label>
<input type="text" name="slug" required value={formData.slug} onChange={handleChange} placeholder="z.B. tischler-berlin" pattern="^[a-z0-9\-]+$" className="w-full px-4 py-3 border border-gray-200 rounded-xl focus:ring-2 focus:ring-[#E63946] focus:border-[#E63946] outline-none transition-all placeholder:text-gray-300" />
<p className="text-[11px] text-gray-400 mt-2 leading-relaxed">Landingpage unter: <span className="text-[#E63946] font-medium">{formData.slug ? `${appBaseUrl}/${formData.slug}` : `${appBaseUrl}/ihr-slug`}</span></p>
</div>
<div>
<label className="block text-xs font-semibold text-gray-400 uppercase tracking-widest mb-2">Planungs-Modell</label>
<select name="plan" value={formData.plan} onChange={handleChange} className="w-full px-4 py-3 border border-gray-200 rounded-xl focus:ring-2 focus:ring-[#E63946] focus:border-[#E63946] outline-none transition-all bg-white">
<option value="pilot">Pilot</option>
<option value="standard">Standard</option>
<option value="pro">Pro</option>
<option value="verband">Verband</option>
</select>
</div>
<button type="button" onClick={nextStep} disabled={!formData.name || !formData.slug} className="w-full bg-gray-900 text-white font-semibold py-3.5 px-6 rounded-xl hover:bg-black transition-all shadow-md active:scale-[0.98] disabled:opacity-50 disabled:scale-100">
Weiter zu Branding
</button>
</div>
)}
{step === 2 && (
<div className="space-y-5 animate-in fade-in slide-in-from-right-4 duration-300">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-semibold text-gray-400 uppercase tracking-widest mb-2">Initialer Admin (Email)</label>
<input type="email" name="adminEmail" value={formData.adminEmail} onChange={handleChange} placeholder="admin@tischler-berlin.de" className="w-full px-4 py-3 border border-gray-200 rounded-xl focus:ring-2 focus:ring-[#E63946] focus:border-[#E63946] outline-none transition-all placeholder:text-gray-300" />
</div>
<div>
<label className="block text-xs font-semibold text-gray-400 uppercase tracking-widest mb-2">Passwort setzen</label>
<input type="text" name="adminPassword" value={formData.adminPassword} onChange={handleChange} placeholder="Sicheres Passwort" className="w-full px-4 py-3 border border-gray-200 rounded-xl focus:ring-2 focus:ring-[#E63946] focus:border-[#E63946] outline-none transition-all placeholder:text-gray-300" />
</div>
</div>
<div>
<label className="block text-xs font-semibold text-gray-400 uppercase tracking-widest mb-2">Organisations-Logo</label>
<div className="flex items-center gap-4">
{formData.logoUrl ? (
<div className="w-14 h-14 rounded-xl border border-gray-200 overflow-hidden bg-gray-50 flex items-center justify-center p-2">
<img src={formData.logoUrl} alt="Logo" className="max-w-full max-h-full object-contain" />
</div>
) : (
<div className="w-14 h-14 rounded-xl border-2 border-dashed border-gray-200 flex items-center justify-center text-gray-300">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-6 h-6">
<path strokeLinecap="round" strokeLinejoin="round" d="m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 0 0 1.5-1.5V6a1.5 1.5 0 0 0-1.5-1.5H3.75A1.5 1.5 0 0 0 2.25 6v12a1.5 1.5 0 0 0 1.5 1.5Zm10.5-11.25h.008v.008h-.008V8.25Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z" />
</svg>
</div>
)}
<label className="flex-1">
<div className={`px-4 py-3 border border-gray-200 rounded-xl w-full text-center text-sm font-semibold cursor-pointer transition-all hover:bg-gray-50 ${isUploading ? 'opacity-50' : ''}`}>
{isUploading ? 'Wird hochgeladen...' : formData.logoUrl ? 'Logo ändern' : 'Bild auswählen'}
</div>
<input type="file" onChange={handleUpload} accept="image/*" className="hidden" disabled={isUploading} />
</label>
</div>
</div>
<div>
<label className="block text-xs font-semibold text-gray-400 uppercase tracking-widest mb-2">Primärfarbe (CI)</label>
<div className="flex gap-4 items-center">
<input type="color" name="primaryColor" value={formData.primaryColor} onChange={handleChange} className="w-14 h-14 p-1 rounded-xl cursor-pointer border border-gray-200" />
<div className="flex-1">
<input type="text" value={formData.primaryColor?.toUpperCase()} readOnly className="px-4 py-3 border border-gray-200 rounded-xl w-full bg-gray-50 text-gray-500 font-mono text-sm" />
</div>
</div>
</div>
<div className="pt-4 flex gap-3">
<button type="button" onClick={prevStep} className="flex-1 bg-white text-gray-500 font-semibold py-3.5 px-6 rounded-xl border border-gray-200 hover:bg-gray-50 transition-all">
Zurück
</button>
<button type="button" onClick={nextStep} disabled={!formData.adminEmail || !formData.adminPassword} className="flex-[2] bg-gray-900 text-white font-semibold py-3.5 px-6 rounded-xl hover:bg-black transition-all shadow-md active:scale-[0.98] disabled:opacity-50">
Weiter zur Landingpage
</button>
</div>
</div>
)}
{step === 3 && (
<div className="space-y-5 animate-in fade-in slide-in-from-right-4 duration-300">
<div className="bg-blue-50/50 p-5 rounded-xl border border-blue-100">
<h3 className="text-sm font-bold text-blue-900 mb-2 font-outfit">KI Content-Erstellung</h3>
<p className="text-xs text-blue-700 leading-relaxed mb-4">
Beschreiben Sie in wenigen Stichpunkten, worauf die Innung fokussiert ist (Region, Tradition, Ausbildung, etc.). Die KI generiert daraus eine moderne Landingpage.
</p>
<textarea
value={aiContext}
onChange={(e) => setAiContext(e.target.value)}
placeholder="z.B. Kreishandwerkerschaft Niederrhein, Fokus auf Ausbildung und Digitalisierung im Handwerk..."
className="w-full px-4 py-3 border border-blue-200 bg-white rounded-xl focus:ring-2 focus:ring-blue-500 outline-none transition-all placeholder:text-gray-400 text-sm min-h-[80px]"
/>
<button
type="button"
onClick={handleGenerateContent}
disabled={isGenerating || !aiContext}
className="mt-3 w-full bg-blue-600 text-white font-semibold py-2.5 px-6 rounded-lg hover:bg-blue-700 transition-all shadow-sm disabled:opacity-50 flex items-center justify-center gap-2 text-sm"
>
{isGenerating ? (
<>
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
Generieren...
</>
) : '✨ Content generieren'}
</button>
</div>
<div>
<label className="block text-xs font-semibold text-gray-400 uppercase tracking-widest mb-2">Überschrift</label>
<input type="text" name="landingPageTitle" value={formData.landingPageTitle} onChange={handleChange} placeholder="Zukunft durch Handwerk" className="w-full px-4 py-3 border border-gray-200 rounded-xl focus:ring-2 focus:ring-[#E63946] focus:border-[#E63946] outline-none transition-all placeholder:text-gray-300 font-bold" />
</div>
<div>
<label className="block text-xs font-semibold text-gray-400 uppercase tracking-widest mb-2">Einleitungstext</label>
<textarea name="landingPageText" value={formData.landingPageText} onChange={(e) => setFormData(prev => ({ ...prev, landingPageText: e.target.value }))} placeholder="Wir sind..." className="w-full px-4 py-3 border border-gray-200 rounded-xl focus:ring-2 focus:ring-[#E63946] focus:border-[#E63946] outline-none transition-all placeholder:text-gray-300 min-h-[100px] text-sm leading-relaxed" />
</div>
<div className="pt-4 flex gap-3">
<button type="button" onClick={prevStep} className="flex-1 bg-white text-gray-500 font-semibold py-3.5 px-6 rounded-xl border border-gray-200 hover:bg-gray-50 transition-all">
Zurück
</button>
<button type="button" onClick={nextStep} className="flex-[2] bg-gray-900 text-white font-semibold py-3.5 px-6 rounded-xl hover:bg-black transition-all shadow-md active:scale-[0.98]">
Weiter zu Erweitert
</button>
</div>
</div>
)}
{step === 4 && (
<div className="space-y-5 animate-in fade-in slide-in-from-right-4 duration-300">
<div>
<label className="block text-xs font-semibold text-gray-400 uppercase tracking-widest mb-2">Hero-Titelbild</label>
<div className="flex items-center gap-4">
{formData.landingPageHeroImage ? (
<div className="w-24 h-14 rounded-xl border border-gray-200 overflow-hidden bg-gray-50 flex items-center justify-center p-0">
<img src={formData.landingPageHeroImage} alt="Hero" className="max-w-full max-h-full object-cover" />
</div>
) : (
<div className="w-24 h-14 rounded-xl border-2 border-dashed border-gray-200 flex items-center justify-center text-gray-300">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-6 h-6">
<path strokeLinecap="round" strokeLinejoin="round" d="m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 0 0 1.5-1.5V6a1.5 1.5 0 0 0-1.5-1.5H3.75A1.5 1.5 0 0 0 2.25 6v12a1.5 1.5 0 0 0 1.5 1.5Zm10.5-11.25h.008v.008h-.008V8.25Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z" />
</svg>
</div>
)}
<label className="flex-1">
<div className={`px-4 py-3 border border-gray-200 rounded-xl w-full text-center text-sm font-semibold cursor-pointer transition-all hover:bg-gray-50 ${isHeroUploading ? 'opacity-50' : ''}`}>
{isHeroUploading ? 'Wird hochgeladen...' : formData.landingPageHeroImage ? 'Bild ändern' : 'Bild auswählen'}
</div>
<input type="file" onChange={handleHeroUpload} accept="image/*" className="hidden" disabled={isHeroUploading} />
</label>
</div>
</div>
{formData.landingPageHeroImage && (
<div>
<label className="block text-xs font-semibold text-gray-400 uppercase tracking-widest mb-1">
Hero-Deckkraft (Opacity: {formData.landingPageHeroOverlayOpacity}%)
</label>
<input
type="range"
name="landingPageHeroOverlayOpacity"
min="0"
max="100"
value={formData.landingPageHeroOverlayOpacity}
onChange={handleChange}
className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-[#E63946]"
/>
<p className="text-xs text-gray-400 mt-1">Bestimmt, wie stark das Bild abgedunkelt/aufgehellt wird, um den Text lesbar zu machen.</p>
</div>
)}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-semibold text-gray-400 uppercase tracking-widest mb-2">Sekundärfarbe (Optional)</label>
<div className="flex gap-4 items-center">
<input type="color" name="secondaryColor" value={formData.secondaryColor || '#ffffff'} onChange={handleChange} className="w-14 h-14 p-1 rounded-xl cursor-pointer border border-gray-200" />
<div className="flex-1">
<input type="text" name="secondaryColor" value={formData.secondaryColor?.toUpperCase()} onChange={handleChange} placeholder="#FFFFFF" className="px-4 py-3 border border-gray-200 rounded-xl w-full bg-white text-gray-700 font-mono text-sm" />
</div>
</div>
</div>
</div>
<div>
<label className="block text-xs font-semibold text-gray-400 uppercase tracking-widest mb-2">Vorteile / Features</label>
<textarea name="landingPageFeatures" value={formData.landingPageFeatures} onChange={handleChange} placeholder="Ein Benefit pro Zeile..." className="w-full px-4 py-3 border border-gray-200 rounded-xl focus:ring-2 focus:ring-[#E63946] focus:border-[#E63946] outline-none transition-all placeholder:text-gray-300 min-h-[120px] text-sm leading-relaxed" />
<p className="text-xs text-gray-400 mt-2">Bitte geben Sie pro Zeile einen Vorteil ein. Diese werden als Checkliste auf der Landingpage angezeigt.</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-xs font-semibold text-gray-400 uppercase tracking-widest mb-2">App Store URL</label>
<input type="url" name="appStoreUrl" value={formData.appStoreUrl} onChange={handleChange} placeholder="https://apps.apple.com/..." className="w-full px-4 py-3 border border-gray-200 rounded-xl focus:ring-2 focus:ring-[#E63946] focus:border-[#E63946] outline-none transition-all placeholder:text-gray-300 text-sm" />
</div>
<div>
<label className="block text-xs font-semibold text-gray-400 uppercase tracking-widest mb-2">Google Play URL</label>
<input type="url" name="playStoreUrl" value={formData.playStoreUrl} onChange={handleChange} placeholder="https://play.google.com/..." className="w-full px-4 py-3 border border-gray-200 rounded-xl focus:ring-2 focus:ring-[#E63946] focus:border-[#E63946] outline-none transition-all placeholder:text-gray-300 text-sm" />
</div>
</div>
<div>
<label className="block text-xs font-semibold text-gray-400 uppercase tracking-widest mb-2">Footer-Text (Impressum, etc.)</label>
<textarea name="landingPageFooter" value={formData.landingPageFooter} onChange={handleChange} placeholder="Zusätzliche Infos für den Footer..." className="w-full px-4 py-3 border border-gray-200 rounded-xl focus:ring-2 focus:ring-[#E63946] focus:border-[#E63946] outline-none transition-all placeholder:text-gray-300 min-h-[80px] text-sm leading-relaxed" />
</div>
<div className="pt-4 flex gap-3">
<button type="button" onClick={prevStep} className="flex-1 bg-white text-gray-500 font-semibold py-3.5 px-6 rounded-xl border border-gray-200 hover:bg-gray-50 transition-all">
Zurück
</button>
<button type="submit" disabled={isPending} className="flex-[2] bg-[#E63946] text-white font-semibold py-3.5 px-6 rounded-xl hover:bg-[#D62839] transition-all shadow-md shadow-red-100 active:scale-[0.98] disabled:opacity-50 flex justify-center items-center gap-2">
{isPending ? 'Wird erstellt...' : 'Innung anlegen'}
</button>
</div>
</div>
)}
{step === 5 && (
<div className="text-center animate-in fade-in zoom-in-95 duration-700 py-4">
<div className="w-24 h-24 bg-[#E8F5E9] text-[#2E7D32] rounded-full flex items-center justify-center mx-auto mb-8 animate-in zoom-in-50 duration-500 delay-150">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2.5} stroke="currentColor" className="w-10 h-10">
<path strokeLinecap="round" strokeLinejoin="round" d="m4.5 12.75 6 6 9-13.5" />
</svg>
</div>
<h3 className="text-xl font-bold text-gray-900 mb-2">Innung erfolgreich angelegt!</h3>
<p className="text-gray-500 text-sm mb-10">Die Datenumgebung sowie die Subdomain<br />wurden eingerichtet.</p>
<div className="bg-[#F8FEFB] p-6 rounded-2xl border border-[#E1F5EA] text-left mb-8">
<p className="text-[10px] font-bold text-[#8CAB99] uppercase tracking-[0.15em] mb-4">Ihre neue Landingpage (Localhost) / Subdomain</p>
<a href={`${appBaseUrl}/${formData.slug}`} target="_blank" rel="noreferrer" className="text-[#E63946] font-bold text-lg hover:underline block break-all">
{appBaseUrl}/{formData.slug}
</a>
</div>
<button type="button" onClick={() => {
router.push('/superadmin')
}} className="w-full bg-[#F3F4F6] text-[#4B5563] font-bold py-4 px-6 rounded-2xl hover:bg-gray-200 transition-all active:scale-[0.98]">
Zurück zur Übersicht
</button>
</div>
)}
</form>
</div>
</div>
)
}

View File

@@ -0,0 +1,355 @@
export function LandingPagePreview({ formData }: { formData: any }) {
const primaryColor = formData.primaryColor || '#E63946'
const secondaryColor = formData.secondaryColor || undefined
const title = formData.landingPageTitle || formData.name || 'Zukunft durch Handwerk'
const text = formData.landingPageText || 'Wir sind Ihre lokale Vertretung des Handwerks. Mit starker Gemeinschaft und klaren Zielen setzen wir uns für die Betriebe in unserer Region ein.'
const features = formData.landingPageFeatures || '✅ Exzellente Ausbildung\n✅ Starke Gemeinschaft\n✅ Politische Interessenvertretung'
const footer = formData.landingPageFooter || '© 2024 Innung'
const sectionTitle = formData.landingPageSectionTitle || `${formData.name || 'Ihre Innung'} Gemeinsam stark fürs Handwerk`
const buttonText = formData.landingPageButtonText || 'Jetzt App laden'
return (
<div className="w-full h-full bg-white overflow-y-auto font-sans flex flex-col relative">
{/* Header */}
<header className="px-8 py-6 flex items-center justify-between sticky top-0 z-50 shadow-sm" style={{
background: `linear-gradient(to right, #ffffff 0%, ${primaryColor}20 50%, ${primaryColor} 100%)`
}}>
<div className="flex items-center gap-4">
{formData.logoUrl ? (
<img src={formData.logoUrl} alt="Logo" className="h-10 object-contain" />
) : (
<div className="w-10 h-10 bg-white rounded-lg flex items-center justify-center text-xs font-bold text-gray-400 shadow-sm">LOGO</div>
)}
<span className="font-bold text-lg text-gray-800">{formData.name || 'Innungs-Logo'}</span>
</div>
<nav className="flex gap-6 text-sm font-medium text-gray-800 hidden md:flex">
<a href="#about" className="hover:text-black">Über uns</a>
<a href="#leistungen" className="hover:text-black">Leistungen</a>
<a href="#app" className="hover:text-black">App</a>
</nav>
<a
href="#mitglied-werden"
className="px-5 py-2.5 rounded-full bg-white font-semibold text-sm cursor-pointer shadow-md hover:bg-gray-50 transition-all"
style={{ color: primaryColor }}
>
Mitglieder verwalten
</a>
</header>
{/* Hero Section */}
<section id="about" className="relative px-8 py-20 flex flex-col items-center justify-center text-center overflow-hidden min-h-[400px]">
{/* Background Image / Pattern */}
{formData.landingPageHeroImage ? (
<div className="absolute inset-0 z-0">
<img src={formData.landingPageHeroImage} alt="Hero Background" className="w-full h-full object-cover" />
<div
className="absolute inset-0 bg-white"
style={{ opacity: formData.landingPageHeroOverlayOpacity !== undefined ? formData.landingPageHeroOverlayOpacity / 100 : 0.5 }}
></div>
<div className="absolute inset-0 bg-gradient-to-b from-white/30 via-transparent to-white/90"></div>
</div>
) : (
<div className="absolute inset-0 z-0 opacity-[0.03]" style={{ backgroundImage: 'radial-gradient(#000 1px, transparent 1px)', backgroundSize: '24px 24px' }}></div>
)}
<div className="relative z-10 max-w-3xl mx-auto space-y-6">
<div className="inline-block px-4 py-1.5 rounded-full text-xs font-bold tracking-wider uppercase mb-2 shadow-sm" style={{ backgroundColor: `${primaryColor}15`, color: primaryColor }}>
{formData.name || 'Ihre Innung'}
</div>
<h1 className="text-4xl md:text-5xl font-black text-gray-900 tracking-tight leading-[1.1]">
{title}
</h1>
<p className="text-lg text-gray-600 leading-relaxed max-w-2xl mx-auto font-medium">
{text}
</p>
<div className="pt-6 flex gap-4 justify-center">
<a
href="#apps"
className="px-8 py-3.5 rounded-full text-white font-semibold shadow-lg hover:opacity-90 transition-all cursor-pointer transform hover:-translate-y-0.5 block"
style={{ backgroundColor: primaryColor }}
>
{buttonText}
</a>
<a
href="#leistungen"
className="px-8 py-3.5 rounded-full font-semibold border shadow-sm transition-all cursor-pointer block hover:opacity-80"
style={{
backgroundColor: 'white',
borderColor: secondaryColor || '#e5e7eb',
color: secondaryColor || '#374151'
}}
>
Mehr erfahren
</a>
</div>
</div>
</section>
{/* Features / Benefits */}
<section id="leistungen" className="px-8 py-16" style={{ backgroundColor: secondaryColor ? `${secondaryColor}08` : '#f9fafb' }}>
<div className="max-w-5xl mx-auto">
<h2 className="text-2xl font-bold text-center mb-12 text-gray-800">Ihre Vorteile als Mitglied</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
{features.split('\n').filter((f: string) => f.trim() !== '').map((feature: string, idx: number) => (
<div key={idx} className="bg-white p-6 rounded-2xl shadow-sm border border-gray-100 flex flex-col items-center text-center space-y-4 hover:shadow-md transition-shadow">
<div className="w-12 h-12 rounded-full flex items-center justify-center" style={{ backgroundColor: secondaryColor ? `${secondaryColor}15` : `${primaryColor}15`, color: secondaryColor || primaryColor }}>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="w-6 h-6">
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
</svg>
</div>
<h3 className="font-semibold text-gray-800">{feature.replace(/^[-\*\✅\ ]+/, '')}</h3>
</div>
))}
</div>
</div>
</section>
{/* App Features Grid */}
<section id="app" className="px-8 py-20 bg-white">
<div className="max-w-6xl mx-auto">
<div className="text-center mb-16 space-y-4">
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full text-sm font-semibold mb-2" style={{ backgroundColor: `${primaryColor}10`, color: primaryColor }}>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M13 10V3L4 14h7v7l9-11h-7z" /></svg>
Alles in einer App
</div>
<h2 className="text-3xl md:text-4xl font-black text-gray-900">{sectionTitle}</h2>
<p className="text-lg text-gray-500 max-w-2xl mx-auto">
Verpassen Sie keine wichtigen Branchen-Updates mehr. Vernetzen Sie sich mit anderen Betrieben und verwalten Sie Termine bequem auf dem Smartphone.
</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
{/* Feature 1: Aktuelles */}
<div className="p-8 rounded-[2rem] border border-gray-100 bg-white shadow-sm hover:shadow-xl hover:-translate-y-1 transition-all duration-300 group">
<div className="w-14 h-14 rounded-2xl bg-gray-50 flex items-center justify-center mb-6 group-hover:scale-110 transition-transform duration-300" style={{ color: primaryColor }}>
<svg className="w-7 h-7" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}><path strokeLinecap="round" strokeLinejoin="round" d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9a2 2 0 00-2-2h-2m-4-3H9M7 16h6M7 8h6v4H7V8z" /></svg>
</div>
<h3 className="text-xl font-bold text-gray-900 mb-3">Aktuelles</h3>
<p className="text-gray-500 leading-relaxed">
Lesen Sie die wichtigsten Branchen-News und bleiben Sie immer auf dem aktuellsten Stand.
</p>
</div>
{/* Feature 2: Termine */}
<div className="p-8 rounded-[2rem] border border-gray-100 bg-white shadow-sm hover:shadow-xl hover:-translate-y-1 transition-all duration-300 group">
<div className="w-14 h-14 rounded-2xl bg-gray-50 flex items-center justify-center mb-6 group-hover:scale-110 transition-transform duration-300" style={{ color: primaryColor }}>
<svg className="w-7 h-7" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}><path strokeLinecap="round" strokeLinejoin="round" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg>
</div>
<h3 className="text-xl font-bold text-gray-900 mb-3">Termine</h3>
<p className="text-gray-500 leading-relaxed">
Verwalten Sie Veranstaltungen, Fortbildungen und Innungsversammlungen direkt in Ihrem Kalender.
</p>
</div>
{/* Feature 3: Stellen */}
<div className="p-8 rounded-[2rem] border border-gray-100 bg-white shadow-sm hover:shadow-xl hover:-translate-y-1 transition-all duration-300 group">
<div className="w-14 h-14 rounded-2xl bg-gray-50 flex items-center justify-center mb-6 group-hover:scale-110 transition-transform duration-300" style={{ color: primaryColor }}>
<svg className="w-7 h-7" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}><path strokeLinecap="round" strokeLinejoin="round" d="M21 13.255A23.931 23.931 0 0112 15c-3.183 0-6.22-.62-9-1.745M16 6V4a2 2 0 00-2-2h-4a2 2 0 00-2 2v2m4 6h.01M5 20h14a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" /></svg>
</div>
<h3 className="text-xl font-bold text-gray-900 mb-3">Stellenbörse</h3>
<p className="text-gray-500 leading-relaxed">
Finden Sie neue Fachkräfte oder Auszubildende. Veröffentlichen Sie Ihre offenen Stellenangebote branchenintern.
</p>
</div>
{/* Feature 4: Nachrichten */}
<div className="p-8 rounded-[2rem] border border-gray-100 bg-white shadow-sm hover:shadow-xl hover:-translate-y-1 transition-all duration-300 group">
<div className="w-14 h-14 rounded-2xl bg-gray-50 flex items-center justify-center mb-6 group-hover:scale-110 transition-transform duration-300" style={{ color: primaryColor }}>
<svg className="w-7 h-7" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}><path strokeLinecap="round" strokeLinejoin="round" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" /></svg>
</div>
<h3 className="text-xl font-bold text-gray-900 mb-3">Nachrichten</h3>
<p className="text-gray-500 leading-relaxed">
Tauschen Sie sich mit anderen Betrieben aus. Schnelle Kontaktaufnahme über direkte Einzel- und Gruppenchats.
</p>
</div>
{/* Feature 5: Profil */}
<div className="p-8 rounded-[2rem] border border-gray-100 bg-white shadow-sm hover:shadow-xl hover:-translate-y-1 transition-all duration-300 group">
<div className="w-14 h-14 rounded-2xl bg-gray-50 flex items-center justify-center mb-6 group-hover:scale-110 transition-transform duration-300" style={{ color: primaryColor }}>
<svg className="w-7 h-7" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}><path strokeLinecap="round" strokeLinejoin="round" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" /></svg>
</div>
<h3 className="text-xl font-bold text-gray-900 mb-3">Profil & Ausweis</h3>
<p className="text-gray-500 leading-relaxed">
Ihr digitaler Mitgliedsausweis immer griffbereit. Verwalten Sie das Profil Ihres Betriebs komfortabel in der App.
</p>
</div>
{/* Feature 6: Partner */}
<div className="p-8 rounded-[2rem] border border-gray-100 bg-white shadow-sm hover:shadow-xl hover:-translate-y-1 transition-all duration-300 group">
<div className="w-14 h-14 rounded-2xl bg-gray-50 flex items-center justify-center mb-6 group-hover:scale-110 transition-transform duration-300" style={{ color: primaryColor }}>
<svg className="w-7 h-7" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}><path strokeLinecap="round" strokeLinejoin="round" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" /></svg>
</div>
<h3 className="text-xl font-bold text-gray-900 mb-3">Netzwerk</h3>
<p className="text-gray-500 leading-relaxed">
Profitieren Sie von starken Kooperationen und Angeboten ausgewählter Partnerbetriebe in der Region.
</p>
</div>
</div>
</div>
</section>
{/* Application Mock */}
<section id="apps" className="px-8 py-32 relative overflow-hidden" style={{
background: `linear-gradient(to bottom, #ffffff 0%, ${primaryColor} 40%, #111827 100%)`
}}>
{/* Decorative background elements */}
<div className="absolute inset-0 opacity-10" style={{ backgroundImage: 'radial-gradient(circle at 2px 2px, white 1px, transparent 0)', backgroundSize: '40px 40px' }}></div>
<div className="absolute top-0 right-0 -mr-40 -mt-40 w-[500px] h-[500px] rounded-full bg-white/20 blur-[100px] pointer-events-none"></div>
<div className="absolute bottom-0 left-0 -ml-40 -mb-40 w-[500px] h-[500px] rounded-full border-[40px] border-white/5 pointer-events-none"></div>
<div className="max-w-6xl mx-auto flex flex-col md:flex-row items-center gap-16 relative z-10">
<div className="flex-1 text-left space-y-8 text-white">
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-white/10 backdrop-blur-md border border-white/20 text-sm font-medium">
<span className="w-2 h-2 rounded-full bg-green-400 animate-pulse"></span>
Jetzt verfügbar
</div>
<h2 className="text-4xl md:text-5xl font-black leading-tight">
Laden Sie unsere App herunter
</h2>
<p className="text-white/80 text-xl leading-relaxed max-w-lg">
Bleiben Sie immer auf dem Laufenden mit der {formData.name || 'Innungs'}-App für Mitglieder. Alle News, Termine und Ihr digitaler Mitgliedsausweis direkt auf Ihrem Smartphone.
</p>
<div className="flex flex-wrap gap-4 pt-4">
{(!formData.appStoreUrl && !formData.playStoreUrl) || formData.appStoreUrl ? (
<a href={formData.appStoreUrl || "#"} target="_blank" rel="noreferrer" className="bg-black hover:bg-black/80 text-white px-8 py-4 rounded-2xl cursor-pointer transition-all flex items-center gap-4 shadow-xl hover:shadow-2xl transform hover:-translate-y-1">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512" className="w-8 h-8 fill-current"><path d="M318.7 268.7c-.2-36.7 16.4-64.4 50-84.8-18.8-26.9-47.2-41.7-84.7-44.6-35.5-2.8-74.3 20.7-88.5 20.7-15 0-49.4-19.7-76.4-19.7C63.3 141.2 4 184.8 4 273.5q0 39.3 14.4 81.2c12.8 36.7 59 126.7 107.2 125.2 25.2-.6 43-17.9 75.8-17.9 31.8 0 48.3 17.9 76.4 17.9 48.6-.7 90.4-82.5 102.6-119.3-65.2-30.7-61.7-90-61.7-91.9zm-56.6-164.2c27.3-32.4 24.8-61.9 24-72.5-24.1 1.4-52 16.4-67.9 34.9-17.5 19.8-27.8 44.3-25.6 71.9 26.1 2 49.9-11.4 69.5-34.3z" /></svg>
<div>
<div className="text-xs text-white/70">Download on the</div>
<div className="text-lg font-semibold leading-none">App Store</div>
</div>
</a>
) : null}
{(!formData.appStoreUrl && !formData.playStoreUrl) || formData.playStoreUrl ? (
<a href={formData.playStoreUrl || "#"} target="_blank" rel="noreferrer" className="bg-black hover:bg-black/80 text-white px-8 py-4 rounded-2xl cursor-pointer transition-all flex items-center gap-4 shadow-xl hover:shadow-2xl transform hover:-translate-y-1">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" className="w-8 h-8 fill-current"><path d="M325.3 234.3L104.6 13l280.8 161.2-60.1 60.1zM47 0C34 6.8 25.3 19.2 25.3 35.3v441.3c0 16.1 8.7 28.5 21.7 35.3l256.6-256L47 0zm425.2 225.6l-58.9-34.1-65.7 64.5 65.7 64.5 60.1-34.1c18-14.3 18-46.5-1.2-60.8zM104.6 499l280.8-161.2-60.1-60.1L104.6 499z" /></svg>
<div>
<div className="text-xs text-white/70">GET IT ON</div>
<div className="text-lg font-semibold leading-none">Google Play</div>
</div>
</a>
) : null}
</div>
</div>
<div className="flex-1 w-full flex justify-center mt-12 md:mt-0 perspective-[2000px]">
<div className="relative w-[280px] h-[580px] rounded-[3rem] border-[12px] border-black bg-black shadow-2xl overflow-hidden transform rotate-y-[-15deg] rotate-x-[10deg] rotate-z-[5deg] hover:rotate-y-[0deg] hover:rotate-x-[0deg] hover:rotate-z-[0deg] transition-all duration-700 ease-out">
{/* Notch */}
<div className="absolute top-0 left-1/2 -translate-x-1/2 w-32 h-6 bg-black rounded-b-3xl z-20"></div>
{/* App Screenshot Mockup */}
<div className="w-full h-full bg-gray-50 flex flex-col pt-6">
{/* App Header */}
<div className="px-5 py-4 flex items-center justify-between bg-white border-b border-gray-100">
<div className="flex items-center gap-3">
{formData.logoUrl ? (
<img src={formData.logoUrl} alt="Logo" className="w-8 h-8 object-contain" />
) : (
<div className="w-8 h-8 rounded-full flex items-center justify-center text-white font-bold text-xs shadow-sm" style={{ backgroundColor: primaryColor }}>
{formData.name ? formData.name.charAt(0).toUpperCase() : 'I'}
</div>
)}
<div className="font-bold text-sm text-gray-800 truncate w-28">{formData.name || 'Ihre Innung'}</div>
</div>
<div className="w-8 h-8 rounded-full bg-gray-100 flex items-center justify-center">
<svg className="w-4 h-4 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" /></svg>
</div>
</div>
{/* App Content */}
<div className="p-5 space-y-6 flex-1 overflow-hidden">
<div className="w-full h-32 rounded-2xl relative overflow-hidden flex items-end p-4 shadow-sm" style={{ backgroundColor: primaryColor }}>
<div className="absolute inset-0 bg-black/10"></div>
<div className="absolute -top-10 -right-10 w-32 h-32 bg-white/10 rounded-full blur-2xl"></div>
<div className="relative z-10 text-white font-bold text-lg leading-tight">Willkommen,<br />Max Mustermann</div>
</div>
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="text-sm font-bold text-gray-800">Aktuelle News</div>
<div className="text-xs text-gray-400 font-medium">Alle ansehen</div>
</div>
<div className="space-y-3">
<div className="bg-white rounded-xl border border-gray-100 p-3 flex gap-3 shadow-sm items-center">
<div className="w-12 h-12 rounded-lg flex-shrink-0" style={{ backgroundColor: `${primaryColor}15` }}></div>
<div className="flex-1 space-y-2">
<div className="h-3 w-5/6 bg-gray-200 rounded-full"></div>
<div className="h-2 w-full bg-gray-100 rounded-full"></div>
</div>
</div>
<div className="bg-white rounded-xl border border-gray-100 p-3 flex gap-3 shadow-sm items-center">
<div className="w-12 h-12 rounded-lg flex-shrink-0" style={{ backgroundColor: `${primaryColor}15` }}></div>
<div className="flex-1 space-y-2">
<div className="h-3 w-2/3 bg-gray-200 rounded-full"></div>
<div className="h-2 w-4/5 bg-gray-100 rounded-full"></div>
</div>
</div>
</div>
</div>
</div>
{/* App Bottom Nav */}
<div className="h-[72px] bg-white border-t border-gray-100 flex items-center justify-between px-4 pb-2 pt-2 shadow-[0_-4px_20px_rgba(0,0,0,0.03)] z-20">
<div className="flex flex-col items-center gap-1 w-1/6">
<svg className="w-5 h-5" style={{ color: primaryColor }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" /></svg>
<span className="text-[9px] font-semibold" style={{ color: primaryColor }}>Start</span>
</div>
<div className="flex flex-col items-center gap-1 text-gray-400 w-1/6">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9a2 2 0 00-2-2h-2m-4-3H9M7 16h6M7 8h6v4H7V8z" /></svg>
<span className="text-[9px] font-medium">Aktuelles</span>
</div>
<div className="flex flex-col items-center gap-1 text-gray-400 w-1/6">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg>
<span className="text-[9px] font-medium">Termine</span>
</div>
<div className="flex flex-col items-center gap-1 text-gray-400 w-1/6">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M21 13.255A23.931 23.931 0 0112 15c-3.183 0-6.22-.62-9-1.745M16 6V4a2 2 0 00-2-2h-4a2 2 0 00-2 2v2m4 6h.01M5 20h14a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" /></svg>
<span className="text-[9px] font-medium">Stellen</span>
</div>
<div className="flex flex-col items-center gap-1 text-gray-400 w-1/6">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" /></svg>
<span className="text-[9px] font-medium">Nachricht..</span>
</div>
<div className="flex flex-col items-center gap-1 text-gray-400 w-1/6">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" /></svg>
<span className="text-[9px] font-medium">Profil</span>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
{/* CTA Section */}
<section id="mitglied-werden" className="px-8 py-24 bg-gray-50 text-center relative z-20">
<div className="max-w-3xl mx-auto space-y-8">
<h2 className="text-3xl md:text-4xl font-bold text-gray-900">Werden Sie jetzt Teil der Gemeinschaft</h2>
<p className="text-lg text-gray-600">
Profitieren Sie von unserem starken Netzwerk, exklusiven Brancheninformationen und unserer digitalen Innungs-App.
</p>
<a
href="#apps"
className="inline-block px-10 py-4 rounded-full text-white font-bold text-lg shadow-xl hover:shadow-2xl hover:-translate-y-1 transition-all"
style={{ backgroundColor: primaryColor }}
>
Jetzt Mitglied werden
</a>
</div>
</section>
{/* Footer */}
<footer className="bg-gray-900 text-gray-400 py-12 px-8 text-center text-sm">
<div className="max-w-4xl mx-auto space-y-4">
<div className="text-gray-300 font-bold text-lg mb-6">{formData.name || 'Innungs-Logo'}</div>
<div className="whitespace-pre-wrap">{footer}</div>
<div className="pt-8 border-t border-gray-800 flex justify-center gap-6">
<a href="#" className="hover:text-white transition-colors">Impressum</a>
<a href="#" className="hover:text-white transition-colors">Datenschutz</a>
<a href="#" className="hover:text-white transition-colors">Kontakt</a>
</div>
</div>
</footer>
</div>
)
}

View File

@@ -0,0 +1,477 @@
'use server'
import { prisma, Prisma } from '@innungsapp/shared'
import { auth, getSanitizedHeaders } from '@/lib/auth'
import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'
import { z } from 'zod'
import { sendAdminCredentialsEmail } from '@/lib/email'
// @ts-ignore
import { hashPassword } from 'better-auth/crypto'
function normalizeEmail(email: string | null | undefined): string {
return (email ?? '').trim().toLowerCase()
}
function toJsonbText(value: string | undefined): Prisma.InputJsonValue | Prisma.NullableJsonNullValueInput {
if (!value) {
return Prisma.DbNull
}
return value
}
/**
* Sets a credential (email+password) account for a user.
* Uses direct DB write with better-auth's hashPassword for compatibility.
*/
async function setCredentialPassword(userId: string, password: string) {
const hashedPassword = await hashPassword(password)
const updated = await prisma.account.updateMany({
where: { userId, providerId: 'credential' },
data: { password: hashedPassword, accountId: userId },
})
if (updated.count === 0) {
await prisma.account.create({
data: {
id: crypto.randomUUID(),
userId,
accountId: userId,
providerId: 'credential',
password: hashedPassword,
},
})
}
}
async function requireSuperAdmin() {
const session = await auth.api.getSession({ headers: await getSanitizedHeaders() })
const superAdminEmail = process.env.SUPERADMIN_EMAIL || 'superadmin@innungsapp.de'
// An admin is either specifically the superadmin email OR has the 'admin' role from better-auth admin plugin
const isSuperAdmin = session?.user && (
session.user.email === superAdminEmail ||
(session.user as any).role === 'admin'
)
if (!isSuperAdmin) {
return null
}
return session
}
const createOrgSchema = z.object({
name: z.string().min(2, 'Name muss mindestens 2 Zeichen lang sein'),
slug: z
.string()
.min(2, 'Slug muss mindestens 2 Zeichen lang sein')
.regex(/^[a-z0-9-]+$/, 'Slug darf nur Kleinbuchstaben, Zahlen und Bindestriche enthalten'),
contactEmail: z.string().email('Ungueltige E-Mail Adresse').optional().or(z.literal('')),
adminEmail: z.string().email('Ungueltige Admin E-Mail').optional().or(z.literal('')),
adminPassword: z.string().min(8, 'Passwort muss mindestens 8 Zeichen lang sein').optional().or(z.literal('')),
logoUrl: z.string().optional().nullable(),
plan: z.enum(['pilot', 'standard', 'pro', 'verband']).default('pilot'),
primaryColor: z.string().regex(/^#([0-9a-fA-F]{6})$/, 'Ungueltige Farbe').optional().or(z.literal('')),
secondaryColor: z.string().regex(/^#([0-9a-fA-F]{6})$/, 'Ungueltige Farbe').optional().or(z.literal('')),
landingPageTitle: z.string().optional(),
landingPageText: z.string().optional(),
landingPageHeroImage: z.string().optional().nullable(),
landingPageHeroOverlayOpacity: z.number().min(0).max(100).optional().default(50),
landingPageFeatures: z.string().optional(),
landingPageFooter: z.string().optional(),
landingPageSectionTitle: z.string().optional(),
landingPageButtonText: z.string().optional(),
appStoreUrl: z.string().url('Ungueltige URL').optional().or(z.literal('')),
playStoreUrl: z.string().url('Ungueltige URL').optional().or(z.literal('')),
})
const updateOrgSchema = z.object({
name: z.string().min(2, 'Name muss mindestens 2 Zeichen lang sein'),
plan: z.enum(['pilot', 'standard', 'pro', 'verband']),
contactEmail: z.string().email('Ungueltige E-Mail Adresse').optional().or(z.literal('')),
logoUrl: z.string().optional().nullable(),
primaryColor: z.string().regex(/^#([0-9a-fA-F]{6})$/, 'Ungueltige Farbe').optional().or(z.literal('')),
secondaryColor: z.string().regex(/^#([0-9a-fA-F]{6})$/, 'Ungueltige Farbe').optional().or(z.literal('')),
landingPageTitle: z.string().optional(),
landingPageText: z.string().optional(),
landingPageHeroImage: z.string().optional().nullable(),
landingPageFeatures: z.string().optional(),
landingPageFooter: z.string().optional(),
landingPageSectionTitle: z.string().optional(),
landingPageButtonText: z.string().optional(),
appStoreUrl: z.string().url('Ungueltige URL').optional().or(z.literal('')),
playStoreUrl: z.string().url('Ungueltige URL').optional().or(z.literal('')),
})
const createAdminSchema = z.object({
orgId: z.string(),
name: z.string().min(2, 'Name ist zu kurz'),
email: z.string().email('Ungueltige E-Mail Adresse'),
password: z.string().min(8, 'Passwort muss mindestens 8 Zeichen lang sein'),
})
const createMemberSchema = z.object({
orgId: z.string(),
name: z.string().min(2, 'Name ist zu kurz'),
email: z.string().email('Ungueltige E-Mail Adresse'),
betrieb: z.string().min(2, 'Betrieb ist zu kurz'),
sparte: z.string().min(2, 'Sparte ist zu kurz'),
ort: z.string().min(2, 'Ort ist zu kurz'),
})
export async function createOrganization(prevState: any, formData: FormData) {
const session = await requireSuperAdmin()
if (!session) return { success: false, error: 'Nicht autorisiert.' }
try {
const rawData = {
name: (formData.get('name') as string).trim(),
slug: (formData.get('slug') as string).trim().toLowerCase(),
contactEmail: (formData.get('contactEmail') as string).trim(),
adminEmail: normalizeEmail(formData.get('adminEmail') as string),
adminPassword: formData.get('adminPassword') as string,
logoUrl: formData.get('logoUrl') as string,
plan: (formData.get('plan') as string) || 'pilot',
primaryColor: formData.get('primaryColor') as string,
secondaryColor: formData.get('secondaryColor') as string,
landingPageTitle: (formData.get('landingPageTitle') as string).trim(),
landingPageText: (formData.get('landingPageText') as string).trim(),
landingPageHeroImage: formData.get('landingPageHeroImage') as string,
landingPageHeroOverlayOpacity: Number(formData.get('landingPageHeroOverlayOpacity') || '50'),
landingPageFeatures: (formData.get('landingPageFeatures') as string).trim(),
landingPageFooter: (formData.get('landingPageFooter') as string).trim(),
landingPageSectionTitle: (formData.get('landingPageSectionTitle') as string || '').trim(),
landingPageButtonText: (formData.get('landingPageButtonText') as string || '').trim(),
appStoreUrl: (formData.get('appStoreUrl') as string || '').trim(),
playStoreUrl: (formData.get('playStoreUrl') as string || '').trim(),
}
const validatedData = createOrgSchema.parse(rawData)
const existingOrg = await prisma.organization.findUnique({
where: { slug: validatedData.slug },
})
if (existingOrg) {
return { success: false, error: 'Diese Kurzbezeichnung (Slug) existiert bereits.' }
}
const org = await prisma.organization.create({
data: {
name: validatedData.name,
slug: validatedData.slug,
contactEmail: validatedData.contactEmail || validatedData.adminEmail || null,
plan: validatedData.plan,
primaryColor: validatedData.primaryColor || '#E63946',
secondaryColor: validatedData.secondaryColor || null,
logoUrl: validatedData.logoUrl || null,
landingPageTitle: validatedData.landingPageTitle || null,
landingPageText: validatedData.landingPageText || null,
landingPageHeroImage: validatedData.landingPageHeroImage || null,
// @ts-ignore
landingPageHeroOverlayOpacity: validatedData.landingPageHeroOverlayOpacity,
landingPageFeatures: toJsonbText(validatedData.landingPageFeatures),
landingPageFooter: toJsonbText(validatedData.landingPageFooter),
landingPageSectionTitle: validatedData.landingPageSectionTitle || null,
landingPageButtonText: validatedData.landingPageButtonText || null,
appStoreUrl: validatedData.appStoreUrl || null,
playStoreUrl: validatedData.playStoreUrl || null,
},
})
if (validatedData.adminEmail) {
let user = await prisma.user.findUnique({ where: { email: validatedData.adminEmail } })
if (!user) {
user = await prisma.user.create({
data: {
id: crypto.randomUUID(),
name: validatedData.adminEmail.split('@')[0],
email: validatedData.adminEmail,
emailVerified: true,
mustChangePassword: !!validatedData.adminPassword,
},
})
} else {
// If user exists, we still want to make sure they are verified and maybe force password change
user = await prisma.user.update({
where: { id: user.id },
data: {
emailVerified: true,
...(validatedData.adminPassword ? { mustChangePassword: true } : {}),
},
})
}
await prisma.userRole.upsert({
where: {
orgId_userId: {
orgId: org.id,
userId: user.id,
},
},
update: { role: 'admin' },
create: {
orgId: org.id,
userId: user.id,
role: 'admin',
},
})
if (validatedData.adminPassword) {
await setCredentialPassword(user.id, validatedData.adminPassword)
try {
await sendAdminCredentialsEmail({
to: validatedData.adminEmail,
adminName: user.name || validatedData.adminEmail.split('@')[0],
orgName: org.name,
password: validatedData.adminPassword,
loginUrl: process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3010',
})
} catch (emailError) {
console.error('E-Mail konnte nicht gesendet werden:', emailError)
}
}
}
revalidatePath('/superadmin')
return { success: true, error: '' }
} catch (error) {
if (error instanceof z.ZodError) {
return { success: false, error: error.errors[0].message }
}
return { success: false, error: 'Ein unerwarteter Fehler ist aufgetreten.' }
}
}
export async function updateOrganization(id: string, prevState: any, formData: FormData) {
const session = await requireSuperAdmin()
if (!session) return { success: false, error: 'Nicht autorisiert.' }
try {
const rawData = {
name: (formData.get('name') as string).trim(),
plan: formData.get('plan') as string,
contactEmail: (formData.get('contactEmail') as string).trim(),
logoUrl: formData.get('logoUrl') as string,
primaryColor: formData.get('primaryColor') as string,
secondaryColor: formData.get('secondaryColor') as string,
landingPageTitle: (formData.get('landingPageTitle') as string).trim(),
landingPageText: (formData.get('landingPageText') as string).trim(),
landingPageHeroImage: formData.get('landingPageHeroImage') as string,
landingPageFeatures: (formData.get('landingPageFeatures') as string).trim(),
landingPageFooter: (formData.get('landingPageFooter') as string).trim(),
landingPageSectionTitle: (formData.get('landingPageSectionTitle') as string || '').trim(),
landingPageButtonText: (formData.get('landingPageButtonText') as string || '').trim(),
appStoreUrl: (formData.get('appStoreUrl') as string || '').trim(),
playStoreUrl: (formData.get('playStoreUrl') as string || '').trim(),
}
const validatedData = updateOrgSchema.parse(rawData)
await prisma.organization.update({
where: { id },
data: {
name: validatedData.name,
plan: validatedData.plan,
contactEmail: validatedData.contactEmail || null,
logoUrl: validatedData.logoUrl || null,
primaryColor: validatedData.primaryColor || '#E63946',
secondaryColor: validatedData.secondaryColor || null,
landingPageTitle: validatedData.landingPageTitle || null,
landingPageText: validatedData.landingPageText || null,
landingPageHeroImage: validatedData.landingPageHeroImage || null,
landingPageFeatures: toJsonbText(validatedData.landingPageFeatures),
landingPageFooter: toJsonbText(validatedData.landingPageFooter),
landingPageSectionTitle: validatedData.landingPageSectionTitle || null,
landingPageButtonText: validatedData.landingPageButtonText || null,
appStoreUrl: validatedData.appStoreUrl || null,
playStoreUrl: validatedData.playStoreUrl || null,
},
})
revalidatePath('/superadmin')
revalidatePath(`/superadmin/organizations/${id}`)
return { success: true, error: '' }
} catch (error) {
if (error instanceof z.ZodError) {
return { success: false, error: error.errors[0].message }
}
return { success: false, error: 'Ein unerwarteter Fehler ist aufgetreten.' }
}
}
export async function toggleAiFeature(id: string, enabled: boolean) {
const session = await requireSuperAdmin()
if (!session) return { success: false, error: 'Nicht autorisiert.' }
await prisma.organization.update({
where: { id },
data: { aiEnabled: enabled },
})
revalidatePath('/superadmin')
revalidatePath(`/superadmin/organizations/${id}`)
return { success: true, error: '' }
}
export async function deleteOrganization(id: string) {
const session = await requireSuperAdmin()
if (!session) return { success: false, error: 'Nicht autorisiert.' }
await prisma.organization.delete({ where: { id } })
revalidatePath('/superadmin')
redirect('/superadmin')
}
export async function createAdmin(prevState: any, formData: FormData) {
const session = await requireSuperAdmin()
if (!session) return { success: false, error: 'Nicht autorisiert.' }
try {
const rawData = {
orgId: formData.get('orgId') as string,
name: (formData.get('name') as string).trim(),
email: normalizeEmail(formData.get('email') as string),
password: formData.get('password') as string,
}
const validatedData = createAdminSchema.parse(rawData)
let user = await prisma.user.findUnique({ where: { email: validatedData.email } })
if (!user) {
user = await prisma.user.create({
data: {
id: crypto.randomUUID(),
name: validatedData.name,
email: validatedData.email,
emailVerified: true,
mustChangePassword: true,
},
})
} else {
user = await prisma.user.update({
where: { id: user.id },
data: {
emailVerified: true,
mustChangePassword: true,
},
})
}
await setCredentialPassword(user.id, validatedData.password)
await prisma.userRole.upsert({
where: {
orgId_userId: {
orgId: validatedData.orgId,
userId: user.id,
},
},
update: { role: 'admin' },
create: {
orgId: validatedData.orgId,
userId: user.id,
role: 'admin',
},
})
const org = await prisma.organization.findUnique({
where: { id: validatedData.orgId },
select: { name: true },
})
try {
await sendAdminCredentialsEmail({
to: validatedData.email,
adminName: validatedData.name,
orgName: org?.name || 'Ihre Innung',
password: validatedData.password,
loginUrl: process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3010',
})
} catch (emailError) {
console.error('E-Mail konnte nicht gesendet werden (Admin wurde trotzdem angelegt):', emailError)
}
revalidatePath(`/superadmin/organizations/${validatedData.orgId}`)
return { success: true, error: '' }
} catch (error) {
console.error('Failed to create admin:', error)
if (error instanceof z.ZodError) {
return { success: false, error: error.errors[0].message }
}
return { success: false, error: 'Ein Fehler ist aufgetreten.' }
}
}
export async function removeUserRole(id: string, orgId: string) {
const session = await requireSuperAdmin()
if (!session) return { success: false, error: 'Nicht autorisiert.' }
await prisma.userRole.delete({ where: { id } })
revalidatePath(`/superadmin/organizations/${orgId}`)
return { success: true, error: '' }
}
export async function updateUserRole(id: string, orgId: string, role: string) {
const session = await requireSuperAdmin()
if (!session) return { success: false, error: 'Nicht autorisiert.' }
await prisma.userRole.update({
where: { id },
data: { role },
})
revalidatePath(`/superadmin/organizations/${orgId}`)
return { success: true, error: '' }
}
export async function removeMember(id: string, orgId: string) {
const session = await requireSuperAdmin()
if (!session) return { success: false, error: 'Nicht autorisiert.' }
await prisma.member.delete({ where: { id } })
revalidatePath(`/superadmin/organizations/${orgId}`)
return { success: true, error: '' }
}
export async function createMember(prevState: any, formData: FormData) {
const session = await requireSuperAdmin()
if (!session) return { success: false, error: 'Nicht autorisiert.' }
try {
const rawData = {
orgId: formData.get('orgId') as string,
name: (formData.get('name') as string).trim(),
email: normalizeEmail(formData.get('email') as string),
betrieb: (formData.get('betrieb') as string).trim(),
sparte: (formData.get('sparte') as string).trim(),
ort: (formData.get('ort') as string).trim(),
}
const validatedData = createMemberSchema.parse(rawData)
await prisma.member.create({
data: {
orgId: validatedData.orgId,
name: validatedData.name,
email: validatedData.email,
betrieb: validatedData.betrieb,
sparte: validatedData.sparte,
ort: validatedData.ort,
status: 'aktiv',
},
})
revalidatePath(`/superadmin/organizations/${validatedData.orgId}`)
return { success: true, error: '' }
} catch (error) {
console.error('Failed to create member:', error)
if (error instanceof z.ZodError) {
return { success: false, error: error.errors[0].message }
}
return { success: false, error: 'Ein Fehler ist aufgetreten.' }
}
}

View File

@@ -0,0 +1,30 @@
import { CreateOrgForm } from '../CreateOrgForm'
import Link from 'next/link'
export default function CreateOrgPage() {
return (
<div className="h-full w-full flex flex-col p-6 gap-6">
<div className="flex items-center gap-4 shrink-0">
<Link
href="/superadmin"
className="p-2.5 bg-white border border-gray-200 text-gray-400 rounded-xl hover:bg-gray-50 hover:text-gray-600 transition-colors"
title="Zurück zur Übersicht"
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="w-5 h-5">
<path strokeLinecap="round" strokeLinejoin="round" d="M10.5 19.5 3 12m0 0 7.5-7.5M3 12h18" />
</svg>
</Link>
<div className="space-y-1">
<h1 className="text-2xl font-black text-gray-900 tracking-tight font-outfit">
Neue Innung anlegen
</h1>
<p className="text-sm text-gray-400 font-medium">Legen Sie hier eine neue Innung an und konfigurieren Sie die Branding-Daten.</p>
</div>
</div>
<div className="flex-1 overflow-hidden min-h-0">
<CreateOrgForm />
</div>
</div>
)
}

View File

@@ -0,0 +1,119 @@
import { prisma } from '@innungsapp/shared'
import Link from 'next/link'
import { ExternalLink, Settings, Layout, Search } from 'lucide-react'
export default async function LandingPagesOverview({
searchParams,
}: {
searchParams: Promise<{ q?: string }>
}) {
const { q = '' } = await searchParams
const organizations = await prisma.organization.findMany({
where: q ? {
OR: [
{ name: { contains: q } },
{ slug: { contains: q } },
]
} : {},
orderBy: { name: 'asc' },
})
return (
<div className="space-y-8 animate-in fade-in duration-500">
{/* Header */}
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div>
<h1 className="text-3xl font-black text-gray-900 tracking-tight font-outfit">
Landingpage-Verwaltung
</h1>
<p className="text-gray-500 font-medium">Alle Mandanten-Landingpages auf einen Blick verwalten.</p>
</div>
<div className="relative group w-full md:w-72">
<div className="absolute inset-y-0 left-3 flex items-center pointer-events-none text-gray-400 group-focus-within:text-[#E63946] transition-colors">
<Search size={18} />
</div>
<form method="GET">
<input
type="search"
name="q"
defaultValue={q}
placeholder="Landingpage suchen..."
className="w-full pl-10 pr-4 py-2.5 bg-white border rounded-xl text-sm outline-none focus:border-[#E63946] focus:ring-4 focus:ring-red-500/5 transition-all shadow-sm"
/>
</form>
</div>
</div>
{/* Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{organizations.length === 0 ? (
<div className="col-span-full py-20 bg-white border border-dashed rounded-3xl flex flex-col items-center justify-center text-center">
<div className="bg-gray-50 p-4 rounded-2xl mb-4 text-gray-400">
<Layout size={40} strokeWidth={1.5} />
</div>
<p className="text-gray-500 font-medium">Keine Landingpages gefunden.</p>
{q && <Link href="/superadmin/landingpages" className="text-[#E63946] font-bold mt-2 text-sm hover:underline">Suche zurücksetzen</Link>}
</div>
) : (
organizations.map((org) => (
<div key={org.id} className="group bg-white rounded-3xl border border-gray-100 p-6 hover:border-[#E63946] hover:shadow-2xl hover:shadow-red-500/5 transition-all duration-500 flex flex-col h-full relative overflow-hidden">
{/* Accent line */}
<div className="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-gray-50 via-gray-100 to-gray-50 group-hover:from-red-100 group-hover:via-[#E63946] group-hover:to-red-100 transition-all duration-500" />
<div className="flex items-start justify-between mb-4">
<div className="space-y-1">
<h3 className="font-black text-xl text-gray-900 group-hover:text-[#E63946] transition-colors truncate max-w-[200px]">
{org.name}
</h3>
<div className="flex items-center gap-1.5 text-xs font-mono text-gray-400">
<span className="text-[#E63946] opacity-50">/</span>
<span>{org.slug}</span>
</div>
</div>
<div className="p-2.5 bg-gray-50 rounded-2xl text-gray-400 group-hover:bg-red-50 group-hover:text-[#E63946] transition-all duration-500">
<Layout size={20} strokeWidth={2} />
</div>
</div>
<div className="flex-1 space-y-4">
<div className="bg-gray-50/50 rounded-2xl p-4 border border-gray-100">
<div className="flex items-center justify-between text-[11px] font-bold uppercase tracking-widest text-gray-400 mb-2">
<span>Status</span>
<span className="flex items-center gap-1 text-green-500">
<span className="w-1.5 h-1.5 rounded-full bg-green-500 animate-pulse" />
Online
</span>
</div>
<div className="text-sm font-medium text-gray-600 truncate">
{org.landingPageTitle || 'Standard-Title'}
</div>
</div>
</div>
<div className="mt-8 grid grid-cols-2 gap-3">
<a
href={`/${org.slug}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center gap-2 py-3 bg-gray-50 text-gray-600 rounded-2xl text-sm font-bold hover:bg-gray-100 transition-all border border-transparent"
>
<ExternalLink size={16} />
Ansehen
</a>
<Link
href={`/superadmin/organizations/${org.id}`}
className="flex items-center justify-center gap-2 py-3 bg-gray-900 text-white rounded-2xl text-sm font-bold hover:bg-black transition-all hover:shadow-lg hover:shadow-black/10 shadow-sm"
>
<Settings size={16} />
Editieren
</Link>
</div>
</div>
))
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,53 @@
import { auth, getSanitizedHeaders } from '@/lib/auth'
import { redirect } from 'next/navigation'
import Link from 'next/link'
export default async function SuperAdminLayout({
children,
}: {
children: React.ReactNode
}) {
const session = await auth.api.getSession({ headers: await getSanitizedHeaders() })
if (!session?.user) {
redirect('/login')
}
const superAdminEmail = process.env.SUPERADMIN_EMAIL || 'superadmin@innungsapp.de'
const isSuperAdmin = session.user.email === superAdminEmail || session.user.role === 'admin'
if (!isSuperAdmin) {
redirect('/dashboard') // Normal admins go back to dashboard
}
return (
<div className="min-h-screen bg-gray-50 flex flex-col">
{/* Super Admin Header */}
<header className="bg-gray-900 text-white border-t-2 border-brand-500 border-b border-gray-800">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between h-12 items-center">
<div className="flex items-center gap-8">
<span
className="font-bold text-base tracking-tight hover:text-gray-200 transition-colors"
style={{ fontFamily: "'Syne', system-ui, sans-serif" }}
>
<Link href="/superadmin">Super Admin</Link>
</span>
{/* Super Admin Navigation */}
<nav className="hidden md:flex gap-6 text-sm font-medium text-gray-400">
<Link href="/superadmin" className="hover:text-white transition-colors">Übersicht</Link>
<Link href="/superadmin/landingpages" className="hover:text-white transition-colors">Landingpages</Link>
</nav>
</div>
<span className="text-xs text-gray-400">{session.user.email}</span>
</div>
</div>
</header>
{/* Main Content */}
<main className="flex-1 max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 w-full">
{children}
</main>
</div>
)
}

View File

@@ -0,0 +1,89 @@
'use client'
import { useActionState, useState } from 'react'
import { createAdmin } from '../../actions'
export function CreateAdminForm({ orgId }: { orgId: string }) {
const [state, action, isPending] = useActionState(createAdmin, { success: false, error: '' })
const [showForm, setShowForm] = useState(false)
if (!showForm) {
return (
<button
onClick={() => setShowForm(true)}
className="w-full py-2 border-2 border-dashed border-gray-200 rounded-lg text-sm text-gray-500 hover:border-brand-500 hover:text-brand-500 transition-all font-medium"
>
+ Administrator hinzufügen
</button>
)
}
return (
<div className="bg-gray-50 border rounded-xl p-4 space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-gray-900">Neuen Admin anlegen</h3>
<button
onClick={() => setShowForm(false)}
className="text-xs text-gray-400 hover:text-gray-600"
>
Abbrechen
</button>
</div>
<form action={action} className="space-y-3">
<input type="hidden" name="orgId" value={orgId} />
<div>
<label className="block text-[10px] font-bold text-gray-400 uppercase tracking-wider mb-1">Name</label>
<input
name="name"
required
placeholder="z.B. Max Mustermann"
className="w-full px-3 py-2 border rounded-lg text-sm focus:ring-2 focus:ring-brand-500 outline-none"
/>
</div>
<div>
<label className="block text-[10px] font-bold text-gray-400 uppercase tracking-wider mb-1">E-Mail</label>
<input
name="email"
type="email"
required
placeholder="admin@beispiel.de"
className="w-full px-3 py-2 border rounded-lg text-sm focus:ring-2 focus:ring-brand-500 outline-none"
/>
</div>
<div>
<label className="block text-[10px] font-bold text-gray-400 uppercase tracking-wider mb-1">Passwort</label>
<div className="relative">
<input
name="password"
type="text"
required
defaultValue={Math.random().toString(36).slice(-10)}
className="w-full px-3 py-2 border rounded-lg text-sm font-mono focus:ring-2 focus:ring-brand-500 outline-none"
/>
<p className="text-[10px] text-gray-400 mt-1">Das Passwort muss dem Admin manuell mitgeteilt werden.</p>
</div>
</div>
{state.error && (
<p className="text-xs text-red-600 bg-red-50 p-2 rounded">{state.error}</p>
)}
{state.success && (
<p className="text-xs text-green-600 bg-green-50 p-2 rounded">Administrator erfolgreich angelegt.</p>
)}
<button
type="submit"
disabled={isPending}
className="w-full bg-gray-900 text-white py-2 rounded-lg text-sm font-medium hover:bg-gray-800 disabled:opacity-50 transition-colors"
>
{isPending ? 'Wird angelegt...' : 'Admin anlegen'}
</button>
</form>
</div>
)
}

View File

@@ -0,0 +1,94 @@
'use client'
import { useActionState } from 'react'
import { createMember } from '../../actions'
const initialState = {
success: false,
error: '',
}
export function CreateMemberForm({ orgId }: { orgId: string }) {
const [state, action, isPending] = useActionState(createMember, initialState)
return (
<div className="space-y-4">
<h3 className="text-sm font-semibold text-gray-900 uppercase tracking-wider">Mitglied manuell hinzufügen</h3>
<form action={action} className="space-y-3">
<input type="hidden" name="orgId" value={orgId} />
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label className="block text-[10px] font-bold text-gray-400 uppercase tracking-wider mb-1">Name (Ansprechpartner)</label>
<input
name="name"
type="text"
required
placeholder="Anrede Vorname Nachname"
className="w-full px-3 py-2 border rounded-lg text-sm focus:ring-2 focus:ring-brand-500 outline-none"
/>
</div>
<div>
<label className="block text-[10px] font-bold text-gray-400 uppercase tracking-wider mb-1">E-Mail</label>
<input
name="email"
type="email"
required
placeholder="email@beispiel.de"
className="w-full px-3 py-2 border rounded-lg text-sm focus:ring-2 focus:ring-brand-500 outline-none"
/>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div>
<label className="block text-[10px] font-bold text-gray-400 uppercase tracking-wider mb-1">Betrieb</label>
<input
name="betrieb"
type="text"
required
placeholder="Name des Betriebs"
className="w-full px-3 py-2 border rounded-lg text-sm focus:ring-2 focus:ring-brand-500 outline-none"
/>
</div>
<div>
<label className="block text-[10px] font-bold text-gray-400 uppercase tracking-wider mb-1">Sparte</label>
<input
name="sparte"
type="text"
required
placeholder="z.B. Sanitär"
className="w-full px-3 py-2 border rounded-lg text-sm focus:ring-2 focus:ring-brand-500 outline-none"
/>
</div>
<div>
<label className="block text-[10px] font-bold text-gray-400 uppercase tracking-wider mb-1">Ort</label>
<input
name="ort"
type="text"
required
placeholder="Stadt"
className="w-full px-3 py-2 border rounded-lg text-sm focus:ring-2 focus:ring-brand-500 outline-none"
/>
</div>
</div>
{state.error && (
<p className="text-xs text-red-600 bg-red-50 p-2 rounded">{state.error}</p>
)}
{state.success && (
<p className="text-xs text-green-600 bg-green-50 p-2 rounded">Mitglied erfolgreich angelegt.</p>
)}
<button
type="submit"
disabled={isPending}
className="w-full bg-gray-900 text-white py-2 rounded-lg text-sm font-medium hover:bg-gray-800 disabled:opacity-50 transition-colors"
>
{isPending ? 'Wird angelegt...' : 'Mitglied anlegen'}
</button>
</form>
</div>
)
}

View File

@@ -0,0 +1,19 @@
'use client'
import { deleteOrganization } from '../../actions'
export function DeleteOrgButton({ id, name }: { id: string; name: string }) {
async function handleDelete() {
if (!confirm(`Innung "${name}" wirklich unwiderruflich löschen? Alle Daten (Mitglieder, News, Termine, Stellen) werden gelöscht.`)) return
await deleteOrganization(id)
}
return (
<button
onClick={handleDelete}
className="w-full mt-2 px-4 py-2 bg-red-600 text-white text-sm font-medium rounded-lg hover:bg-red-700 transition-colors"
>
Innung löschen
</button>
)
}

View File

@@ -0,0 +1,351 @@
'use client'
import { useActionState, useState } from 'react'
import { updateOrganization } from '../../actions'
function jsonToText(value: unknown): string {
if (value == null) {
return ''
}
if (typeof value === 'string') {
return value
}
if (Array.isArray(value)) {
return value
.map((item) => (typeof item === 'string' ? item : JSON.stringify(item)))
.join('\n')
}
return JSON.stringify(value)
}
interface Props {
org: {
id: string
name: string
plan: string
contactEmail: string | null
logoUrl: string | null
primaryColor: string | null
secondaryColor: string | null
landingPageTitle: string | null
landingPageText: string | null
landingPageSectionTitle: string | null
landingPageButtonText: string | null
landingPageHeroImage: string | null
landingPageHeroOverlayOpacity: number | null
landingPageFeatures: unknown
landingPageFooter: unknown
appStoreUrl: string | null
playStoreUrl: string | null
}
}
const initialState = { success: false, error: '' }
export function EditOrgForm({ org }: Props) {
const boundAction = updateOrganization.bind(null, org.id)
const [state, formAction, isPending] = useActionState(boundAction, initialState)
const [logoUrl, setLogoUrl] = useState(org.logoUrl || '')
const [heroImageUrl, setHeroImageUrl] = useState(org.landingPageHeroImage || '')
const [isUploading, setIsUploading] = useState<{ logo?: boolean; hero?: boolean }>({})
const [themeColor, setThemeColor] = useState(org.primaryColor || '#E63946')
const [secondaryColor, setSecondaryColor] = useState(org.secondaryColor || '#FFFFFF')
const initialFeatures = jsonToText(org.landingPageFeatures)
const initialFooter = jsonToText(org.landingPageFooter)
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>, type: 'logo' | 'hero') => {
const file = e.target.files?.[0]
if (!file) return
setIsUploading(prev => ({ ...prev, [type]: true }))
const uploadFormData = new FormData()
uploadFormData.append('file', file)
try {
const res = await fetch('/api/upload', {
method: 'POST',
body: uploadFormData
})
const data = await res.json()
if (data.url) {
if (type === 'logo') setLogoUrl(data.url)
if (type === 'hero') setHeroImageUrl(data.url)
}
} catch (err) {
console.error('Upload failed', err)
} finally {
setIsUploading(prev => ({ ...prev, [type]: false }))
}
}
return (
<div className="bg-white rounded-xl border p-6">
<h2 className="text-base font-semibold text-gray-900 mb-4">Innung bearbeiten</h2>
{state.success && (
<div className="mb-4 p-3 bg-green-50 text-green-700 rounded-lg text-sm">Änderungen gespeichert.</div>
)}
{state.error && (
<div className="mb-4 p-3 bg-red-50 text-red-700 rounded-lg text-sm">{state.error}</div>
)}
<form action={formAction} className="space-y-6">
{/* BASISDATEN */}
<div className="space-y-4">
<h3 className="text-sm font-semibold text-gray-900 border-b pb-2">Basisdaten</h3>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Name der Innung</label>
<input
type="text"
name="name"
required
defaultValue={org.name}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Plan</label>
<select
name="plan"
defaultValue={org.plan}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-brand-500 focus:border-brand-500 bg-white"
>
<option value="pilot">Pilot</option>
<option value="standard">Standard</option>
<option value="pro">Pro</option>
<option value="verband">Verband</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Kontakt E-Mail</label>
<input
type="email"
name="contactEmail"
defaultValue={org.contactEmail ?? ''}
placeholder="info@innung.de"
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
/>
</div>
</div>
{/* BRANDING */}
<div className="space-y-4 pt-4">
<h3 className="text-sm font-semibold text-gray-900 border-b pb-2">Branding</h3>
<input type="hidden" name="logoUrl" value={logoUrl} />
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Logo</label>
<div className="flex items-center gap-3">
{logoUrl ? (
<div className="w-10 h-10 rounded border bg-gray-50 flex items-center justify-center p-1">
<img src={logoUrl} alt="Logo" className="max-w-full max-h-full object-contain" />
</div>
) : (
<div className="w-10 h-10 rounded border-2 border-dashed flex items-center justify-center text-gray-300">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-5 h-5">
<path strokeLinecap="round" strokeLinejoin="round" d="m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 0 0 1.5-1.5V6a1.5 1.5 0 0 0-1.5-1.5H3.75A1.5 1.5 0 0 0 2.25 6v12a1.5 1.5 0 0 0 1.5 1.5Zm10.5-11.25h.008v.008h-.008V8.25Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z" />
</svg>
</div>
)}
<label className="flex-1 cursor-pointer">
<div className={`px-3 py-2 border rounded-lg text-sm text-center font-medium hover:bg-gray-50 transition-colors ${isUploading.logo ? 'opacity-50' : ''}`}>
{isUploading.logo ? 'Wird hochgeladen...' : 'Logo ändern'}
</div>
<input type="file" onChange={(e) => handleUpload(e, 'logo')} accept="image/*" className="hidden" disabled={isUploading.logo} />
</label>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Primärfarbe</label>
<div className="flex gap-2">
<input
type="color"
name="primaryColor"
value={themeColor}
onChange={(e) => setThemeColor(e.target.value)}
className="h-9 w-12 p-1 border rounded cursor-pointer"
/>
<input
type="text"
value={themeColor}
onChange={(e) => setThemeColor(e.target.value)}
className="flex-1 px-3 py-2 border rounded-lg focus:ring-2 focus:ring-brand-500 font-mono text-sm"
pattern="^#([A-Fa-f0-9]{6})$"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Sekundärfarbe</label>
<div className="flex gap-2">
<input
type="color"
name="secondaryColor"
value={secondaryColor}
onChange={(e) => setSecondaryColor(e.target.value)}
className="h-9 w-12 p-1 border rounded cursor-pointer"
/>
<input
type="text"
value={secondaryColor}
onChange={(e) => setSecondaryColor(e.target.value)}
className="flex-1 px-3 py-2 border rounded-lg focus:ring-2 focus:ring-brand-500 font-mono text-sm"
pattern="^#([A-Fa-f0-9]{6})$"
/>
</div>
</div>
</div>
</div>
{/* LANDING PAGE */}
<div className="space-y-4 pt-4">
<h3 className="text-sm font-semibold text-gray-900 border-b pb-2">Landing Page</h3>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Hero Titel</label>
<input
type="text"
name="landingPageTitle"
defaultValue={org.landingPageTitle ?? ''}
placeholder="Zukunft des Handwerks gestalten"
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-brand-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Hero Untertitel / Text</label>
<textarea
name="landingPageText"
defaultValue={org.landingPageText ?? ''}
rows={3}
placeholder="Gemeinsam stark für unsere Region."
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-brand-500"
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Aufmacher Überschrift (Buttons)</label>
<input
type="text"
name="landingPageSectionTitle"
defaultValue={org.landingPageSectionTitle ?? ''}
placeholder={`${org.name || 'Ihre Innung'} Gemeinsam stark fürs Handwerk`}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-brand-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Button Text (CTA)</label>
<input
type="text"
name="landingPageButtonText"
defaultValue={org.landingPageButtonText ?? ''}
placeholder="Jetzt Mitglied werden"
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-brand-500"
/>
</div>
</div>
<input type="hidden" name="landingPageHeroImage" value={heroImageUrl} />
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Hero Hintergrundbild</label>
<div className="flex items-center gap-3">
<label className="flex-1 cursor-pointer">
<div className={`px-3 py-2 border rounded-lg text-sm text-center font-medium hover:bg-gray-50 transition-colors ${isUploading.hero ? 'opacity-50' : ''}`}>
{isUploading.hero ? 'Wird hochgeladen...' : 'Bild auswählen'}
</div>
<input type="file" onChange={(e) => handleUpload(e, 'hero')} accept="image/*" className="hidden" disabled={isUploading.hero} />
</label>
{heroImageUrl && (
<button type="button" onClick={() => setHeroImageUrl('')} className="text-red-500 hover:text-red-600 text-sm">
Entfernen
</button>
)}
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1 flex justify-between">
<span>Overlay Deckkraft</span>
<span className="text-gray-500">{org.landingPageHeroOverlayOpacity ?? 50}%</span>
</label>
<input
type="range"
name="landingPageHeroOverlayOpacity"
min="0"
max="100"
defaultValue={org.landingPageHeroOverlayOpacity ?? 50}
className="w-full"
/>
<p className="text-xs text-gray-500 mt-1">Legt fest, wie dunkel der Schleier über dem Hintergrundbild ist, damit der Text gut lesbar bleibt.</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Vorteile / Features</label>
<textarea
name="landingPageFeatures"
defaultValue={initialFeatures}
rows={5}
placeholder="Ein Benefit pro Zeile..."
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-brand-500"
/>
<p className="text-xs text-gray-500 mt-1">Bitte geben Sie pro Zeile einen Vorteil ein. Diese werden als Checkliste auf der Landingpage angezeigt.</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">App Store URL</label>
<input
type="url"
name="appStoreUrl"
defaultValue={org.appStoreUrl ?? ''}
placeholder="https://apps.apple.com/..."
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-brand-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Google Play URL</label>
<input
type="url"
name="playStoreUrl"
defaultValue={org.playStoreUrl ?? ''}
placeholder="https://play.google.com/..."
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-brand-500"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Footer Text</label>
<textarea
name="landingPageFooter"
defaultValue={initialFooter}
rows={2}
placeholder="© 2024 Innung. Alle Rechte vorbehalten."
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-brand-500"
/>
</div>
</div>
<div className="pt-2">
<button
type="submit"
disabled={isPending}
className="w-full bg-brand-500 text-white font-medium py-2 px-4 rounded-lg hover:bg-brand-600 transition-colors disabled:opacity-50"
>
{isPending ? 'Wird gespeichert…' : 'Speichern'}
</button>
</div>
</form>
</div>
)
}

View File

@@ -0,0 +1,25 @@
'use client'
import { removeMember } from '../../actions'
import { useState } from 'react'
export function MemberActions({ member, orgId }: { member: { id: string, name: string }, orgId: string }) {
const [isPending, setIsPending] = useState(false)
const handleRemove = async () => {
if (!confirm(`Möchten Sie das Mitglied ${member.name} wirklich entfernen?`)) return
setIsPending(true)
await removeMember(member.id, orgId)
setIsPending(false)
}
return (
<button
onClick={handleRemove}
disabled={isPending}
className="text-xs text-red-600 hover:text-red-700 font-medium transition-colors"
>
Entfernen
</button>
)
}

View File

@@ -0,0 +1,42 @@
'use client'
import { removeUserRole, updateUserRole } from '../../actions'
import { useState } from 'react'
export function UserRoleActions({ ur, orgId }: { ur: { id: string, role: string, user: { email: string } }, orgId: string }) {
const [isPending, setIsPending] = useState(false)
const handleRemove = async () => {
if (!confirm(`Möchten Sie den Zugriff für ${ur.user.email} wirklich entfernen?`)) return
setIsPending(true)
await removeUserRole(ur.id, orgId)
setIsPending(false)
}
const handleToggleRole = async () => {
const newRole = ur.role === 'admin' ? 'member' : 'admin'
setIsPending(true)
await updateUserRole(ur.id, orgId, newRole)
setIsPending(false)
}
return (
<div className="flex items-center gap-2">
<button
onClick={handleToggleRole}
disabled={isPending}
className="text-xs text-gray-600 hover:text-brand-600 font-medium transition-colors"
title={ur.role === 'admin' ? 'Zum Mitglied machen' : 'Zum Admin machen'}
>
{ur.role === 'admin' ? 'Rolle: Admin' : 'Rolle: Mitglied'}
</button>
<button
onClick={handleRemove}
disabled={isPending}
className="text-xs text-red-600 hover:text-red-700 font-medium transition-colors"
>
Entfernen
</button>
</div>
)
}

View File

@@ -0,0 +1,234 @@
import { prisma } from '@innungsapp/shared'
import { notFound } from 'next/navigation'
import { format } from 'date-fns'
import { de } from 'date-fns/locale'
import Link from 'next/link'
import { EditOrgForm } from './EditOrgForm'
import { DeleteOrgButton } from './DeleteOrgButton'
import { CreateAdminForm } from './CreateAdminForm'
import { CreateMemberForm } from './CreateMemberForm'
import { UserRoleActions } from './UserRoleActions'
import { MemberActions } from './MemberActions'
import { toggleAiFeature } from '../../actions'
const PLAN_COLORS: Record<string, string> = {
pilot: 'bg-gray-100 text-gray-700',
standard: 'bg-blue-100 text-blue-800',
pro: 'bg-purple-100 text-purple-800',
verband: 'bg-amber-100 text-amber-800',
}
export default async function OrgDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
const org = await prisma.organization.findUnique({
where: { id },
include: {
_count: {
select: {
members: true,
userRoles: true,
news: true,
termine: true,
stellen: true,
},
},
userRoles: {
include: { user: true },
},
members: {
take: 5,
orderBy: { createdAt: 'desc' },
select: { id: true, name: true, betrieb: true, status: true, createdAt: true },
},
},
})
if (!org) notFound()
const planColor = PLAN_COLORS[org.plan] ?? 'bg-gray-100 text-gray-700'
return (
<div className="space-y-6">
{/* Breadcrumb */}
<div className="flex items-center gap-2 text-sm text-gray-500">
<Link href="/superadmin" className="hover:text-gray-900 transition-colors">
Alle Innungen
</Link>
</div>
{/* Header */}
<div className="flex items-start justify-between">
<div>
<div className="flex items-center gap-3">
<h1 className="text-2xl font-bold text-gray-900">{org.name}</h1>
<span className={`text-xs font-semibold px-2.5 py-0.5 rounded ${planColor}`}>
{org.plan}
</span>
</div>
<div className="flex items-center gap-3 mt-1 text-sm text-gray-500">
<span className="font-mono bg-gray-100 px-2 py-0.5 rounded text-[11px]">{org.slug}</span>
<span></span>
<span>Erstellt {format(org.createdAt, 'dd. MMMM yyyy', { locale: de })}</span>
{org.avvAccepted && (
<>
<span></span>
<span className="text-green-600">AVV akzeptiert</span>
</>
)}
</div>
</div>
</div>
{/* Stats */}
<div className="grid grid-cols-2 sm:grid-cols-5 gap-3">
{[
{ label: 'Mitglieder', value: org._count.members },
{ label: 'Admins', value: org._count.userRoles },
{ label: 'News', value: org._count.news },
{ label: 'Termine', value: org._count.termine },
{ label: 'Stellen', value: org._count.stellen },
].map(({ label, value }) => (
<div key={label} className="bg-white rounded-xl border p-4 text-center">
<div className="text-2xl font-bold text-gray-900">{value}</div>
<div className="text-xs text-gray-500 mt-0.5">{label}</div>
</div>
))}
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Edit form */}
<div className="lg:col-span-1 space-y-4">
<EditOrgForm org={org} />
{/* KI-Assistent */}
<div className="bg-white rounded-xl border p-4 space-y-3">
<div>
<h3 className="text-sm font-semibold text-gray-900">KI-Assistent</h3>
<p className="text-xs text-gray-500 mt-0.5">
Aktiviert den KI-Chat-Assistenten für Mitglieder dieser Innung.
</p>
</div>
<div className="flex items-center justify-between">
<span className={`text-sm font-medium ${org.aiEnabled ? 'text-green-700' : 'text-gray-400'}`}>
{org.aiEnabled ? 'Aktiviert' : 'Deaktiviert'}
</span>
<form action={async () => {
'use server'
await toggleAiFeature(org.id, !org.aiEnabled)
}}>
<button
type="submit"
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none ${org.aiEnabled ? 'bg-green-500' : 'bg-gray-200'
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${org.aiEnabled ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</form>
</div>
</div>
{/* Danger zone */}
<div className="bg-white rounded-xl border border-red-200 p-4">
<h3 className="text-sm font-semibold text-red-700 mb-1">Gefahrenzone</h3>
<p className="text-xs text-gray-500 mb-3">
Das Löschen einer Innung entfernt alle zugehörigen Daten unwiderruflich.
</p>
<DeleteOrgButton id={org.id} name={org.name} />
</div>
</div>
{/* Right column: admins + recent members */}
<div className="lg:col-span-2 space-y-6">
{/* Admins */}
<div className="bg-white rounded-xl border overflow-hidden">
<div className="p-4 border-b flex items-center justify-between">
<h2 className="text-base font-semibold text-gray-900">
Nutzer & Rollen ({org.userRoles.length})
</h2>
</div>
<div className="p-4 bg-gray-50/50 border-b">
<CreateAdminForm orgId={org.id} />
</div>
<div className="divide-y">
{org.userRoles.length === 0 ? (
<p className="p-4 text-sm text-gray-400">Noch keine Nutzer zugewiesen.</p>
) : (
org.userRoles.map((ur) => (
<div key={ur.id} className="p-4 flex items-center justify-between">
<div>
<div className="text-sm font-medium text-gray-900">{ur.user.name}</div>
<div className="text-xs text-gray-500">
{ur.user.email}
<span className="ml-2 font-mono text-[10px] bg-gray-100 px-1 py-0.5 rounded">
{ur.role}
</span>
</div>
</div>
<div className="flex items-center gap-4">
{ur.user.emailVerified ? (
<span className="text-[10px] text-green-600 bg-green-50 px-2 py-0.5 rounded-full uppercase font-bold tracking-wider">
Verifiziert
</span>
) : (
<span className="text-[10px] text-amber-600 bg-amber-50 px-2 py-0.5 rounded-full uppercase font-bold tracking-wider">
Eingeladen
</span>
)}
<UserRoleActions ur={ur} orgId={org.id} />
</div>
</div>
))
)}
</div>
</div>
{/* Recent members */}
<div className="bg-white rounded-xl border overflow-hidden">
<div className="p-4 border-b flex items-center justify-between">
<h2 className="text-base font-semibold text-gray-900">
Mitglieder
</h2>
<span className="text-xs text-gray-400">{org._count.members} gesamt</span>
</div>
<div className="p-4 bg-gray-50/50 border-b">
<CreateMemberForm orgId={org.id} />
</div>
<div className="divide-y">
{org.members.length === 0 ? (
<p className="p-4 text-sm text-gray-400">Noch keine Mitglieder.</p>
) : (
org.members.map((m) => (
<div key={m.id} className="p-4 flex items-center justify-between">
<div>
<div className="text-sm font-medium text-gray-900">{m.name}</div>
<div className="text-xs text-gray-500">{m.betrieb}</div>
</div>
<div className="flex items-center gap-4">
<span
className={`text-xs px-2 py-0.5 rounded-full ${m.status === 'aktiv'
? 'bg-green-50 text-green-700'
: 'bg-gray-100 text-gray-500'
}`}
>
{m.status}
</span>
<span className="text-xs text-gray-400">
{format(m.createdAt, 'dd.MM.yy', { locale: de })}
</span>
<MemberActions member={m} orgId={org.id} />
</div>
</div>
))
)}
</div>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,236 @@
import { prisma } from '@innungsapp/shared'
import { format } from 'date-fns'
import { de } from 'date-fns/locale'
import Link from 'next/link'
import { toggleAiFeature } from './actions'
const PLAN_LABELS: Record<string, string> = {
pilot: 'Pilot',
standard: 'Standard',
pro: 'Pro',
verband: 'Verband',
}
const PLAN_COLORS: Record<string, string> = {
pilot: 'bg-gray-100 text-gray-700',
standard: 'bg-blue-100 text-blue-800',
pro: 'bg-purple-100 text-purple-800',
verband: 'bg-amber-100 text-amber-800',
}
const PAGE_SIZE = 20
export default async function SuperAdminPage({
searchParams,
}: {
searchParams: Promise<{ q?: string; page?: string }>
}) {
const { q = '', page = '1' } = await searchParams
const currentPage = Math.max(1, parseInt(page, 10))
const skip = (currentPage - 1) * PAGE_SIZE
const where = q
? {
OR: [
{ name: { contains: q } },
{ slug: { contains: q } },
{ contactEmail: { contains: q } },
],
}
: {}
const [organizations, total] = await Promise.all([
prisma.organization.findMany({
where,
orderBy: { createdAt: 'desc' },
skip,
take: PAGE_SIZE,
include: { _count: { select: { members: true, userRoles: true } } },
}),
prisma.organization.count({ where }),
])
const totalPages = Math.ceil(total / PAGE_SIZE)
return (
<div className="max-w-[1400px] mx-auto space-y-12 py-4">
<div className="flex justify-between items-center">
<div className="text-left space-y-2">
<h1 className="text-3xl font-black text-gray-900 tracking-tight font-outfit">
Innungs-Verwaltung <span className="text-[#E63946]">PRO</span>
</h1>
<p className="text-gray-400 font-medium">Hierüber werden alle Mandanten der Lösung verwaltet.</p>
</div>
<Link
href="/superadmin/create"
className="bg-[#E63946] text-white font-bold py-3 px-6 rounded-xl hover:bg-[#D62839] transition-all shadow-md shadow-red-100 flex items-center gap-2"
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2.5} stroke="currentColor" className="w-5 h-5">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
Neue Innung anlegen
</Link>
</div>
<div className="grid grid-cols-1 gap-12 items-start">
{/* List */}
<div className="space-y-6">
{/* Search & Filter */}
<div className="bg-white p-2 rounded-2xl border shadow-sm flex items-center">
<form method="GET" className="flex-1 flex gap-2">
<div className="relative flex-1 group">
<div className="absolute inset-y-0 left-3 flex items-center pointer-events-none text-gray-400 group-focus-within:text-[#E63946] transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="w-4 h-4">
<path strokeLinecap="round" strokeLinejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" />
</svg>
</div>
<input
type="search"
name="q"
defaultValue={q}
placeholder="Innung suchen..."
className="w-full pl-9 pr-4 py-3 bg-transparent text-sm outline-none placeholder:text-gray-300"
/>
</div>
<button
type="submit"
className="px-6 py-2.5 bg-gray-900 text-white rounded-xl text-sm font-bold hover:bg-black transition-all active:scale-[0.98]"
>
Suchen
</button>
{q && (
<Link
href="/superadmin"
className="p-2.5 bg-gray-50 text-gray-400 rounded-xl hover:bg-gray-100 transition-colors"
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="w-5 h-5">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18 18 6M6 6l12 12" />
</svg>
</Link>
)}
</form>
</div>
<div className="space-y-4">
<div className="flex items-center justify-between px-2">
<h2 className="text-sm font-bold text-gray-400 uppercase tracking-widest">
Registrierte Innungen ({total})
</h2>
</div>
<div className="flex flex-col gap-4">
{organizations.length === 0 ? (
<div className="bg-white p-12 text-center rounded-2xl border border-dashed border-gray-200">
<div className="text-gray-300 mb-2">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1} stroke="currentColor" className="w-12 h-12 mx-auto">
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 21v-7.5a.75.75 0 0 1 .75-.75h3a.75.75 0 0 1 .75.75V21m-1.5 0H21m-8.47-17.69-6 6a.75.75 0 0 0-.215.53V21m1.5 0H1.875a.375.375 0 0 1-.375-.375V11.25c0-4.46 3.07-8.189 7.5-9.088a9 9 0 0 1 1.585-.152Z" />
</svg>
</div>
<p className="text-gray-500 font-medium">
{q ? 'Keine Treffer für Ihre Suche.' : 'Bisher keine Innungen angelegt.'}
</p>
</div>
) : (
organizations.map((org) => (
<div key={org.id} className="group bg-white p-6 rounded-2xl border hover:border-[#E63946] hover:shadow-xl hover:shadow-red-500/5 transition-all duration-300 relative overflow-hidden">
<div className="flex justify-between items-start gap-6 relative z-10">
<Link href={`/superadmin/organizations/${org.id}`} className="flex-1 min-w-0">
<div className="flex items-center gap-3 mb-2">
<h3 className="font-bold text-lg text-gray-900 group-hover:text-[#E63946] transition-colors">{org.name}</h3>
<span className={`text-[10px] font-black uppercase tracking-tighter px-2 py-0.5 rounded-full border ${PLAN_COLORS[org.plan] ?? 'bg-gray-100 text-gray-700'}`}>
{org.plan}
</span>
</div>
<div className="flex items-center gap-4 text-xs text-gray-400 font-medium">
<div className="flex items-center gap-1.5 font-mono">
<span className="text-[#E63946]">@</span>
<span>{org.slug}</span>
</div>
<span className="w-1 h-1 rounded-full bg-gray-200" />
<span>{org.contactEmail || 'Keine Kontaktmail'}</span>
</div>
</Link>
<div className="flex items-center gap-2 lg:opacity-0 group-hover:opacity-100 transition-all duration-300 transform translate-x-2 group-hover:translate-x-0">
<form action={async () => {
'use server'
await toggleAiFeature(org.id, !org.aiEnabled)
}}>
<button
type="submit"
className={`p-2 rounded-xl border transition-all ${org.aiEnabled
? 'bg-green-50 text-green-600 border-green-100 hover:bg-red-50 hover:text-red-600'
: 'bg-gray-50 text-gray-400 border-gray-100 hover:bg-green-50 hover:text-green-600'}`}
title={org.aiEnabled ? 'KI Deaktivieren' : 'KI Aktivieren'}
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="w-5 h-5">
<path strokeLinecap="round" strokeLinejoin="round" d="M15.59 14.37a6 6 0 0 1-5.84 7.38v-4.8m5.84-2.58a14.98 14.98 0 0 0 6.16-12.12A14.98 14.98 0 0 0 9.59 8.31m5.84 6.06a6.01 6.01 0 0 1-5.84-1.29m0 0a6.01 6.01 0 0 1 0-8.5l.08.08a6.01 6.01 0 0 1 0 8.42Z" />
</svg>
</button>
</form>
<Link
href={`/superadmin/organizations/${org.id}`}
className="p-2 bg-gray-900 text-white rounded-xl hover:bg-black transition-all"
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="w-5 h-5">
<path strokeLinecap="round" strokeLinejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" />
</svg>
</Link>
</div>
</div>
<div className="mt-6 flex items-center gap-6">
<div className="flex flex-col">
<span className="text-[10px] text-gray-400 font-bold uppercase tracking-wider">Mitglieder</span>
<span className="font-bold text-gray-900">{org._count.members}</span>
</div>
<div className="w-px h-6 bg-gray-100" />
<div className="flex flex-col">
<span className="text-[10px] text-gray-400 font-bold uppercase tracking-wider">Admins</span>
<span className="font-bold text-gray-900">{org._count.userRoles}</span>
</div>
<div className="w-px h-6 bg-gray-100 ml-auto" />
<div className="flex flex-col items-end">
<span className="text-[10px] text-gray-400 font-bold uppercase tracking-wider">Erstellt am</span>
<span className="text-xs font-semibold text-gray-600">{format(org.createdAt, 'dd.MM.yyyy', { locale: de })}</span>
</div>
</div>
</div>
))
)}
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="pt-8 flex items-center justify-between border-t border-gray-100">
<span className="text-xs font-bold text-gray-400 uppercase tracking-widest">
Seite {currentPage} / {totalPages}
</span>
<div className="flex gap-2">
{currentPage > 1 && (
<Link
href={`/superadmin?${new URLSearchParams({ q, page: String(currentPage - 1) })}`}
className="px-4 py-2 bg-white border border-gray-200 rounded-xl text-xs font-bold text-gray-600 hover:bg-gray-50 transition-all active:scale-[0.98]"
>
Zurück
</Link>
)}
{currentPage < totalPages && (
<Link
href={`/superadmin?${new URLSearchParams({ q, page: String(currentPage + 1) })}`}
className="px-4 py-2 bg-white border border-gray-200 rounded-xl text-xs font-bold text-gray-600 hover:bg-gray-50 transition-all active:scale-[0.98]"
>
Weiter
</Link>
)}
</div>
</div>
)}
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,181 @@
'use client'
import { useState, useEffect } from 'react'
import { Sparkles, Copy, Check } from 'lucide-react'
import { trpc } from '@/lib/trpc-client'
interface AIGeneratorProps {
type: 'news' | 'stelle'
onApply?: (text: string) => void
}
const THINKING_STEPS = [
'KI denkt nach…',
'Thema wird analysiert…',
'Recherchiere Inhalte…',
'Struktur wird geplant…',
'Einleitung wird formuliert…',
'Hauptteil wird ausgearbeitet…',
'Formulierungen werden verfeinert…',
'Fachbegriffe werden geprüft…',
'Absätze werden aufgeteilt…',
'Zwischenüberschriften werden gesetzt…',
'Stil wird angepasst…',
'Rechtschreibung wird kontrolliert…',
'Markdown wird formatiert…',
'Überschrift wird optimiert…',
'Fazit wird formuliert…',
'Länge wird angepasst…',
'Ton wird auf Zielgruppe abgestimmt…',
'Aufzählungen werden erstellt…',
'Fettungen werden gesetzt…',
'Satzfluss wird geprüft…',
'Grammatik wird überprüft…',
'Keywords werden eingebaut…',
'Einleitung wird überarbeitet…',
'Abschnitte werden umstrukturiert…',
'Wiederholungen werden entfernt…',
'Zeichensetzung wird geprüft…',
'Leerzeilen werden optimiert…',
'Fachlich wird validiert…',
'Lesbarkeit wird verbessert…',
'Zusammenfassung wird erstellt…',
'Text wird poliert…',
'Letzte Korrekturen…',
'Fast fertig…',
]
export function AIGenerator({ type, onApply }: AIGeneratorProps) {
const { data: org } = trpc.organizations.me.useQuery()
const [prompt, setPrompt] = useState('')
const [format, setFormat] = useState('markdown')
const [loading, setLoading] = useState(false)
const [generatedText, setGeneratedText] = useState('')
const [copied, setCopied] = useState(false)
const [stepIndex, setStepIndex] = useState(0)
useEffect(() => {
if (!loading) { setStepIndex(0); return }
const interval = setInterval(() => {
setStepIndex((i) => (i + 1) % THINKING_STEPS.length)
}, 5000)
return () => clearInterval(interval)
}, [loading])
async function handleGenerate() {
if (!prompt.trim()) return
setLoading(true)
setGeneratedText('')
try {
const res = await fetch('/api/ai/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt, type, format }),
})
if (!res.ok) {
throw new Error('Fehler bei der Generierung')
}
const data = await res.json()
setGeneratedText(data.text)
} catch (err) {
alert((err as Error).message)
} finally {
setLoading(false)
}
}
function handleCopy() {
navigator.clipboard.writeText(generatedText)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
if (org && !org.aiEnabled) return null
return (
<div className="bg-white rounded-xl border border-brand-100 shadow-sm p-6 space-y-4 flex flex-col h-full bg-gradient-to-br from-white to-brand-50/20">
<div className="flex items-center gap-2 mb-2">
<Sparkles className="w-5 h-5 text-brand-500" />
<h2 className="text-lg font-bold text-gray-900">KI-Assistent</h2>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{type === 'news' ? 'Worum geht es in dem News-Beitrag?' : 'Beschreiben Sie die Stelle für die Lehrlingsbörse'}
</label>
<textarea
rows={3}
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
placeholder={type === 'news' ? "Schreibe einen Artikel über..." : "Eine kurze Zusammenfassung der Aufgaben..."}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500"
/>
</div>
<div className="flex items-center justify-between">
<select
value={format}
onChange={(e) => setFormat(e.target.value)}
className="px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500 bg-white"
>
<option value="markdown">Markdown Format</option>
<option value="text">Einfacher Text</option>
</select>
<button
type="button"
onClick={handleGenerate}
disabled={loading || !prompt.trim()}
className="flex items-center gap-2 bg-brand-500 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-brand-600 disabled:opacity-60 transition-colors"
>
{loading ? 'Generiere...' : 'Generieren'}
<Sparkles className="w-4 h-4" />
</button>
</div>
{loading && (
<div className="flex items-center gap-3 px-4 py-3 bg-brand-50 border border-brand-100 rounded-lg">
<div className="flex items-center gap-1">
<span className="w-2 h-2 rounded-full bg-brand-400 animate-bounce [animation-delay:0ms]" />
<span className="w-2 h-2 rounded-full bg-brand-400 animate-bounce [animation-delay:150ms]" />
<span className="w-2 h-2 rounded-full bg-brand-400 animate-bounce [animation-delay:300ms]" />
</div>
<span className="text-sm text-brand-700 font-medium transition-all">{THINKING_STEPS[stepIndex]}</span>
</div>
)}
{generatedText && (
<div className="mt-4 flex-1 flex flex-col min-h-[300px] space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-gray-700">Ergebnis:</span>
<div className="flex gap-4">
<button
type="button"
onClick={handleCopy}
className="flex items-center gap-1 text-xs text-brand-600 hover:text-brand-700 font-medium transition-colors"
>
{copied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
{copied ? 'Kopiert!' : 'Kopieren'}
</button>
{onApply && (
<button
type="button"
onClick={() => onApply(generatedText)}
className="flex items-center gap-1 text-xs text-brand-600 hover:text-brand-700 font-medium transition-colors"
>
<Check className="w-4 h-4" />
Übernehmen
</button>
)}
</div>
</div>
<textarea
readOnly
value={generatedText}
className="w-full flex-1 p-3 border border-gray-200 rounded-lg text-sm bg-gray-50 focus:outline-none focus:ring-2 focus:ring-brand-500/50"
/>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,136 @@
'use client'
import { useEffect, useState } from 'react'
import { createAuthClient } from 'better-auth/react'
const authClient = createAuthClient({
// Keep auth requests on the current origin (important for tenant subdomains).
baseURL: typeof window !== 'undefined'
? window.location.origin
: (process.env.NEXT_PUBLIC_APP_URL ?? 'http://localhost:3010'),
})
interface LoginFormProps {
primaryColor?: string
}
export function LoginForm({ primaryColor = '#C99738' }: LoginFormProps) {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [successMessage, setSuccessMessage] = useState('')
useEffect(() => {
const params = new URLSearchParams(window.location.search)
const emailParam = params.get('email')
if (emailParam) setEmail(emailParam)
const messageParam = params.get('message')
if (messageParam === 'password_changed') {
setSuccessMessage('Passwort erfolgreich geändert. Bitte melden Sie sich mit Ihrem neuen Passwort an.')
}
}, [])
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setLoading(true)
setError('')
const result = await authClient.signIn.email({
email,
password,
callbackURL: '/dashboard',
})
setLoading(false)
if (result.error) {
setError(result.error.message ?? 'E-Mail oder Passwort falsch.')
return
}
// Use callbackUrl if present, otherwise go to dashboard
// mustChangePassword is handled by the dashboard ForcePasswordChange component
const params = new URLSearchParams(window.location.search)
const callbackUrl = params.get('callbackUrl')
let target = '/dashboard'
if (callbackUrl?.startsWith('/')) {
target = callbackUrl
// Normalize stale tenant-prefixed callback URLs like /test/dashboard
// when already on the tenant subdomain test.localhost.
const hostname = window.location.hostname
const parts = hostname.split('.')
const isTenantSubdomain =
parts.length > 2 || (parts.length === 2 && parts[1] === 'localhost')
const tenantSlug = isTenantSubdomain ? parts[0] : null
if (tenantSlug && target.startsWith(`/${tenantSlug}/`)) {
target = target.slice(tenantSlug.length + 1) || '/dashboard'
}
}
window.location.href = target
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
{successMessage && (
<p className="text-sm text-green-700 bg-green-50 border border-green-200 px-3 py-2 rounded-lg">
{successMessage}
</p>
)}
<div>
<label
htmlFor="email"
className="block text-xs font-medium text-gray-600 mb-1 uppercase tracking-wide"
>
E-Mail-Adresse
</label>
<input
id="email"
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="admin@ihre-innung.de"
className="w-full px-3 py-2.5 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:border-transparent"
style={{ '--tw-ring-color': primaryColor } as any}
/>
</div>
<div>
<label
htmlFor="password"
className="block text-xs font-medium text-gray-600 mb-1 uppercase tracking-wide"
>
Passwort
</label>
<input
id="password"
type="password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="********"
className="w-full px-3 py-2.5 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:border-transparent"
style={{ '--tw-ring-color': primaryColor } as any}
/>
</div>
{error && (
<p className="text-sm text-red-600 bg-red-50 px-3 py-2 rounded-lg">{error}</p>
)}
<button
type="submit"
disabled={loading}
className="w-full text-white py-2.5 px-4 rounded-lg text-sm font-medium disabled:opacity-60 disabled:cursor-not-allowed transition-colors"
style={{ backgroundColor: primaryColor }}
>
{loading ? 'Bitte warten...' : 'Anmelden'}
</button>
</form>
)
}

View File

@@ -0,0 +1,55 @@
'use client'
import { createAuthClient } from 'better-auth/react'
import { useRouter, usePathname } from 'next/navigation'
import { LogOut } from 'lucide-react'
const authClient = createAuthClient({
// Keep auth requests on the current origin (important for tenant subdomains).
baseURL: typeof window !== 'undefined'
? window.location.origin
: (process.env.NEXT_PUBLIC_APP_URL ?? 'http://localhost:3010'),
})
const PAGE_TITLES: Record<string, string> = {
'/dashboard': 'Übersicht',
'/dashboard/mitglieder': 'Mitglieder',
'/dashboard/news': 'News',
'/dashboard/termine': 'Termine',
'/dashboard/stellen': 'Lehrlingsbörse',
'/dashboard/einstellungen': 'Einstellungen',
}
export function Header() {
const router = useRouter()
const pathname = usePathname()
const title = Object.entries(PAGE_TITLES)
.sort((a, b) => b[0].length - a[0].length)
.find(([path]) => pathname === path || pathname.startsWith(path + '/'))?.[1] ?? 'Dashboard'
async function handleSignOut() {
await authClient.signOut()
router.push('/login')
}
return (
<header className="h-14 bg-white border-b flex items-center justify-between px-6 flex-shrink-0">
<h2
className="text-sm font-semibold text-gray-700 tracking-tight"
style={{ fontFamily: "'Syne', system-ui, sans-serif" }}
>
{title}
</h2>
<div className="flex items-center gap-3">
<button
onClick={handleSignOut}
className="flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-900 transition-colors"
>
<LogOut size={14} />
Abmelden
</button>
</div>
</header>
)
}

Some files were not shown because too many files have changed in this diff Show More