From c53a71a5f954cc3ac56cc2c4f081750332ced026 Mon Sep 17 00:00:00 2001 From: Timo Knuth Date: Thu, 19 Feb 2026 14:21:51 +0100 Subject: [PATCH] feat: Implement mobile application and lead processing utilities. --- innungsapp/CLAUDE.md | 116 + innungsapp/README.md | 137 + .../apps/admin/app/api/push-token/route.ts | 23 + .../app/dashboard/mitglieder/[id]/page.tsx | 155 + innungsapp/apps/admin/package.json | 3 + .../react-native-css-interop/android.js | 0 .../.cache/react-native-css-interop/ios.js | 0 .../.cache/react-native-css-interop/macos.js | 0 .../.cache/react-native-css-interop/native.js | 0 .../react-native-css-interop/windows.js | 0 innungsapp/apps/mobile/.gitignore | 6 + innungsapp/apps/mobile/app.json | 10 +- innungsapp/apps/mobile/app/(app)/_layout.tsx | 79 +- .../apps/mobile/app/(app)/home/index.tsx | 464 + .../apps/mobile/app/(app)/members/[id].tsx | 257 +- .../apps/mobile/app/(app)/members/index.tsx | 152 +- .../apps/mobile/app/(app)/news/[id].tsx | 396 +- .../apps/mobile/app/(app)/news/index.tsx | 173 +- .../apps/mobile/app/(app)/profil/index.tsx | 389 +- .../apps/mobile/app/(app)/stellen/[id].tsx | 383 +- .../apps/mobile/app/(app)/stellen/index.tsx | 126 +- .../apps/mobile/app/(app)/termine/[id].tsx | 481 +- .../apps/mobile/app/(app)/termine/index.tsx | 142 +- .../apps/mobile/app/(auth)/check-email.tsx | 118 +- innungsapp/apps/mobile/app/(auth)/login.tsx | 193 +- innungsapp/apps/mobile/app/_layout.tsx | 25 +- innungsapp/apps/mobile/app/index.tsx | 5 +- .../apps/mobile/assets/adaptive-icon.png | Bin 0 -> 68 bytes innungsapp/apps/mobile/assets/favicon.png | Bin 0 -> 68 bytes innungsapp/apps/mobile/assets/icon.png | Bin 0 -> 68 bytes .../apps/mobile/assets/notification-icon.png | Bin 0 -> 68 bytes innungsapp/apps/mobile/assets/splash.png | Bin 0 -> 68 bytes .../mobile/components/members/MemberCard.tsx | 115 + .../mobile/components/news/AttachmentRow.tsx | 105 + .../apps/mobile/components/news/NewsCard.tsx | 133 + .../mobile/components/stellen/StelleCard.tsx | 162 + .../components/termine/AnmeldeButton.tsx | 67 + .../mobile/components/termine/TerminCard.tsx | 253 + .../apps/mobile/components/ui/Avatar.tsx | 93 + .../apps/mobile/components/ui/Badge.tsx | 46 + .../apps/mobile/components/ui/Button.tsx | 66 +- innungsapp/apps/mobile/components/ui/Card.tsx | 24 +- .../apps/mobile/components/ui/EmptyState.tsx | 59 + .../mobile/components/ui/LoadingSpinner.tsx | 9 + innungsapp/apps/mobile/eas.json | 36 + innungsapp/apps/mobile/eslint.config.js | 10 + innungsapp/apps/mobile/global.css | 64 + innungsapp/apps/mobile/hooks/useAuth.ts | 10 +- innungsapp/apps/mobile/hooks/useMembers.ts | 32 +- innungsapp/apps/mobile/hooks/useNews.ts | 24 +- innungsapp/apps/mobile/hooks/useStellen.ts | 13 +- innungsapp/apps/mobile/hooks/useTermine.ts | 34 +- innungsapp/apps/mobile/lib/mock-data.ts | 311 + innungsapp/apps/mobile/lib/notifications.ts | 2 + innungsapp/apps/mobile/lib/theme.config.ts | 43 + .../apps/mobile/lib/{trpc.ts => trpc.tsx} | 10 +- innungsapp/apps/mobile/lib/utils.ts | 6 + innungsapp/apps/mobile/metro.config.js | 43 +- .../metro/react-native-css-interop-metro.js | 289 + innungsapp/apps/mobile/nativewind-env.d.ts | 3 + innungsapp/apps/mobile/package.json | 62 +- innungsapp/apps/mobile/store/auth.store.ts | 52 +- innungsapp/apps/mobile/tailwind.config.js | 65 +- innungsapp/apps/mobile/tsc_output.txt | Bin 0 -> 10152 bytes innungsapp/apps/mobile/tsc_output_2.txt | 10 + innungsapp/apps/mobile/tsc_output_utf8.txt | 32 + innungsapp/apps/mobile/tsconfig.json | 13 +- innungsapp/package.json | 3 +- innungsapp/pnpm-lock.yaml | 12497 ++++++++++++++++ leads/analysis/red_flags.md | 54 + .../batch6_results_part1.json | 62 + .../batch6_results_part2.json | 62 + .../batch6_targets.json | 152 + .../cologne_leads.csv | 10 + .../duesseldorf_batch1.csv | 6 + .../duesseldorf_batch2.csv | 6 + .../duesseldorf_batch3_4.csv | 11 + .../duesseldorf_batch5.csv | 13 + .../duesseldorf_innungen.pdf | Bin 0 -> 163476 bytes .../duesseldorf_leads.csv | 1 + .../duesseldorf_raw.txt | 238 + .../duesseldorf_targets.json | 1002 ++ leads/identify_missing_leads.py | 105 + leads/leads.csv | 120 + leads/missing_leads.md | 227 + leads/raw/final_leads.csv | 143 + .../raw/innungen_leads_koeln_duesseldorf.csv | 34 + leads/raw/leads.csv | 143 + leads/raw/leads_unterfranken.csv | 146 + leads/raw/leads_unterfranken_v2.csv | 130 + leads/raw/unterfranken.pdf | Bin 0 -> 359874 bytes recover_cologne_leads.py | 67 + scripts/analyze_leads_quality.py | 93 + scripts/apply_verification_fixes.py | 76 + scripts/debug_pdf.py | 36 + scripts/deduplicate_leads.py | 22 + scripts/download_cologne.py | 15 + scripts/dump_duesseldorf_text.py | 15 + scripts/extract_duesseldorf.py | 62 + scripts/extract_emails_direct.py | 35 + scripts/extract_leads.py | 85 + scripts/extract_leads_unterfranken_v2.py | 180 + scripts/extract_pdf_links.py | 28 + scripts/filter_duesseldorf.py | 55 + scripts/finalize_leads.py | 78 + scripts/find_emails_in_dump.py | 20 + scripts/generate_leads.py | 280 + scripts/merge_leads.py | 39 + scripts/organize_project.py | 85 + scripts/parse_cologne_serp.py | 55 + scripts/parse_duesseldorf_batch1.py | 74 + scripts/parse_duesseldorf_batch2.py | 73 + scripts/parse_duesseldorf_batch5.py | 82 + scripts/parse_duesseldorf_batches_3_4.py | 77 + scripts/parse_duesseldorf_targets.py | 42 + scripts/prepare_batch6.py | 16 + scripts/prepare_batch6_v2.py | 40 + scripts/preview_duesseldorf_pdf.py | 22 + unique_guilds.txt | 77 + unterfranken_dump.txt | 1808 +++ 120 files changed, 24080 insertions(+), 851 deletions(-) create mode 100644 innungsapp/CLAUDE.md create mode 100644 innungsapp/README.md create mode 100644 innungsapp/apps/admin/app/api/push-token/route.ts create mode 100644 innungsapp/apps/admin/app/dashboard/mitglieder/[id]/page.tsx create mode 100644 innungsapp/apps/mobile/.cache/react-native-css-interop/android.js create mode 100644 innungsapp/apps/mobile/.cache/react-native-css-interop/ios.js create mode 100644 innungsapp/apps/mobile/.cache/react-native-css-interop/macos.js create mode 100644 innungsapp/apps/mobile/.cache/react-native-css-interop/native.js create mode 100644 innungsapp/apps/mobile/.cache/react-native-css-interop/windows.js create mode 100644 innungsapp/apps/mobile/.gitignore create mode 100644 innungsapp/apps/mobile/app/(app)/home/index.tsx create mode 100644 innungsapp/apps/mobile/assets/adaptive-icon.png create mode 100644 innungsapp/apps/mobile/assets/favicon.png create mode 100644 innungsapp/apps/mobile/assets/icon.png create mode 100644 innungsapp/apps/mobile/assets/notification-icon.png create mode 100644 innungsapp/apps/mobile/assets/splash.png create mode 100644 innungsapp/apps/mobile/components/members/MemberCard.tsx create mode 100644 innungsapp/apps/mobile/components/news/AttachmentRow.tsx create mode 100644 innungsapp/apps/mobile/components/news/NewsCard.tsx create mode 100644 innungsapp/apps/mobile/components/stellen/StelleCard.tsx create mode 100644 innungsapp/apps/mobile/components/termine/AnmeldeButton.tsx create mode 100644 innungsapp/apps/mobile/components/termine/TerminCard.tsx create mode 100644 innungsapp/apps/mobile/components/ui/Avatar.tsx create mode 100644 innungsapp/apps/mobile/components/ui/Badge.tsx create mode 100644 innungsapp/apps/mobile/components/ui/EmptyState.tsx create mode 100644 innungsapp/apps/mobile/components/ui/LoadingSpinner.tsx create mode 100644 innungsapp/apps/mobile/eas.json create mode 100644 innungsapp/apps/mobile/eslint.config.js create mode 100644 innungsapp/apps/mobile/lib/mock-data.ts create mode 100644 innungsapp/apps/mobile/lib/theme.config.ts rename innungsapp/apps/mobile/lib/{trpc.ts => trpc.tsx} (85%) create mode 100644 innungsapp/apps/mobile/lib/utils.ts create mode 100644 innungsapp/apps/mobile/metro/react-native-css-interop-metro.js create mode 100644 innungsapp/apps/mobile/nativewind-env.d.ts create mode 100644 innungsapp/apps/mobile/tsc_output.txt create mode 100644 innungsapp/apps/mobile/tsc_output_2.txt create mode 100644 innungsapp/apps/mobile/tsc_output_utf8.txt create mode 100644 innungsapp/pnpm-lock.yaml create mode 100644 leads/analysis/red_flags.md create mode 100644 leads/cologne_duesseldorf_data/batch6_results_part1.json create mode 100644 leads/cologne_duesseldorf_data/batch6_results_part2.json create mode 100644 leads/cologne_duesseldorf_data/batch6_targets.json create mode 100644 leads/cologne_duesseldorf_data/cologne_leads.csv create mode 100644 leads/cologne_duesseldorf_data/duesseldorf_batch1.csv create mode 100644 leads/cologne_duesseldorf_data/duesseldorf_batch2.csv create mode 100644 leads/cologne_duesseldorf_data/duesseldorf_batch3_4.csv create mode 100644 leads/cologne_duesseldorf_data/duesseldorf_batch5.csv create mode 100644 leads/cologne_duesseldorf_data/duesseldorf_innungen.pdf create mode 100644 leads/cologne_duesseldorf_data/duesseldorf_leads.csv create mode 100644 leads/cologne_duesseldorf_data/duesseldorf_raw.txt create mode 100644 leads/cologne_duesseldorf_data/duesseldorf_targets.json create mode 100644 leads/identify_missing_leads.py create mode 100644 leads/leads.csv create mode 100644 leads/missing_leads.md create mode 100644 leads/raw/final_leads.csv create mode 100644 leads/raw/innungen_leads_koeln_duesseldorf.csv create mode 100644 leads/raw/leads.csv create mode 100644 leads/raw/leads_unterfranken.csv create mode 100644 leads/raw/leads_unterfranken_v2.csv create mode 100644 leads/raw/unterfranken.pdf create mode 100644 recover_cologne_leads.py create mode 100644 scripts/analyze_leads_quality.py create mode 100644 scripts/apply_verification_fixes.py create mode 100644 scripts/debug_pdf.py create mode 100644 scripts/deduplicate_leads.py create mode 100644 scripts/download_cologne.py create mode 100644 scripts/dump_duesseldorf_text.py create mode 100644 scripts/extract_duesseldorf.py create mode 100644 scripts/extract_emails_direct.py create mode 100644 scripts/extract_leads.py create mode 100644 scripts/extract_leads_unterfranken_v2.py create mode 100644 scripts/extract_pdf_links.py create mode 100644 scripts/filter_duesseldorf.py create mode 100644 scripts/finalize_leads.py create mode 100644 scripts/find_emails_in_dump.py create mode 100644 scripts/generate_leads.py create mode 100644 scripts/merge_leads.py create mode 100644 scripts/organize_project.py create mode 100644 scripts/parse_cologne_serp.py create mode 100644 scripts/parse_duesseldorf_batch1.py create mode 100644 scripts/parse_duesseldorf_batch2.py create mode 100644 scripts/parse_duesseldorf_batch5.py create mode 100644 scripts/parse_duesseldorf_batches_3_4.py create mode 100644 scripts/parse_duesseldorf_targets.py create mode 100644 scripts/prepare_batch6.py create mode 100644 scripts/prepare_batch6_v2.py create mode 100644 scripts/preview_duesseldorf_pdf.py create mode 100644 unique_guilds.txt create mode 100644 unterfranken_dump.txt diff --git a/innungsapp/CLAUDE.md b/innungsapp/CLAUDE.md new file mode 100644 index 0000000..75ba4a0 --- /dev/null +++ b/innungsapp/CLAUDE.md @@ -0,0 +1,116 @@ +# 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 + +## 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 diff --git a/innungsapp/README.md b/innungsapp/README.md new file mode 100644 index 0000000..c3c097d --- /dev/null +++ b/innungsapp/README.md @@ -0,0 +1,137 @@ +# InnungsApp + +Die digitale Plattform für Innungen — News, Mitgliederverzeichnis, Termine und Lehrlingsbörse. + +## Stack + +| Schicht | Technologie | +|---|---| +| **Monorepo** | pnpm Workspaces + Turborepo | +| **Mobile App** | Expo (React Native) + Expo Router | +| **Admin Dashboard** | Next.js 15 (App Router) | +| **API** | tRPC v11 | +| **Auth** | better-auth (Magic Links) | +| **Datenbank** | PostgreSQL + Prisma ORM | +| **Styling Mobile** | NativeWind v4 (Tailwind CSS) | +| **Styling Admin** | Tailwind CSS | +| **State Management** | Zustand (Mobile) + React Query (beide Apps) | + +## Projekt-Struktur + +``` +innungsapp/ +├── apps/ +│ ├── mobile/ # Expo React Native App (iOS + Android) +│ └── admin/ # Next.js Admin Dashboard +├── packages/ +│ └── shared/ # TypeScript-Typen + Prisma Client +└── ... +``` + +## Setup + +### Voraussetzungen + +- Node.js >= 20 +- pnpm >= 9 +- PostgreSQL-Datenbank +- SMTP-Server (für Magic Links) + +### 1. Abhängigkeiten installieren + +```bash +pnpm install +``` + +### 2. Umgebungsvariablen + +```bash +cp .env.example apps/admin/.env.local +# .env.local befüllen (DATABASE_URL, BETTER_AUTH_SECRET, SMTP_*) +``` + +### 3. Datenbank einrichten + +```bash +# Prisma Client generieren +pnpm db:generate + +# Migrationen anwenden +pnpm db:migrate + +# Demo-Daten einspielen (optional) +pnpm db:seed +``` + +### 4. Entwicklung starten + +```bash +# Admin Dashboard (http://localhost:3000) +pnpm --filter @innungsapp/admin dev + +# Mobile App (Expo DevTools) +pnpm --filter @innungsapp/mobile dev +``` + +Oder alles parallel: +```bash +pnpm dev +``` + +## Datenbank-Schema + +Das Schema befindet sich in `packages/shared/prisma/schema.prisma`. + +Wichtige Tabellen: +- `organizations` — Innungen (Multi-Tenancy) +- `members` — Mitglieder (verknüpft mit Auth-User nach Einladung) +- `user_roles` — Berechtigungen (admin | member) +- `news`, `news_reads`, `news_attachments` — News-System +- `termine`, `termin_anmeldungen` — Terminverwaltung +- `stellen` — Lehrlingsbörse (öffentlich lesbar) + +## Auth-Flow + +1. **Admin einrichten:** Seed-Daten oder manuell in der DB +2. **Mitglied einladen:** Admin erstellt Mitglied → "Einladung senden" → Magic Link per E-Mail +3. **Mitglied loggt ein:** Magic Link → Session → App-Zugang + +## API (tRPC) + +Alle API-Endpunkte sind typsicher über tRPC definiert: + +- `organizations.*` — Org-Einstellungen, Stats, AVV +- `members.*` — CRUD, Einladungen +- `news.*` — CRUD, Lesestatus, Push-Benachrichtigungen +- `termine.*` — CRUD, Anmeldungen +- `stellen.*` — Public + Auth-geschützte Endpunkte + +## Deployment + +### Admin (Vercel) + +```bash +# Umgebungsvariablen in Vercel setzen: +# DATABASE_URL, BETTER_AUTH_SECRET, BETTER_AUTH_URL, SMTP_* + +# Deploy +vercel --cwd apps/admin +``` + +### Mobile (EAS Build) + +```bash +cd apps/mobile +eas build --platform all --profile production +eas submit --platform all +``` + +## DSGVO / AVV + +- AVV-Akzeptanz in Admin → Einstellungen (Pflichtfeld vor Go-Live) +- Alle personenbezogenen Daten in EU-Region (Datenbankserver in Deutschland empfohlen) +- Keine Daten an Dritte außer Expo Push API (anonymisierte Token) + +## Roadmap + +Siehe `innung-app-mvp.md` für die vollständige Roadmap. diff --git a/innungsapp/apps/admin/app/api/push-token/route.ts b/innungsapp/apps/admin/app/api/push-token/route.ts new file mode 100644 index 0000000..53cbc7e --- /dev/null +++ b/innungsapp/apps/admin/app/api/push-token/route.ts @@ -0,0 +1,23 @@ +import { NextRequest, NextResponse } from 'next/server' +import { auth } from '@/lib/auth' +import { prisma } from '@innungsapp/shared' + +export async function POST(req: NextRequest) { + const session = await auth.api.getSession({ headers: 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 }) +} diff --git a/innungsapp/apps/admin/app/dashboard/mitglieder/[id]/page.tsx b/innungsapp/apps/admin/app/dashboard/mitglieder/[id]/page.tsx new file mode 100644 index 0000000..16a7f9d --- /dev/null +++ b/innungsapp/apps/admin/app/dashboard/mitglieder/[id]/page.tsx @@ -0,0 +1,155 @@ +'use client' + +import { use } from 'react' +import { useRouter } from 'next/navigation' +import { trpc } from '@/lib/trpc-client' +import Link from 'next/link' +import { useState, useEffect } from 'react' +import { SPARTEN, MEMBER_STATUS_LABELS } from '@innungsapp/shared' + +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 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, + }) + + 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, + istAusbildungsbetrieb: member.istAusbildungsbetrieb, + seit: member.seit ?? undefined, + }) + } + }, [member]) + + if (isLoading) return
Wird geladen...
+ 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-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500' + + return ( +
+
+ + ← Zurück + +

Mitglied bearbeiten

+
+ + {/* Invite Status */} +
+
+

App-Zugang

+

+ {member.userId + ? '✓ Mitglied hat sich eingeloggt' + : 'Noch nicht eingeladen / eingeloggt'} +

+
+ {!member.userId && ( + + )} +
+ +
+
+
+ + setForm({ ...form, name: e.target.value })} className={inputClass} /> +
+
+ + setForm({ ...form, betrieb: e.target.value })} className={inputClass} /> +
+
+ + +
+
+ + setForm({ ...form, ort: e.target.value })} className={inputClass} /> +
+
+ + setForm({ ...form, email: e.target.value })} className={inputClass} /> +
+
+ + setForm({ ...form, telefon: e.target.value })} className={inputClass} /> +
+
+ + +
+
+ + setForm({ ...form, seit: e.target.value ? Number(e.target.value) : undefined })} className={inputClass} /> +
+
+ +
+
+ + {updateMutation.error && ( +

{updateMutation.error.message}

+ )} + +
+ + + Abbrechen + +
+
+
+ ) +} diff --git a/innungsapp/apps/admin/package.json b/innungsapp/apps/admin/package.json index 0f2c212..fd45088 100644 --- a/innungsapp/apps/admin/package.json +++ b/innungsapp/apps/admin/package.json @@ -2,6 +2,9 @@ "name": "@innungsapp/admin", "version": "0.1.0", "private": true, + "exports": { + ".": "./server/routers/index.ts" + }, "scripts": { "dev": "next dev", "build": "next build", diff --git a/innungsapp/apps/mobile/.cache/react-native-css-interop/android.js b/innungsapp/apps/mobile/.cache/react-native-css-interop/android.js new file mode 100644 index 0000000..e69de29 diff --git a/innungsapp/apps/mobile/.cache/react-native-css-interop/ios.js b/innungsapp/apps/mobile/.cache/react-native-css-interop/ios.js new file mode 100644 index 0000000..e69de29 diff --git a/innungsapp/apps/mobile/.cache/react-native-css-interop/macos.js b/innungsapp/apps/mobile/.cache/react-native-css-interop/macos.js new file mode 100644 index 0000000..e69de29 diff --git a/innungsapp/apps/mobile/.cache/react-native-css-interop/native.js b/innungsapp/apps/mobile/.cache/react-native-css-interop/native.js new file mode 100644 index 0000000..e69de29 diff --git a/innungsapp/apps/mobile/.cache/react-native-css-interop/windows.js b/innungsapp/apps/mobile/.cache/react-native-css-interop/windows.js new file mode 100644 index 0000000..e69de29 diff --git a/innungsapp/apps/mobile/.gitignore b/innungsapp/apps/mobile/.gitignore new file mode 100644 index 0000000..5873d9a --- /dev/null +++ b/innungsapp/apps/mobile/.gitignore @@ -0,0 +1,6 @@ + +# @generated expo-cli sync-2b81b286409207a5da26e14c78851eb30d8ccbdb +# The following patterns were generated by expo-cli + +expo-env.d.ts +# @end expo-cli \ No newline at end of file diff --git a/innungsapp/apps/mobile/app.json b/innungsapp/apps/mobile/app.json index 7a7d792..52d7c97 100644 --- a/innungsapp/apps/mobile/app.json +++ b/innungsapp/apps/mobile/app.json @@ -26,7 +26,10 @@ "backgroundColor": "#E63946" }, "package": "de.innungsapp.mobile", - "permissions": ["RECEIVE_BOOT_COMPLETED", "SCHEDULE_EXACT_ALARM"] + "permissions": [ + "RECEIVE_BOOT_COMPLETED", + "SCHEDULE_EXACT_ALARM" + ] }, "web": { "bundler": "metro", @@ -50,10 +53,11 @@ { "calendarPermission": "Die App benötigt Zugriff auf Ihren Kalender." } - ] + ], + "expo-web-browser" ], "experiments": { "typedRoutes": true } } -} +} diff --git a/innungsapp/apps/mobile/app/(app)/_layout.tsx b/innungsapp/apps/mobile/app/(app)/_layout.tsx index 0ee6f30..b9c16da 100644 --- a/innungsapp/apps/mobile/app/(app)/_layout.tsx +++ b/innungsapp/apps/mobile/app/(app)/_layout.tsx @@ -1,11 +1,7 @@ -import { Tabs } from 'expo-router' -import { useAuthStore } from '@/store/auth.store' -import { Redirect } from 'expo-router' +import { Tabs, Redirect } from 'expo-router' import { Platform } from 'react-native' - -function TabIcon({ emoji }: { emoji: string }) { - return null // Replaced by tabBarIcon in options -} +import { Ionicons } from '@expo/vector-icons' +import { useAuthStore } from '@/store/auth.store' export default function AppLayout() { const session = useAuthStore((s) => s.session) @@ -17,62 +13,75 @@ export default function AppLayout() { return ( ( - /* Replace with actual icons after @expo/vector-icons setup */ - + title: 'Start', + tabBarIcon: ({ color, focused }) => ( + ), - headerShown: false, }} /> , - headerShown: false, + title: 'Aktuelles', + tabBarIcon: ({ color, focused }) => ( + + ), }} /> , - headerShown: false, + tabBarIcon: ({ color, focused }) => ( + + ), }} /> , - headerShown: false, + tabBarIcon: ({ color, focused }) => ( + + ), }} /> , - headerShown: false, + tabBarIcon: ({ color, focused }) => ( + + ), }} /> + + + + + + ) } diff --git a/innungsapp/apps/mobile/app/(app)/home/index.tsx b/innungsapp/apps/mobile/app/(app)/home/index.tsx new file mode 100644 index 0000000..07bd375 --- /dev/null +++ b/innungsapp/apps/mobile/app/(app)/home/index.tsx @@ -0,0 +1,464 @@ +import { View, Text, ScrollView, TouchableOpacity, TextInput, StyleSheet, Platform, Image } from 'react-native' +import { SafeAreaView } from 'react-native-safe-area-context' +import { Ionicons } from '@expo/vector-icons' +import { useRouter } from 'expo-router' +import { format } from 'date-fns' +import { de } from 'date-fns/locale' +import { NEWS_KATEGORIE_LABELS } from '@innungsapp/shared/types' +import { useNewsList } from '@/hooks/useNews' +import { useTermineListe } from '@/hooks/useTermine' +import { useNewsReadStore } from '@/store/news.store' + +// Helper to truncate text +function getNewsExcerpt(value: string) { + const normalized = value + .replace(/[#*_`>-]/g, ' ') + .replace(/\s+/g, ' ') + .trim() + return normalized.length > 85 ? `${normalized.slice(0, 85)}...` : normalized +} + +export default function HomeScreen() { + const router = useRouter() + const { data: newsItems = [] } = useNewsList() + const { data: termine = [] } = useTermineListe(true) + const readIds = useNewsReadStore((s) => s.readIds) + + const latestNews = newsItems.slice(0, 2) + const upcomingEvents = termine.slice(0, 3) + const unreadCount = newsItems.filter((item) => !(item.isRead || readIds.has(item.id))).length + + const QUICK_ACTIONS = [ + { label: 'Mitglieder', icon: 'people', color: '#003B7E', bg: '#E0F2FE', route: '/(app)/members' }, + { label: 'Termine', icon: 'calendar', color: '#B45309', bg: '#FEF3C7', route: '/(app)/termine' }, + { label: 'Stellen', icon: 'briefcase', color: '#059669', bg: '#D1FAE5', route: '/(app)/stellen' }, + { label: 'Profil', icon: 'person', color: '#4F46E5', bg: '#E0E7FF', route: '/(app)/profil' }, + ] + + return ( + + {/* Decorative Background Element */} + + + + + {/* Header Section */} + + + + I + + + Willkommen zurück, + Demo Admin + + + + + + {unreadCount > 0 && ( + + {unreadCount} + + )} + + + + {/* Search Bar */} + + + + + + {/* Quick Actions Grid */} + + Schnellzugriff + + {QUICK_ACTIONS.map((action, i) => ( + router.push(action.route as never)} + > + + + + {action.label} + + ))} + + + + {/* News Section */} + + + Aktuelles + router.push('/(app)/news' as never)}> + Alle anzeigen + + + + + {latestNews.map((item) => ( + router.push(`/(app)/news/${item.id}` as never)} + > + + + + {NEWS_KATEGORIE_LABELS[item.kategorie]} + + + + {item.publishedAt ? format(item.publishedAt, 'dd. MMM', { locale: de }) : 'Entwurf'} + + + + {item.title} + {getNewsExcerpt(item.body)} + + ))} + + + + {/* Upcoming Events */} + + + Anstehende Termine + router.push('/(app)/termine' as never)}> + Kalender + + + + + {upcomingEvents.map((event, index) => ( + router.push(`/(app)/termine/${event.id}` as never)} + activeOpacity={0.7} + > + + {format(event.datum, 'MMM', { locale: de })} + {format(event.datum, 'dd')} + + + + {event.titel} + + {event.uhrzeit} • {event.ort} + + + + + + ))} + + + + + + + ) +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#F8FAFC', // Slate-50 + }, + bgDecoration: { + position: 'absolute', + top: -100, + left: 0, + right: 0, + height: 400, + backgroundColor: '#003B7E', // Primary brand color + opacity: 0.05, + transform: [{ scaleX: 1.5 }, { scaleY: 1 }], + borderBottomLeftRadius: 200, + borderBottomRightRadius: 200, + }, + safeArea: { + flex: 1, + }, + scrollContent: { + padding: 20, + paddingBottom: 40, + gap: 24, + }, + + // Header + header: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginTop: 4, + }, + headerLeft: { + flexDirection: 'row', + alignItems: 'center', + gap: 12, + }, + avatar: { + width: 44, + height: 44, + borderRadius: 14, + backgroundColor: '#003B7E', + alignItems: 'center', + justifyContent: 'center', + shadowColor: '#003B7E', + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.2, + shadowRadius: 8, + elevation: 4, + }, + avatarText: { + color: '#FFFFFF', + fontSize: 20, + fontWeight: '700', + }, + greeting: { + fontSize: 13, + color: '#64748B', + fontWeight: '500', + }, + username: { + fontSize: 18, + fontWeight: '700', + color: '#0F172A', + }, + notificationBtn: { + width: 40, + height: 40, + borderRadius: 20, + backgroundColor: '#FFFFFF', + alignItems: 'center', + justifyContent: 'center', + borderWidth: 1, + borderColor: '#E2E8F0', + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.05, + shadowRadius: 4, + elevation: 1, + }, + badge: { + position: 'absolute', + top: -2, + right: -2, + backgroundColor: '#EF4444', + width: 16, + height: 16, + borderRadius: 8, + alignItems: 'center', + justifyContent: 'center', + borderWidth: 1.5, + borderColor: '#FFFFFF', + }, + badgeText: { + color: '#FFF', + fontSize: 9, + fontWeight: 'bold', + }, + + // Search + searchContainer: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: '#FFFFFF', + borderWidth: 1, + borderColor: '#E2E8F0', + borderRadius: 16, + paddingHorizontal: 14, + paddingVertical: 12, + gap: 10, + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.03, + shadowRadius: 4, + elevation: 1, + }, + searchInput: { + flex: 1, + fontSize: 15, + color: '#0F172A', + }, + + // Sections + section: { + gap: 12, + }, + sectionHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + }, + sectionTitle: { + fontSize: 18, + fontWeight: '700', + color: '#0F172A', + }, + linkText: { + fontSize: 14, + fontWeight: '600', + color: '#003B7E', + }, + + // Grid + grid: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 12, + }, + gridItem: { + width: '48%', // Approx half with gap + backgroundColor: '#FFFFFF', + padding: 16, + borderRadius: 20, + alignItems: 'center', + justifyContent: 'center', + gap: 10, + borderWidth: 1, + borderColor: '#E2E8F0', + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.02, + shadowRadius: 8, + elevation: 2, + }, + gridIcon: { + width: 48, + height: 48, + borderRadius: 14, + alignItems: 'center', + justifyContent: 'center', + }, + gridLabel: { + fontSize: 14, + fontWeight: '600', + color: '#334155', + }, + + // News Cards + cardsColumn: { + gap: 12, + }, + newsCard: { + backgroundColor: '#FFFFFF', + padding: 16, + borderRadius: 18, + borderWidth: 1, + borderColor: '#E2E8F0', + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.04, + shadowRadius: 6, + elevation: 2, + }, + newsHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 8, + }, + categoryBadge: { + paddingHorizontal: 8, + paddingVertical: 4, + backgroundColor: '#F1F5F9', + borderRadius: 8, + }, + categoryText: { + fontSize: 10, + fontWeight: '700', + color: '#475569', + textTransform: 'uppercase', + }, + dateText: { + fontSize: 12, + color: '#94A3B8', + }, + newsTitle: { + fontSize: 16, + fontWeight: '700', + color: '#0F172A', + marginBottom: 6, + lineHeight: 22, + }, + newsBody: { + fontSize: 14, + color: '#64748B', + lineHeight: 20, + }, + + // Events List + eventsList: { + backgroundColor: '#FFFFFF', + borderRadius: 20, + borderWidth: 1, + borderColor: '#E2E8F0', + paddingVertical: 4, + }, + eventRow: { + flexDirection: 'row', + alignItems: 'center', + padding: 12, + gap: 14, + }, + eventBorder: { + borderBottomWidth: 1, + borderBottomColor: '#F1F5F9', + }, + dateBox: { + width: 50, + height: 50, + borderRadius: 14, + backgroundColor: '#F8FAFC', + alignItems: 'center', + justifyContent: 'center', + borderWidth: 1, + borderColor: '#E2E8F0', + }, + dateMonth: { + fontSize: 10, + fontWeight: '700', + textTransform: 'uppercase', + color: '#64748B', + marginBottom: -2, + }, + dateDay: { + fontSize: 18, + fontWeight: '800', + color: '#0F172A', + }, + eventInfo: { + flex: 1, + gap: 2, + }, + eventTitle: { + fontSize: 15, + fontWeight: '700', + color: '#0F172A', + }, + eventMeta: { + fontSize: 12, + color: '#64748B', + fontWeight: '500', + }, +}) + diff --git a/innungsapp/apps/mobile/app/(app)/members/[id].tsx b/innungsapp/apps/mobile/app/(app)/members/[id].tsx index d91199b..86c98ea 100644 --- a/innungsapp/apps/mobile/app/(app)/members/[id].tsx +++ b/innungsapp/apps/mobile/app/(app)/members/[id].tsx @@ -1,107 +1,238 @@ import { - View, - Text, - ScrollView, - TouchableOpacity, - Linking, - ActivityIndicator, + View, Text, ScrollView, TouchableOpacity, Linking, ActivityIndicator, StyleSheet, } from 'react-native' import { SafeAreaView } from 'react-native-safe-area-context' import { useLocalSearchParams, useRouter } from 'expo-router' -import { trpc } from '@/lib/trpc' +import { Ionicons } from '@expo/vector-icons' +import { useMemberDetail } from '@/hooks/useMembers' import { Avatar } from '@/components/ui/Avatar' export default function MemberDetailScreen() { const { id } = useLocalSearchParams<{ id: string }>() const router = useRouter() - const { data: member, isLoading } = trpc.members.byId.useQuery({ id }) + const { data: member, isLoading } = useMemberDetail(id) if (isLoading) { return ( - - + + ) } - if (!member) return null + const fields = [ + member.sparte ? ['SPARTE', member.sparte] : null, + member.ort ? ['ORT', member.ort] : null, + member.seit ? ['MITGLIED SEIT', String(member.seit)] : null, + ].filter(Boolean) as [string, string][] + return ( - - {/* Header */} - - router.back()} className="mr-3"> - ← Zurück + + {/* Nav */} + + router.back()} style={styles.backBtn} activeOpacity={0.7}> + + Zurück + - - {/* Profile Header */} - - - {member.name} - {member.betrieb} + + {/* Hero */} + + + {member.name} + {member.betrieb} {member.istAusbildungsbetrieb && ( - - - 🎓 Ausbildungsbetrieb - + + + Ausbildungsbetrieb )} + + {/* Details */} - - - - {member.seit && ( - - )} + + {fields.map(([label, value], idx) => ( + + {label} + {value} + + ))} - {/* Contact Buttons */} - + {/* Actions */} + {member.telefon && ( Linking.openURL(`tel:${member.telefon}`)} - className="bg-brand-500 rounded-2xl py-4 flex-row items-center justify-center gap-2" + style={styles.btnPrimary} + activeOpacity={0.82} > - 📞 - - Anrufen - + + Anrufen )} - Linking.openURL( - `mailto:${member.email}?subject=InnungsApp%20Anfrage` - ) - } - className="bg-white border border-gray-200 rounded-2xl py-4 flex-row items-center justify-center gap-2" + onPress={() => Linking.openURL(`mailto:${member.email}`)} + style={styles.btnSecondary} + activeOpacity={0.8} > - ✉️ - - E-Mail senden - + + E-Mail senden - - ) } -function InfoRow({ label, value }: { label: string; value: string }) { - return ( - - {label} - {value} - - ) -} +const styles = StyleSheet.create({ + safeArea: { + flex: 1, + backgroundColor: '#F8FAFC', + }, + loadingContainer: { + flex: 1, + backgroundColor: '#FFFFFF', + alignItems: 'center', + justifyContent: 'center', + }, + navBar: { + backgroundColor: '#FFFFFF', + paddingHorizontal: 16, + paddingVertical: 12, + }, + backBtn: { + flexDirection: 'row', + alignItems: 'center', + gap: 2, + }, + backText: { + fontSize: 14, + fontWeight: '600', + color: '#003B7E', + }, + divider: { + height: 1, + backgroundColor: '#E2E8F0', + }, + hero: { + backgroundColor: '#FFFFFF', + alignItems: 'center', + paddingVertical: 32, + paddingHorizontal: 24, + }, + heroName: { + fontSize: 21, + fontWeight: '800', + color: '#0F172A', + letterSpacing: -0.4, + marginTop: 14, + textAlign: 'center', + }, + heroCompany: { + fontSize: 14, + color: '#475569', + marginTop: 3, + textAlign: 'center', + }, + ausbildungPill: { + flexDirection: 'row', + alignItems: 'center', + gap: 5, + backgroundColor: '#F0FDF4', + paddingHorizontal: 12, + paddingVertical: 6, + borderRadius: 99, + marginTop: 10, + }, + ausbildungText: { + fontSize: 12, + color: '#15803D', + fontWeight: '600', + }, + card: { + backgroundColor: '#FFFFFF', + marginHorizontal: 16, + marginTop: 16, + borderRadius: 16, + overflow: 'hidden', + shadowColor: '#1C1917', + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 0.05, + shadowRadius: 8, + elevation: 1, + }, + fieldRow: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 16, + paddingVertical: 13, + }, + fieldRowBorder: { + borderBottomWidth: 1, + borderBottomColor: '#F9F9F9', + }, + fieldLabel: { + fontSize: 10, + fontWeight: '700', + color: '#64748B', + letterSpacing: 0.8, + width: 110, + }, + fieldValue: { + flex: 1, + fontSize: 14, + fontWeight: '500', + color: '#0F172A', + }, + actions: { + marginHorizontal: 16, + marginTop: 16, + marginBottom: 32, + gap: 10, + }, + btnPrimary: { + backgroundColor: '#003B7E', + borderRadius: 14, + paddingVertical: 15, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: 8, + shadowColor: '#003B7E', + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.24, + shadowRadius: 10, + elevation: 5, + }, + btnPrimaryText: { + color: '#FFFFFF', + fontSize: 15, + fontWeight: '600', + letterSpacing: 0.2, + }, + btnSecondary: { + backgroundColor: '#FFFFFF', + borderRadius: 14, + paddingVertical: 15, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: 8, + borderWidth: 1, + borderColor: '#E2E8F0', + }, + btnSecondaryText: { + color: '#0F172A', + fontSize: 15, + fontWeight: '600', + }, +}) diff --git a/innungsapp/apps/mobile/app/(app)/members/index.tsx b/innungsapp/apps/mobile/app/(app)/members/index.tsx index 0bbad1e..c1ae345 100644 --- a/innungsapp/apps/mobile/app/(app)/members/index.tsx +++ b/innungsapp/apps/mobile/app/(app)/members/index.tsx @@ -1,14 +1,10 @@ import { - View, - Text, - FlatList, - TextInput, - TouchableOpacity, - RefreshControl, + View, Text, FlatList, TextInput, TouchableOpacity, RefreshControl, StyleSheet, } from 'react-native' import { SafeAreaView } from 'react-native-safe-area-context' import { useRouter } from 'expo-router' -import { trpc } from '@/lib/trpc' +import { Ionicons } from '@expo/vector-icons' +import { useMembersList } from '@/hooks/useMembers' import { useMembersFilterStore } from '@/store/members.store' import { MemberCard } from '@/components/members/MemberCard' import { EmptyState } from '@/components/ui/EmptyState' @@ -20,78 +16,70 @@ export default function MembersScreen() { const nurAusbildungsbetriebe = useMembersFilterStore((s) => s.nurAusbildungsbetriebe) const setSearch = useMembersFilterStore((s) => s.setSearch) const setNurAusbildungsbetriebe = useMembersFilterStore((s) => s.setNurAusbildungsbetriebe) - - const { data, isLoading, refetch, isRefetching } = trpc.members.list.useQuery({ - search: search || undefined, - ausbildungsbetrieb: nurAusbildungsbetriebe || undefined, - status: 'aktiv', - }) + const { data, isLoading, refetch, isRefetching } = useMembersList() return ( - + {/* Header */} - - Mitglieder + + + Mitglieder + {data && ( + {data.length} gesamt + )} + - {/* Search */} - - 🔍 + {/* Search bar */} + + - {/* Filter: Ausbildungsbetriebe */} + {/* Training filter */} setNurAusbildungsbetriebe(!nurAusbildungsbetriebe)} - className="flex-row items-center gap-2 py-1" + style={styles.toggleRow} + activeOpacity={0.7} > - + {nurAusbildungsbetriebe && ( - + )} - Nur Ausbildungsbetriebe + Nur Ausbildungsbetriebe - {/* List */} + + {isLoading ? ( ) : ( item.id} - contentContainerStyle={{ padding: 12, gap: 8 }} + contentContainerStyle={styles.list} refreshControl={ - + } renderItem={({ item }) => ( router.push(`/(app)/members/${item.id}`)} + onPress={() => router.push(`/(app)/members/${item.id}` as never)} /> )} ListEmptyComponent={ } /> @@ -99,3 +87,81 @@ export default function MembersScreen() { ) } + +const styles = StyleSheet.create({ + safeArea: { + flex: 1, + backgroundColor: '#F8FAFC', + }, + header: { + backgroundColor: '#FFFFFF', + paddingHorizontal: 20, + paddingTop: 18, + paddingBottom: 14, + }, + titleRow: { + flexDirection: 'row', + alignItems: 'baseline', + justifyContent: 'space-between', + marginBottom: 14, + }, + screenTitle: { + fontSize: 28, + fontWeight: '800', + color: '#0F172A', + letterSpacing: -0.5, + }, + countText: { + fontSize: 13, + color: '#64748B', + fontWeight: '500', + }, + searchBar: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: '#F4F4F5', + borderRadius: 14, + paddingHorizontal: 12, + paddingVertical: 11, + gap: 8, + marginBottom: 10, + }, + searchInput: { + flex: 1, + fontSize: 14, + color: '#0F172A', + }, + toggleRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + paddingTop: 2, + }, + checkbox: { + width: 18, + height: 18, + borderRadius: 5, + borderWidth: 1.5, + borderColor: '#D4D4D8', + backgroundColor: '#FFFFFF', + alignItems: 'center', + justifyContent: 'center', + }, + checkboxActive: { + backgroundColor: '#003B7E', + borderColor: '#003B7E', + }, + toggleLabel: { + fontSize: 13, + color: '#52525B', + fontWeight: '500', + }, + divider: { + height: 1, + backgroundColor: '#E2E8F0', + }, + list: { + padding: 16, + gap: 10, + }, +}) diff --git a/innungsapp/apps/mobile/app/(app)/news/[id].tsx b/innungsapp/apps/mobile/app/(app)/news/[id].tsx index 0d901db..819042c 100644 --- a/innungsapp/apps/mobile/app/(app)/news/[id].tsx +++ b/innungsapp/apps/mobile/app/(app)/news/[id].tsx @@ -1,87 +1,383 @@ import { - View, - Text, - ScrollView, - TouchableOpacity, - ActivityIndicator, + View, Text, ScrollView, TouchableOpacity, ActivityIndicator, + StyleSheet, Platform, } from 'react-native' import { SafeAreaView } from 'react-native-safe-area-context' import { useLocalSearchParams, useRouter } from 'expo-router' import { useEffect } from 'react' -import { trpc } from '@/lib/trpc' -import { useNewsReadStore } from '@/store/news.store' +import { Ionicons } from '@expo/vector-icons' +import { useNewsDetail } from '@/hooks/useNews' import { AttachmentRow } from '@/components/news/AttachmentRow' import { Badge } from '@/components/ui/Badge' -import { NEWS_KATEGORIE_LABELS } from '@innungsapp/shared' +import { NEWS_KATEGORIE_LABELS } from '@innungsapp/shared/types' import { format } from 'date-fns' import { de } from 'date-fns/locale' +// --------------------------------------------------------------------------- +// Lightweight markdown renderer (headings, bold, bullets, paragraphs) +// --------------------------------------------------------------------------- +function MarkdownBody({ source }: { source: string }) { + const blocks = source.split(/\n\n+/) + return ( + + {blocks.map((block, i) => { + const trimmed = block.trim() + if (!trimmed) return null + + // H2 ## + if (trimmed.startsWith('## ')) { + return ( + + {trimmed.slice(3)} + + ) + } + // H3 ### + if (trimmed.startsWith('### ')) { + return ( + + {trimmed.slice(4)} + + ) + } + // H1 # + if (trimmed.startsWith('# ')) { + return ( + + {trimmed.slice(2)} + + ) + } + // Bullet list + if (trimmed.startsWith('- ')) { + const items = trimmed.split('\n').filter(Boolean) + return ( + + {items.map((line, j) => { + const text = line.replace(/^-\s+/, '') + return ( + + + {renderInline(text)} + + ) + })} + + ) + } + // Paragraph (with inline bold) + return ( + + {renderInline(trimmed)} + + ) + })} + + ) +} + +/** Render **bold** inline within a Text node */ +function renderInline(text: string): React.ReactNode[] { + const parts = text.split(/(\*\*.*?\*\*)/) + return parts.map((part, i) => { + if (part.startsWith('**') && part.endsWith('**')) { + return ( + + {part.slice(2, -2)} + + ) + } + return {part} + }) +} + +// --------------------------------------------------------------------------- +// Screen +// --------------------------------------------------------------------------- export default function NewsDetailScreen() { const { id } = useLocalSearchParams<{ id: string }>() const router = useRouter() - const markRead = useNewsReadStore((s) => s.markRead) - const markReadMutation = trpc.news.markRead.useMutation() + const { data: news, isLoading, onOpen } = useNewsDetail(id) - const { data: news, isLoading } = trpc.news.byId.useQuery({ id }) - - useEffect(() => { - if (news) { - markRead(id) - markReadMutation.mutate({ newsId: id }) - } - }, [news?.id]) + useEffect(() => { if (news) onOpen() }, [news?.id]) if (isLoading) { return ( - - + + ) } - if (!news) return null + const initials = (news.author?.name ?? 'I') + .split(' ') + .map((n) => n.charAt(0)) + .slice(0, 2) + .join('') + return ( - - {/* Header */} - - router.back()} className="mr-3"> - ← Zurück + + {/* Nav bar */} + + router.back()} + style={styles.backBtn} + activeOpacity={0.7} + > + + Neuigkeiten - - {news.title} - - - + + {/* ── Hero header ────────────────────────────────────────── */} + + - - {news.title} - + {news.title} - - {news.author?.name ?? 'InnungsApp'} ·{' '} - {news.publishedAt - ? format(new Date(news.publishedAt), 'dd. MMMM yyyy', { locale: de }) - : ''} - + {/* Author + date row */} + + + {initials} + + + {news.author?.name ?? 'Innung'} + {news.publishedAt && ( + + {format(new Date(news.publishedAt), 'dd. MMMM yyyy', { locale: de })} + + )} + + + - {/* Simple Markdown renderer — plain text for MVP */} - - {news.body.replace(/^#+\s/gm, '').replace(/\*\*(.*?)\*\*/g, '$1')} - + {/* ── Separator ──────────────────────────────────────────── */} + - {/* Attachments */} + {/* ── Article body ───────────────────────────────────────── */} + + + {/* ── Attachments ────────────────────────────────────────── */} {news.attachments.length > 0 && ( - - Anhänge - {news.attachments.map((a) => ( - - ))} + + + + + ANHÄNGE ({news.attachments.length}) + + + + {news.attachments.map((a, idx) => ( + + {idx > 0 && } + + + ))} + )} + + {/* Bottom spacer */} + ) } + +// --------------------------------------------------------------------------- +// Styles +// --------------------------------------------------------------------------- +const styles = StyleSheet.create({ + safeArea: { + flex: 1, + backgroundColor: '#FAFAFA', + }, + loadingContainer: { + flex: 1, + backgroundColor: '#FAFAFA', + alignItems: 'center', + justifyContent: 'center', + }, + // Nav + navBar: { + backgroundColor: '#FAFAFA', + paddingHorizontal: 16, + paddingVertical: 10, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: '#E2E8F0', + }, + backBtn: { + flexDirection: 'row', + alignItems: 'center', + gap: 2, + alignSelf: 'flex-start', + }, + backText: { + fontSize: 15, + fontWeight: '600', + color: '#003B7E', + }, + // Hero + hero: { + backgroundColor: '#FFFFFF', + paddingHorizontal: 20, + paddingTop: 22, + paddingBottom: 20, + gap: 12, + }, + heroTitle: { + fontSize: 24, + fontWeight: '800', + color: '#0F172A', + letterSpacing: -0.5, + lineHeight: 32, + marginTop: 4, + }, + metaRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 10, + marginTop: 4, + }, + avatarCircle: { + width: 38, + height: 38, + borderRadius: 19, + backgroundColor: '#003B7E', + alignItems: 'center', + justifyContent: 'center', + }, + avatarText: { + color: '#FFFFFF', + fontSize: 13, + fontWeight: '700', + letterSpacing: 0.5, + }, + authorName: { + fontSize: 14, + fontWeight: '600', + color: '#0F172A', + lineHeight: 18, + }, + dateText: { + fontSize: 12, + color: '#94A3B8', + marginTop: 1, + }, + heroSeparator: { + height: 4, + backgroundColor: '#F1F5F9', + }, + // Scroll + scrollContent: { + flexGrow: 1, + }, + // Attachments + attachmentsSection: { + marginHorizontal: 20, + marginTop: 28, + }, + attachmentsHeader: { + flexDirection: 'row', + alignItems: 'center', + gap: 6, + marginBottom: 10, + }, + attachmentsLabel: { + fontSize: 11, + fontWeight: '700', + color: '#64748B', + letterSpacing: 0.8, + }, + attachmentsCard: { + backgroundColor: '#FFFFFF', + borderRadius: 16, + paddingHorizontal: 16, + overflow: 'hidden', + ...Platform.select({ + ios: { + shadowColor: '#1C1917', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.06, + shadowRadius: 10, + }, + android: { elevation: 2 }, + }), + }, + attachmentsDivider: { + height: StyleSheet.hairlineWidth, + backgroundColor: '#F1F5F9', + }, +}) + +// Markdown styles +const md = StyleSheet.create({ + container: { + backgroundColor: '#FFFFFF', + paddingHorizontal: 20, + paddingTop: 22, + paddingBottom: 8, + gap: 14, + }, + h1: { + fontSize: 22, + fontWeight: '800', + color: '#0F172A', + letterSpacing: -0.3, + lineHeight: 30, + }, + h2: { + fontSize: 18, + fontWeight: '700', + color: '#0F172A', + letterSpacing: -0.2, + lineHeight: 26, + marginTop: 8, + paddingBottom: 6, + borderBottomWidth: 2, + borderBottomColor: '#EFF6FF', + }, + h3: { + fontSize: 16, + fontWeight: '700', + color: '#1E293B', + lineHeight: 24, + marginTop: 4, + }, + paragraph: { + fontSize: 16, + color: '#334155', + lineHeight: 28, + letterSpacing: 0.1, + }, + list: { + gap: 8, + }, + listItem: { + flexDirection: 'row', + alignItems: 'flex-start', + gap: 10, + }, + bullet: { + width: 6, + height: 6, + borderRadius: 3, + backgroundColor: '#003B7E', + marginTop: 10, + flexShrink: 0, + }, + listText: { + flex: 1, + fontSize: 16, + color: '#334155', + lineHeight: 28, + }, +}) diff --git a/innungsapp/apps/mobile/app/(app)/news/index.tsx b/innungsapp/apps/mobile/app/(app)/news/index.tsx index 3271f70..d97517a 100644 --- a/innungsapp/apps/mobile/app/(app)/news/index.tsx +++ b/innungsapp/apps/mobile/app/(app)/news/index.tsx @@ -1,100 +1,157 @@ import { - View, - Text, - FlatList, - TouchableOpacity, - RefreshControl, - ScrollView, + View, Text, FlatList, TouchableOpacity, RefreshControl, ScrollView, StyleSheet, } from 'react-native' import { SafeAreaView } from 'react-native-safe-area-context' import { useState } from 'react' -import { trpc } from '@/lib/trpc' import { useRouter } from 'expo-router' +import { useNewsList } from '@/hooks/useNews' import { NewsCard } from '@/components/news/NewsCard' import { EmptyState } from '@/components/ui/EmptyState' import { LoadingSpinner } from '@/components/ui/LoadingSpinner' -import { NEWS_KATEGORIE_LABELS } from '@innungsapp/shared' -const FILTER_OPTIONS = [ +const FILTERS = [ { value: undefined, label: 'Alle' }, { value: 'Wichtig', label: 'Wichtig' }, - { value: 'Pruefung', label: 'Prüfung' }, - { value: 'Foerderung', label: 'Förderung' }, + { value: 'Pruefung', label: 'Pruefung' }, + { value: 'Foerderung', label: 'Foerderung' }, { value: 'Veranstaltung', label: 'Veranstaltung' }, ] export default function NewsScreen() { const router = useRouter() const [kategorie, setKategorie] = useState(undefined) - const { data, isLoading, refetch, isRefetching } = trpc.news.list.useQuery({ - kategorie: kategorie as never, - }) + const { data, isLoading, refetch, isRefetching } = useNewsList(kategorie) + + const unreadCount = data?.filter((n) => !n.isRead).length ?? 0 return ( - - {/* Header */} - - News + + + + Aktuelles + {unreadCount > 0 && ( + + {unreadCount} neu + + )} + + + + {FILTERS.map((opt) => { + const active = kategorie === opt.value + return ( + setKategorie(opt.value)} + style={[styles.chip, active && styles.chipActive]} + activeOpacity={0.85} + > + + {opt.label} + + + ) + })} + - {/* Kategorie Filter */} - - {FILTER_OPTIONS.map((opt) => ( - setKategorie(opt.value)} - className={`px-4 py-1.5 rounded-full border ${ - kategorie === opt.value - ? 'bg-brand-500 border-brand-500' - : 'bg-white border-gray-200' - }`} - > - - {opt.label} - - - ))} - + - {/* List */} {isLoading ? ( ) : ( item.id} - contentContainerStyle={{ padding: 12, gap: 8 }} + contentContainerStyle={styles.list} refreshControl={ - + } renderItem={({ item }) => ( router.push(`/(app)/news/${item.id}`)} + onPress={() => router.push(`/(app)/news/${item.id}` as never)} /> )} ListEmptyComponent={ - + } /> )} ) } + +const styles = StyleSheet.create({ + safeArea: { + flex: 1, + backgroundColor: '#F8FAFC', + }, + header: { + backgroundColor: '#FFFFFF', + paddingHorizontal: 20, + paddingTop: 14, + paddingBottom: 0, + }, + titleRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + marginBottom: 14, + }, + screenTitle: { + fontSize: 28, + fontWeight: '800', + color: '#0F172A', + letterSpacing: -0.5, + }, + unreadBadge: { + backgroundColor: '#EFF6FF', + paddingHorizontal: 10, + paddingVertical: 4, + borderRadius: 99, + }, + unreadBadgeText: { + color: '#003B7E', + fontSize: 12, + fontWeight: '700', + }, + filterScroll: { + paddingBottom: 14, + gap: 8, + paddingRight: 20, + }, + chip: { + paddingHorizontal: 14, + paddingVertical: 7, + borderRadius: 99, + borderWidth: 1, + borderColor: '#E2E8F0', + backgroundColor: '#FFFFFF', + }, + chipActive: { + backgroundColor: '#003B7E', + borderColor: '#003B7E', + }, + chipLabel: { + fontSize: 13, + fontWeight: '600', + color: '#64748B', + }, + chipLabelActive: { + color: '#FFFFFF', + }, + divider: { + height: 1, + backgroundColor: '#E2E8F0', + }, + list: { + padding: 16, + gap: 10, + paddingBottom: 30, + }, +}) diff --git a/innungsapp/apps/mobile/app/(app)/profil/index.tsx b/innungsapp/apps/mobile/app/(app)/profil/index.tsx index 5c2ab28..01e348d 100644 --- a/innungsapp/apps/mobile/app/(app)/profil/index.tsx +++ b/innungsapp/apps/mobile/app/(app)/profil/index.tsx @@ -1,97 +1,330 @@ -import { - View, - Text, - ScrollView, - TouchableOpacity, - Linking, - ActivityIndicator, -} from 'react-native' +import { View, Text, ScrollView, TouchableOpacity, Alert, StyleSheet } from 'react-native' import { SafeAreaView } from 'react-native-safe-area-context' -import { trpc } from '@/lib/trpc' +import { Ionicons } from '@expo/vector-icons' import { useAuth } from '@/hooks/useAuth' -import { Avatar } from '@/components/ui/Avatar' +import { MOCK_MEMBER_ME } from '@/lib/mock-data' + +type Item = { + label: string + icon: React.ComponentProps['name'] + badge?: string +} + +const MENU_ITEMS: Item[] = [ + { label: 'Persoenliche Daten', icon: 'person-outline' }, + { label: 'Betriebsdaten', icon: 'business-outline', badge: 'Aktiv' }, + { label: 'Mitteilungen', icon: 'notifications-outline', badge: '2' }, + { label: 'Sicherheit & Login', icon: 'shield-checkmark-outline' }, + { label: 'Hilfe & Support', icon: 'help-circle-outline' }, +] export default function ProfilScreen() { const { signOut } = useAuth() - const { data: member, isLoading } = trpc.members.me.useQuery() + const member = MOCK_MEMBER_ME + + const initials = member.name + .split(' ') + .slice(0, 2) + .map((chunk) => chunk[0]?.toUpperCase() ?? '') + .join('') + + const openPlaceholder = () => { + Alert.alert('Hinweis', 'Dieser Bereich folgt in einer naechsten Version.') + } return ( - - - Mein Profil - - - - {isLoading ? ( - - + + + + + {initials} + + + - ) : member ? ( - <> - {/* Profile */} - - - {member.name} - {member.betrieb} - {member.org.name} + {member.name} + Innungsgeschaeftsfuehrer + + + Admin-Status - - {/* Member Details */} - - - {member.telefon && } - - - {member.seit && } + + Verifiziert - - - - Änderungen an Ihren Daten nehmen Sie über die Innungsgeschäftsstelle vor. - - - - ) : null} - - {/* Links */} - - Linking.openURL('https://innungsapp.de/datenschutz')} - className="flex-row items-center justify-between px-4 py-3.5 border-b border-gray-50" - > - Datenschutzerklärung - - - Linking.openURL('https://innungsapp.de/impressum')} - className="flex-row items-center justify-between px-4 py-3.5" - > - Impressum - - + - {/* Logout */} - - - Abmelden - + Mein Account + + {MENU_ITEMS.map((item, index) => ( + + + + + + {item.label} + + + {item.badge ? ( + + {item.badge} + + ) : null} + + + + ))} - + Unterstuetzung + + Probleme oder Fragen? + + Unser Support-Team hilft Ihnen gerne bei technischen Schwierigkeiten weiter. + + + Support kontaktieren + + + + + void signOut()}> + + Abmelden + + + InnungsApp Version 2.4.0 ) } -function InfoRow({ label, value }: { label: string; value: string }) { - return ( - - {label} - {value} - - ) -} +const styles = StyleSheet.create({ + safeArea: { + flex: 1, + backgroundColor: '#F8FAFC', + }, + content: { + paddingHorizontal: 18, + paddingBottom: 30, + gap: 14, + }, + hero: { + backgroundColor: '#FFFFFF', + alignItems: 'center', + paddingTop: 24, + paddingBottom: 18, + borderRadius: 22, + borderWidth: 1, + borderColor: '#E2E8F0', + marginTop: 8, + }, + avatarWrap: { + position: 'relative', + }, + avatarText: { + width: 94, + height: 94, + borderRadius: 47, + backgroundColor: '#DBEAFE', + borderWidth: 4, + borderColor: '#FFFFFF', + overflow: 'hidden', + textAlign: 'center', + textAlignVertical: 'center', + color: '#003B7E', + fontSize: 34, + fontWeight: '800', + includeFontPadding: false, + }, + settingsBtn: { + position: 'absolute', + right: 0, + bottom: 2, + width: 30, + height: 30, + borderRadius: 15, + backgroundColor: '#FFFFFF', + borderWidth: 1, + borderColor: '#E2E8F0', + alignItems: 'center', + justifyContent: 'center', + }, + name: { + marginTop: 14, + fontSize: 24, + fontWeight: '800', + color: '#0F172A', + }, + role: { + marginTop: 2, + fontSize: 12, + fontWeight: '700', + letterSpacing: 0.5, + color: '#64748B', + textTransform: 'uppercase', + }, + badgesRow: { + marginTop: 10, + flexDirection: 'row', + gap: 8, + }, + statusBadge: { + backgroundColor: '#DCFCE7', + borderRadius: 999, + paddingHorizontal: 10, + paddingVertical: 4, + }, + statusBadgeText: { + color: '#166534', + fontSize: 11, + fontWeight: '700', + }, + verifyBadge: { + backgroundColor: '#DBEAFE', + }, + verifyBadgeText: { + color: '#1D4ED8', + }, + sectionTitle: { + marginTop: 2, + paddingLeft: 2, + fontSize: 11, + textTransform: 'uppercase', + letterSpacing: 1.1, + color: '#94A3B8', + fontWeight: '800', + }, + menuCard: { + backgroundColor: '#FFFFFF', + borderRadius: 18, + borderWidth: 1, + borderColor: '#E2E8F0', + overflow: 'hidden', + }, + menuRow: { + paddingHorizontal: 14, + paddingVertical: 12, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + }, + menuRowBorder: { + borderBottomWidth: 1, + borderBottomColor: '#F1F5F9', + }, + menuLeft: { + flexDirection: 'row', + alignItems: 'center', + gap: 12, + }, + menuIcon: { + width: 36, + height: 36, + borderRadius: 11, + backgroundColor: '#F1F5F9', + alignItems: 'center', + justifyContent: 'center', + }, + menuLabel: { + fontSize: 14, + fontWeight: '700', + color: '#1E293B', + }, + menuRight: { + flexDirection: 'row', + alignItems: 'center', + gap: 7, + }, + rowBadge: { + paddingHorizontal: 8, + paddingVertical: 3, + borderRadius: 999, + }, + rowBadgeActive: { + backgroundColor: '#DCFCE7', + }, + rowBadgeAlert: { + backgroundColor: '#EF4444', + }, + rowBadgeText: { + fontSize: 10, + fontWeight: '700', + color: '#FFFFFF', + }, + supportCard: { + borderRadius: 18, + backgroundColor: '#003B7E', + padding: 16, + overflow: 'hidden', + position: 'relative', + }, + supportTitle: { + color: '#FFFFFF', + fontSize: 18, + fontWeight: '800', + marginBottom: 4, + maxWidth: 180, + }, + supportText: { + color: '#BFDBFE', + fontSize: 12, + lineHeight: 18, + marginBottom: 12, + maxWidth: 240, + }, + supportBtn: { + alignSelf: 'flex-start', + backgroundColor: 'rgba(255,255,255,0.15)', + borderWidth: 1, + borderColor: 'rgba(255,255,255,0.25)', + borderRadius: 10, + paddingHorizontal: 12, + paddingVertical: 8, + }, + supportBtnText: { + color: '#FFFFFF', + fontSize: 12, + fontWeight: '700', + textTransform: 'uppercase', + letterSpacing: 0.6, + }, + supportIcon: { + position: 'absolute', + right: -10, + bottom: -12, + }, + logoutBtn: { + marginTop: 4, + backgroundColor: '#FEF2F2', + borderRadius: 14, + borderWidth: 1, + borderColor: '#FECACA', + paddingVertical: 14, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: 8, + }, + logoutText: { + color: '#B91C1C', + fontSize: 14, + fontWeight: '800', + textTransform: 'uppercase', + letterSpacing: 0.8, + }, + footer: { + textAlign: 'center', + marginTop: 4, + fontSize: 10, + fontWeight: '700', + letterSpacing: 1, + color: '#94A3B8', + textTransform: 'uppercase', + }, +}) diff --git a/innungsapp/apps/mobile/app/(app)/stellen/[id].tsx b/innungsapp/apps/mobile/app/(app)/stellen/[id].tsx index 25df29b..2781155 100644 --- a/innungsapp/apps/mobile/app/(app)/stellen/[id].tsx +++ b/innungsapp/apps/mobile/app/(app)/stellen/[id].tsx @@ -1,94 +1,335 @@ import { - View, - Text, - ScrollView, - TouchableOpacity, - Linking, - ActivityIndicator, + View, Text, ScrollView, TouchableOpacity, Linking, ActivityIndicator, StyleSheet, Platform } from 'react-native' import { SafeAreaView } from 'react-native-safe-area-context' -import { useLocalSearchParams, useRouter } from 'expo-router' -import { trpc } from '@/lib/trpc' +import { useLocalSearchParams, useRouter, Stack } from 'expo-router' +import { Ionicons } from '@expo/vector-icons' +import { useStelleDetail } from '@/hooks/useStellen' +import { Button } from '@/components/ui/Button' + +const SPARTE_COLOR: Record = { + elektro: '#1D4ED8', sanitär: '#0E7490', it: '#7C3AED', + info: '#7C3AED', heizung: '#D97706', maler: '#059669', +} +function getSparteColor(sparte: string): string { + const lower = sparte.toLowerCase() + for (const [k, v] of Object.entries(SPARTE_COLOR)) { + if (lower.includes(k)) return v + } + return '#003B7E' +} export default function StelleDetailScreen() { const { id } = useLocalSearchParams<{ id: string }>() const router = useRouter() - const { data: stelle, isLoading } = trpc.stellen.byId.useQuery({ id }) + const { data: stelle, isLoading } = useStelleDetail(id) if (isLoading) { return ( - - - + + + ) } - if (!stelle) return null - const betreffVorlage = `Bewerbung als Auszubildender bei ${stelle.member.betrieb}` - const bewerbungsUrl = `mailto:${stelle.kontaktEmail}?subject=${encodeURIComponent(betreffVorlage)}` + const color = getSparteColor(stelle.sparte) + const initial = stelle.sparte.charAt(0).toUpperCase() + const bewerbungsUrl = `mailto:${stelle.kontaktEmail}?subject=${encodeURIComponent(`Bewerbung als Auszubildender bei ${stelle.member.betrieb}`)}` return ( - - - router.back()} className="mr-3"> - ← Zurück - - - - + <> + + {/* Header */} - - - 🎓 - - {stelle.member.betrieb} - {stelle.member.ort} - {stelle.org.name} - - - {/* Details */} - - - - {stelle.lehrjahr && } - {stelle.verguetung && } - - - {stelle.beschreibung && ( - - Über die Stelle - {stelle.beschreibung} - - )} - - {/* CTA */} - - Linking.openURL(bewerbungsUrl)} - className="bg-brand-500 rounded-2xl py-4 flex-row items-center justify-center gap-2" - > - ✉️ - Jetzt bewerben + + router.back()} style={styles.backButton}> + + + + + - {stelle.kontaktName && ( - - Ansprechperson: {stelle.kontaktName} - - )} - - - + + + {/* Header Card */} + + + {initial} + + Auszubildender {stelle.sparte} + {stelle.member.betrieb} + + + + + {stelle.member.ort} · {stelle.org.name} + + + + + {/* Key Facts */} + Eckdaten + + + + + + + Anzahl Stellen + {stelle.stellenAnz} + + + + {stelle.lehrjahr && ( + + + + + + Lehrjahr + {stelle.lehrjahr} + + + )} + + {stelle.verguetung && ( + + + + + + Vergütung + {stelle.verguetung} + + + )} + + + {/* Description */} + {stelle.beschreibung && ( + + Beschreibung + {stelle.beschreibung} + + )} + + {/* Contact */} + {stelle.kontaktName && ( + + Ansprechpartner + + + {stelle.kontaktName.charAt(0)} + + + {stelle.kontaktName} + Recruiting + + + + )} + + + + {/* Footer */} + +