feat: Implement mobile application and lead processing utilities.
This commit is contained in:
parent
fca42db4d2
commit
c53a71a5f9
|
|
@ -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
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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 })
|
||||||
|
}
|
||||||
|
|
@ -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 <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-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/mitglieder" className="text-gray-400 hover:text-gray-600">
|
||||||
|
← Zurück
|
||||||
|
</Link>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Mitglied bearbeiten</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Invite Status */}
|
||||||
|
<div className="bg-white rounded-xl border shadow-sm 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>
|
||||||
|
|
||||||
|
<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 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}>
|
||||||
|
{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>
|
||||||
|
<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>
|
||||||
|
<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">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>
|
||||||
|
|
||||||
|
{updateMutation.error && (
|
||||||
|
<p className="text-sm text-red-600 bg-red-50 px-4 py-2 rounded-lg">{updateMutation.error.message}</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>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,9 @@
|
||||||
"name": "@innungsapp/admin",
|
"name": "@innungsapp/admin",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
"exports": {
|
||||||
|
".": "./server/routers/index.ts"
|
||||||
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
|
||||||
|
# @generated expo-cli sync-2b81b286409207a5da26e14c78851eb30d8ccbdb
|
||||||
|
# The following patterns were generated by expo-cli
|
||||||
|
|
||||||
|
expo-env.d.ts
|
||||||
|
# @end expo-cli
|
||||||
|
|
@ -26,7 +26,10 @@
|
||||||
"backgroundColor": "#E63946"
|
"backgroundColor": "#E63946"
|
||||||
},
|
},
|
||||||
"package": "de.innungsapp.mobile",
|
"package": "de.innungsapp.mobile",
|
||||||
"permissions": ["RECEIVE_BOOT_COMPLETED", "SCHEDULE_EXACT_ALARM"]
|
"permissions": [
|
||||||
|
"RECEIVE_BOOT_COMPLETED",
|
||||||
|
"SCHEDULE_EXACT_ALARM"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"web": {
|
"web": {
|
||||||
"bundler": "metro",
|
"bundler": "metro",
|
||||||
|
|
@ -50,7 +53,8 @@
|
||||||
{
|
{
|
||||||
"calendarPermission": "Die App benötigt Zugriff auf Ihren Kalender."
|
"calendarPermission": "Die App benötigt Zugriff auf Ihren Kalender."
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"expo-web-browser"
|
||||||
],
|
],
|
||||||
"experiments": {
|
"experiments": {
|
||||||
"typedRoutes": true
|
"typedRoutes": true
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,7 @@
|
||||||
import { Tabs } from 'expo-router'
|
import { Tabs, Redirect } from 'expo-router'
|
||||||
import { useAuthStore } from '@/store/auth.store'
|
|
||||||
import { Redirect } from 'expo-router'
|
|
||||||
import { Platform } from 'react-native'
|
import { Platform } from 'react-native'
|
||||||
|
import { Ionicons } from '@expo/vector-icons'
|
||||||
function TabIcon({ emoji }: { emoji: string }) {
|
import { useAuthStore } from '@/store/auth.store'
|
||||||
return null // Replaced by tabBarIcon in options
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AppLayout() {
|
export default function AppLayout() {
|
||||||
const session = useAuthStore((s) => s.session)
|
const session = useAuthStore((s) => s.session)
|
||||||
|
|
@ -17,62 +13,75 @@ export default function AppLayout() {
|
||||||
return (
|
return (
|
||||||
<Tabs
|
<Tabs
|
||||||
screenOptions={{
|
screenOptions={{
|
||||||
tabBarActiveTintColor: '#E63946',
|
tabBarActiveTintColor: '#003B7E',
|
||||||
tabBarInactiveTintColor: '#6b7280',
|
tabBarInactiveTintColor: '#64748B',
|
||||||
tabBarStyle: {
|
tabBarStyle: {
|
||||||
borderTopColor: '#e5e7eb',
|
borderTopWidth: 1,
|
||||||
backgroundColor: 'white',
|
borderTopColor: '#E2E8F0',
|
||||||
paddingBottom: Platform.OS === 'ios' ? 8 : 4,
|
backgroundColor: '#FFFFFF',
|
||||||
height: Platform.OS === 'ios' ? 82 : 60,
|
height: Platform.OS === 'ios' ? 88 : 64,
|
||||||
|
paddingBottom: Platform.OS === 'ios' ? 28 : 8,
|
||||||
|
paddingTop: 8,
|
||||||
},
|
},
|
||||||
headerStyle: { backgroundColor: 'white' },
|
tabBarLabelStyle: {
|
||||||
headerTitleStyle: { fontWeight: '700', color: '#111827' },
|
fontSize: 11,
|
||||||
headerShadowVisible: false,
|
fontWeight: '600',
|
||||||
|
letterSpacing: 0.1,
|
||||||
|
},
|
||||||
|
headerShown: false,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Tabs.Screen
|
<Tabs.Screen
|
||||||
name="news"
|
name="home/index"
|
||||||
options={{
|
options={{
|
||||||
title: 'News',
|
title: 'Start',
|
||||||
tabBarIcon: ({ color }) => (
|
tabBarIcon: ({ color, focused }) => (
|
||||||
/* Replace with actual icons after @expo/vector-icons setup */
|
<Ionicons name={focused ? 'home' : 'home-outline'} size={23} color={color} />
|
||||||
<TabIcon emoji="📰" />
|
|
||||||
),
|
),
|
||||||
headerShown: false,
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Tabs.Screen
|
<Tabs.Screen
|
||||||
name="members"
|
name="news/index"
|
||||||
options={{
|
options={{
|
||||||
title: 'Mitglieder',
|
title: 'Aktuelles',
|
||||||
tabBarIcon: () => <TabIcon emoji="👥" />,
|
tabBarIcon: ({ color, focused }) => (
|
||||||
headerShown: false,
|
<Ionicons name={focused ? 'newspaper' : 'newspaper-outline'} size={23} color={color} />
|
||||||
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Tabs.Screen
|
<Tabs.Screen
|
||||||
name="termine"
|
name="termine/index"
|
||||||
options={{
|
options={{
|
||||||
title: 'Termine',
|
title: 'Termine',
|
||||||
tabBarIcon: () => <TabIcon emoji="📅" />,
|
tabBarIcon: ({ color, focused }) => (
|
||||||
headerShown: false,
|
<Ionicons name={focused ? 'calendar' : 'calendar-outline'} size={23} color={color} />
|
||||||
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Tabs.Screen
|
<Tabs.Screen
|
||||||
name="stellen"
|
name="stellen/index"
|
||||||
options={{
|
options={{
|
||||||
title: 'Stellen',
|
title: 'Stellen',
|
||||||
tabBarIcon: () => <TabIcon emoji="🎓" />,
|
tabBarIcon: ({ color, focused }) => (
|
||||||
headerShown: false,
|
<Ionicons name={focused ? 'briefcase' : 'briefcase-outline'} size={23} color={color} />
|
||||||
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Tabs.Screen
|
<Tabs.Screen
|
||||||
name="profil"
|
name="profil/index"
|
||||||
options={{
|
options={{
|
||||||
title: 'Profil',
|
title: 'Profil',
|
||||||
tabBarIcon: () => <TabIcon emoji="👤" />,
|
tabBarIcon: ({ color, focused }) => (
|
||||||
headerShown: false,
|
<Ionicons name={focused ? 'person' : 'person-outline'} size={23} color={color} />
|
||||||
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Tabs.Screen name="members/index" options={{ href: null }} />
|
||||||
|
<Tabs.Screen name="news/[id]" options={{ href: null }} />
|
||||||
|
<Tabs.Screen name="members/[id]" options={{ href: null }} />
|
||||||
|
<Tabs.Screen name="termine/[id]" options={{ href: null }} />
|
||||||
|
<Tabs.Screen name="stellen/[id]" options={{ href: null }} />
|
||||||
</Tabs>
|
</Tabs>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
||||||
|
<View style={styles.container}>
|
||||||
|
{/* Decorative Background Element */}
|
||||||
|
<View style={styles.bgDecoration} />
|
||||||
|
|
||||||
|
<SafeAreaView style={styles.safeArea} edges={['top']}>
|
||||||
|
<ScrollView
|
||||||
|
contentContainerStyle={styles.scrollContent}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
>
|
||||||
|
{/* Header Section */}
|
||||||
|
<View style={styles.header}>
|
||||||
|
<View style={styles.headerLeft}>
|
||||||
|
<View style={styles.avatar}>
|
||||||
|
<Text style={styles.avatarText}>I</Text>
|
||||||
|
</View>
|
||||||
|
<View>
|
||||||
|
<Text style={styles.greeting}>Willkommen zurück,</Text>
|
||||||
|
<Text style={styles.username}>Demo Admin</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.notificationBtn}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
>
|
||||||
|
<Ionicons name="notifications-outline" size={22} color="#1E293B" />
|
||||||
|
{unreadCount > 0 && (
|
||||||
|
<View style={styles.badge}>
|
||||||
|
<Text style={styles.badgeText}>{unreadCount}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Search Bar */}
|
||||||
|
<View style={styles.searchContainer}>
|
||||||
|
<Ionicons name="search-outline" size={20} color="#94A3B8" />
|
||||||
|
<TextInput
|
||||||
|
editable={false}
|
||||||
|
placeholder="Suchen..."
|
||||||
|
placeholderTextColor="#94A3B8"
|
||||||
|
style={styles.searchInput}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Quick Actions Grid */}
|
||||||
|
<View style={styles.section}>
|
||||||
|
<Text style={styles.sectionTitle}>Schnellzugriff</Text>
|
||||||
|
<View style={styles.grid}>
|
||||||
|
{QUICK_ACTIONS.map((action, i) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={i}
|
||||||
|
style={styles.gridItem}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
onPress={() => router.push(action.route as never)}
|
||||||
|
>
|
||||||
|
<View style={[styles.gridIcon, { backgroundColor: action.bg }]}>
|
||||||
|
<Ionicons name={action.icon as any} size={24} color={action.color} />
|
||||||
|
</View>
|
||||||
|
<Text style={styles.gridLabel}>{action.label}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* News Section */}
|
||||||
|
<View style={styles.section}>
|
||||||
|
<View style={styles.sectionHeader}>
|
||||||
|
<Text style={styles.sectionTitle}>Aktuelles</Text>
|
||||||
|
<TouchableOpacity onPress={() => router.push('/(app)/news' as never)}>
|
||||||
|
<Text style={styles.linkText}>Alle anzeigen</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.cardsColumn}>
|
||||||
|
{latestNews.map((item) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={item.id}
|
||||||
|
style={styles.newsCard}
|
||||||
|
activeOpacity={0.9}
|
||||||
|
onPress={() => router.push(`/(app)/news/${item.id}` as never)}
|
||||||
|
>
|
||||||
|
<View style={styles.newsHeader}>
|
||||||
|
<View style={styles.categoryBadge}>
|
||||||
|
<Text style={styles.categoryText}>
|
||||||
|
{NEWS_KATEGORIE_LABELS[item.kategorie]}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.dateText}>
|
||||||
|
{item.publishedAt ? format(item.publishedAt, 'dd. MMM', { locale: de }) : 'Entwurf'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text style={styles.newsTitle} numberOfLines={2}>{item.title}</Text>
|
||||||
|
<Text style={styles.newsBody} numberOfLines={2}>{getNewsExcerpt(item.body)}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Upcoming Events */}
|
||||||
|
<View style={styles.section}>
|
||||||
|
<View style={styles.sectionHeader}>
|
||||||
|
<Text style={styles.sectionTitle}>Anstehende Termine</Text>
|
||||||
|
<TouchableOpacity onPress={() => router.push('/(app)/termine' as never)}>
|
||||||
|
<Text style={styles.linkText}>Kalender</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.eventsList}>
|
||||||
|
{upcomingEvents.map((event, index) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={event.id}
|
||||||
|
style={[styles.eventRow, index !== upcomingEvents.length - 1 && styles.eventBorder]}
|
||||||
|
onPress={() => router.push(`/(app)/termine/${event.id}` as never)}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<View style={styles.dateBox}>
|
||||||
|
<Text style={styles.dateMonth}>{format(event.datum, 'MMM', { locale: de })}</Text>
|
||||||
|
<Text style={styles.dateDay}>{format(event.datum, 'dd')}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.eventInfo}>
|
||||||
|
<Text style={styles.eventTitle} numberOfLines={1}>{event.titel}</Text>
|
||||||
|
<Text style={styles.eventMeta} numberOfLines={1}>
|
||||||
|
{event.uhrzeit} • {event.ort}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Ionicons name="chevron-forward" size={16} color="#CBD5E1" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
</ScrollView>
|
||||||
|
</SafeAreaView>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
|
@ -1,107 +1,238 @@
|
||||||
import {
|
import {
|
||||||
View,
|
View, Text, ScrollView, TouchableOpacity, Linking, ActivityIndicator, StyleSheet,
|
||||||
Text,
|
|
||||||
ScrollView,
|
|
||||||
TouchableOpacity,
|
|
||||||
Linking,
|
|
||||||
ActivityIndicator,
|
|
||||||
} from 'react-native'
|
} from 'react-native'
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context'
|
import { SafeAreaView } from 'react-native-safe-area-context'
|
||||||
import { useLocalSearchParams, useRouter } from 'expo-router'
|
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'
|
import { Avatar } from '@/components/ui/Avatar'
|
||||||
|
|
||||||
export default function MemberDetailScreen() {
|
export default function MemberDetailScreen() {
|
||||||
const { id } = useLocalSearchParams<{ id: string }>()
|
const { id } = useLocalSearchParams<{ id: string }>()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { data: member, isLoading } = trpc.members.byId.useQuery({ id })
|
const { data: member, isLoading } = useMemberDetail(id)
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<SafeAreaView className="flex-1 bg-white items-center justify-center">
|
<SafeAreaView style={styles.loadingContainer}>
|
||||||
<ActivityIndicator size="large" color="#E63946" />
|
<ActivityIndicator size="large" color="#003B7E" />
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!member) return null
|
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 (
|
return (
|
||||||
<SafeAreaView className="flex-1 bg-gray-50" edges={['top']}>
|
<SafeAreaView style={styles.safeArea} edges={['top']}>
|
||||||
{/* Header */}
|
{/* Nav */}
|
||||||
<View className="flex-row items-center px-4 py-3 bg-white border-b border-gray-100">
|
<View style={styles.navBar}>
|
||||||
<TouchableOpacity onPress={() => router.back()} className="mr-3">
|
<TouchableOpacity onPress={() => router.back()} style={styles.backBtn} activeOpacity={0.7}>
|
||||||
<Text className="text-brand-500 text-base">← Zurück</Text>
|
<Ionicons name="chevron-back" size={20} color="#003B7E" />
|
||||||
|
<Text style={styles.backText}>Zurück</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
<View style={styles.divider} />
|
||||||
|
|
||||||
<ScrollView>
|
<ScrollView showsVerticalScrollIndicator={false}>
|
||||||
{/* Profile Header */}
|
{/* Hero */}
|
||||||
<View className="bg-white px-6 py-8 items-center border-b border-gray-100">
|
<View style={styles.hero}>
|
||||||
<Avatar
|
<Avatar name={member.name} imageUrl={member.avatarUrl ?? undefined} size={80} shadow />
|
||||||
name={member.name}
|
<Text style={styles.heroName}>{member.name}</Text>
|
||||||
imageUrl={member.avatarUrl ?? undefined}
|
<Text style={styles.heroCompany}>{member.betrieb}</Text>
|
||||||
size={80}
|
|
||||||
/>
|
|
||||||
<Text className="text-2xl font-bold text-gray-900 mt-4">{member.name}</Text>
|
|
||||||
<Text className="text-gray-500 mt-1">{member.betrieb}</Text>
|
|
||||||
{member.istAusbildungsbetrieb && (
|
{member.istAusbildungsbetrieb && (
|
||||||
<View className="mt-2 bg-green-100 px-3 py-1 rounded-full">
|
<View style={styles.ausbildungPill}>
|
||||||
<Text className="text-green-700 text-xs font-medium">
|
<Ionicons name="school" size={12} color="#15803D" />
|
||||||
🎓 Ausbildungsbetrieb
|
<Text style={styles.ausbildungText}>Ausbildungsbetrieb</Text>
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.divider} />
|
||||||
|
|
||||||
{/* Details */}
|
{/* Details */}
|
||||||
<View className="bg-white mx-4 mt-4 rounded-2xl overflow-hidden border border-gray-100">
|
<View style={styles.card}>
|
||||||
<InfoRow label="Sparte" value={member.sparte} />
|
{fields.map(([label, value], idx) => (
|
||||||
<InfoRow label="Ort" value={member.ort} />
|
<View
|
||||||
{member.seit && (
|
key={label}
|
||||||
<InfoRow label="Mitglied seit" value={String(member.seit)} />
|
style={[styles.fieldRow, idx < fields.length - 1 && styles.fieldRowBorder]}
|
||||||
)}
|
>
|
||||||
|
<Text style={styles.fieldLabel}>{label}</Text>
|
||||||
|
<Text style={styles.fieldValue}>{value}</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Contact Buttons */}
|
{/* Actions */}
|
||||||
<View className="mx-4 mt-4 gap-3">
|
<View style={styles.actions}>
|
||||||
{member.telefon && (
|
{member.telefon && (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => Linking.openURL(`tel:${member.telefon}`)}
|
onPress={() => 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}
|
||||||
>
|
>
|
||||||
<Text className="text-white text-xl">📞</Text>
|
<Ionicons name="call" size={18} color="#FFFFFF" />
|
||||||
<Text className="text-white font-semibold text-base">
|
<Text style={styles.btnPrimaryText}>Anrufen</Text>
|
||||||
Anrufen
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
)}
|
)}
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() =>
|
onPress={() => Linking.openURL(`mailto:${member.email}`)}
|
||||||
Linking.openURL(
|
style={styles.btnSecondary}
|
||||||
`mailto:${member.email}?subject=InnungsApp%20Anfrage`
|
activeOpacity={0.8}
|
||||||
)
|
|
||||||
}
|
|
||||||
className="bg-white border border-gray-200 rounded-2xl py-4 flex-row items-center justify-center gap-2"
|
|
||||||
>
|
>
|
||||||
<Text className="text-gray-900 text-xl">✉️</Text>
|
<Ionicons name="mail-outline" size={18} color="#0F172A" />
|
||||||
<Text className="text-gray-900 font-semibold text-base">
|
<Text style={styles.btnSecondaryText}>E-Mail senden</Text>
|
||||||
E-Mail senden
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View className="h-8" />
|
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function InfoRow({ label, value }: { label: string; value: string }) {
|
const styles = StyleSheet.create({
|
||||||
return (
|
safeArea: {
|
||||||
<View className="flex-row items-center px-4 py-3 border-b border-gray-50 last:border-0">
|
flex: 1,
|
||||||
<Text className="text-sm text-gray-500 w-32">{label}</Text>
|
backgroundColor: '#F8FAFC',
|
||||||
<Text className="text-sm text-gray-900 font-medium flex-1">{value}</Text>
|
},
|
||||||
</View>
|
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',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,10 @@
|
||||||
import {
|
import {
|
||||||
View,
|
View, Text, FlatList, TextInput, TouchableOpacity, RefreshControl, StyleSheet,
|
||||||
Text,
|
|
||||||
FlatList,
|
|
||||||
TextInput,
|
|
||||||
TouchableOpacity,
|
|
||||||
RefreshControl,
|
|
||||||
} from 'react-native'
|
} from 'react-native'
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context'
|
import { SafeAreaView } from 'react-native-safe-area-context'
|
||||||
import { useRouter } from 'expo-router'
|
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 { useMembersFilterStore } from '@/store/members.store'
|
||||||
import { MemberCard } from '@/components/members/MemberCard'
|
import { MemberCard } from '@/components/members/MemberCard'
|
||||||
import { EmptyState } from '@/components/ui/EmptyState'
|
import { EmptyState } from '@/components/ui/EmptyState'
|
||||||
|
|
@ -20,78 +16,70 @@ export default function MembersScreen() {
|
||||||
const nurAusbildungsbetriebe = useMembersFilterStore((s) => s.nurAusbildungsbetriebe)
|
const nurAusbildungsbetriebe = useMembersFilterStore((s) => s.nurAusbildungsbetriebe)
|
||||||
const setSearch = useMembersFilterStore((s) => s.setSearch)
|
const setSearch = useMembersFilterStore((s) => s.setSearch)
|
||||||
const setNurAusbildungsbetriebe = useMembersFilterStore((s) => s.setNurAusbildungsbetriebe)
|
const setNurAusbildungsbetriebe = useMembersFilterStore((s) => s.setNurAusbildungsbetriebe)
|
||||||
|
const { data, isLoading, refetch, isRefetching } = useMembersList()
|
||||||
const { data, isLoading, refetch, isRefetching } = trpc.members.list.useQuery({
|
|
||||||
search: search || undefined,
|
|
||||||
ausbildungsbetrieb: nurAusbildungsbetriebe || undefined,
|
|
||||||
status: 'aktiv',
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView className="flex-1 bg-gray-50" edges={['top']}>
|
<SafeAreaView style={styles.safeArea} edges={['top']}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<View className="bg-white px-4 pt-3 pb-2 border-b border-gray-100">
|
<View style={styles.header}>
|
||||||
<Text className="text-xl font-bold text-gray-900 mb-3">Mitglieder</Text>
|
<View style={styles.titleRow}>
|
||||||
|
<Text style={styles.screenTitle}>Mitglieder</Text>
|
||||||
|
{data && (
|
||||||
|
<Text style={styles.countText}>{data.length} gesamt</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
{/* Search */}
|
{/* Search bar */}
|
||||||
<View className="flex-row items-center bg-gray-100 rounded-xl px-3 py-2 mb-2">
|
<View style={styles.searchBar}>
|
||||||
<Text className="text-gray-400 mr-2">🔍</Text>
|
<Ionicons name="search-outline" size={16} color="#64748B" />
|
||||||
<TextInput
|
<TextInput
|
||||||
className="flex-1 text-sm text-gray-900"
|
style={styles.searchInput}
|
||||||
placeholder="Name, Betrieb, Ort, Sparte ..."
|
placeholder="Name, Betrieb, Ort, Sparte ..."
|
||||||
placeholderTextColor="#9ca3af"
|
placeholderTextColor="#64748B"
|
||||||
value={search}
|
value={search}
|
||||||
onChangeText={setSearch}
|
onChangeText={setSearch}
|
||||||
clearButtonMode="while-editing"
|
clearButtonMode="while-editing"
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Filter: Ausbildungsbetriebe */}
|
{/* Training filter */}
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => setNurAusbildungsbetriebe(!nurAusbildungsbetriebe)}
|
onPress={() => setNurAusbildungsbetriebe(!nurAusbildungsbetriebe)}
|
||||||
className="flex-row items-center gap-2 py-1"
|
style={styles.toggleRow}
|
||||||
>
|
activeOpacity={0.7}
|
||||||
<View
|
|
||||||
className={`w-5 h-5 rounded border-2 items-center justify-center ${
|
|
||||||
nurAusbildungsbetriebe
|
|
||||||
? 'bg-brand-500 border-brand-500'
|
|
||||||
: 'border-gray-300 bg-white'
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
|
<View style={[styles.checkbox, nurAusbildungsbetriebe && styles.checkboxActive]}>
|
||||||
{nurAusbildungsbetriebe && (
|
{nurAusbildungsbetriebe && (
|
||||||
<Text className="text-white text-xs">✓</Text>
|
<Ionicons name="checkmark" size={11} color="#FFFFFF" />
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
<Text className="text-sm text-gray-600">Nur Ausbildungsbetriebe</Text>
|
<Text style={styles.toggleLabel}>Nur Ausbildungsbetriebe</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* List */}
|
<View style={styles.divider} />
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<LoadingSpinner />
|
<LoadingSpinner />
|
||||||
) : (
|
) : (
|
||||||
<FlatList
|
<FlatList
|
||||||
data={data ?? []}
|
data={data ?? []}
|
||||||
keyExtractor={(item) => item.id}
|
keyExtractor={(item) => item.id}
|
||||||
contentContainerStyle={{ padding: 12, gap: 8 }}
|
contentContainerStyle={styles.list}
|
||||||
refreshControl={
|
refreshControl={
|
||||||
<RefreshControl
|
<RefreshControl refreshing={isRefetching} onRefresh={refetch} tintColor="#003B7E" />
|
||||||
refreshing={isRefetching}
|
|
||||||
onRefresh={refetch}
|
|
||||||
tintColor="#E63946"
|
|
||||||
/>
|
|
||||||
}
|
}
|
||||||
renderItem={({ item }) => (
|
renderItem={({ item }) => (
|
||||||
<MemberCard
|
<MemberCard
|
||||||
member={item}
|
member={item}
|
||||||
onPress={() => router.push(`/(app)/members/${item.id}`)}
|
onPress={() => router.push(`/(app)/members/${item.id}` as never)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
ListEmptyComponent={
|
ListEmptyComponent={
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon="👥"
|
icon="people-outline"
|
||||||
title="Keine Mitglieder"
|
title="Keine Mitglieder"
|
||||||
subtitle={search ? 'Keine Treffer für Ihre Suche' : 'Noch keine Mitglieder'}
|
subtitle={search ? 'Keine Treffer für deine Suche' : 'Noch keine Mitglieder eingetragen'}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
@ -99,3 +87,81 @@ export default function MembersScreen() {
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,87 +1,383 @@
|
||||||
import {
|
import {
|
||||||
View,
|
View, Text, ScrollView, TouchableOpacity, ActivityIndicator,
|
||||||
Text,
|
StyleSheet, Platform,
|
||||||
ScrollView,
|
|
||||||
TouchableOpacity,
|
|
||||||
ActivityIndicator,
|
|
||||||
} from 'react-native'
|
} from 'react-native'
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context'
|
import { SafeAreaView } from 'react-native-safe-area-context'
|
||||||
import { useLocalSearchParams, useRouter } from 'expo-router'
|
import { useLocalSearchParams, useRouter } from 'expo-router'
|
||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
import { trpc } from '@/lib/trpc'
|
import { Ionicons } from '@expo/vector-icons'
|
||||||
import { useNewsReadStore } from '@/store/news.store'
|
import { useNewsDetail } from '@/hooks/useNews'
|
||||||
import { AttachmentRow } from '@/components/news/AttachmentRow'
|
import { AttachmentRow } from '@/components/news/AttachmentRow'
|
||||||
import { Badge } from '@/components/ui/Badge'
|
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 { format } from 'date-fns'
|
||||||
import { de } from 'date-fns/locale'
|
import { de } from 'date-fns/locale'
|
||||||
|
|
||||||
export default function NewsDetailScreen() {
|
// ---------------------------------------------------------------------------
|
||||||
const { id } = useLocalSearchParams<{ id: string }>()
|
// Lightweight markdown renderer (headings, bold, bullets, paragraphs)
|
||||||
const router = useRouter()
|
// ---------------------------------------------------------------------------
|
||||||
const markRead = useNewsReadStore((s) => s.markRead)
|
function MarkdownBody({ source }: { source: string }) {
|
||||||
const markReadMutation = trpc.news.markRead.useMutation()
|
const blocks = source.split(/\n\n+/)
|
||||||
|
|
||||||
const { data: news, isLoading } = trpc.news.byId.useQuery({ id })
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (news) {
|
|
||||||
markRead(id)
|
|
||||||
markReadMutation.mutate({ newsId: id })
|
|
||||||
}
|
|
||||||
}, [news?.id])
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView className="flex-1 bg-white items-center justify-center">
|
<View style={md.container}>
|
||||||
<ActivityIndicator size="large" color="#E63946" />
|
{blocks.map((block, i) => {
|
||||||
</SafeAreaView>
|
const trimmed = block.trim()
|
||||||
|
if (!trimmed) return null
|
||||||
|
|
||||||
|
// H2 ##
|
||||||
|
if (trimmed.startsWith('## ')) {
|
||||||
|
return (
|
||||||
|
<Text key={i} style={md.h2}>
|
||||||
|
{trimmed.slice(3)}
|
||||||
|
</Text>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// H3 ###
|
||||||
|
if (trimmed.startsWith('### ')) {
|
||||||
|
return (
|
||||||
|
<Text key={i} style={md.h3}>
|
||||||
|
{trimmed.slice(4)}
|
||||||
|
</Text>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// H1 #
|
||||||
|
if (trimmed.startsWith('# ')) {
|
||||||
|
return (
|
||||||
|
<Text key={i} style={md.h1}>
|
||||||
|
{trimmed.slice(2)}
|
||||||
|
</Text>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Bullet list
|
||||||
|
if (trimmed.startsWith('- ')) {
|
||||||
|
const items = trimmed.split('\n').filter(Boolean)
|
||||||
|
return (
|
||||||
|
<View key={i} style={md.list}>
|
||||||
|
{items.map((line, j) => {
|
||||||
|
const text = line.replace(/^-\s+/, '')
|
||||||
|
return (
|
||||||
|
<View key={j} style={md.listItem}>
|
||||||
|
<View style={md.bullet} />
|
||||||
|
<Text style={md.listText}>{renderInline(text)}</Text>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Paragraph (with inline bold)
|
||||||
|
return (
|
||||||
|
<Text key={i} style={md.paragraph}>
|
||||||
|
{renderInline(trimmed)}
|
||||||
|
</Text>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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 (
|
||||||
|
<Text key={i} style={{ fontWeight: '700', color: '#0F172A' }}>
|
||||||
|
{part.slice(2, -2)}
|
||||||
|
</Text>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return <Text key={i}>{part}</Text>
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Screen
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
export default function NewsDetailScreen() {
|
||||||
|
const { id } = useLocalSearchParams<{ id: string }>()
|
||||||
|
const router = useRouter()
|
||||||
|
const { data: news, isLoading, onOpen } = useNewsDetail(id)
|
||||||
|
|
||||||
|
useEffect(() => { if (news) onOpen() }, [news?.id])
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={styles.loadingContainer}>
|
||||||
|
<ActivityIndicator size="large" color="#003B7E" />
|
||||||
|
</SafeAreaView>
|
||||||
|
)
|
||||||
|
}
|
||||||
if (!news) return null
|
if (!news) return null
|
||||||
|
|
||||||
|
const initials = (news.author?.name ?? 'I')
|
||||||
|
.split(' ')
|
||||||
|
.map((n) => n.charAt(0))
|
||||||
|
.slice(0, 2)
|
||||||
|
.join('')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView className="flex-1 bg-white" edges={['top']}>
|
<SafeAreaView style={styles.safeArea} edges={['top']}>
|
||||||
{/* Header */}
|
{/* Nav bar */}
|
||||||
<View className="flex-row items-center px-4 py-3 border-b border-gray-100">
|
<View style={styles.navBar}>
|
||||||
<TouchableOpacity onPress={() => router.back()} className="mr-3">
|
<TouchableOpacity
|
||||||
<Text className="text-brand-500 text-base">← Zurück</Text>
|
onPress={() => router.back()}
|
||||||
|
style={styles.backBtn}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<Ionicons name="chevron-back" size={22} color="#003B7E" />
|
||||||
|
<Text style={styles.backText}>Neuigkeiten</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<Text className="font-semibold text-gray-900 flex-1" numberOfLines={1}>
|
|
||||||
{news.title}
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<ScrollView contentContainerStyle={{ padding: 16 }}>
|
<ScrollView
|
||||||
<Badge label={NEWS_KATEGORIE_LABELS[news.kategorie]} kategorie={news.kategorie} />
|
contentContainerStyle={styles.scrollContent}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
>
|
||||||
|
{/* ── Hero header ────────────────────────────────────────── */}
|
||||||
|
<View style={styles.hero}>
|
||||||
|
<Badge
|
||||||
|
label={NEWS_KATEGORIE_LABELS[news.kategorie]}
|
||||||
|
kategorie={news.kategorie}
|
||||||
|
/>
|
||||||
|
|
||||||
<Text className="text-2xl font-bold text-gray-900 mt-3 mb-2">
|
<Text style={styles.heroTitle}>{news.title}</Text>
|
||||||
{news.title}
|
|
||||||
|
{/* Author + date row */}
|
||||||
|
<View style={styles.metaRow}>
|
||||||
|
<View style={styles.avatarCircle}>
|
||||||
|
<Text style={styles.avatarText}>{initials}</Text>
|
||||||
|
</View>
|
||||||
|
<View>
|
||||||
|
<Text style={styles.authorName}>{news.author?.name ?? 'Innung'}</Text>
|
||||||
|
{news.publishedAt && (
|
||||||
|
<Text style={styles.dateText}>
|
||||||
|
{format(new Date(news.publishedAt), 'dd. MMMM yyyy', { locale: de })}
|
||||||
</Text>
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
<Text className="text-sm text-gray-500 mb-6">
|
{/* ── Separator ──────────────────────────────────────────── */}
|
||||||
{news.author?.name ?? 'InnungsApp'} ·{' '}
|
<View style={styles.heroSeparator} />
|
||||||
{news.publishedAt
|
|
||||||
? format(new Date(news.publishedAt), 'dd. MMMM yyyy', { locale: de })
|
|
||||||
: ''}
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
{/* Simple Markdown renderer — plain text for MVP */}
|
{/* ── Article body ───────────────────────────────────────── */}
|
||||||
<Text className="text-base text-gray-700 leading-7">
|
<MarkdownBody source={news.body} />
|
||||||
{news.body.replace(/^#+\s/gm, '').replace(/\*\*(.*?)\*\*/g, '$1')}
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
{/* Attachments */}
|
{/* ── Attachments ────────────────────────────────────────── */}
|
||||||
{news.attachments.length > 0 && (
|
{news.attachments.length > 0 && (
|
||||||
<View className="mt-8 border-t border-gray-100 pt-4">
|
<View style={styles.attachmentsSection}>
|
||||||
<Text className="font-semibold text-gray-900 mb-3">Anhänge</Text>
|
<View style={styles.attachmentsHeader}>
|
||||||
{news.attachments.map((a) => (
|
<Ionicons name="attach" size={14} color="#64748B" />
|
||||||
<AttachmentRow key={a.id} attachment={a} />
|
<Text style={styles.attachmentsLabel}>
|
||||||
|
ANHÄNGE ({news.attachments.length})
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.attachmentsCard}>
|
||||||
|
{news.attachments.map((a, idx) => (
|
||||||
|
<View key={a.id}>
|
||||||
|
{idx > 0 && <View style={styles.attachmentsDivider} />}
|
||||||
|
<AttachmentRow attachment={a} />
|
||||||
|
</View>
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Bottom spacer */}
|
||||||
|
<View style={{ height: 48 }} />
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 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,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,100 +1,157 @@
|
||||||
import {
|
import {
|
||||||
View,
|
View, Text, FlatList, TouchableOpacity, RefreshControl, ScrollView, StyleSheet,
|
||||||
Text,
|
|
||||||
FlatList,
|
|
||||||
TouchableOpacity,
|
|
||||||
RefreshControl,
|
|
||||||
ScrollView,
|
|
||||||
} from 'react-native'
|
} from 'react-native'
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context'
|
import { SafeAreaView } from 'react-native-safe-area-context'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { trpc } from '@/lib/trpc'
|
|
||||||
import { useRouter } from 'expo-router'
|
import { useRouter } from 'expo-router'
|
||||||
|
import { useNewsList } from '@/hooks/useNews'
|
||||||
import { NewsCard } from '@/components/news/NewsCard'
|
import { NewsCard } from '@/components/news/NewsCard'
|
||||||
import { EmptyState } from '@/components/ui/EmptyState'
|
import { EmptyState } from '@/components/ui/EmptyState'
|
||||||
import { LoadingSpinner } from '@/components/ui/LoadingSpinner'
|
import { LoadingSpinner } from '@/components/ui/LoadingSpinner'
|
||||||
import { NEWS_KATEGORIE_LABELS } from '@innungsapp/shared'
|
|
||||||
|
|
||||||
const FILTER_OPTIONS = [
|
const FILTERS = [
|
||||||
{ value: undefined, label: 'Alle' },
|
{ value: undefined, label: 'Alle' },
|
||||||
{ value: 'Wichtig', label: 'Wichtig' },
|
{ value: 'Wichtig', label: 'Wichtig' },
|
||||||
{ value: 'Pruefung', label: 'Prüfung' },
|
{ value: 'Pruefung', label: 'Pruefung' },
|
||||||
{ value: 'Foerderung', label: 'Förderung' },
|
{ value: 'Foerderung', label: 'Foerderung' },
|
||||||
{ value: 'Veranstaltung', label: 'Veranstaltung' },
|
{ value: 'Veranstaltung', label: 'Veranstaltung' },
|
||||||
]
|
]
|
||||||
|
|
||||||
export default function NewsScreen() {
|
export default function NewsScreen() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [kategorie, setKategorie] = useState<string | undefined>(undefined)
|
const [kategorie, setKategorie] = useState<string | undefined>(undefined)
|
||||||
const { data, isLoading, refetch, isRefetching } = trpc.news.list.useQuery({
|
const { data, isLoading, refetch, isRefetching } = useNewsList(kategorie)
|
||||||
kategorie: kategorie as never,
|
|
||||||
})
|
const unreadCount = data?.filter((n) => !n.isRead).length ?? 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView className="flex-1 bg-gray-50" edges={['top']}>
|
<SafeAreaView style={styles.safeArea} edges={['top']}>
|
||||||
{/* Header */}
|
<View style={styles.header}>
|
||||||
<View className="bg-white px-4 py-3 border-b border-gray-100">
|
<View style={styles.titleRow}>
|
||||||
<Text className="text-xl font-bold text-gray-900">News</Text>
|
<Text style={styles.screenTitle}>Aktuelles</Text>
|
||||||
|
{unreadCount > 0 && (
|
||||||
|
<View style={styles.unreadBadge}>
|
||||||
|
<Text style={styles.unreadBadgeText}>{unreadCount} neu</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Kategorie Filter */}
|
|
||||||
<ScrollView
|
<ScrollView
|
||||||
horizontal
|
horizontal
|
||||||
showsHorizontalScrollIndicator={false}
|
showsHorizontalScrollIndicator={false}
|
||||||
className="bg-white border-b border-gray-100"
|
contentContainerStyle={styles.filterScroll}
|
||||||
contentContainerStyle={{ paddingHorizontal: 12, paddingVertical: 10, gap: 8 }}
|
|
||||||
>
|
>
|
||||||
{FILTER_OPTIONS.map((opt) => (
|
{FILTERS.map((opt) => {
|
||||||
|
const active = kategorie === opt.value
|
||||||
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
key={String(opt.value)}
|
key={String(opt.value)}
|
||||||
onPress={() => setKategorie(opt.value)}
|
onPress={() => setKategorie(opt.value)}
|
||||||
className={`px-4 py-1.5 rounded-full border ${
|
style={[styles.chip, active && styles.chipActive]}
|
||||||
kategorie === opt.value
|
activeOpacity={0.85}
|
||||||
? 'bg-brand-500 border-brand-500'
|
|
||||||
: 'bg-white border-gray-200'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
className={`text-sm font-medium ${
|
|
||||||
kategorie === opt.value ? 'text-white' : 'text-gray-600'
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
|
<Text style={[styles.chipLabel, active && styles.chipLabelActive]}>
|
||||||
{opt.label}
|
{opt.label}
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.divider} />
|
||||||
|
|
||||||
{/* List */}
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<LoadingSpinner />
|
<LoadingSpinner />
|
||||||
) : (
|
) : (
|
||||||
<FlatList
|
<FlatList
|
||||||
data={data ?? []}
|
data={data ?? []}
|
||||||
keyExtractor={(item) => item.id}
|
keyExtractor={(item) => item.id}
|
||||||
contentContainerStyle={{ padding: 12, gap: 8 }}
|
contentContainerStyle={styles.list}
|
||||||
refreshControl={
|
refreshControl={
|
||||||
<RefreshControl
|
<RefreshControl refreshing={isRefetching} onRefresh={refetch} tintColor="#003B7E" />
|
||||||
refreshing={isRefetching}
|
|
||||||
onRefresh={refetch}
|
|
||||||
tintColor="#E63946"
|
|
||||||
/>
|
|
||||||
}
|
}
|
||||||
renderItem={({ item }) => (
|
renderItem={({ item }) => (
|
||||||
<NewsCard
|
<NewsCard
|
||||||
news={item}
|
news={item}
|
||||||
onPress={() => router.push(`/(app)/news/${item.id}`)}
|
onPress={() => router.push(`/(app)/news/${item.id}` as never)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
ListEmptyComponent={
|
ListEmptyComponent={
|
||||||
<EmptyState
|
<EmptyState icon="N" title="Keine News" subtitle="Noch keine Beitraege veroeffentlicht." />
|
||||||
icon="📰"
|
|
||||||
title="Keine News"
|
|
||||||
subtitle="Noch keine Beiträge für diese Kategorie"
|
|
||||||
/>
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,97 +1,330 @@
|
||||||
import {
|
import { View, Text, ScrollView, TouchableOpacity, Alert, StyleSheet } from 'react-native'
|
||||||
View,
|
|
||||||
Text,
|
|
||||||
ScrollView,
|
|
||||||
TouchableOpacity,
|
|
||||||
Linking,
|
|
||||||
ActivityIndicator,
|
|
||||||
} from 'react-native'
|
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context'
|
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 { 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<typeof Ionicons>['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() {
|
export default function ProfilScreen() {
|
||||||
const { signOut } = useAuth()
|
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 (
|
return (
|
||||||
<SafeAreaView className="flex-1 bg-gray-50" edges={['top']}>
|
<SafeAreaView style={styles.safeArea} edges={['top']}>
|
||||||
<View className="bg-white px-4 py-3 border-b border-gray-100">
|
<ScrollView contentContainerStyle={styles.content} showsVerticalScrollIndicator={false}>
|
||||||
<Text className="text-xl font-bold text-gray-900">Mein Profil</Text>
|
<View style={styles.hero}>
|
||||||
|
<View style={styles.avatarWrap}>
|
||||||
|
<Text style={styles.avatarText}>{initials}</Text>
|
||||||
|
<TouchableOpacity style={styles.settingsBtn} activeOpacity={0.8} onPress={openPlaceholder}>
|
||||||
|
<Ionicons name="settings-outline" size={15} color="#64748B" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.name}>{member.name}</Text>
|
||||||
|
<Text style={styles.role}>Innungsgeschaeftsfuehrer</Text>
|
||||||
|
<View style={styles.badgesRow}>
|
||||||
|
<View style={styles.statusBadge}>
|
||||||
|
<Text style={styles.statusBadgeText}>Admin-Status</Text>
|
||||||
|
</View>
|
||||||
|
<View style={[styles.statusBadge, styles.verifyBadge]}>
|
||||||
|
<Text style={[styles.statusBadgeText, styles.verifyBadgeText]}>Verifiziert</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<ScrollView>
|
<Text style={styles.sectionTitle}>Mein Account</Text>
|
||||||
{isLoading ? (
|
<View style={styles.menuCard}>
|
||||||
<View className="py-16 items-center">
|
{MENU_ITEMS.map((item, index) => (
|
||||||
<ActivityIndicator color="#E63946" />
|
<TouchableOpacity
|
||||||
|
key={item.label}
|
||||||
|
style={[styles.menuRow, index < MENU_ITEMS.length - 1 && styles.menuRowBorder]}
|
||||||
|
activeOpacity={0.82}
|
||||||
|
onPress={openPlaceholder}
|
||||||
|
>
|
||||||
|
<View style={styles.menuLeft}>
|
||||||
|
<View style={styles.menuIcon}>
|
||||||
|
<Ionicons name={item.icon} size={18} color="#475569" />
|
||||||
</View>
|
</View>
|
||||||
) : member ? (
|
<Text style={styles.menuLabel}>{item.label}</Text>
|
||||||
<>
|
|
||||||
{/* Profile */}
|
|
||||||
<View className="bg-white px-4 py-8 items-center border-b border-gray-100">
|
|
||||||
<Avatar name={member.name} size={72} />
|
|
||||||
<Text className="text-xl font-bold text-gray-900 mt-4">{member.name}</Text>
|
|
||||||
<Text className="text-gray-500 mt-1">{member.betrieb}</Text>
|
|
||||||
<Text className="text-gray-400 text-sm mt-0.5">{member.org.name}</Text>
|
|
||||||
</View>
|
</View>
|
||||||
|
<View style={styles.menuRight}>
|
||||||
{/* Member Details */}
|
{item.badge ? (
|
||||||
<View className="bg-white mx-4 mt-4 rounded-2xl overflow-hidden border border-gray-100">
|
<View style={[styles.rowBadge, item.badge === 'Aktiv' ? styles.rowBadgeActive : styles.rowBadgeAlert]}>
|
||||||
<InfoRow label="E-Mail" value={member.email} />
|
<Text style={styles.rowBadgeText}>{item.badge}</Text>
|
||||||
{member.telefon && <InfoRow label="Telefon" value={member.telefon} />}
|
|
||||||
<InfoRow label="Sparte" value={member.sparte} />
|
|
||||||
<InfoRow label="Ort" value={member.ort} />
|
|
||||||
{member.seit && <InfoRow label="Mitglied seit" value={String(member.seit)} />}
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View className="mx-4 mt-2">
|
|
||||||
<Text className="text-xs text-gray-400 px-1">
|
|
||||||
Änderungen an Ihren Daten nehmen Sie über die Innungsgeschäftsstelle vor.
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</>
|
|
||||||
) : null}
|
) : null}
|
||||||
|
<Ionicons name="chevron-forward" size={16} color="#94A3B8" />
|
||||||
{/* Links */}
|
</View>
|
||||||
<View className="bg-white mx-4 mt-4 rounded-2xl overflow-hidden border border-gray-100">
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => Linking.openURL('https://innungsapp.de/datenschutz')}
|
|
||||||
className="flex-row items-center justify-between px-4 py-3.5 border-b border-gray-50"
|
|
||||||
>
|
|
||||||
<Text className="text-gray-700">Datenschutzerklärung</Text>
|
|
||||||
<Text className="text-gray-400">›</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => Linking.openURL('https://innungsapp.de/impressum')}
|
|
||||||
className="flex-row items-center justify-between px-4 py-3.5"
|
|
||||||
>
|
|
||||||
<Text className="text-gray-700">Impressum</Text>
|
|
||||||
<Text className="text-gray-400">›</Text>
|
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Logout */}
|
<Text style={styles.sectionTitle}>Unterstuetzung</Text>
|
||||||
<View className="mx-4 mt-4">
|
<View style={styles.supportCard}>
|
||||||
<TouchableOpacity
|
<Text style={styles.supportTitle}>Probleme oder Fragen?</Text>
|
||||||
onPress={signOut}
|
<Text style={styles.supportText}>
|
||||||
className="bg-red-50 border border-red-200 rounded-2xl py-4 items-center"
|
Unser Support-Team hilft Ihnen gerne bei technischen Schwierigkeiten weiter.
|
||||||
>
|
</Text>
|
||||||
<Text className="text-red-600 font-semibold">Abmelden</Text>
|
<TouchableOpacity style={styles.supportBtn} activeOpacity={0.84} onPress={openPlaceholder}>
|
||||||
|
<Text style={styles.supportBtnText}>Support kontaktieren</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
<Ionicons name="help-circle-outline" size={70} color="rgba(255,255,255,0.12)" style={styles.supportIcon} />
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View className="h-8" />
|
<TouchableOpacity style={styles.logoutBtn} activeOpacity={0.84} onPress={() => void signOut()}>
|
||||||
|
<Ionicons name="log-out-outline" size={20} color="#B91C1C" />
|
||||||
|
<Text style={styles.logoutText}>Abmelden</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<Text style={styles.footer}>InnungsApp Version 2.4.0</Text>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function InfoRow({ label, value }: { label: string; value: string }) {
|
const styles = StyleSheet.create({
|
||||||
return (
|
safeArea: {
|
||||||
<View className="flex-row items-center px-4 py-3 border-b border-gray-50 last:border-0">
|
flex: 1,
|
||||||
<Text className="text-sm text-gray-500 w-28">{label}</Text>
|
backgroundColor: '#F8FAFC',
|
||||||
<Text className="text-sm text-gray-900 flex-1">{value}</Text>
|
},
|
||||||
</View>
|
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',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,94 +1,335 @@
|
||||||
import {
|
import {
|
||||||
View,
|
View, Text, ScrollView, TouchableOpacity, Linking, ActivityIndicator, StyleSheet, Platform
|
||||||
Text,
|
|
||||||
ScrollView,
|
|
||||||
TouchableOpacity,
|
|
||||||
Linking,
|
|
||||||
ActivityIndicator,
|
|
||||||
} from 'react-native'
|
} from 'react-native'
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context'
|
import { SafeAreaView } from 'react-native-safe-area-context'
|
||||||
import { useLocalSearchParams, useRouter } from 'expo-router'
|
import { useLocalSearchParams, useRouter, Stack } from 'expo-router'
|
||||||
import { trpc } from '@/lib/trpc'
|
import { Ionicons } from '@expo/vector-icons'
|
||||||
|
import { useStelleDetail } from '@/hooks/useStellen'
|
||||||
|
import { Button } from '@/components/ui/Button'
|
||||||
|
|
||||||
|
const SPARTE_COLOR: Record<string, string> = {
|
||||||
|
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() {
|
export default function StelleDetailScreen() {
|
||||||
const { id } = useLocalSearchParams<{ id: string }>()
|
const { id } = useLocalSearchParams<{ id: string }>()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { data: stelle, isLoading } = trpc.stellen.byId.useQuery({ id })
|
const { data: stelle, isLoading } = useStelleDetail(id)
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<SafeAreaView className="flex-1 bg-white items-center justify-center">
|
<View style={styles.loadingContainer}>
|
||||||
<ActivityIndicator size="large" color="#E63946" />
|
<ActivityIndicator size="large" color="#003B7E" />
|
||||||
</SafeAreaView>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!stelle) return null
|
if (!stelle) return null
|
||||||
|
|
||||||
const betreffVorlage = `Bewerbung als Auszubildender bei ${stelle.member.betrieb}`
|
const color = getSparteColor(stelle.sparte)
|
||||||
const bewerbungsUrl = `mailto:${stelle.kontaktEmail}?subject=${encodeURIComponent(betreffVorlage)}`
|
const initial = stelle.sparte.charAt(0).toUpperCase()
|
||||||
|
const bewerbungsUrl = `mailto:${stelle.kontaktEmail}?subject=${encodeURIComponent(`Bewerbung als Auszubildender bei ${stelle.member.betrieb}`)}`
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView className="flex-1 bg-gray-50" edges={['top']}>
|
<>
|
||||||
<View className="flex-row items-center px-4 py-3 bg-white border-b border-gray-100">
|
<Stack.Screen options={{ headerShown: false }} />
|
||||||
<TouchableOpacity onPress={() => router.back()} className="mr-3">
|
<SafeAreaView style={styles.container} edges={['top']}>
|
||||||
<Text className="text-brand-500 text-base">← Zurück</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<ScrollView>
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<View className="bg-white px-4 py-6 border-b border-gray-100">
|
<View style={styles.header}>
|
||||||
<View className="bg-brand-50 rounded-xl w-16 h-16 items-center justify-center mb-4">
|
<TouchableOpacity onPress={() => router.back()} style={styles.backButton}>
|
||||||
<Text className="text-3xl">🎓</Text>
|
<Ionicons name="arrow-back" size={24} color="#0F172A" />
|
||||||
</View>
|
|
||||||
<Text className="text-2xl font-bold text-gray-900">{stelle.member.betrieb}</Text>
|
|
||||||
<Text className="text-gray-500 mt-1">{stelle.member.ort}</Text>
|
|
||||||
<Text className="text-gray-500">{stelle.org.name}</Text>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Details */}
|
|
||||||
<View className="bg-white mx-4 mt-4 rounded-2xl overflow-hidden border border-gray-100">
|
|
||||||
<DetailRow label="Sparte" value={stelle.sparte} />
|
|
||||||
<DetailRow label="Anzahl Stellen" value={String(stelle.stellenAnz)} />
|
|
||||||
{stelle.lehrjahr && <DetailRow label="Lehrjahr" value={stelle.lehrjahr} />}
|
|
||||||
{stelle.verguetung && <DetailRow label="Vergütung" value={stelle.verguetung} />}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{stelle.beschreibung && (
|
|
||||||
<View className="bg-white mx-4 mt-4 rounded-2xl p-4 border border-gray-100">
|
|
||||||
<Text className="font-semibold text-gray-900 mb-2">Über die Stelle</Text>
|
|
||||||
<Text className="text-gray-600 leading-6">{stelle.beschreibung}</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* CTA */}
|
|
||||||
<View className="mx-4 mt-6">
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => Linking.openURL(bewerbungsUrl)}
|
|
||||||
className="bg-brand-500 rounded-2xl py-4 flex-row items-center justify-center gap-2"
|
|
||||||
>
|
|
||||||
<Text className="text-white text-xl">✉️</Text>
|
|
||||||
<Text className="text-white font-semibold text-base">Jetzt bewerben</Text>
|
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
{stelle.kontaktName && (
|
<View style={styles.headerSpacer} />
|
||||||
<Text className="text-center text-sm text-gray-400 mt-3">
|
<TouchableOpacity style={styles.shareButton}>
|
||||||
Ansprechperson: {stelle.kontaktName}
|
<Ionicons name="share-outline" size={24} color="#0F172A" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<ScrollView contentContainerStyle={styles.scrollContent} showsVerticalScrollIndicator={false}>
|
||||||
|
|
||||||
|
{/* Header Card */}
|
||||||
|
<View style={styles.heroCard}>
|
||||||
|
<View style={[styles.logoBox, { backgroundColor: color + '15' }]}>
|
||||||
|
<Text style={[styles.logoText, { color }]}>{initial}</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.jobTitle}>Auszubildender {stelle.sparte}</Text>
|
||||||
|
<Text style={styles.companyName}>{stelle.member.betrieb}</Text>
|
||||||
|
|
||||||
|
<View style={styles.locationBadge}>
|
||||||
|
<Ionicons name="location-sharp" size={14} color="#64748B" />
|
||||||
|
<Text style={styles.locationText}>
|
||||||
|
{stelle.member.ort} · {stelle.org.name}
|
||||||
</Text>
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Key Facts */}
|
||||||
|
<Text style={styles.sectionHeader}>Eckdaten</Text>
|
||||||
|
<View style={styles.factsContainer}>
|
||||||
|
<View style={styles.factItem}>
|
||||||
|
<View style={styles.factIconBox}>
|
||||||
|
<Ionicons name="people-outline" size={20} color="#003B7E" />
|
||||||
|
</View>
|
||||||
|
<View>
|
||||||
|
<Text style={styles.factLabel}>Anzahl Stellen</Text>
|
||||||
|
<Text style={styles.factValue}>{stelle.stellenAnz}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{stelle.lehrjahr && (
|
||||||
|
<View style={styles.factItem}>
|
||||||
|
<View style={styles.factIconBox}>
|
||||||
|
<Ionicons name="school-outline" size={20} color="#003B7E" />
|
||||||
|
</View>
|
||||||
|
<View>
|
||||||
|
<Text style={styles.factLabel}>Lehrjahr</Text>
|
||||||
|
<Text style={styles.factValue}>{stelle.lehrjahr}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{stelle.verguetung && (
|
||||||
|
<View style={styles.factItem}>
|
||||||
|
<View style={styles.factIconBox}>
|
||||||
|
<Ionicons name="cash-outline" size={20} color="#003B7E" />
|
||||||
|
</View>
|
||||||
|
<View>
|
||||||
|
<Text style={styles.factLabel}>Vergütung</Text>
|
||||||
|
<Text style={styles.factValue}>{stelle.verguetung}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View className="h-8" />
|
{/* Description */}
|
||||||
|
{stelle.beschreibung && (
|
||||||
|
<View style={styles.section}>
|
||||||
|
<Text style={styles.sectionHeader}>Beschreibung</Text>
|
||||||
|
<Text style={styles.description}>{stelle.beschreibung}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Contact */}
|
||||||
|
{stelle.kontaktName && (
|
||||||
|
<View style={styles.contactBox}>
|
||||||
|
<Text style={styles.contactTitle}>Ansprechpartner</Text>
|
||||||
|
<View style={styles.contactRow}>
|
||||||
|
<View style={styles.avatarPlaceholder}>
|
||||||
|
<Text style={styles.avatarInitials}>{stelle.kontaktName.charAt(0)}</Text>
|
||||||
|
</View>
|
||||||
|
<View>
|
||||||
|
<Text style={styles.contactName}>{stelle.kontaktName}</Text>
|
||||||
|
<Text style={styles.contactRole}>Recruiting</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<View style={styles.footer}>
|
||||||
|
<Button label="Jetzt bewerben" onPress={() => Linking.openURL(bewerbungsUrl)} />
|
||||||
|
</View>
|
||||||
|
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function DetailRow({ label, value }: { label: string; value: string }) {
|
const styles = StyleSheet.create({
|
||||||
return (
|
container: {
|
||||||
<View className="flex-row items-center px-4 py-3 border-b border-gray-50 last:border-0">
|
flex: 1,
|
||||||
<Text className="text-sm text-gray-500 w-32">{label}</Text>
|
backgroundColor: '#F8FAFC',
|
||||||
<Text className="text-sm text-gray-900 font-medium flex-1">{value}</Text>
|
},
|
||||||
</View>
|
loadingContainer: {
|
||||||
)
|
flex: 1,
|
||||||
}
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 12,
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: '#F8FAFC',
|
||||||
|
},
|
||||||
|
backButton: {
|
||||||
|
padding: 8,
|
||||||
|
borderRadius: 12,
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 1 },
|
||||||
|
shadowOpacity: 0.05,
|
||||||
|
shadowRadius: 2,
|
||||||
|
elevation: 1,
|
||||||
|
},
|
||||||
|
headerSpacer: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
shareButton: {
|
||||||
|
padding: 8,
|
||||||
|
},
|
||||||
|
scrollContent: {
|
||||||
|
padding: 24,
|
||||||
|
paddingBottom: 40,
|
||||||
|
},
|
||||||
|
heroCard: {
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 32,
|
||||||
|
},
|
||||||
|
logoBox: {
|
||||||
|
width: 80,
|
||||||
|
height: 80,
|
||||||
|
borderRadius: 24,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
logoText: {
|
||||||
|
fontSize: 32,
|
||||||
|
fontWeight: '800',
|
||||||
|
},
|
||||||
|
jobTitle: {
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: '800',
|
||||||
|
color: '#0F172A',
|
||||||
|
textAlign: 'center',
|
||||||
|
marginBottom: 8,
|
||||||
|
letterSpacing: -0.5,
|
||||||
|
},
|
||||||
|
companyName: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#64748B',
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
locationBadge: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: '#F1F5F9',
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 6,
|
||||||
|
borderRadius: 99,
|
||||||
|
gap: 6,
|
||||||
|
},
|
||||||
|
locationText: {
|
||||||
|
fontSize: 13,
|
||||||
|
color: '#475569',
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
sectionHeader: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#0F172A',
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
factsContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: 12,
|
||||||
|
marginBottom: 32,
|
||||||
|
},
|
||||||
|
factItem: {
|
||||||
|
flex: 1,
|
||||||
|
minWidth: '45%',
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
padding: 16,
|
||||||
|
borderRadius: 16,
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 12,
|
||||||
|
shadowColor: '#64748B',
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
shadowOpacity: 0.03,
|
||||||
|
shadowRadius: 8,
|
||||||
|
elevation: 1,
|
||||||
|
},
|
||||||
|
factIconBox: {
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
borderRadius: 10,
|
||||||
|
backgroundColor: '#EFF6FF',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
factLabel: {
|
||||||
|
fontSize: 11,
|
||||||
|
color: '#94A3B8',
|
||||||
|
fontWeight: '600',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
marginBottom: 2,
|
||||||
|
},
|
||||||
|
factValue: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#0F172A',
|
||||||
|
},
|
||||||
|
section: {
|
||||||
|
marginBottom: 32,
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
fontSize: 16,
|
||||||
|
lineHeight: 26,
|
||||||
|
color: '#334155',
|
||||||
|
},
|
||||||
|
contactBox: {
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
padding: 20,
|
||||||
|
borderRadius: 20,
|
||||||
|
marginBottom: 32,
|
||||||
|
},
|
||||||
|
contactTitle: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#94A3B8',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
marginBottom: 16,
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
},
|
||||||
|
contactRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 12,
|
||||||
|
},
|
||||||
|
avatarPlaceholder: {
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
borderRadius: 24,
|
||||||
|
backgroundColor: '#F1F5F9',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
avatarInitials: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#64748B',
|
||||||
|
},
|
||||||
|
contactName: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#0F172A',
|
||||||
|
},
|
||||||
|
contactRole: {
|
||||||
|
fontSize: 13,
|
||||||
|
color: '#64748B',
|
||||||
|
},
|
||||||
|
footer: {
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
padding: 20,
|
||||||
|
paddingBottom: Platform.OS === 'ios' ? 32 : 20,
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: '#F1F5F9',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,70 +1,60 @@
|
||||||
import {
|
import { View, Text, FlatList, RefreshControl, StyleSheet } from 'react-native'
|
||||||
View,
|
|
||||||
Text,
|
|
||||||
FlatList,
|
|
||||||
TouchableOpacity,
|
|
||||||
RefreshControl,
|
|
||||||
} from 'react-native'
|
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context'
|
import { SafeAreaView } from 'react-native-safe-area-context'
|
||||||
import { useRouter } from 'expo-router'
|
import { useRouter } from 'expo-router'
|
||||||
import { trpc } from '@/lib/trpc'
|
import { useStellenListe } from '@/hooks/useStellen'
|
||||||
import { StelleCard } from '@/components/stellen/StelleCard'
|
import { StelleCard } from '@/components/stellen/StelleCard'
|
||||||
import { EmptyState } from '@/components/ui/EmptyState'
|
import { EmptyState } from '@/components/ui/EmptyState'
|
||||||
import { LoadingSpinner } from '@/components/ui/LoadingSpinner'
|
import { LoadingSpinner } from '@/components/ui/LoadingSpinner'
|
||||||
import { useAuth } from '@/hooks/useAuth'
|
|
||||||
|
|
||||||
export default function StellenScreen() {
|
export default function StellenScreen() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { isAuthenticated } = useAuth()
|
const { data, isLoading, refetch, isRefetching } = useStellenListe()
|
||||||
const { data, isLoading, refetch, isRefetching } = trpc.stellen.listPublic.useQuery({})
|
|
||||||
|
const totalStellen = data?.reduce((sum, s) => sum + s.stellenAnz, 0) ?? 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView className="flex-1 bg-gray-50" edges={['top']}>
|
<SafeAreaView style={styles.safeArea} edges={['top']}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<View className="bg-white px-4 pt-3 pb-3 border-b border-gray-100">
|
<View style={styles.header}>
|
||||||
<View className="flex-row items-center justify-between">
|
<View style={styles.titleRow}>
|
||||||
<View>
|
<View style={styles.titleBlock}>
|
||||||
<Text className="text-xl font-bold text-gray-900">Lehrlingsbörse</Text>
|
<Text style={styles.screenTitle}>Lehrlingsbörse</Text>
|
||||||
<Text className="text-sm text-gray-500 mt-0.5">
|
<Text style={styles.subtitle}>Ausbildungsplätze in deiner Innung</Text>
|
||||||
{data?.length ?? 0} Angebote
|
</View>
|
||||||
</Text>
|
|
||||||
|
{/* Counter */}
|
||||||
|
{totalStellen > 0 && (
|
||||||
|
<View style={styles.counter}>
|
||||||
|
<Text style={styles.counterNumber}>{totalStellen}</Text>
|
||||||
|
<Text style={styles.counterLabel}>Stellen</Text>
|
||||||
</View>
|
</View>
|
||||||
{isAuthenticated && (
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => router.push('/(app)/stellen/neu')}
|
|
||||||
className="bg-brand-500 px-4 py-2 rounded-xl"
|
|
||||||
>
|
|
||||||
<Text className="text-white text-sm font-medium">+ Stelle anbieten</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.divider} />
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<LoadingSpinner />
|
<LoadingSpinner />
|
||||||
) : (
|
) : (
|
||||||
<FlatList
|
<FlatList
|
||||||
data={data ?? []}
|
data={data ?? []}
|
||||||
keyExtractor={(item) => item.id}
|
keyExtractor={(item) => item.id}
|
||||||
contentContainerStyle={{ padding: 12, gap: 8 }}
|
contentContainerStyle={styles.list}
|
||||||
refreshControl={
|
refreshControl={
|
||||||
<RefreshControl
|
<RefreshControl refreshing={isRefetching} onRefresh={refetch} tintColor="#003B7E" />
|
||||||
refreshing={isRefetching}
|
|
||||||
onRefresh={refetch}
|
|
||||||
tintColor="#E63946"
|
|
||||||
/>
|
|
||||||
}
|
}
|
||||||
renderItem={({ item }) => (
|
renderItem={({ item }) => (
|
||||||
<StelleCard
|
<StelleCard
|
||||||
stelle={item}
|
stelle={item}
|
||||||
onPress={() => router.push(`/(app)/stellen/${item.id}`)}
|
onPress={() => router.push(`/(app)/stellen/${item.id}` as never)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
ListEmptyComponent={
|
ListEmptyComponent={
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon="🎓"
|
icon="school-outline"
|
||||||
title="Keine Angebote"
|
title="Keine Angebote"
|
||||||
subtitle="Aktuell sind keine Ausbildungsplätze verfügbar"
|
subtitle="Aktuell keine Ausbildungsplätze verfügbar"
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
@ -72,3 +62,69 @@ export default function StellenScreen() {
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
safeArea: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: '#F8FAFC',
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
paddingTop: 18,
|
||||||
|
paddingBottom: 16,
|
||||||
|
},
|
||||||
|
titleRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
},
|
||||||
|
titleBlock: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
screenTitle: {
|
||||||
|
fontSize: 26,
|
||||||
|
fontWeight: '800',
|
||||||
|
color: '#0F172A',
|
||||||
|
letterSpacing: -0.5,
|
||||||
|
},
|
||||||
|
subtitle: {
|
||||||
|
fontSize: 13,
|
||||||
|
color: '#64748B',
|
||||||
|
marginTop: 2,
|
||||||
|
},
|
||||||
|
counter: {
|
||||||
|
backgroundColor: '#003B7E',
|
||||||
|
borderRadius: 14,
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
paddingVertical: 8,
|
||||||
|
alignItems: 'center',
|
||||||
|
minWidth: 56,
|
||||||
|
shadowColor: '#003B7E',
|
||||||
|
shadowOffset: { width: 0, height: 3 },
|
||||||
|
shadowOpacity: 0.25,
|
||||||
|
shadowRadius: 8,
|
||||||
|
elevation: 4,
|
||||||
|
},
|
||||||
|
counterNumber: {
|
||||||
|
color: '#FFFFFF',
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: '800',
|
||||||
|
lineHeight: 24,
|
||||||
|
},
|
||||||
|
counterLabel: {
|
||||||
|
color: 'rgba(255,255,255,0.75)',
|
||||||
|
fontSize: 9,
|
||||||
|
fontWeight: '600',
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
},
|
||||||
|
divider: {
|
||||||
|
height: 1,
|
||||||
|
backgroundColor: '#E2E8F0',
|
||||||
|
},
|
||||||
|
list: {
|
||||||
|
padding: 16,
|
||||||
|
gap: 10,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,152 +1,379 @@
|
||||||
import {
|
import {
|
||||||
View,
|
View, Text, ScrollView, TouchableOpacity, Linking, ActivityIndicator, Alert, StyleSheet, Platform,
|
||||||
Text,
|
|
||||||
ScrollView,
|
|
||||||
TouchableOpacity,
|
|
||||||
Linking,
|
|
||||||
ActivityIndicator,
|
|
||||||
Alert,
|
|
||||||
} from 'react-native'
|
} from 'react-native'
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context'
|
import { SafeAreaView } from 'react-native-safe-area-context'
|
||||||
import { useLocalSearchParams, useRouter } from 'expo-router'
|
import { useLocalSearchParams, useRouter, Stack } from 'expo-router'
|
||||||
import { trpc } from '@/lib/trpc'
|
import { Ionicons } from '@expo/vector-icons'
|
||||||
|
import { useTerminDetail, useToggleAnmeldung } from '@/hooks/useTermine'
|
||||||
import { AnmeldeButton } from '@/components/termine/AnmeldeButton'
|
import { AnmeldeButton } from '@/components/termine/AnmeldeButton'
|
||||||
import { Badge } from '@/components/ui/Badge'
|
import { Badge } from '@/components/ui/Badge'
|
||||||
import { TERMIN_TYP_LABELS } from '@innungsapp/shared'
|
import { TERMIN_TYP_LABELS } from '@innungsapp/shared/types'
|
||||||
import { format } from 'date-fns'
|
import { format } from 'date-fns'
|
||||||
import { de } from 'date-fns/locale'
|
import { de } from 'date-fns/locale'
|
||||||
import * as Calendar from 'expo-calendar'
|
|
||||||
|
|
||||||
export default function TerminDetailScreen() {
|
export default function TerminDetailScreen() {
|
||||||
const { id } = useLocalSearchParams<{ id: string }>()
|
const { id } = useLocalSearchParams<{ id: string }>()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { data: termin, isLoading } = trpc.termine.byId.useQuery({ id })
|
const { data: termin, isLoading } = useTerminDetail(id)
|
||||||
const toggleMutation = trpc.termine.toggleAnmeldung.useMutation({
|
const { mutate, isPending } = useToggleAnmeldung()
|
||||||
onSuccess: () => {
|
|
||||||
// Invalidate queries
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
async function addToCalendar() {
|
|
||||||
if (!termin) return
|
|
||||||
const { status } = await Calendar.requestCalendarPermissionsAsync()
|
|
||||||
if (status !== 'granted') {
|
|
||||||
Alert.alert('Keine Berechtigung', 'Bitte erlauben Sie den Kalender-Zugriff in den Einstellungen.')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const calendars = await Calendar.getCalendarsAsync(Calendar.EntityTypes.EVENT)
|
|
||||||
const defaultCal = calendars.find((c) => c.isPrimary) ?? calendars[0]
|
|
||||||
|
|
||||||
if (!defaultCal) {
|
|
||||||
Alert.alert('Kein Kalender', 'Es wurde kein Kalender gefunden.')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const startDate = new Date(termin.datum)
|
|
||||||
if (termin.uhrzeit) {
|
|
||||||
const [h, m] = termin.uhrzeit.split(':').map(Number)
|
|
||||||
startDate.setHours(h, m)
|
|
||||||
}
|
|
||||||
|
|
||||||
await Calendar.createEventAsync(defaultCal.id, {
|
|
||||||
title: termin.titel,
|
|
||||||
startDate,
|
|
||||||
endDate: startDate,
|
|
||||||
location: termin.adresse ?? termin.ort ?? undefined,
|
|
||||||
notes: termin.beschreibung ?? undefined,
|
|
||||||
})
|
|
||||||
|
|
||||||
Alert.alert('Termin gespeichert', 'Der Termin wurde in Ihren Kalender eingetragen.')
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<SafeAreaView className="flex-1 bg-white items-center justify-center">
|
<View style={styles.loadingContainer}>
|
||||||
<ActivityIndicator size="large" color="#E63946" />
|
<ActivityIndicator size="large" color="#003B7E" />
|
||||||
</SafeAreaView>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!termin) return null
|
if (!termin) return null
|
||||||
|
|
||||||
const datumFormatted = format(new Date(termin.datum), 'EEEE, dd. MMMM yyyy', { locale: de })
|
const datum = new Date(termin.datum)
|
||||||
|
const isPast = datum < new Date()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView className="flex-1 bg-gray-50" edges={['top']}>
|
<>
|
||||||
<View className="flex-row items-center px-4 py-3 bg-white border-b border-gray-100">
|
<Stack.Screen options={{ headerShown: false }} />
|
||||||
<TouchableOpacity onPress={() => router.back()} className="mr-3">
|
<SafeAreaView style={styles.container} edges={['top']}>
|
||||||
<Text className="text-brand-500 text-base">← Zurück</Text>
|
{/* Header */}
|
||||||
|
<View style={styles.header}>
|
||||||
|
<TouchableOpacity onPress={() => router.back()} style={styles.backButton}>
|
||||||
|
<Ionicons name="arrow-back" size={24} color="#0F172A" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
<View style={styles.headerSpacer} />
|
||||||
|
<TouchableOpacity style={styles.shareButton}>
|
||||||
|
<Ionicons name="share-outline" size={24} color="#0F172A" />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<ScrollView>
|
<ScrollView contentContainerStyle={styles.scrollContent} showsVerticalScrollIndicator={false}>
|
||||||
<View className="bg-white px-4 py-6 border-b border-gray-100">
|
{/* Date Badge */}
|
||||||
<Badge label={TERMIN_TYP_LABELS[termin.typ]} typ={termin.typ} />
|
<View style={styles.dateline}>
|
||||||
<Text className="text-2xl font-bold text-gray-900 mt-3">{termin.titel}</Text>
|
<View style={styles.calendarIcon}>
|
||||||
<Text className="text-gray-500 mt-2 capitalize">{datumFormatted}</Text>
|
<Ionicons name="calendar" size={18} color="#003B7E" />
|
||||||
{termin.uhrzeit && (
|
</View>
|
||||||
<Text className="text-gray-500">
|
<Text style={styles.dateText}>
|
||||||
{termin.uhrzeit}{termin.endeUhrzeit ? ` – ${termin.endeUhrzeit}` : ''} Uhr
|
{format(datum, 'EEEE, d. MMMM yyyy', { locale: de })}
|
||||||
</Text>
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text style={styles.title}>{termin.titel}</Text>
|
||||||
|
|
||||||
|
<View style={styles.badgesRow}>
|
||||||
|
<Badge label={TERMIN_TYP_LABELS[termin.typ]} typ={termin.typ} />
|
||||||
|
{termin.isAngemeldet && (
|
||||||
|
<View style={styles.registeredBadge}>
|
||||||
|
<Ionicons name="checkmark-circle" size={14} color="#059669" />
|
||||||
|
<Text style={styles.registeredText}>Angemeldet</Text>
|
||||||
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View className="bg-white mx-4 mt-4 rounded-2xl overflow-hidden border border-gray-100">
|
{/* Info Card */}
|
||||||
|
<View style={styles.card}>
|
||||||
|
{/* Time */}
|
||||||
|
{termin.uhrzeit && (
|
||||||
|
<View style={styles.infoRow}>
|
||||||
|
<View style={styles.iconBox}>
|
||||||
|
<Ionicons name="time-outline" size={22} color="#64748B" />
|
||||||
|
</View>
|
||||||
|
<View style={styles.infoContent}>
|
||||||
|
<Text style={styles.infoLabel}>Uhrzeit</Text>
|
||||||
|
<Text style={styles.infoValue}>
|
||||||
|
{termin.uhrzeit}
|
||||||
|
{termin.endeUhrzeit ? ` – ${termin.endeUhrzeit}` : ''} Uhr
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{termin.uhrzeit && termin.ort && <View style={styles.divider} />}
|
||||||
|
|
||||||
|
{/* Location */}
|
||||||
{termin.ort && (
|
{termin.ort && (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
|
style={styles.infoRow}
|
||||||
onPress={() =>
|
onPress={() =>
|
||||||
termin.adresse &&
|
termin.adresse &&
|
||||||
Linking.openURL(
|
Linking.openURL(`https://maps.google.com/?q=${encodeURIComponent(termin.adresse)}`)
|
||||||
`https://maps.google.com/?q=${encodeURIComponent(termin.adresse)}`
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
className="flex-row items-center px-4 py-3 border-b border-gray-50"
|
activeOpacity={termin.adresse ? 0.7 : 1}
|
||||||
>
|
>
|
||||||
<Text className="text-2xl mr-3">📍</Text>
|
<View style={styles.iconBox}>
|
||||||
<View>
|
<Ionicons name="location-outline" size={22} color="#64748B" />
|
||||||
<Text className="font-medium text-gray-900">{termin.ort}</Text>
|
</View>
|
||||||
|
<View style={styles.infoContent}>
|
||||||
|
<Text style={styles.infoLabel}>Ort</Text>
|
||||||
|
<Text style={styles.infoValue}>{termin.ort}</Text>
|
||||||
{termin.adresse && (
|
{termin.adresse && (
|
||||||
<Text className="text-sm text-brand-500 mt-0.5">{termin.adresse}</Text>
|
<Text style={styles.infoSub}>{termin.adresse}</Text>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
{termin.adresse && (
|
||||||
|
<Ionicons name="chevron-forward" size={20} color="#CBD5E1" />
|
||||||
|
)}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
)}
|
)}
|
||||||
<View className="flex-row items-center px-4 py-3">
|
|
||||||
<Text className="text-2xl mr-3">👥</Text>
|
{(termin.uhrzeit || termin.ort) && (termin.teilnehmerAnzahl !== undefined) && <View style={styles.divider} />}
|
||||||
<Text className="text-gray-700">
|
|
||||||
|
{/* Participants */}
|
||||||
|
<View style={styles.infoRow}>
|
||||||
|
<View style={styles.iconBox}>
|
||||||
|
<Ionicons name="people-outline" size={22} color="#64748B" />
|
||||||
|
</View>
|
||||||
|
<View style={styles.infoContent}>
|
||||||
|
<Text style={styles.infoLabel}>Teilnehmer</Text>
|
||||||
|
<Text style={styles.infoValue}>
|
||||||
{termin.teilnehmerAnzahl} Anmeldungen
|
{termin.teilnehmerAnzahl} Anmeldungen
|
||||||
{termin.maxTeilnehmer ? ` / ${termin.maxTeilnehmer} Plätze` : ''}
|
|
||||||
</Text>
|
</Text>
|
||||||
|
{termin.maxTeilnehmer && (
|
||||||
|
<Text style={styles.infoSub}>{termin.maxTeilnehmer} Plätze verfügbar</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
{termin.beschreibung && (
|
{termin.beschreibung && (
|
||||||
<View className="bg-white mx-4 mt-4 rounded-2xl p-4 border border-gray-100">
|
<View style={styles.section}>
|
||||||
<Text className="font-semibold text-gray-900 mb-2">Beschreibung</Text>
|
<Text style={styles.sectionTitle}>Beschreibung</Text>
|
||||||
<Text className="text-gray-600 leading-6">{termin.beschreibung}</Text>
|
<Text style={styles.description}>{termin.beschreibung}</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Actions */}
|
</ScrollView>
|
||||||
<View className="mx-4 mt-4 gap-3">
|
|
||||||
|
{/* Bottom Action Bar – only for upcoming events */}
|
||||||
|
{isPast ? (
|
||||||
|
<View style={styles.pastBar}>
|
||||||
|
<Ionicons name="time-outline" size={16} color="#94A3B8" />
|
||||||
|
<Text style={styles.pastBarText}>Vergangener Termin – keine Anmeldung möglich</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<View style={styles.actionBar}>
|
||||||
|
<View style={styles.actionButtonContainer}>
|
||||||
<AnmeldeButton
|
<AnmeldeButton
|
||||||
terminId={id}
|
terminId={id}
|
||||||
isAngemeldet={termin.isAngemeldet}
|
isAngemeldet={termin.isAngemeldet}
|
||||||
onToggle={() => toggleMutation.mutate({ terminId: id })}
|
onToggle={() => mutate({ terminId: id })}
|
||||||
isLoading={toggleMutation.isPending}
|
isLoading={isPending}
|
||||||
/>
|
/>
|
||||||
|
</View>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={addToCalendar}
|
style={styles.calendarButton}
|
||||||
className="bg-white border border-gray-200 rounded-2xl py-4 flex-row items-center justify-center gap-2"
|
onPress={() => Alert.alert('Kalender', 'Funktion folgt in Kürze')}
|
||||||
>
|
>
|
||||||
<Text className="text-gray-900 text-xl">📅</Text>
|
<Ionicons name="calendar-outline" size={24} color="#0F172A" />
|
||||||
<Text className="text-gray-900 font-semibold">Zum Kalender hinzufügen</Text>
|
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
<View className="h-8" />
|
|
||||||
</ScrollView>
|
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: '#F8FAFC',
|
||||||
|
},
|
||||||
|
loadingContainer: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 12,
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: '#F8FAFC',
|
||||||
|
},
|
||||||
|
backButton: {
|
||||||
|
padding: 8,
|
||||||
|
borderRadius: 12,
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 1 },
|
||||||
|
shadowOpacity: 0.05,
|
||||||
|
shadowRadius: 2,
|
||||||
|
elevation: 1,
|
||||||
|
},
|
||||||
|
headerSpacer: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
shareButton: {
|
||||||
|
padding: 8,
|
||||||
|
},
|
||||||
|
scrollContent: {
|
||||||
|
padding: 24,
|
||||||
|
paddingBottom: 100,
|
||||||
|
},
|
||||||
|
dateline: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 12,
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
calendarIcon: {
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
borderRadius: 8,
|
||||||
|
backgroundColor: '#EFF6FF',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
dateText: {
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#003B7E',
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 28,
|
||||||
|
fontWeight: '800',
|
||||||
|
color: '#0F172A',
|
||||||
|
lineHeight: 34,
|
||||||
|
marginBottom: 16,
|
||||||
|
letterSpacing: -0.5,
|
||||||
|
},
|
||||||
|
badgesRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 12,
|
||||||
|
marginBottom: 32,
|
||||||
|
},
|
||||||
|
registeredBadge: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
paddingVertical: 4,
|
||||||
|
backgroundColor: '#ECFDF5',
|
||||||
|
borderRadius: 6,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#A7F3D0',
|
||||||
|
},
|
||||||
|
registeredText: {
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#047857',
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
borderRadius: 20,
|
||||||
|
padding: 20,
|
||||||
|
shadowColor: '#64748B',
|
||||||
|
shadowOffset: { width: 0, height: 4 },
|
||||||
|
shadowOpacity: 0.04,
|
||||||
|
shadowRadius: 12,
|
||||||
|
elevation: 2,
|
||||||
|
marginBottom: 32,
|
||||||
|
},
|
||||||
|
infoRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
gap: 16,
|
||||||
|
},
|
||||||
|
iconBox: {
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
borderRadius: 10,
|
||||||
|
backgroundColor: '#F1F5F9',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
infoContent: {
|
||||||
|
flex: 1,
|
||||||
|
paddingTop: 2,
|
||||||
|
},
|
||||||
|
infoLabel: {
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#94A3B8',
|
||||||
|
marginBottom: 2,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
},
|
||||||
|
infoValue: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#0F172A',
|
||||||
|
lineHeight: 22,
|
||||||
|
},
|
||||||
|
infoSub: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#64748B',
|
||||||
|
marginTop: 2,
|
||||||
|
lineHeight: 20,
|
||||||
|
},
|
||||||
|
divider: {
|
||||||
|
height: 1,
|
||||||
|
backgroundColor: '#F1F5F9',
|
||||||
|
marginVertical: 16,
|
||||||
|
marginLeft: 56,
|
||||||
|
},
|
||||||
|
section: {
|
||||||
|
marginBottom: 24,
|
||||||
|
},
|
||||||
|
sectionTitle: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#0F172A',
|
||||||
|
marginBottom: 12,
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
fontSize: 16,
|
||||||
|
lineHeight: 26,
|
||||||
|
color: '#334155',
|
||||||
|
},
|
||||||
|
actionBar: {
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
paddingVertical: 16,
|
||||||
|
paddingBottom: Platform.OS === 'ios' ? 32 : 16,
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: 12,
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: '#F1F5F9',
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: -4 },
|
||||||
|
shadowOpacity: 0.02,
|
||||||
|
shadowRadius: 8,
|
||||||
|
elevation: 4,
|
||||||
|
},
|
||||||
|
pastBar: {
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
backgroundColor: '#F8FAFC',
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
paddingVertical: 14,
|
||||||
|
paddingBottom: Platform.OS === 'ios' ? 28 : 14,
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: '#E2E8F0',
|
||||||
|
},
|
||||||
|
pastBarText: {
|
||||||
|
fontSize: 13,
|
||||||
|
color: '#94A3B8',
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
actionButtonContainer: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
calendarButton: {
|
||||||
|
width: 52,
|
||||||
|
height: 52,
|
||||||
|
borderRadius: 14,
|
||||||
|
backgroundColor: '#F1F5F9',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,78 +1,78 @@
|
||||||
import {
|
import {
|
||||||
View,
|
View, Text, FlatList, TouchableOpacity, RefreshControl, StyleSheet,
|
||||||
Text,
|
|
||||||
FlatList,
|
|
||||||
TouchableOpacity,
|
|
||||||
RefreshControl,
|
|
||||||
} from 'react-native'
|
} from 'react-native'
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context'
|
import { SafeAreaView } from 'react-native-safe-area-context'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useRouter } from 'expo-router'
|
import { useRouter } from 'expo-router'
|
||||||
import { trpc } from '@/lib/trpc'
|
import { useTermineListe, useToggleAnmeldung } from '@/hooks/useTermine'
|
||||||
import { TerminCard } from '@/components/termine/TerminCard'
|
import { TerminCard } from '@/components/termine/TerminCard'
|
||||||
import { EmptyState } from '@/components/ui/EmptyState'
|
import { EmptyState } from '@/components/ui/EmptyState'
|
||||||
import { LoadingSpinner } from '@/components/ui/LoadingSpinner'
|
import { LoadingSpinner } from '@/components/ui/LoadingSpinner'
|
||||||
|
|
||||||
|
type Tab = 'kommend' | 'vergangen'
|
||||||
|
|
||||||
export default function TermineScreen() {
|
export default function TermineScreen() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [tab, setTab] = useState<'kommend' | 'vergangen'>('kommend')
|
const [tab, setTab] = useState<Tab>('kommend')
|
||||||
|
const { data, isLoading, refetch, isRefetching } = useTermineListe(tab === 'kommend')
|
||||||
const { data, isLoading, refetch, isRefetching } = trpc.termine.list.useQuery({
|
const { mutate, isPending } = useToggleAnmeldung()
|
||||||
upcoming: tab === 'kommend',
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView className="flex-1 bg-gray-50" edges={['top']}>
|
<SafeAreaView style={styles.safeArea} edges={['top']}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<View className="bg-white px-4 py-3 border-b border-gray-100">
|
<View style={styles.header}>
|
||||||
<Text className="text-xl font-bold text-gray-900 mb-3">Termine</Text>
|
<Text style={styles.screenTitle}>Termine</Text>
|
||||||
{/* Tabs */}
|
|
||||||
<View className="flex-row gap-1 bg-gray-100 p-1 rounded-xl">
|
{/* Segment control */}
|
||||||
{(['kommend', 'vergangen'] as const).map((t) => (
|
<View style={styles.segmentContainer}>
|
||||||
|
{(['kommend', 'vergangen'] as Tab[]).map((t) => {
|
||||||
|
const active = tab === t
|
||||||
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
key={t}
|
key={t}
|
||||||
onPress={() => setTab(t)}
|
onPress={() => setTab(t)}
|
||||||
className={`flex-1 py-2 rounded-lg items-center ${
|
style={[styles.segment, active && styles.segmentActive]}
|
||||||
tab === t ? 'bg-white shadow-sm' : ''
|
activeOpacity={0.8}
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
className={`text-sm font-medium ${
|
|
||||||
tab === t ? 'text-gray-900' : 'text-gray-500'
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
|
<Text style={[styles.segmentLabel, active && styles.segmentLabelActive]}>
|
||||||
{t === 'kommend' ? 'Bevorstehend' : 'Vergangen'}
|
{t === 'kommend' ? 'Bevorstehend' : 'Vergangen'}
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.divider} />
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<LoadingSpinner />
|
<LoadingSpinner />
|
||||||
) : (
|
) : (
|
||||||
<FlatList
|
<FlatList
|
||||||
data={data ?? []}
|
data={data ?? []}
|
||||||
keyExtractor={(item) => item.id}
|
keyExtractor={(item) => item.id}
|
||||||
contentContainerStyle={{ padding: 12, gap: 8 }}
|
contentContainerStyle={styles.list}
|
||||||
refreshControl={
|
refreshControl={
|
||||||
<RefreshControl
|
<RefreshControl refreshing={isRefetching} onRefresh={refetch} tintColor="#003B7E" />
|
||||||
refreshing={isRefetching}
|
|
||||||
onRefresh={refetch}
|
|
||||||
tintColor="#E63946"
|
|
||||||
/>
|
|
||||||
}
|
}
|
||||||
renderItem={({ item }) => (
|
renderItem={({ item }) => (
|
||||||
<TerminCard
|
<TerminCard
|
||||||
termin={item}
|
termin={item}
|
||||||
onPress={() => router.push(`/(app)/termine/${item.id}`)}
|
isPast={tab === 'vergangen'}
|
||||||
|
onPress={() => router.push(`/(app)/termine/${item.id}` as never)}
|
||||||
|
onToggleAnmeldung={() => mutate({ terminId: item.id })}
|
||||||
|
isToggling={isPending}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
ListEmptyComponent={
|
ListEmptyComponent={
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon="📅"
|
icon="calendar-outline"
|
||||||
title={tab === 'kommend' ? 'Keine Termine' : 'Keine vergangenen Termine'}
|
title="Keine Termine"
|
||||||
subtitle="Es sind aktuell keine Termine eingetragen"
|
subtitle={
|
||||||
|
tab === 'kommend'
|
||||||
|
? 'Aktuell keine bevorstehenden Termine'
|
||||||
|
: 'Keine vergangenen Termine'
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
@ -80,3 +80,61 @@ export default function TermineScreen() {
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
safeArea: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: '#F8FAFC',
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
paddingTop: 18,
|
||||||
|
paddingBottom: 14,
|
||||||
|
},
|
||||||
|
screenTitle: {
|
||||||
|
fontSize: 28,
|
||||||
|
fontWeight: '800',
|
||||||
|
color: '#0F172A',
|
||||||
|
letterSpacing: -0.5,
|
||||||
|
marginBottom: 14,
|
||||||
|
},
|
||||||
|
segmentContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
backgroundColor: '#F4F4F5',
|
||||||
|
borderRadius: 14,
|
||||||
|
padding: 3,
|
||||||
|
gap: 3,
|
||||||
|
},
|
||||||
|
segment: {
|
||||||
|
flex: 1,
|
||||||
|
paddingVertical: 9,
|
||||||
|
borderRadius: 11,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
segmentActive: {
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 1 },
|
||||||
|
shadowOpacity: 0.08,
|
||||||
|
shadowRadius: 4,
|
||||||
|
elevation: 2,
|
||||||
|
},
|
||||||
|
segmentLabel: {
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: '500',
|
||||||
|
color: '#64748B',
|
||||||
|
},
|
||||||
|
segmentLabelActive: {
|
||||||
|
color: '#0F172A',
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
divider: {
|
||||||
|
height: 1,
|
||||||
|
backgroundColor: '#E2E8F0',
|
||||||
|
},
|
||||||
|
list: {
|
||||||
|
padding: 16,
|
||||||
|
gap: 10,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,44 +1,106 @@
|
||||||
import { View, Text, TouchableOpacity } from 'react-native'
|
import { View, Text, TouchableOpacity, StyleSheet } from 'react-native'
|
||||||
import { useRouter, useLocalSearchParams } from 'expo-router'
|
import { useRouter, useLocalSearchParams } from 'expo-router'
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context'
|
import { SafeAreaView } from 'react-native-safe-area-context'
|
||||||
|
import { Ionicons } from '@expo/vector-icons'
|
||||||
|
|
||||||
export default function CheckEmailScreen() {
|
export default function CheckEmailScreen() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { email } = useLocalSearchParams<{ email: string }>()
|
const { email } = useLocalSearchParams<{ email: string }>()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView className="flex-1 bg-white">
|
<SafeAreaView style={styles.safeArea}>
|
||||||
<View className="flex-1 justify-center items-center px-6">
|
<View style={styles.content}>
|
||||||
{/* Envelope Illustration */}
|
<View style={styles.iconBox}>
|
||||||
<View className="w-24 h-24 bg-brand-50 rounded-full items-center justify-center mb-6">
|
<Ionicons name="checkmark-circle" size={46} color="#15803D" />
|
||||||
<Text className="text-5xl">📧</Text>
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<Text className="text-2xl font-bold text-gray-900 text-center mb-3">
|
<Text style={styles.title}>E-Mail pruefen</Text>
|
||||||
Schau in dein Postfach
|
<Text style={styles.body}>Wir haben einen Login-Link gesendet an:</Text>
|
||||||
</Text>
|
|
||||||
<Text className="text-gray-500 text-center leading-6 mb-2">
|
<View style={styles.emailBox}>
|
||||||
Wir haben einen Login-Link an
|
<Text style={styles.emailText} numberOfLines={1}>{email}</Text>
|
||||||
</Text>
|
</View>
|
||||||
<Text className="font-semibold text-gray-900 text-center mb-6">
|
|
||||||
{email}
|
<Text style={styles.hint}>
|
||||||
</Text>
|
Bitte oeffnen Sie den Link in Ihrer E-Mail, um sich anzumelden.
|
||||||
<Text className="text-gray-500 text-center leading-6">
|
|
||||||
Klicken Sie auf den Link in der E-Mail, um sich einzuloggen.
|
|
||||||
Der Link ist 24 Stunden gültig.
|
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<View className="mt-10 space-y-3 w-full">
|
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => router.back()}
|
onPress={() => router.back()}
|
||||||
className="py-3 items-center"
|
style={styles.backBtn}
|
||||||
|
activeOpacity={0.8}
|
||||||
>
|
>
|
||||||
<Text className="text-brand-500 font-medium">
|
<Ionicons name="chevron-back" size={16} color="#003B7E" />
|
||||||
← Andere E-Mail verwenden
|
<Text style={styles.backText}>Andere E-Mail verwenden</Text>
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
safeArea: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingHorizontal: 28,
|
||||||
|
},
|
||||||
|
iconBox: {
|
||||||
|
width: 88,
|
||||||
|
height: 88,
|
||||||
|
backgroundColor: '#DCFCE7',
|
||||||
|
borderRadius: 44,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
marginBottom: 24,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: '800',
|
||||||
|
color: '#0F172A',
|
||||||
|
textAlign: 'center',
|
||||||
|
marginBottom: 10,
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#64748B',
|
||||||
|
textAlign: 'center',
|
||||||
|
marginBottom: 6,
|
||||||
|
},
|
||||||
|
emailBox: {
|
||||||
|
backgroundColor: '#F8FAFC',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#E2E8F0',
|
||||||
|
borderRadius: 14,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 10,
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
emailText: {
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#0F172A',
|
||||||
|
},
|
||||||
|
hint: {
|
||||||
|
fontSize: 13,
|
||||||
|
color: '#64748B',
|
||||||
|
textAlign: 'center',
|
||||||
|
lineHeight: 19,
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
},
|
||||||
|
backBtn: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 4,
|
||||||
|
marginTop: 28,
|
||||||
|
},
|
||||||
|
backText: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#003B7E',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,12 @@
|
||||||
import {
|
import {
|
||||||
View,
|
View, Text, TextInput, TouchableOpacity,
|
||||||
Text,
|
KeyboardAvoidingView, Platform, ActivityIndicator, StyleSheet,
|
||||||
TextInput,
|
|
||||||
TouchableOpacity,
|
|
||||||
KeyboardAvoidingView,
|
|
||||||
Platform,
|
|
||||||
ActivityIndicator,
|
|
||||||
} from 'react-native'
|
} from 'react-native'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useRouter } from 'expo-router'
|
import { useRouter } from 'expo-router'
|
||||||
import { authClient } from '@/lib/auth-client'
|
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context'
|
import { SafeAreaView } from 'react-native-safe-area-context'
|
||||||
|
import { Ionicons } from '@expo/vector-icons'
|
||||||
|
import { authClient } from '@/lib/auth-client'
|
||||||
|
|
||||||
export default function LoginScreen() {
|
export default function LoginScreen() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
@ -18,16 +14,16 @@ export default function LoginScreen() {
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
const canSubmit = email.trim().length > 0 && !loading
|
||||||
|
|
||||||
async function handleSendLink() {
|
async function handleSendLink() {
|
||||||
if (!email.trim()) return
|
if (!email.trim()) return
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError('')
|
setError('')
|
||||||
|
|
||||||
const result = await authClient.signIn.magicLink({
|
const result = await authClient.signIn.magicLink({
|
||||||
email: email.trim().toLowerCase(),
|
email: email.trim().toLowerCase(),
|
||||||
callbackURL: '/news',
|
callbackURL: '/home',
|
||||||
})
|
})
|
||||||
|
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
if (result.error) {
|
if (result.error) {
|
||||||
setError(result.error.message ?? 'Ein Fehler ist aufgetreten.')
|
setError(result.error.message ?? 'Ein Fehler ist aufgetreten.')
|
||||||
|
|
@ -37,33 +33,28 @@ export default function LoginScreen() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView className="flex-1 bg-white">
|
<SafeAreaView style={styles.safeArea}>
|
||||||
<KeyboardAvoidingView
|
<KeyboardAvoidingView
|
||||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||||
className="flex-1"
|
style={styles.keyboardView}
|
||||||
>
|
>
|
||||||
<View className="flex-1 justify-center px-6">
|
<View style={styles.content}>
|
||||||
{/* Logo */}
|
<View style={styles.logoSection}>
|
||||||
<View className="items-center mb-10">
|
<View style={styles.logoBox}>
|
||||||
<View className="w-20 h-20 bg-brand-500 rounded-2xl items-center justify-center mb-4">
|
<Text style={styles.logoLetter}>I</Text>
|
||||||
<Text className="text-white font-bold text-3xl">I</Text>
|
|
||||||
</View>
|
</View>
|
||||||
<Text className="text-2xl font-bold text-gray-900">InnungsApp</Text>
|
<Text style={styles.appName}>InnungsApp</Text>
|
||||||
<Text className="text-gray-500 mt-1 text-center">
|
<Text style={styles.tagline}>Ihre Kreishandwerkerschaft digital</Text>
|
||||||
Die digitale Plattform Ihrer Innung
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Form */}
|
<View style={styles.form}>
|
||||||
<View className="space-y-4">
|
<Text style={styles.inputLabel}>Email-Adresse</Text>
|
||||||
<View>
|
<View style={styles.inputWrap}>
|
||||||
<Text className="text-sm font-medium text-gray-700 mb-2">
|
<Ionicons name="mail-outline" size={18} color="#94A3B8" />
|
||||||
E-Mail-Adresse
|
|
||||||
</Text>
|
|
||||||
<TextInput
|
<TextInput
|
||||||
className="border border-gray-300 rounded-xl px-4 py-3.5 text-base text-gray-900 bg-gray-50"
|
style={styles.input}
|
||||||
placeholder="ihre@email.de"
|
placeholder="beispiel@handwerk.de"
|
||||||
placeholderTextColor="#9ca3af"
|
placeholderTextColor="#94A3B8"
|
||||||
keyboardType="email-address"
|
keyboardType="email-address"
|
||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
autoCorrect={false}
|
autoCorrect={false}
|
||||||
|
|
@ -75,37 +66,141 @@ export default function LoginScreen() {
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{error ? (
|
{error ? (
|
||||||
<View className="bg-red-50 rounded-xl px-4 py-3">
|
<View style={styles.errorBox}>
|
||||||
<Text className="text-red-600 text-sm">{error}</Text>
|
<Text style={styles.errorText}>{error}</Text>
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={handleSendLink}
|
onPress={handleSendLink}
|
||||||
disabled={loading || !email.trim()}
|
disabled={!canSubmit}
|
||||||
className={`rounded-xl py-4 items-center ${
|
style={[styles.submitBtn, !canSubmit && styles.submitBtnDisabled]}
|
||||||
loading || !email.trim() ? 'bg-gray-200' : 'bg-brand-500'
|
activeOpacity={0.85}
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<ActivityIndicator color="white" />
|
<ActivityIndicator color="#FFFFFF" />
|
||||||
) : (
|
) : (
|
||||||
<Text
|
<View style={styles.submitContent}>
|
||||||
className={`font-semibold text-base ${
|
<Text style={styles.submitLabel}>Login-Link senden</Text>
|
||||||
loading || !email.trim() ? 'text-gray-400' : 'text-white'
|
<Ionicons name="arrow-forward" size={16} color="#FFFFFF" />
|
||||||
}`}
|
</View>
|
||||||
>
|
|
||||||
Magic Link senden
|
|
||||||
</Text>
|
|
||||||
)}
|
)}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
||||||
<Text className="text-center text-sm text-gray-400">
|
|
||||||
Kein Passwort nötig — Zugang nur per Admin-Einladung
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
<Text style={styles.hint}>
|
||||||
|
Noch kein Zugang? Kontaktieren Sie Ihre Innungsgeschaeftsstelle.
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</KeyboardAvoidingView>
|
</KeyboardAvoidingView>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
safeArea: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
},
|
||||||
|
keyboardView: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
paddingHorizontal: 24,
|
||||||
|
},
|
||||||
|
logoSection: {
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 40,
|
||||||
|
},
|
||||||
|
logoBox: {
|
||||||
|
width: 64,
|
||||||
|
height: 64,
|
||||||
|
backgroundColor: '#003B7E',
|
||||||
|
borderRadius: 18,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
logoLetter: {
|
||||||
|
color: '#FFFFFF',
|
||||||
|
fontSize: 30,
|
||||||
|
fontWeight: '900',
|
||||||
|
},
|
||||||
|
appName: {
|
||||||
|
fontSize: 30,
|
||||||
|
fontWeight: '800',
|
||||||
|
color: '#0F172A',
|
||||||
|
letterSpacing: -0.6,
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
tagline: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#64748B',
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
form: {
|
||||||
|
gap: 12,
|
||||||
|
},
|
||||||
|
inputLabel: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#334155',
|
||||||
|
},
|
||||||
|
inputWrap: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: '#F8FAFC',
|
||||||
|
borderRadius: 14,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#E2E8F0',
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
flex: 1,
|
||||||
|
paddingVertical: 13,
|
||||||
|
color: '#0F172A',
|
||||||
|
fontSize: 15,
|
||||||
|
},
|
||||||
|
errorBox: {
|
||||||
|
backgroundColor: '#FEF2F2',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#FECACA',
|
||||||
|
borderRadius: 12,
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
paddingVertical: 10,
|
||||||
|
},
|
||||||
|
errorText: {
|
||||||
|
color: '#B91C1C',
|
||||||
|
fontSize: 13,
|
||||||
|
},
|
||||||
|
submitBtn: {
|
||||||
|
backgroundColor: '#003B7E',
|
||||||
|
borderRadius: 14,
|
||||||
|
paddingVertical: 14,
|
||||||
|
alignItems: 'center',
|
||||||
|
marginTop: 4,
|
||||||
|
},
|
||||||
|
submitBtnDisabled: {
|
||||||
|
backgroundColor: '#CBD5E1',
|
||||||
|
},
|
||||||
|
submitContent: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
},
|
||||||
|
submitLabel: {
|
||||||
|
color: '#FFFFFF',
|
||||||
|
fontWeight: '700',
|
||||||
|
fontSize: 15,
|
||||||
|
},
|
||||||
|
hint: {
|
||||||
|
marginTop: 24,
|
||||||
|
textAlign: 'center',
|
||||||
|
color: '#64748B',
|
||||||
|
fontSize: 13,
|
||||||
|
lineHeight: 18,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,7 @@
|
||||||
import '../global.css'
|
import '../global.css'
|
||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
import { Stack } from 'expo-router'
|
import { Stack, SplashScreen } from 'expo-router'
|
||||||
import { SplashScreen } from 'expo-router'
|
|
||||||
import { QueryClientProvider } from '@tanstack/react-query'
|
|
||||||
import { queryClient } from '@/lib/trpc'
|
|
||||||
import { TRPCProvider } from '@/lib/trpc'
|
|
||||||
import { useAuthStore } from '@/store/auth.store'
|
import { useAuthStore } from '@/store/auth.store'
|
||||||
import { setupPushNotifications } from '@/lib/notifications'
|
|
||||||
|
|
||||||
SplashScreen.preventAutoHideAsync()
|
SplashScreen.preventAutoHideAsync()
|
||||||
|
|
||||||
|
|
@ -18,20 +13,14 @@ export default function RootLayout() {
|
||||||
initAuth().finally(() => SplashScreen.hideAsync())
|
initAuth().finally(() => SplashScreen.hideAsync())
|
||||||
}, [initAuth])
|
}, [initAuth])
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setupPushNotifications().catch(console.error)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
if (!isInitialized) return null
|
if (!isInitialized) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TRPCProvider>
|
|
||||||
<Stack screenOptions={{ headerShown: false }}>
|
<Stack screenOptions={{ headerShown: false }}>
|
||||||
<Stack.Screen name="index" />
|
<Stack.Screen name="index" />
|
||||||
<Stack.Screen name="(auth)" options={{ animation: 'fade' }} />
|
<Stack.Screen name="(auth)" options={{ animation: 'fade' }} />
|
||||||
<Stack.Screen name="(app)" options={{ animation: 'fade' }} />
|
<Stack.Screen name="(app)" options={{ animation: 'fade' }} />
|
||||||
<Stack.Screen name="stellen-public" />
|
<Stack.Screen name="stellen-public/index" />
|
||||||
</Stack>
|
</Stack>
|
||||||
</TRPCProvider>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,5 +3,8 @@ import { useAuthStore } from '@/store/auth.store'
|
||||||
|
|
||||||
export default function Index() {
|
export default function Index() {
|
||||||
const session = useAuthStore((s) => s.session)
|
const session = useAuthStore((s) => s.session)
|
||||||
return <Redirect href={session ? '/(app)/news' : '/(auth)/login'} />
|
if (session) {
|
||||||
|
return <Redirect href={'/(app)/home' as never} />
|
||||||
|
}
|
||||||
|
return <Redirect href="/(auth)/login" />
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 68 B |
Binary file not shown.
|
After Width: | Height: | Size: 68 B |
Binary file not shown.
|
After Width: | Height: | Size: 68 B |
Binary file not shown.
|
After Width: | Height: | Size: 68 B |
Binary file not shown.
|
After Width: | Height: | Size: 68 B |
|
|
@ -0,0 +1,115 @@
|
||||||
|
import { View, Text, TouchableOpacity, StyleSheet } from 'react-native'
|
||||||
|
import { Ionicons } from '@expo/vector-icons'
|
||||||
|
import { Avatar } from '@/components/ui/Avatar'
|
||||||
|
|
||||||
|
interface MemberCardProps {
|
||||||
|
member: {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
betrieb: string
|
||||||
|
sparte: string
|
||||||
|
ort: string
|
||||||
|
istAusbildungsbetrieb: boolean
|
||||||
|
avatarUrl: string | null
|
||||||
|
}
|
||||||
|
onPress: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MemberCard({ member, onPress }: MemberCardProps) {
|
||||||
|
return (
|
||||||
|
<TouchableOpacity onPress={onPress} style={styles.card} activeOpacity={0.76}>
|
||||||
|
<Avatar name={member.name} imageUrl={member.avatarUrl ?? undefined} size={48} />
|
||||||
|
|
||||||
|
<View style={styles.info}>
|
||||||
|
<Text style={styles.name} numberOfLines={1}>
|
||||||
|
{member.name}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.company} numberOfLines={1}>
|
||||||
|
{member.betrieb}
|
||||||
|
</Text>
|
||||||
|
<View style={styles.tagsRow}>
|
||||||
|
<View style={styles.tag}>
|
||||||
|
<Text style={styles.tagText}>{member.sparte}</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.separator}>·</Text>
|
||||||
|
<Text style={styles.location}>{member.ort}</Text>
|
||||||
|
{member.istAusbildungsbetrieb && (
|
||||||
|
<View style={styles.ausbildungTag}>
|
||||||
|
<Text style={styles.ausbildungText}>Ausbildung</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Ionicons name="chevron-forward" size={18} color="#D4D4D8" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
card: {
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
borderRadius: 16,
|
||||||
|
padding: 14,
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 12,
|
||||||
|
shadowColor: '#1C1917',
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
shadowOpacity: 0.08,
|
||||||
|
shadowRadius: 12,
|
||||||
|
elevation: 3,
|
||||||
|
},
|
||||||
|
info: {
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
},
|
||||||
|
name: {
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#0F172A',
|
||||||
|
letterSpacing: -0.2,
|
||||||
|
},
|
||||||
|
company: {
|
||||||
|
fontSize: 13,
|
||||||
|
color: '#475569',
|
||||||
|
marginTop: 2,
|
||||||
|
},
|
||||||
|
tagsRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: 6,
|
||||||
|
marginTop: 6,
|
||||||
|
},
|
||||||
|
tag: {
|
||||||
|
backgroundColor: '#F4F4F5',
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
paddingVertical: 2,
|
||||||
|
borderRadius: 99,
|
||||||
|
},
|
||||||
|
tagText: {
|
||||||
|
fontSize: 11,
|
||||||
|
color: '#475569',
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
separator: {
|
||||||
|
fontSize: 11,
|
||||||
|
color: '#D4D4D8',
|
||||||
|
},
|
||||||
|
location: {
|
||||||
|
fontSize: 11,
|
||||||
|
color: '#64748B',
|
||||||
|
},
|
||||||
|
ausbildungTag: {
|
||||||
|
backgroundColor: '#F0FDF4',
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
paddingVertical: 2,
|
||||||
|
borderRadius: 99,
|
||||||
|
},
|
||||||
|
ausbildungText: {
|
||||||
|
fontSize: 11,
|
||||||
|
color: '#15803D',
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
@ -0,0 +1,105 @@
|
||||||
|
import { TouchableOpacity, Text, View, StyleSheet, Platform } from 'react-native'
|
||||||
|
import { Ionicons } from '@expo/vector-icons'
|
||||||
|
import * as WebBrowser from 'expo-web-browser'
|
||||||
|
|
||||||
|
interface Attachment {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
storagePath: string
|
||||||
|
sizeBytes: number | null
|
||||||
|
mimeType?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const API_URL = process.env.EXPO_PUBLIC_API_URL ?? 'http://localhost:3000'
|
||||||
|
|
||||||
|
function getFileIcon(mimeType?: string | null): keyof typeof Ionicons.glyphMap {
|
||||||
|
if (!mimeType) return 'document-outline'
|
||||||
|
if (mimeType.includes('pdf')) return 'document-text-outline'
|
||||||
|
if (mimeType.startsWith('image/')) return 'image-outline'
|
||||||
|
if (mimeType.includes('spreadsheet') || mimeType.includes('excel')) return 'grid-outline'
|
||||||
|
if (mimeType.includes('word') || mimeType.includes('document')) return 'document-outline'
|
||||||
|
return 'attach-outline'
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSize(bytes: number): string {
|
||||||
|
if (bytes < 1024) return `${bytes} B`
|
||||||
|
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`
|
||||||
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AttachmentRow({ attachment }: { attachment: Attachment }) {
|
||||||
|
const url = `${API_URL}/uploads/${attachment.storagePath}`
|
||||||
|
const icon = getFileIcon(attachment.mimeType)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => WebBrowser.openBrowserAsync(url)}
|
||||||
|
style={styles.row}
|
||||||
|
activeOpacity={0.75}
|
||||||
|
>
|
||||||
|
<View style={styles.iconBox}>
|
||||||
|
<Ionicons name={icon} size={20} color="#003B7E" />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.info}>
|
||||||
|
<Text style={styles.name} numberOfLines={1}>
|
||||||
|
{attachment.name}
|
||||||
|
</Text>
|
||||||
|
{attachment.sizeBytes != null && (
|
||||||
|
<Text style={styles.meta}>{formatSize(attachment.sizeBytes)}</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.openChip}>
|
||||||
|
<Ionicons name="download-outline" size={13} color="#003B7E" />
|
||||||
|
<Text style={styles.openText}>Öffnen</Text>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
row: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 12,
|
||||||
|
paddingVertical: 13,
|
||||||
|
},
|
||||||
|
iconBox: {
|
||||||
|
width: 42,
|
||||||
|
height: 42,
|
||||||
|
borderRadius: 12,
|
||||||
|
backgroundColor: '#EFF6FF',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
flexShrink: 0,
|
||||||
|
},
|
||||||
|
info: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
name: {
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#0F172A',
|
||||||
|
lineHeight: 18,
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
fontSize: 11,
|
||||||
|
color: '#94A3B8',
|
||||||
|
marginTop: 2,
|
||||||
|
},
|
||||||
|
openChip: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 4,
|
||||||
|
backgroundColor: '#EFF6FF',
|
||||||
|
paddingHorizontal: 11,
|
||||||
|
paddingVertical: 6,
|
||||||
|
borderRadius: 99,
|
||||||
|
},
|
||||||
|
openText: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#003B7E',
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
@ -0,0 +1,133 @@
|
||||||
|
import { View, Text, TouchableOpacity, StyleSheet } from 'react-native'
|
||||||
|
import { Ionicons } from '@expo/vector-icons'
|
||||||
|
import { Badge } from '@/components/ui/Badge'
|
||||||
|
import { NEWS_KATEGORIE_LABELS } from '@innungsapp/shared/types'
|
||||||
|
import { format } from 'date-fns'
|
||||||
|
import { de } from 'date-fns/locale'
|
||||||
|
import { useNewsReadStore } from '@/store/news.store'
|
||||||
|
|
||||||
|
interface NewsCardProps {
|
||||||
|
news: {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
kategorie: string
|
||||||
|
publishedAt: Date | null
|
||||||
|
isRead: boolean
|
||||||
|
author: { name: string } | null
|
||||||
|
}
|
||||||
|
onPress: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NewsCard({ news, onPress }: NewsCardProps) {
|
||||||
|
const localReadIds = useNewsReadStore((s) => s.readIds)
|
||||||
|
const isRead = news.isRead || localReadIds.has(news.id)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity onPress={onPress} style={styles.card} activeOpacity={0.84}>
|
||||||
|
{!isRead && (
|
||||||
|
<View style={styles.newBadge}>
|
||||||
|
<Text style={styles.newBadgeText}>Neu</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<View style={styles.content}>
|
||||||
|
<View style={styles.topRow}>
|
||||||
|
<Badge label={NEWS_KATEGORIE_LABELS[news.kategorie]} kategorie={news.kategorie} />
|
||||||
|
<Text style={styles.dateText}>
|
||||||
|
{news.publishedAt
|
||||||
|
? format(new Date(news.publishedAt), 'dd. MMM', { locale: de })
|
||||||
|
: 'Entwurf'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text style={isRead ? styles.titleRead : styles.titleUnread} numberOfLines={2}>
|
||||||
|
{news.title}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View style={styles.metaRow}>
|
||||||
|
<View style={styles.dot} />
|
||||||
|
<Text style={styles.authorText}>{news.author?.name ?? 'Innung'}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Ionicons name="chevron-forward" size={16} color="#94A3B8" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
card: {
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#E2E8F0',
|
||||||
|
borderRadius: 16,
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: 14,
|
||||||
|
shadowColor: '#0F172A',
|
||||||
|
shadowOffset: { width: 0, height: 1 },
|
||||||
|
shadowOpacity: 0.06,
|
||||||
|
shadowRadius: 10,
|
||||||
|
elevation: 2,
|
||||||
|
position: 'relative',
|
||||||
|
},
|
||||||
|
newBadge: {
|
||||||
|
position: 'absolute',
|
||||||
|
right: 30,
|
||||||
|
top: 8,
|
||||||
|
borderRadius: 999,
|
||||||
|
backgroundColor: '#EF4444',
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
paddingVertical: 2,
|
||||||
|
zIndex: 2,
|
||||||
|
},
|
||||||
|
newBadgeText: {
|
||||||
|
color: '#FFFFFF',
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
flex: 1,
|
||||||
|
marginRight: 10,
|
||||||
|
},
|
||||||
|
topRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
dateText: {
|
||||||
|
fontSize: 11,
|
||||||
|
color: '#94A3B8',
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
titleUnread: {
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#0F172A',
|
||||||
|
lineHeight: 21,
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
titleRead: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '500',
|
||||||
|
color: '#475569',
|
||||||
|
lineHeight: 20,
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
metaRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
},
|
||||||
|
dot: {
|
||||||
|
width: 4,
|
||||||
|
height: 4,
|
||||||
|
borderRadius: 2,
|
||||||
|
backgroundColor: '#CBD5E1',
|
||||||
|
},
|
||||||
|
authorText: {
|
||||||
|
fontSize: 11,
|
||||||
|
color: '#64748B',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
@ -0,0 +1,162 @@
|
||||||
|
import { View, Text, TouchableOpacity, StyleSheet } from 'react-native'
|
||||||
|
import { Ionicons } from '@expo/vector-icons'
|
||||||
|
|
||||||
|
const SPARTE_COLOR: Record<string, string> = {
|
||||||
|
elektro: '#1D4ED8',
|
||||||
|
sanit: '#0F766E',
|
||||||
|
it: '#4338CA',
|
||||||
|
heizung: '#B45309',
|
||||||
|
maler: '#15803D',
|
||||||
|
info: '#4338CA',
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSparteColor(sparte: string): string {
|
||||||
|
const lower = sparte.toLowerCase()
|
||||||
|
for (const [key, color] of Object.entries(SPARTE_COLOR)) {
|
||||||
|
if (lower.includes(key)) return color
|
||||||
|
}
|
||||||
|
return '#003B7E'
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StelleCardProps {
|
||||||
|
stelle: {
|
||||||
|
id: string
|
||||||
|
sparte: string
|
||||||
|
stellenAnz: number
|
||||||
|
verguetung: string | null
|
||||||
|
lehrjahr: string | null
|
||||||
|
member: { betrieb: string; ort: string }
|
||||||
|
org: { name: string }
|
||||||
|
}
|
||||||
|
onPress: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StelleCard({ stelle, onPress }: StelleCardProps) {
|
||||||
|
const color = getSparteColor(stelle.sparte)
|
||||||
|
const initial = stelle.sparte.charAt(0).toUpperCase()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity onPress={onPress} style={styles.card} activeOpacity={0.82}>
|
||||||
|
<View style={styles.row}>
|
||||||
|
<View style={[styles.iconBox, { backgroundColor: `${color}18` }]}>
|
||||||
|
<Text style={[styles.iconLetter, { color }]}>{initial}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.content}>
|
||||||
|
<Text style={styles.company} numberOfLines={1}>
|
||||||
|
{stelle.member.betrieb}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.sparte}>{stelle.sparte}</Text>
|
||||||
|
|
||||||
|
<View style={styles.chips}>
|
||||||
|
<View style={styles.chipBrand}>
|
||||||
|
<Text style={styles.chipBrandText}>
|
||||||
|
{stelle.stellenAnz} Stelle{stelle.stellenAnz > 1 ? 'n' : ''}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
{stelle.lehrjahr ? (
|
||||||
|
<View style={styles.chip}>
|
||||||
|
<Text style={styles.chipText}>{stelle.lehrjahr}</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
{stelle.verguetung ? (
|
||||||
|
<View style={styles.chip}>
|
||||||
|
<Text style={styles.chipText}>{stelle.verguetung}</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.locationRow}>
|
||||||
|
<Ionicons name="location-outline" size={12} color="#64748B" />
|
||||||
|
<Text style={styles.location}>{stelle.member.ort}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Ionicons name="chevron-forward" size={16} color="#94A3B8" />
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
card: {
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
borderRadius: 16,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#E2E8F0',
|
||||||
|
padding: 14,
|
||||||
|
shadowColor: '#0F172A',
|
||||||
|
shadowOffset: { width: 0, height: 1 },
|
||||||
|
shadowOpacity: 0.06,
|
||||||
|
shadowRadius: 8,
|
||||||
|
elevation: 2,
|
||||||
|
},
|
||||||
|
row: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
gap: 12,
|
||||||
|
},
|
||||||
|
iconBox: {
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
borderRadius: 14,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
iconLetter: {
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: '800',
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
flex: 1,
|
||||||
|
gap: 4,
|
||||||
|
},
|
||||||
|
company: {
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#0F172A',
|
||||||
|
letterSpacing: -0.2,
|
||||||
|
},
|
||||||
|
sparte: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#64748B',
|
||||||
|
},
|
||||||
|
chips: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: 6,
|
||||||
|
marginTop: 4,
|
||||||
|
},
|
||||||
|
chipBrand: {
|
||||||
|
backgroundColor: '#EFF6FF',
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
paddingVertical: 3,
|
||||||
|
borderRadius: 99,
|
||||||
|
},
|
||||||
|
chipBrandText: {
|
||||||
|
fontSize: 11,
|
||||||
|
color: '#003B7E',
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
chip: {
|
||||||
|
backgroundColor: '#F1F5F9',
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
paddingVertical: 3,
|
||||||
|
borderRadius: 99,
|
||||||
|
},
|
||||||
|
chipText: {
|
||||||
|
fontSize: 11,
|
||||||
|
color: '#475569',
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
locationRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 4,
|
||||||
|
marginTop: 2,
|
||||||
|
},
|
||||||
|
location: {
|
||||||
|
fontSize: 11,
|
||||||
|
color: '#64748B',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
@ -0,0 +1,67 @@
|
||||||
|
import { TouchableOpacity, Text, ActivityIndicator, StyleSheet } from 'react-native'
|
||||||
|
|
||||||
|
interface AnmeldeButtonProps {
|
||||||
|
terminId: string
|
||||||
|
isAngemeldet: boolean
|
||||||
|
onToggle: () => void
|
||||||
|
isLoading: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AnmeldeButton({ isAngemeldet, onToggle, isLoading }: AnmeldeButtonProps) {
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={onToggle}
|
||||||
|
disabled={isLoading}
|
||||||
|
style={[
|
||||||
|
styles.btn,
|
||||||
|
isAngemeldet ? styles.btnRegistered : styles.btnRegister,
|
||||||
|
isLoading && styles.disabled,
|
||||||
|
]}
|
||||||
|
activeOpacity={0.82}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<ActivityIndicator color={isAngemeldet ? '#52525B' : '#FFFFFF'} />
|
||||||
|
) : (
|
||||||
|
<Text style={[styles.label, isAngemeldet && styles.labelRegistered]}>
|
||||||
|
{isAngemeldet ? '✓ Angemeldet – Abmelden' : 'Jetzt anmelden'}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
btn: {
|
||||||
|
borderRadius: 14,
|
||||||
|
paddingVertical: 15,
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
btnRegister: {
|
||||||
|
backgroundColor: '#003B7E',
|
||||||
|
shadowColor: '#003B7E',
|
||||||
|
shadowOffset: { width: 0, height: 4 },
|
||||||
|
shadowOpacity: 0.24,
|
||||||
|
shadowRadius: 10,
|
||||||
|
elevation: 5,
|
||||||
|
},
|
||||||
|
btnRegistered: {
|
||||||
|
backgroundColor: '#F4F4F5',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#E2E8F0',
|
||||||
|
},
|
||||||
|
disabled: {
|
||||||
|
opacity: 0.5,
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#FFFFFF',
|
||||||
|
letterSpacing: 0.2,
|
||||||
|
},
|
||||||
|
labelRegistered: {
|
||||||
|
color: '#52525B',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
|
@ -0,0 +1,253 @@
|
||||||
|
import { View, Text, TouchableOpacity, ActivityIndicator, StyleSheet } from 'react-native'
|
||||||
|
import { Ionicons } from '@expo/vector-icons'
|
||||||
|
import { Badge } from '@/components/ui/Badge'
|
||||||
|
import { TERMIN_TYP_LABELS } from '@innungsapp/shared/types'
|
||||||
|
import { format } from 'date-fns'
|
||||||
|
import { de } from 'date-fns/locale'
|
||||||
|
|
||||||
|
interface TerminCardProps {
|
||||||
|
termin: {
|
||||||
|
id: string
|
||||||
|
titel: string
|
||||||
|
datum: Date
|
||||||
|
uhrzeit: string | null
|
||||||
|
ort: string | null
|
||||||
|
typ: string
|
||||||
|
isAngemeldet: boolean
|
||||||
|
teilnehmerAnzahl: number
|
||||||
|
}
|
||||||
|
onPress: () => void
|
||||||
|
onToggleAnmeldung?: () => void
|
||||||
|
isToggling?: boolean
|
||||||
|
isPast?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TerminCard({
|
||||||
|
termin,
|
||||||
|
onPress,
|
||||||
|
onToggleAnmeldung,
|
||||||
|
isToggling = false,
|
||||||
|
isPast = false,
|
||||||
|
}: TerminCardProps) {
|
||||||
|
const datum = new Date(termin.datum)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={onPress}
|
||||||
|
style={[styles.card, isPast && styles.cardPast]}
|
||||||
|
activeOpacity={0.76}
|
||||||
|
>
|
||||||
|
{/* Date column */}
|
||||||
|
<View style={[styles.dateColumn, isPast && styles.dateColumnPast]}>
|
||||||
|
<Text style={[styles.dayNumber, isPast && styles.dayNumberPast]}>
|
||||||
|
{format(datum, 'dd')}
|
||||||
|
</Text>
|
||||||
|
<Text style={[styles.monthLabel, isPast && styles.monthLabelPast]}>
|
||||||
|
{format(datum, 'MMM', { locale: de }).toUpperCase()}
|
||||||
|
</Text>
|
||||||
|
{!isPast && termin.isAngemeldet && <View style={styles.registeredDot} />}
|
||||||
|
{isPast && (
|
||||||
|
<View style={styles.pastBadge}>
|
||||||
|
<Text style={styles.pastBadgeText}>vorbei</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
<View style={[styles.divider, isPast && styles.dividerPast]} />
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<View style={styles.content}>
|
||||||
|
<Badge label={TERMIN_TYP_LABELS[termin.typ]} typ={termin.typ} />
|
||||||
|
<Text style={[styles.title, isPast && styles.titlePast]} numberOfLines={2}>
|
||||||
|
{termin.titel}
|
||||||
|
</Text>
|
||||||
|
<View style={styles.metaRow}>
|
||||||
|
{termin.uhrzeit && (
|
||||||
|
<View style={styles.metaItem}>
|
||||||
|
<Ionicons name="time-outline" size={12} color={isPast ? '#94A3B8' : '#64748B'} />
|
||||||
|
<Text style={[styles.metaText, isPast && styles.metaTextPast]}>
|
||||||
|
{termin.uhrzeit} Uhr
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{termin.ort && (
|
||||||
|
<View style={styles.metaItem}>
|
||||||
|
<Ionicons name="location-outline" size={12} color={isPast ? '#94A3B8' : '#64748B'} />
|
||||||
|
<Text style={[styles.metaText, isPast && styles.metaTextPast]} numberOfLines={1}>
|
||||||
|
{termin.ort}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Registration status chip — only for upcoming events */}
|
||||||
|
{!isPast && termin.isAngemeldet && onToggleAnmeldung && (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={(e) => {
|
||||||
|
e.stopPropagation?.()
|
||||||
|
onToggleAnmeldung()
|
||||||
|
}}
|
||||||
|
style={styles.registeredChip}
|
||||||
|
disabled={isToggling}
|
||||||
|
activeOpacity={0.75}
|
||||||
|
>
|
||||||
|
{isToggling ? (
|
||||||
|
<ActivityIndicator size="small" color="#047857" style={{ marginRight: 4 }} />
|
||||||
|
) : (
|
||||||
|
<Ionicons name="checkmark-circle" size={14} color="#059669" />
|
||||||
|
)}
|
||||||
|
<Text style={styles.registeredChipText}>
|
||||||
|
Angemeldet · <Text style={styles.abmeldenText}>Abmelden</Text>
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Chevron */}
|
||||||
|
<View style={styles.chevronWrap}>
|
||||||
|
<Ionicons name="chevron-forward" size={20} color={isPast ? '#E2E8F0' : '#CBD5E1'} />
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
card: {
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
borderRadius: 16,
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
overflow: 'hidden',
|
||||||
|
shadowColor: '#1C1917',
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
shadowOpacity: 0.08,
|
||||||
|
shadowRadius: 12,
|
||||||
|
elevation: 3,
|
||||||
|
},
|
||||||
|
cardPast: {
|
||||||
|
shadowOpacity: 0.04,
|
||||||
|
elevation: 1,
|
||||||
|
},
|
||||||
|
dateColumn: {
|
||||||
|
width: 64,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
paddingVertical: 18,
|
||||||
|
backgroundColor: '#EFF6FF',
|
||||||
|
},
|
||||||
|
dateColumnPast: {
|
||||||
|
backgroundColor: '#F4F4F5',
|
||||||
|
},
|
||||||
|
dayNumber: {
|
||||||
|
fontSize: 28,
|
||||||
|
fontWeight: '800',
|
||||||
|
color: '#003B7E',
|
||||||
|
lineHeight: 30,
|
||||||
|
},
|
||||||
|
dayNumberPast: {
|
||||||
|
color: '#94A3B8',
|
||||||
|
},
|
||||||
|
monthLabel: {
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#003B7E',
|
||||||
|
letterSpacing: 1,
|
||||||
|
opacity: 0.75,
|
||||||
|
marginTop: 1,
|
||||||
|
},
|
||||||
|
monthLabelPast: {
|
||||||
|
color: '#94A3B8',
|
||||||
|
opacity: 1,
|
||||||
|
},
|
||||||
|
registeredDot: {
|
||||||
|
width: 6,
|
||||||
|
height: 6,
|
||||||
|
borderRadius: 3,
|
||||||
|
backgroundColor: '#059669',
|
||||||
|
marginTop: 6,
|
||||||
|
},
|
||||||
|
pastBadge: {
|
||||||
|
marginTop: 6,
|
||||||
|
backgroundColor: '#E2E8F0',
|
||||||
|
borderRadius: 99,
|
||||||
|
paddingHorizontal: 6,
|
||||||
|
paddingVertical: 2,
|
||||||
|
},
|
||||||
|
pastBadgeText: {
|
||||||
|
fontSize: 9,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#94A3B8',
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
},
|
||||||
|
divider: {
|
||||||
|
width: 1,
|
||||||
|
alignSelf: 'stretch',
|
||||||
|
marginVertical: 14,
|
||||||
|
backgroundColor: '#F0EDED',
|
||||||
|
},
|
||||||
|
dividerPast: {
|
||||||
|
backgroundColor: '#F4F4F5',
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
flex: 1,
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
paddingVertical: 14,
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#0F172A',
|
||||||
|
letterSpacing: -0.2,
|
||||||
|
lineHeight: 21,
|
||||||
|
marginTop: 2,
|
||||||
|
},
|
||||||
|
titlePast: {
|
||||||
|
color: '#94A3B8',
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
metaRow: {
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 4,
|
||||||
|
marginTop: 4,
|
||||||
|
},
|
||||||
|
metaItem: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 4,
|
||||||
|
},
|
||||||
|
metaText: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#64748B',
|
||||||
|
},
|
||||||
|
metaTextPast: {
|
||||||
|
color: '#94A3B8',
|
||||||
|
},
|
||||||
|
registeredChip: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 5,
|
||||||
|
marginTop: 4,
|
||||||
|
alignSelf: 'flex-start',
|
||||||
|
backgroundColor: '#ECFDF5',
|
||||||
|
borderRadius: 99,
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
paddingVertical: 5,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#A7F3D0',
|
||||||
|
},
|
||||||
|
registeredChipText: {
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#047857',
|
||||||
|
},
|
||||||
|
abmeldenText: {
|
||||||
|
color: '#DC2626',
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
chevronWrap: {
|
||||||
|
paddingRight: 12,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
@ -0,0 +1,93 @@
|
||||||
|
import { View, Text, Image, ViewStyle, ImageStyle } from 'react-native'
|
||||||
|
|
||||||
|
const AVATAR_PALETTES = [
|
||||||
|
{ bg: '#003B7E', text: '#FFFFFF' },
|
||||||
|
{ bg: '#1D4ED8', text: '#FFFFFF' },
|
||||||
|
{ bg: '#059669', text: '#FFFFFF' },
|
||||||
|
{ bg: '#4338CA', text: '#FFFFFF' },
|
||||||
|
{ bg: '#B45309', text: '#FFFFFF' },
|
||||||
|
{ bg: '#0F766E', text: '#FFFFFF' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function getInitials(name: string): string {
|
||||||
|
return name
|
||||||
|
.split(' ')
|
||||||
|
.slice(0, 2)
|
||||||
|
.map((w) => w[0]?.toUpperCase() ?? '')
|
||||||
|
.join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPalette(name: string) {
|
||||||
|
const index = name.charCodeAt(0) % AVATAR_PALETTES.length
|
||||||
|
return AVATAR_PALETTES[index]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AvatarProps {
|
||||||
|
name: string
|
||||||
|
imageUrl?: string
|
||||||
|
size?: number
|
||||||
|
shadow?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Avatar({ name, imageUrl, size = 48, shadow = false }: AvatarProps) {
|
||||||
|
const initials = getInitials(name)
|
||||||
|
const palette = getPalette(name)
|
||||||
|
|
||||||
|
const viewShadowStyle: ViewStyle = shadow
|
||||||
|
? {
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
shadowOpacity: 0.12,
|
||||||
|
shadowRadius: 8,
|
||||||
|
elevation: 3,
|
||||||
|
}
|
||||||
|
: {}
|
||||||
|
|
||||||
|
const imageShadowStyle: ImageStyle = shadow
|
||||||
|
? {
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
shadowOpacity: 0.12,
|
||||||
|
shadowRadius: 8,
|
||||||
|
}
|
||||||
|
: {}
|
||||||
|
|
||||||
|
if (imageUrl) {
|
||||||
|
return (
|
||||||
|
<Image
|
||||||
|
source={{ uri: imageUrl }}
|
||||||
|
style={[
|
||||||
|
{ width: size, height: size, borderRadius: size / 2 },
|
||||||
|
imageShadowStyle,
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
{
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
borderRadius: size / 2,
|
||||||
|
backgroundColor: palette.bg,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
viewShadowStyle,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
color: palette.text,
|
||||||
|
fontSize: size * 0.36,
|
||||||
|
fontWeight: '700',
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{initials}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
import { View, Text, StyleSheet } from 'react-native'
|
||||||
|
|
||||||
|
const BADGE_CONFIG: Record<string, { bg: string; color: string }> = {
|
||||||
|
Wichtig: { bg: '#FEE2E2', color: '#B91C1C' },
|
||||||
|
Pruefung: { bg: '#DBEAFE', color: '#1D4ED8' },
|
||||||
|
Foerderung: { bg: '#DCFCE7', color: '#15803D' },
|
||||||
|
Veranstaltung: { bg: '#E0E7FF', color: '#4338CA' },
|
||||||
|
Allgemein: { bg: '#F1F5F9', color: '#475569' },
|
||||||
|
Versammlung: { bg: '#E0E7FF', color: '#4338CA' },
|
||||||
|
Kurs: { bg: '#DCFCE7', color: '#15803D' },
|
||||||
|
Event: { bg: '#FEF3C7', color: '#B45309' },
|
||||||
|
Sonstiges: { bg: '#F1F5F9', color: '#475569' },
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BadgeProps {
|
||||||
|
label: string
|
||||||
|
kategorie?: string
|
||||||
|
typ?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Badge({ label, kategorie, typ }: BadgeProps) {
|
||||||
|
const cfg =
|
||||||
|
(kategorie && BADGE_CONFIG[kategorie]) ||
|
||||||
|
(typ && BADGE_CONFIG[typ]) ||
|
||||||
|
{ bg: '#F1F5F9', color: '#475569' }
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[styles.pill, { backgroundColor: cfg.bg }]}>
|
||||||
|
<Text style={[styles.label, { color: cfg.color }]}>{label}</Text>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
pill: {
|
||||||
|
alignSelf: 'flex-start',
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
paddingVertical: 4,
|
||||||
|
borderRadius: 99,
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: '700',
|
||||||
|
letterSpacing: 0.2,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
@ -1,47 +1,69 @@
|
||||||
import { TouchableOpacity, Text, ActivityIndicator } from 'react-native'
|
import { Text, TouchableOpacity, ActivityIndicator } from 'react-native'
|
||||||
|
import { cn } from '../../lib/utils'
|
||||||
|
|
||||||
interface ButtonProps {
|
interface ButtonProps {
|
||||||
label: string
|
label: string
|
||||||
onPress: () => void
|
onPress: () => void
|
||||||
variant?: 'primary' | 'secondary' | 'ghost'
|
variant?: 'primary' | 'secondary' | 'ghost' | 'destructive' | 'outline'
|
||||||
|
size?: 'default' | 'sm' | 'lg'
|
||||||
loading?: boolean
|
loading?: boolean
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
icon?: string
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Button({
|
export function Button({
|
||||||
label,
|
label,
|
||||||
onPress,
|
onPress,
|
||||||
variant = 'primary',
|
variant = 'primary',
|
||||||
|
size = 'default',
|
||||||
loading,
|
loading,
|
||||||
disabled,
|
disabled,
|
||||||
icon,
|
className,
|
||||||
}: ButtonProps) {
|
}: ButtonProps) {
|
||||||
const base = 'rounded-2xl py-4 flex-row items-center justify-center gap-2'
|
const isDisabled = disabled || loading
|
||||||
const variants = {
|
|
||||||
primary: `${base} bg-brand-500`,
|
|
||||||
secondary: `${base} bg-white border border-gray-200`,
|
|
||||||
ghost: `${base}`,
|
|
||||||
}
|
|
||||||
const textVariants = {
|
|
||||||
primary: 'text-white font-semibold text-base',
|
|
||||||
secondary: 'text-gray-900 font-semibold text-base',
|
|
||||||
ghost: 'text-brand-500 font-semibold text-base',
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
disabled={disabled || loading}
|
disabled={isDisabled}
|
||||||
className={`${variants[variant]} ${disabled || loading ? 'opacity-50' : ''}`}
|
className={cn(
|
||||||
|
'flex-row items-center justify-center rounded-xl',
|
||||||
|
{
|
||||||
|
'bg-primary shadow-sm': variant === 'primary',
|
||||||
|
'bg-secondary border border-border': variant === 'secondary',
|
||||||
|
'bg-transparent': variant === 'ghost',
|
||||||
|
'bg-destructive': variant === 'destructive',
|
||||||
|
'bg-background border border-input': variant === 'outline',
|
||||||
|
|
||||||
|
'h-10 px-4 py-2': size === 'default',
|
||||||
|
'h-9 rounded-md px-3': size === 'sm',
|
||||||
|
'h-11 rounded-md px-8': size === 'lg',
|
||||||
|
|
||||||
|
'opacity-50': isDisabled,
|
||||||
|
},
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
activeOpacity={0.8}
|
||||||
>
|
>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<ActivityIndicator color={variant === 'primary' ? 'white' : '#E63946'} />
|
<ActivityIndicator
|
||||||
|
color={variant === 'primary' || variant === 'destructive' ? 'white' : 'black'}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<Text
|
||||||
{icon && <Text className="text-xl">{icon}</Text>}
|
className={cn(
|
||||||
<Text className={textVariants[variant]}>{label}</Text>
|
'text-sm font-medium',
|
||||||
</>
|
{
|
||||||
|
'text-primary-foreground': variant === 'primary',
|
||||||
|
'text-secondary-foreground': variant === 'secondary',
|
||||||
|
'text-primary': variant === 'ghost',
|
||||||
|
'text-destructive-foreground': variant === 'destructive',
|
||||||
|
'text-foreground': variant === 'outline',
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
)}
|
)}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,39 @@
|
||||||
import { View, TouchableOpacity } from 'react-native'
|
import { View, TouchableOpacity, ViewStyle } from 'react-native'
|
||||||
|
import { cn } from '../../lib/utils'
|
||||||
|
|
||||||
|
// Keep shadow style for now as it's often better handled natively than via Tailwind utilities for cross-platform consistency
|
||||||
|
// OR use nativewind shadow classes if configured properly. Let's use Tailwind classes for shadow to align with the system if possible,
|
||||||
|
// but often standard CSS shadows don't map perfectly to RN shadow props (elevation vs shadowOffset).
|
||||||
|
// For specific "card" feel, we might want to keep a consistent shadow.
|
||||||
|
// However, the prompt asked for "10x better" design.
|
||||||
|
|
||||||
interface CardProps {
|
interface CardProps {
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
onPress?: () => void
|
onPress?: () => void
|
||||||
className?: string
|
className?: string
|
||||||
|
noPadding?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Card({ children, onPress, className = '' }: CardProps) {
|
export function Card({ children, onPress, className = '', noPadding = false }: CardProps) {
|
||||||
|
const baseClasses = cn(
|
||||||
|
'bg-card rounded-xl border border-border shadow-sm',
|
||||||
|
!noPadding && 'p-4',
|
||||||
|
className
|
||||||
|
)
|
||||||
|
|
||||||
if (onPress) {
|
if (onPress) {
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
className={`bg-white rounded-2xl border border-gray-100 p-4 ${className}`}
|
className={baseClasses}
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.8}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<View className={`bg-white rounded-2xl border border-gray-100 p-4 ${className}`}>
|
<View className={baseClasses}>
|
||||||
{children}
|
{children}
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,59 @@
|
||||||
|
import { Ionicons } from '@expo/vector-icons'
|
||||||
|
import { View, Text, StyleSheet } from 'react-native'
|
||||||
|
|
||||||
|
interface EmptyStateProps {
|
||||||
|
icon: keyof typeof Ionicons.glyphMap
|
||||||
|
title: string
|
||||||
|
subtitle: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EmptyState({ icon, title, subtitle }: EmptyStateProps) {
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<View style={styles.iconBox}>
|
||||||
|
<Ionicons name={icon} size={32} color="#94A3B8" />
|
||||||
|
</View>
|
||||||
|
<Text style={styles.title}>{title}</Text>
|
||||||
|
<Text style={styles.subtitle}>{subtitle}</Text>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
paddingVertical: 80,
|
||||||
|
paddingHorizontal: 32,
|
||||||
|
},
|
||||||
|
iconBox: {
|
||||||
|
width: 72,
|
||||||
|
height: 72,
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
borderRadius: 22,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
marginBottom: 18,
|
||||||
|
shadowColor: '#1C1917',
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
shadowOpacity: 0.06,
|
||||||
|
shadowRadius: 10,
|
||||||
|
elevation: 2,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 17,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#0F172A',
|
||||||
|
textAlign: 'center',
|
||||||
|
letterSpacing: -0.2,
|
||||||
|
},
|
||||||
|
subtitle: {
|
||||||
|
fontSize: 13,
|
||||||
|
color: '#64748B',
|
||||||
|
textAlign: 'center',
|
||||||
|
marginTop: 6,
|
||||||
|
lineHeight: 19,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
import { View, ActivityIndicator } from 'react-native'
|
||||||
|
|
||||||
|
export function LoadingSpinner() {
|
||||||
|
return (
|
||||||
|
<View className="flex-1 items-center justify-center">
|
||||||
|
<ActivityIndicator size="large" color="#003B7E" />
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
{
|
||||||
|
"cli": {
|
||||||
|
"version": ">= 10.0.0"
|
||||||
|
},
|
||||||
|
"build": {
|
||||||
|
"development": {
|
||||||
|
"developmentClient": true,
|
||||||
|
"distribution": "internal",
|
||||||
|
"env": {
|
||||||
|
"EXPO_PUBLIC_API_URL": "http://localhost:3000"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"preview": {
|
||||||
|
"distribution": "internal",
|
||||||
|
"android": {
|
||||||
|
"buildType": "apk"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"production": {
|
||||||
|
"autoIncrement": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"submit": {
|
||||||
|
"production": {
|
||||||
|
"ios": {
|
||||||
|
"appleId": "YOUR_APPLE_ID",
|
||||||
|
"ascAppId": "YOUR_APP_STORE_CONNECT_APP_ID",
|
||||||
|
"appleTeamId": "YOUR_APPLE_TEAM_ID"
|
||||||
|
},
|
||||||
|
"android": {
|
||||||
|
"serviceAccountKeyPath": "./google-service-account.json",
|
||||||
|
"track": "internal"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
// https://docs.expo.dev/guides/using-eslint/
|
||||||
|
const { defineConfig } = require('eslint/config');
|
||||||
|
const expoConfig = require("eslint-config-expo/flat");
|
||||||
|
|
||||||
|
module.exports = defineConfig([
|
||||||
|
expoConfig,
|
||||||
|
{
|
||||||
|
ignores: ["dist/*"],
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
@ -1,3 +1,67 @@
|
||||||
@tailwind base;
|
@tailwind base;
|
||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
:root {
|
||||||
|
--background: 0 0% 100%;
|
||||||
|
--foreground: 222.2 84% 4.9%;
|
||||||
|
|
||||||
|
--card: 0 0% 100%;
|
||||||
|
--card-foreground: 222.2 84% 4.9%;
|
||||||
|
|
||||||
|
--popover: 0 0% 100%;
|
||||||
|
--popover-foreground: 222.2 84% 4.9%;
|
||||||
|
|
||||||
|
--primary: 221.2 83.2% 53.3%;
|
||||||
|
--primary-foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--secondary: 210 40% 96.1%;
|
||||||
|
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||||
|
|
||||||
|
--muted: 210 40% 96.1%;
|
||||||
|
--muted-foreground: 215.4 16.3% 46.9%;
|
||||||
|
|
||||||
|
--accent: 210 40% 96.1%;
|
||||||
|
--accent-foreground: 222.2 47.4% 11.2%;
|
||||||
|
|
||||||
|
--destructive: 0 84.2% 60.2%;
|
||||||
|
--destructive-foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--border: 214.3 31.8% 91.4%;
|
||||||
|
--input: 214.3 31.8% 91.4%;
|
||||||
|
--ring: 221.2 83.2% 53.3%;
|
||||||
|
|
||||||
|
--radius: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--background: 222.2 84% 4.9%;
|
||||||
|
--foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--card: 222.2 84% 4.9%;
|
||||||
|
--card-foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--popover: 222.2 84% 4.9%;
|
||||||
|
--popover-foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--primary: 217.2 91.2% 59.8%;
|
||||||
|
--primary-foreground: 222.2 47.4% 11.2%;
|
||||||
|
|
||||||
|
--secondary: 217.2 32.6% 17.5%;
|
||||||
|
--secondary-foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--muted: 217.2 32.6% 17.5%;
|
||||||
|
--muted-foreground: 215 20.2% 65.1%;
|
||||||
|
|
||||||
|
--accent: 217.2 32.6% 17.5%;
|
||||||
|
--accent-foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--destructive: 0 62.8% 30.6%;
|
||||||
|
--destructive-foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--border: 217.2 32.6% 17.5%;
|
||||||
|
--input: 217.2 32.6% 17.5%;
|
||||||
|
--ring: 224.3 76.3% 48%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { useAuthStore } from '@/store/auth.store'
|
||||||
import { useRouter } from 'expo-router'
|
import { useRouter } from 'expo-router'
|
||||||
|
|
||||||
export function useAuth() {
|
export function useAuth() {
|
||||||
const { session, orgId, role, signOut } = useAuthStore()
|
const { session, signOut } = useAuthStore()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
async function handleSignOut() {
|
async function handleSignOut() {
|
||||||
|
|
@ -12,10 +12,10 @@ export function useAuth() {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
session,
|
session,
|
||||||
orgId,
|
orgId: 'org-1',
|
||||||
role,
|
role: 'member' as const,
|
||||||
isAuthenticated: !!session,
|
isAuthenticated: true, // Mock: immer eingeloggt
|
||||||
isAdmin: role === 'admin',
|
isAdmin: false,
|
||||||
signOut: handleSignOut,
|
signOut: handleSignOut,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,31 @@
|
||||||
import { trpc } from '@/lib/trpc'
|
import { MOCK_MEMBERS } from '@/lib/mock-data'
|
||||||
import { useMembersFilterStore } from '@/store/members.store'
|
import { useMembersFilterStore } from '@/store/members.store'
|
||||||
|
|
||||||
export function useMembersList() {
|
export function useMembersList() {
|
||||||
const search = useMembersFilterStore((s) => s.search)
|
const search = useMembersFilterStore((s) => s.search)
|
||||||
const nurAusbildungsbetriebe = useMembersFilterStore(
|
const nurAusbildungsbetriebe = useMembersFilterStore((s) => s.nurAusbildungsbetriebe)
|
||||||
(s) => s.nurAusbildungsbetriebe
|
|
||||||
)
|
|
||||||
|
|
||||||
return trpc.members.list.useQuery({
|
let data = MOCK_MEMBERS.filter((m) => m.status === 'aktiv')
|
||||||
search: search || undefined,
|
|
||||||
ausbildungsbetrieb: nurAusbildungsbetriebe || undefined,
|
if (search) {
|
||||||
status: 'aktiv',
|
const q = search.toLowerCase()
|
||||||
})
|
data = data.filter(
|
||||||
|
(m) =>
|
||||||
|
m.name.toLowerCase().includes(q) ||
|
||||||
|
m.betrieb.toLowerCase().includes(q) ||
|
||||||
|
m.ort.toLowerCase().includes(q) ||
|
||||||
|
m.sparte.toLowerCase().includes(q)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nurAusbildungsbetriebe) {
|
||||||
|
data = data.filter((m) => m.istAusbildungsbetrieb)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { data, isLoading: false, refetch: () => {}, isRefetching: false }
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useMemberDetail(id: string) {
|
export function useMemberDetail(id: string) {
|
||||||
return trpc.members.byId.useQuery({ id })
|
const data = MOCK_MEMBERS.find((m) => m.id === id) ?? null
|
||||||
|
return { data, isLoading: false }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,28 @@
|
||||||
import { trpc } from '@/lib/trpc'
|
import { useState } from 'react'
|
||||||
|
import { MOCK_NEWS } from '@/lib/mock-data'
|
||||||
import { useNewsReadStore } from '@/store/news.store'
|
import { useNewsReadStore } from '@/store/news.store'
|
||||||
|
|
||||||
export function useNewsList(kategorie?: string) {
|
export function useNewsList(kategorie?: string) {
|
||||||
return trpc.news.list.useQuery({
|
const localReadIds = useNewsReadStore((s) => s.readIds)
|
||||||
kategorie: kategorie as never,
|
const filtered = kategorie
|
||||||
})
|
? MOCK_NEWS.filter((n) => n.kategorie === kategorie)
|
||||||
|
: MOCK_NEWS
|
||||||
|
|
||||||
|
const data = filtered.map((n) => ({
|
||||||
|
...n,
|
||||||
|
isRead: n.isRead || localReadIds.has(n.id),
|
||||||
|
}))
|
||||||
|
|
||||||
|
return { data, isLoading: false, refetch: () => {}, isRefetching: false }
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useNewsDetail(id: string) {
|
export function useNewsDetail(id: string) {
|
||||||
const markRead = useNewsReadStore((s) => s.markRead)
|
const markRead = useNewsReadStore((s) => s.markRead)
|
||||||
const markReadMutation = trpc.news.markRead.useMutation()
|
const news = MOCK_NEWS.find((n) => n.id === id) ?? null
|
||||||
|
|
||||||
const query = trpc.news.byId.useQuery({ id })
|
|
||||||
|
|
||||||
function onOpen() {
|
function onOpen() {
|
||||||
markRead(id)
|
markRead(id)
|
||||||
markReadMutation.mutate({ newsId: id })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return { ...query, onOpen }
|
return { data: news, isLoading: false, onOpen }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,13 @@
|
||||||
import { trpc } from '@/lib/trpc'
|
import { MOCK_STELLEN } from '@/lib/mock-data'
|
||||||
|
|
||||||
export function useStellenListe(opts?: { sparte?: string; lehrjahr?: string }) {
|
export function useStellenListe(opts?: { sparte?: string; lehrjahr?: string }) {
|
||||||
return trpc.stellen.listPublic.useQuery({
|
let data = MOCK_STELLEN.filter((s) => s.aktiv)
|
||||||
sparte: opts?.sparte,
|
if (opts?.sparte) data = data.filter((s) => s.sparte === opts.sparte)
|
||||||
lehrjahr: opts?.lehrjahr,
|
if (opts?.lehrjahr) data = data.filter((s) => s.lehrjahr === opts.lehrjahr)
|
||||||
})
|
return { data, isLoading: false, refetch: () => {}, isRefetching: false }
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useStelleDetail(id: string) {
|
export function useStelleDetail(id: string) {
|
||||||
return trpc.stellen.byId.useQuery({ id })
|
const data = MOCK_STELLEN.find((s) => s.id === id) ?? null
|
||||||
|
return { data, isLoading: false }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,33 @@
|
||||||
import { trpc } from '@/lib/trpc'
|
import { useState } from 'react'
|
||||||
|
import { MOCK_TERMINE } from '@/lib/mock-data'
|
||||||
|
|
||||||
export function useTermineListe(upcoming = true) {
|
export function useTermineListe(upcoming = true) {
|
||||||
return trpc.termine.list.useQuery({ upcoming })
|
const now = new Date()
|
||||||
|
const data = MOCK_TERMINE.filter((t) =>
|
||||||
|
upcoming ? t.datum >= now : t.datum < now
|
||||||
|
).sort((a, b) =>
|
||||||
|
upcoming ? a.datum.getTime() - b.datum.getTime() : b.datum.getTime() - a.datum.getTime()
|
||||||
|
)
|
||||||
|
return { data, isLoading: false, refetch: () => {}, isRefetching: false }
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useTerminDetail(id: string) {
|
export function useTerminDetail(id: string) {
|
||||||
return trpc.termine.byId.useQuery({ id })
|
const data = MOCK_TERMINE.find((t) => t.id === id) ?? null
|
||||||
|
return { data, isLoading: false }
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useToggleAnmeldung() {
|
export function useToggleAnmeldung() {
|
||||||
const utils = trpc.useUtils()
|
const [isPending, setIsPending] = useState(false)
|
||||||
return trpc.termine.toggleAnmeldung.useMutation({
|
|
||||||
onSuccess: () => {
|
function mutate({ terminId }: { terminId: string }) {
|
||||||
utils.termine.list.invalidate()
|
setIsPending(true)
|
||||||
utils.termine.byId.invalidate()
|
const termin = MOCK_TERMINE.find((t) => t.id === terminId)
|
||||||
},
|
if (termin) {
|
||||||
})
|
termin.isAngemeldet = !termin.isAngemeldet
|
||||||
|
termin.teilnehmerAnzahl += termin.isAngemeldet ? 1 : -1
|
||||||
|
}
|
||||||
|
setTimeout(() => setIsPending(false), 300)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { mutate, isPending }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,311 @@
|
||||||
|
// Mock-Daten für Entwicklung ohne Backend
|
||||||
|
|
||||||
|
export const MOCK_ORG = {
|
||||||
|
id: 'org-1',
|
||||||
|
name: 'Innung Elektrotechnik Stuttgart',
|
||||||
|
slug: 'innung-elektro-stuttgart',
|
||||||
|
plan: 'pilot',
|
||||||
|
primaryColor: '#003B7E',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MOCK_MEMBER_ME = {
|
||||||
|
id: 'member-1',
|
||||||
|
orgId: 'org-1',
|
||||||
|
userId: 'user-1',
|
||||||
|
name: 'Demo Admin',
|
||||||
|
betrieb: 'Innungsgeschäftsstelle',
|
||||||
|
sparte: 'Elektrotechnik',
|
||||||
|
ort: 'Stuttgart',
|
||||||
|
telefon: '+49 711 123456',
|
||||||
|
email: 'admin@demo.de',
|
||||||
|
status: 'aktiv' as const,
|
||||||
|
istAusbildungsbetrieb: false,
|
||||||
|
seit: 2020,
|
||||||
|
avatarUrl: null,
|
||||||
|
pushToken: null,
|
||||||
|
createdAt: new Date('2024-01-01'),
|
||||||
|
updatedAt: new Date('2024-01-01'),
|
||||||
|
org: MOCK_ORG,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MOCK_MEMBERS = [
|
||||||
|
{
|
||||||
|
id: 'member-1',
|
||||||
|
orgId: 'org-1',
|
||||||
|
userId: 'user-1',
|
||||||
|
name: 'Klaus Müller',
|
||||||
|
betrieb: 'Elektro Müller GmbH',
|
||||||
|
sparte: 'Elektrotechnik',
|
||||||
|
ort: 'Stuttgart',
|
||||||
|
telefon: '+49 711 123456',
|
||||||
|
email: 'mueller@elektro-mueller.de',
|
||||||
|
status: 'aktiv' as const,
|
||||||
|
istAusbildungsbetrieb: true,
|
||||||
|
seit: 2015,
|
||||||
|
avatarUrl: null,
|
||||||
|
pushToken: null,
|
||||||
|
createdAt: new Date('2024-01-01'),
|
||||||
|
updatedAt: new Date('2024-01-01'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'member-2',
|
||||||
|
orgId: 'org-1',
|
||||||
|
userId: null,
|
||||||
|
name: 'Maria Schmidt',
|
||||||
|
betrieb: 'Schmidt Elektrik',
|
||||||
|
sparte: 'Elektrotechnik',
|
||||||
|
ort: 'Ludwigsburg',
|
||||||
|
telefon: '+49 7141 987654',
|
||||||
|
email: 'schmidt@schmidt-elektrik.de',
|
||||||
|
status: 'aktiv' as const,
|
||||||
|
istAusbildungsbetrieb: false,
|
||||||
|
seit: 2018,
|
||||||
|
avatarUrl: null,
|
||||||
|
pushToken: null,
|
||||||
|
createdAt: new Date('2024-01-01'),
|
||||||
|
updatedAt: new Date('2024-01-01'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'member-3',
|
||||||
|
orgId: 'org-1',
|
||||||
|
userId: null,
|
||||||
|
name: 'Thomas Weber',
|
||||||
|
betrieb: 'Weber & Söhne Elektro',
|
||||||
|
sparte: 'Informationstechnik',
|
||||||
|
ort: 'Esslingen',
|
||||||
|
telefon: '+49 711 555123',
|
||||||
|
email: 'weber@weber-elektro.de',
|
||||||
|
status: 'aktiv' as const,
|
||||||
|
istAusbildungsbetrieb: true,
|
||||||
|
seit: 2012,
|
||||||
|
avatarUrl: null,
|
||||||
|
pushToken: null,
|
||||||
|
createdAt: new Date('2024-01-01'),
|
||||||
|
updatedAt: new Date('2024-01-01'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'member-4',
|
||||||
|
orgId: 'org-1',
|
||||||
|
userId: null,
|
||||||
|
name: 'Anna Bauer',
|
||||||
|
betrieb: 'Bauer Elektrotechnik',
|
||||||
|
sparte: 'Elektrotechnik',
|
||||||
|
ort: 'Stuttgart',
|
||||||
|
telefon: '+49 711 444567',
|
||||||
|
email: 'bauer@bauer-elektro.de',
|
||||||
|
status: 'aktiv' as const,
|
||||||
|
istAusbildungsbetrieb: false,
|
||||||
|
seit: 2021,
|
||||||
|
avatarUrl: null,
|
||||||
|
pushToken: null,
|
||||||
|
createdAt: new Date('2024-01-01'),
|
||||||
|
updatedAt: new Date('2024-01-01'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'member-5',
|
||||||
|
orgId: 'org-1',
|
||||||
|
userId: null,
|
||||||
|
name: 'Peter Hoffmann',
|
||||||
|
betrieb: 'Hoffmann Elektro-Service',
|
||||||
|
sparte: 'Sanitär',
|
||||||
|
ort: 'Böblingen',
|
||||||
|
telefon: '+49 7031 123789',
|
||||||
|
email: 'hoffmann@elektro-service.de',
|
||||||
|
status: 'aktiv' as const,
|
||||||
|
istAusbildungsbetrieb: true,
|
||||||
|
seit: 2010,
|
||||||
|
avatarUrl: null,
|
||||||
|
pushToken: null,
|
||||||
|
createdAt: new Date('2024-01-01'),
|
||||||
|
updatedAt: new Date('2024-01-01'),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export const MOCK_NEWS = [
|
||||||
|
{
|
||||||
|
id: 'news-1',
|
||||||
|
orgId: 'org-1',
|
||||||
|
authorId: 'member-1',
|
||||||
|
title: 'Wichtige Änderungen bei der Gesellenprüfung 2025',
|
||||||
|
body: `## Änderungen ab Herbst 2025\n\nDie Prüfungsordnung wurde angepasst. Folgende Änderungen sind zu beachten:\n\n- Prüfungsteil A (Gesellenstück) wird auf 2 Tage ausgeweitet\n- Neue digitale Komponenten in Prüfungsteil B\n- Anmeldeschluss ist der 15. September 2025\n\nWeitere Details entnehmen Sie dem beigefügten Rundschreiben.`,
|
||||||
|
kategorie: 'Pruefung' as const,
|
||||||
|
publishedAt: new Date('2025-09-01'),
|
||||||
|
createdAt: new Date('2025-09-01'),
|
||||||
|
isRead: false,
|
||||||
|
author: { name: 'Klaus Müller' },
|
||||||
|
attachments: [
|
||||||
|
{
|
||||||
|
id: 'att-1',
|
||||||
|
newsId: 'news-1',
|
||||||
|
name: 'Prüfungsordnung_2025.pdf',
|
||||||
|
storagePath: 'pruefungsordnung.pdf',
|
||||||
|
mimeType: 'application/pdf',
|
||||||
|
sizeBytes: 245000,
|
||||||
|
createdAt: new Date('2025-09-01'),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'news-2',
|
||||||
|
orgId: 'org-1',
|
||||||
|
authorId: 'member-1',
|
||||||
|
title: 'Förderung für Ausbildungsbetriebe: Jetzt beantragen!',
|
||||||
|
body: `## Neue Fördergelder verfügbar\n\nDas Land Baden-Württemberg stellt für das Jahr 2025 zusätzliche Fördermittel für Ausbildungsbetriebe bereit.\n\nFörderhöhe: Bis zu 3.000 € pro Auszubildenden\n\nAnträge bis 31. März 2025 einreichen.`,
|
||||||
|
kategorie: 'Foerderung' as const,
|
||||||
|
publishedAt: new Date('2025-08-15'),
|
||||||
|
createdAt: new Date('2025-08-15'),
|
||||||
|
isRead: true,
|
||||||
|
author: { name: 'Maria Schmidt' },
|
||||||
|
attachments: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'news-3',
|
||||||
|
orgId: 'org-1',
|
||||||
|
authorId: 'member-1',
|
||||||
|
title: 'Innungsversammlung Winter 2025 — Einladung',
|
||||||
|
body: `## Einladung zur Winterversammlung\n\nWir laden herzlich zur jährlichen Winterversammlung ein.\n\nDatum: 3. Dezember 2025\nUhrzeit: 19:00 Uhr\nOrt: Gasthof Zum Lamm, Stuttgart\n\nTagesordnung:\n1. Jahresrückblick\n2. Vorstandswahlen\n3. Verschiedenes`,
|
||||||
|
kategorie: 'Veranstaltung' as const,
|
||||||
|
publishedAt: new Date('2025-11-01'),
|
||||||
|
createdAt: new Date('2025-11-01'),
|
||||||
|
isRead: false,
|
||||||
|
author: { name: 'Klaus Müller' },
|
||||||
|
attachments: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'news-4',
|
||||||
|
orgId: 'org-1',
|
||||||
|
authorId: 'member-1',
|
||||||
|
title: 'Neue Tarifvereinbarung ab Januar 2026',
|
||||||
|
body: `## Tarifeinigung im Elektrohandwerk\n\nDie Tarifverhandlungen wurden erfolgreich abgeschlossen. Ab Januar 2026 gelten folgende Sätze:\n\n- Gesellenlohn: +4,2 %\n- Ausbildungsvergütung: +5,0 %`,
|
||||||
|
kategorie: 'Wichtig' as const,
|
||||||
|
publishedAt: new Date('2025-10-20'),
|
||||||
|
createdAt: new Date('2025-10-20'),
|
||||||
|
isRead: false,
|
||||||
|
author: { name: 'Klaus Müller' },
|
||||||
|
attachments: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'news-5',
|
||||||
|
orgId: 'org-1',
|
||||||
|
authorId: 'member-1',
|
||||||
|
title: 'Mitgliederrundschreiben Oktober 2025',
|
||||||
|
body: `## Allgemeine Informationen\n\nLiebe Innungsmitglieder,\n\nin dieser Ausgabe informieren wir Sie über aktuelle Themen aus dem Elektrohandwerk.`,
|
||||||
|
kategorie: 'Allgemein' as const,
|
||||||
|
publishedAt: new Date('2025-10-01'),
|
||||||
|
createdAt: new Date('2025-10-01'),
|
||||||
|
isRead: true,
|
||||||
|
author: { name: 'Klaus Müller' },
|
||||||
|
attachments: [],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export const MOCK_TERMINE = [
|
||||||
|
{
|
||||||
|
id: 'termin-1',
|
||||||
|
orgId: 'org-1',
|
||||||
|
titel: 'Herbst-Gesellenprüfung 2025',
|
||||||
|
datum: new Date('2025-11-15'),
|
||||||
|
uhrzeit: '08:00',
|
||||||
|
endeDatum: new Date('2025-11-16'),
|
||||||
|
endeUhrzeit: '17:00',
|
||||||
|
ort: 'Berufsschule Stuttgart-Mitte',
|
||||||
|
adresse: 'Neckarstraße 22, 70190 Stuttgart',
|
||||||
|
typ: 'Pruefung' as const,
|
||||||
|
beschreibung: 'Praktische und theoretische Gesellenprüfung für Elektrotechniker.',
|
||||||
|
maxTeilnehmer: 30,
|
||||||
|
createdAt: new Date('2025-09-01'),
|
||||||
|
isAngemeldet: false,
|
||||||
|
teilnehmerAnzahl: 18,
|
||||||
|
anmeldungen: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'termin-2',
|
||||||
|
orgId: 'org-1',
|
||||||
|
titel: 'Innungsversammlung Winter 2025',
|
||||||
|
datum: new Date('2025-12-03'),
|
||||||
|
uhrzeit: '19:00',
|
||||||
|
endeDatum: null,
|
||||||
|
endeUhrzeit: '21:00',
|
||||||
|
ort: 'Gasthof Zum Lamm',
|
||||||
|
adresse: 'Marktplatz 5, 70173 Stuttgart',
|
||||||
|
typ: 'Versammlung' as const,
|
||||||
|
beschreibung: 'Jährliche Winterversammlung mit Jahresrückblick und Vorstandswahlen.',
|
||||||
|
maxTeilnehmer: null,
|
||||||
|
createdAt: new Date('2025-10-01'),
|
||||||
|
isAngemeldet: true,
|
||||||
|
teilnehmerAnzahl: 42,
|
||||||
|
anmeldungen: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'termin-3',
|
||||||
|
orgId: 'org-1',
|
||||||
|
titel: 'Weiterbildung: Photovoltaik & Elektromobilität',
|
||||||
|
datum: new Date('2026-02-20'),
|
||||||
|
uhrzeit: '09:00',
|
||||||
|
endeDatum: null,
|
||||||
|
endeUhrzeit: '17:00',
|
||||||
|
ort: 'Handwerkskammer Stuttgart',
|
||||||
|
adresse: 'Heilbronner Straße 43, 70191 Stuttgart',
|
||||||
|
typ: 'Kurs' as const,
|
||||||
|
beschreibung: 'Zertifizierter Lehrgang für PV-Anlagen und E-Mobilität. Anmeldung erforderlich.',
|
||||||
|
maxTeilnehmer: 20,
|
||||||
|
createdAt: new Date('2025-12-01'),
|
||||||
|
isAngemeldet: false,
|
||||||
|
teilnehmerAnzahl: 14,
|
||||||
|
anmeldungen: [],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export const MOCK_STELLEN = [
|
||||||
|
{
|
||||||
|
id: 'stelle-1',
|
||||||
|
orgId: 'org-1',
|
||||||
|
memberId: 'member-1',
|
||||||
|
sparte: 'Elektrotechnik',
|
||||||
|
stellenAnz: 2,
|
||||||
|
verguetung: '620-750 € / Monat',
|
||||||
|
lehrjahr: 'beliebig',
|
||||||
|
beschreibung: 'Wir suchen motivierte Auszubildende für unser erfolgreiches Elektroinstallationsunternehmen. Moderner Fuhrpark, faire Vergütung, Übernahmechancen.',
|
||||||
|
kontaktEmail: 'mueller@elektro-mueller.de',
|
||||||
|
kontaktName: 'Klaus Müller',
|
||||||
|
aktiv: true,
|
||||||
|
createdAt: new Date('2025-09-01'),
|
||||||
|
updatedAt: new Date('2025-09-01'),
|
||||||
|
member: { betrieb: 'Elektro Müller GmbH', ort: 'Stuttgart' },
|
||||||
|
org: { name: 'Innung Elektrotechnik Stuttgart', slug: 'innung-elektro-stuttgart' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'stelle-2',
|
||||||
|
orgId: 'org-1',
|
||||||
|
memberId: 'member-3',
|
||||||
|
sparte: 'Informationstechnik',
|
||||||
|
stellenAnz: 1,
|
||||||
|
verguetung: '650-800 € / Monat',
|
||||||
|
lehrjahr: '1. Lehrjahr',
|
||||||
|
beschreibung: 'Weber & Söhne sucht einen Auszubildenden im Bereich IT-Systemelektronik. Gute Übernahmechancen nach erfolgreichem Abschluss.',
|
||||||
|
kontaktEmail: 'weber@weber-elektro.de',
|
||||||
|
kontaktName: 'Thomas Weber',
|
||||||
|
aktiv: true,
|
||||||
|
createdAt: new Date('2025-10-01'),
|
||||||
|
updatedAt: new Date('2025-10-01'),
|
||||||
|
member: { betrieb: 'Weber & Söhne Elektro', ort: 'Esslingen' },
|
||||||
|
org: { name: 'Innung Elektrotechnik Stuttgart', slug: 'innung-elektro-stuttgart' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'stelle-3',
|
||||||
|
orgId: 'org-1',
|
||||||
|
memberId: 'member-5',
|
||||||
|
sparte: 'Sanitär',
|
||||||
|
stellenAnz: 3,
|
||||||
|
verguetung: '580-700 € / Monat',
|
||||||
|
lehrjahr: 'beliebig',
|
||||||
|
beschreibung: 'Familienbetrieb sucht Auszubildende für Sanitär- und Heizungsinstallation.',
|
||||||
|
kontaktEmail: 'hoffmann@elektro-service.de',
|
||||||
|
kontaktName: 'Peter Hoffmann',
|
||||||
|
aktiv: true,
|
||||||
|
createdAt: new Date('2025-11-01'),
|
||||||
|
updatedAt: new Date('2025-11-01'),
|
||||||
|
member: { betrieb: 'Hoffmann Elektro-Service', ort: 'Böblingen' },
|
||||||
|
org: { name: 'Innung Elektrotechnik Stuttgart', slug: 'innung-elektro-stuttgart' },
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
@ -8,6 +8,8 @@ Notifications.setNotificationHandler({
|
||||||
shouldShowAlert: true,
|
shouldShowAlert: true,
|
||||||
shouldPlaySound: true,
|
shouldPlaySound: true,
|
||||||
shouldSetBadge: true,
|
shouldSetBadge: true,
|
||||||
|
shouldShowBanner: true,
|
||||||
|
shouldShowList: true,
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
export const themeConfig = {
|
||||||
|
colors: {
|
||||||
|
// Brand Colors
|
||||||
|
primary: '#2563EB', // Vibrant Blue
|
||||||
|
primaryForeground: '#FFFFFF',
|
||||||
|
|
||||||
|
// UI Colors
|
||||||
|
background: '#FFFFFF',
|
||||||
|
foreground: '#0F172A', // Slate 900
|
||||||
|
|
||||||
|
card: '#FFFFFF',
|
||||||
|
cardForeground: '#0F172A',
|
||||||
|
|
||||||
|
popover: '#FFFFFF',
|
||||||
|
popoverForeground: '#0F172A',
|
||||||
|
|
||||||
|
secondary: '#F1F5F9', // Slate 100
|
||||||
|
secondaryForeground: '#0F172A',
|
||||||
|
|
||||||
|
muted: '#F1F5F9',
|
||||||
|
mutedForeground: '#64748B', // Slate 500
|
||||||
|
|
||||||
|
accent: '#F1F5F9',
|
||||||
|
accentForeground: '#0F172A',
|
||||||
|
|
||||||
|
destructive: '#EF4444', // Red 500
|
||||||
|
destructiveForeground: '#FFFFFF',
|
||||||
|
|
||||||
|
border: '#E2E8F0', // Slate 200
|
||||||
|
input: '#E2E8F0',
|
||||||
|
ring: '#2563EB',
|
||||||
|
},
|
||||||
|
layout: {
|
||||||
|
radius: {
|
||||||
|
small: 4,
|
||||||
|
medium: 8,
|
||||||
|
large: 12,
|
||||||
|
xl: 16,
|
||||||
|
'2xl': 24,
|
||||||
|
full: 9999,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} as const;
|
||||||
|
|
@ -36,9 +36,11 @@ const trpcClient = trpc.createClient({
|
||||||
})
|
})
|
||||||
|
|
||||||
export function TRPCProvider({ children }: { children: ReactNode }) {
|
export function TRPCProvider({ children }: { children: ReactNode }) {
|
||||||
return createElement(
|
return (
|
||||||
trpc.Provider,
|
<trpc.Provider client={trpcClient} queryClient={queryClient}>
|
||||||
{ client: trpcClient, queryClient },
|
<QueryClientProvider client={queryClient}>
|
||||||
createElement(QueryClientProvider, { client: queryClient }, children)
|
{children}
|
||||||
|
</QueryClientProvider>
|
||||||
|
</trpc.Provider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
import { type ClassValue, clsx } from 'clsx';
|
||||||
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,43 @@
|
||||||
|
const path = require('path')
|
||||||
|
const { createRequire } = require('module')
|
||||||
const { getDefaultConfig } = require('expo/metro-config')
|
const { getDefaultConfig } = require('expo/metro-config')
|
||||||
const { withNativeWind } = require('nativewind/metro')
|
|
||||||
|
|
||||||
const config = getDefaultConfig(__dirname)
|
const projectRoot = __dirname
|
||||||
|
const workspaceRoot = path.resolve(projectRoot, '../..')
|
||||||
|
const appRequire = createRequire(path.join(projectRoot, 'package.json'))
|
||||||
|
const reactEntry = appRequire.resolve('react')
|
||||||
|
const reactJsxRuntime = appRequire.resolve('react/jsx-runtime')
|
||||||
|
const reactJsxDevRuntime = appRequire.resolve('react/jsx-dev-runtime')
|
||||||
|
const reactRoot = path.dirname(appRequire.resolve('react/package.json'))
|
||||||
|
const reactNativeRoot = path.dirname(appRequire.resolve('react-native/package.json'))
|
||||||
|
|
||||||
module.exports = withNativeWind(config, { input: './global.css' })
|
const config = getDefaultConfig(projectRoot)
|
||||||
|
|
||||||
|
// Monorepo: Metro muss die Workspace-Pakete finden
|
||||||
|
config.watchFolders = [workspaceRoot]
|
||||||
|
config.resolver.nodeModulesPaths = [
|
||||||
|
path.resolve(projectRoot, 'node_modules'),
|
||||||
|
path.resolve(workspaceRoot, 'node_modules'),
|
||||||
|
]
|
||||||
|
config.resolver.extraNodeModules = {
|
||||||
|
...(config.resolver.extraNodeModules || {}),
|
||||||
|
react: reactRoot,
|
||||||
|
'react-native': reactNativeRoot,
|
||||||
|
}
|
||||||
|
const originalResolveRequest = config.resolver.resolveRequest
|
||||||
|
config.resolver.resolveRequest = (context, moduleName, platform) => {
|
||||||
|
if (moduleName === 'react') {
|
||||||
|
return { type: 'sourceFile', filePath: reactEntry }
|
||||||
|
}
|
||||||
|
if (moduleName === 'react/jsx-runtime') {
|
||||||
|
return { type: 'sourceFile', filePath: reactJsxRuntime }
|
||||||
|
}
|
||||||
|
if (moduleName === 'react/jsx-dev-runtime') {
|
||||||
|
return { type: 'sourceFile', filePath: reactJsxDevRuntime }
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolver = originalResolveRequest ?? context.resolveRequest
|
||||||
|
return resolver(context, moduleName, platform)
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = config
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,289 @@
|
||||||
|
"use strict";
|
||||||
|
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||||
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||||
|
};
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.withCssInterop = withCssInterop;
|
||||||
|
const fs_1 = __importDefault(require("fs"));
|
||||||
|
const promises_1 = __importDefault(require("fs/promises"));
|
||||||
|
const path_1 = __importDefault(require("path"));
|
||||||
|
const module_1 = require("module");
|
||||||
|
const nativewindRequire = (0, module_1.createRequire)(require.resolve("nativewind/metro"));
|
||||||
|
const connect_1 = __importDefault(nativewindRequire("connect"));
|
||||||
|
const debug_1 = nativewindRequire("debug");
|
||||||
|
const css_to_rn_1 = nativewindRequire("react-native-css-interop/dist/css-to-rn");
|
||||||
|
const expo_1 = nativewindRequire("react-native-css-interop/dist/metro/expo");
|
||||||
|
let haste;
|
||||||
|
let virtualModulesPossible = undefined;
|
||||||
|
const virtualModules = new Map();
|
||||||
|
const outputDirectory = path_1.default.resolve(__dirname, "../.cache/react-native-css-interop");
|
||||||
|
const isRadonIDE = "REACT_NATIVE_IDE_LIB_PATH" in process.env;
|
||||||
|
function withCssInterop(config, options) {
|
||||||
|
return typeof config === "function"
|
||||||
|
? async function WithCSSInterop() {
|
||||||
|
return getConfig(await config(), options);
|
||||||
|
}
|
||||||
|
: getConfig(config, options);
|
||||||
|
}
|
||||||
|
function getConfig(config, options) {
|
||||||
|
const debug = (0, debug_1.debug)(options.parent?.name || "react-native-css-interop");
|
||||||
|
debug("withCssInterop");
|
||||||
|
debug(`outputDirectory ${outputDirectory}`);
|
||||||
|
debug(`isRadonIDE: ${isRadonIDE}`);
|
||||||
|
(0, expo_1.expoColorSchemeWarning)();
|
||||||
|
const originalResolver = config.resolver?.resolveRequest;
|
||||||
|
const originalGetTransformOptions = config.transformer?.getTransformOptions;
|
||||||
|
const originalMiddleware = config.server?.enhanceMiddleware;
|
||||||
|
const poisonPillPath = "./interop-poison.pill";
|
||||||
|
fs_1.default.mkdirSync(outputDirectory, { recursive: true });
|
||||||
|
fs_1.default.writeFileSync(platformPath("ios"), "");
|
||||||
|
fs_1.default.writeFileSync(platformPath("android"), "");
|
||||||
|
fs_1.default.writeFileSync(platformPath("native"), "");
|
||||||
|
fs_1.default.writeFileSync(platformPath("macos"), "");
|
||||||
|
fs_1.default.writeFileSync(platformPath("windows"), "");
|
||||||
|
return {
|
||||||
|
...config,
|
||||||
|
transformerPath: require.resolve("react-native-css-interop/dist/metro/transformer"),
|
||||||
|
transformer: {
|
||||||
|
...config.transformer,
|
||||||
|
...{
|
||||||
|
cssInterop_transformerPath: config.transformerPath,
|
||||||
|
cssInterop_outputDirectory: path_1.default.relative(process.cwd(), outputDirectory),
|
||||||
|
},
|
||||||
|
async getTransformOptions(entryPoints, transformOptions, getDependenciesOf) {
|
||||||
|
debug(`getTransformOptions.dev ${transformOptions.dev}`);
|
||||||
|
debug(`getTransformOptions.platform ${transformOptions.platform}`);
|
||||||
|
debug(`getTransformOptions.virtualModulesPossible ${Boolean(virtualModulesPossible)}`);
|
||||||
|
const platform = transformOptions.platform || "native";
|
||||||
|
const filePath = platformPath(platform);
|
||||||
|
if (virtualModulesPossible) {
|
||||||
|
await virtualModulesPossible;
|
||||||
|
await startCSSProcessor(filePath, platform, transformOptions.dev, options, debug);
|
||||||
|
}
|
||||||
|
const writeToFileSystem = !virtualModulesPossible || !transformOptions.dev;
|
||||||
|
debug(`getTransformOptions.writeToFileSystem ${writeToFileSystem}`);
|
||||||
|
if (writeToFileSystem) {
|
||||||
|
debug(`getTransformOptions.output ${filePath}`);
|
||||||
|
const watchFn = isRadonIDE
|
||||||
|
? async (css) => {
|
||||||
|
const output = platform === "web"
|
||||||
|
? css.toString()
|
||||||
|
: getNativeJS((0, css_to_rn_1.cssToReactNativeRuntime)(css, options, debug), debug);
|
||||||
|
await promises_1.default.writeFile(filePath, output);
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
const css = await options.getCSSForPlatform(platform, watchFn);
|
||||||
|
const output = platform === "web"
|
||||||
|
? css.toString("utf-8")
|
||||||
|
: getNativeJS((0, css_to_rn_1.cssToReactNativeRuntime)(css, options, debug), debug);
|
||||||
|
await promises_1.default.mkdir(outputDirectory, { recursive: true });
|
||||||
|
await promises_1.default.writeFile(filePath, output);
|
||||||
|
if (platform !== "web") {
|
||||||
|
await promises_1.default.writeFile(filePath.replace(/\.js$/, ".map"), "");
|
||||||
|
}
|
||||||
|
debug(`getTransformOptions.finished`);
|
||||||
|
}
|
||||||
|
return Object.assign({}, await originalGetTransformOptions?.(entryPoints, transformOptions, getDependenciesOf));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
...config.server,
|
||||||
|
enhanceMiddleware: (middleware, metroServer) => {
|
||||||
|
debug(`enhanceMiddleware.setup`);
|
||||||
|
const server = (0, connect_1.default)();
|
||||||
|
const bundler = metroServer.getBundler().getBundler();
|
||||||
|
if (options.forceWriteFileSystem) {
|
||||||
|
debug(`forceWriteFileSystem true`);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
if (!isRadonIDE) {
|
||||||
|
virtualModulesPossible = bundler
|
||||||
|
.getDependencyGraph()
|
||||||
|
.then(async (graph) => {
|
||||||
|
haste = graph?._haste;
|
||||||
|
if (graph?._fileSystem) {
|
||||||
|
ensureFileSystemPatched(graph._fileSystem);
|
||||||
|
}
|
||||||
|
ensureBundlerPatched(bundler);
|
||||||
|
});
|
||||||
|
server.use(async (_, __, next) => {
|
||||||
|
await virtualModulesPossible;
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return originalMiddleware
|
||||||
|
? server.use(originalMiddleware(middleware, metroServer))
|
||||||
|
: server.use(middleware);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
resolver: {
|
||||||
|
...config.resolver,
|
||||||
|
sourceExts: [...(config?.resolver?.sourceExts || []), "css"],
|
||||||
|
resolveRequest: (context, moduleName, platform) => {
|
||||||
|
if (moduleName === poisonPillPath) {
|
||||||
|
return { type: "empty" };
|
||||||
|
}
|
||||||
|
const resolver = originalResolver ?? context.resolveRequest;
|
||||||
|
const resolved = resolver(context, moduleName, platform);
|
||||||
|
if (!("filePath" in resolved && resolved.filePath === options.input)) {
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
platform = platform || "native";
|
||||||
|
const filePath = platformPath(platform);
|
||||||
|
const development = context.isDev || context.dev;
|
||||||
|
const isWebProduction = !development && platform === "web";
|
||||||
|
debug(`resolveRequest.input ${resolved.filePath}`);
|
||||||
|
debug(`resolveRequest.resolvedTo: ${filePath}`);
|
||||||
|
debug(`resolveRequest.development: ${development}`);
|
||||||
|
debug(`resolveRequest.platform: ${platform}`);
|
||||||
|
if (virtualModulesPossible && !isWebProduction) {
|
||||||
|
startCSSProcessor(filePath, platform, development, options, debug);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...resolved,
|
||||||
|
filePath,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
async function startCSSProcessor(filePath, platform, isDev, { input, getCSSForPlatform, ...options }, debug) {
|
||||||
|
if (virtualModules.has(filePath)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
debug(`virtualModules ${filePath}`);
|
||||||
|
debug(`virtualModules.isDev ${isDev}`);
|
||||||
|
debug(`virtualModules.size ${virtualModules.size}`);
|
||||||
|
options = {
|
||||||
|
cache: {
|
||||||
|
keyframes: new Map(),
|
||||||
|
rules: new Map(),
|
||||||
|
rootVariables: {},
|
||||||
|
universalVariables: {},
|
||||||
|
},
|
||||||
|
...options,
|
||||||
|
};
|
||||||
|
if (!isDev) {
|
||||||
|
debug(`virtualModules.fastRefresh disabled`);
|
||||||
|
virtualModules.set(filePath, getCSSForPlatform(platform).then((css) => {
|
||||||
|
return platform === "web"
|
||||||
|
? css
|
||||||
|
: getNativeJS((0, css_to_rn_1.cssToReactNativeRuntime)(css, options), debug);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
debug(`virtualModules.fastRefresh enabled`);
|
||||||
|
virtualModules.set(filePath, getCSSForPlatform(platform, (css) => {
|
||||||
|
debug(`virtualStyles.update ${platform}`);
|
||||||
|
virtualModules.set(filePath, Promise.resolve(platform === "web"
|
||||||
|
? css
|
||||||
|
: getNativeJS((0, css_to_rn_1.cssToReactNativeRuntime)(css, options), debug)));
|
||||||
|
debug(`virtualStyles.emit ${platform}`);
|
||||||
|
if (haste?.emit) {
|
||||||
|
haste.emit("change", {
|
||||||
|
eventsQueue: [
|
||||||
|
{
|
||||||
|
filePath,
|
||||||
|
metadata: {
|
||||||
|
modifiedTime: Date.now(),
|
||||||
|
size: 1,
|
||||||
|
type: "virtual",
|
||||||
|
},
|
||||||
|
type: "change",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}).then((css) => {
|
||||||
|
debug(`virtualStyles.initial ${platform}`);
|
||||||
|
return platform === "web"
|
||||||
|
? css
|
||||||
|
: getNativeJS((0, css_to_rn_1.cssToReactNativeRuntime)(css, options), debug);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function ensureFileSystemPatched(fs) {
|
||||||
|
if (!fs || typeof fs.getSha1 !== "function") {
|
||||||
|
return fs;
|
||||||
|
}
|
||||||
|
if (!fs.getSha1.__css_interop_patched) {
|
||||||
|
const original_getSha1 = fs.getSha1.bind(fs);
|
||||||
|
fs.getSha1 = (filename) => {
|
||||||
|
if (virtualModules.has(filename)) {
|
||||||
|
return `${filename}-${Date.now()}`;
|
||||||
|
}
|
||||||
|
return original_getSha1(filename);
|
||||||
|
};
|
||||||
|
fs.getSha1.__css_interop_patched = true;
|
||||||
|
}
|
||||||
|
return fs;
|
||||||
|
}
|
||||||
|
function ensureBundlerPatched(bundler) {
|
||||||
|
if (bundler.transformFile.__css_interop__patched) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const transformFile = bundler.transformFile.bind(bundler);
|
||||||
|
bundler.transformFile = async function (filePath, transformOptions, fileBuffer) {
|
||||||
|
const virtualModule = virtualModules.get(filePath);
|
||||||
|
if (virtualModule) {
|
||||||
|
fileBuffer = Buffer.from(await virtualModule);
|
||||||
|
}
|
||||||
|
return transformFile(filePath, transformOptions, fileBuffer);
|
||||||
|
};
|
||||||
|
bundler.transformFile.__css_interop__patched = true;
|
||||||
|
}
|
||||||
|
function getNativeJS(data = {}, debug) {
|
||||||
|
debug("Start stringify");
|
||||||
|
const output = `(${stringify(data)})`;
|
||||||
|
debug(`Output size: ${Buffer.byteLength(output, "utf8")} bytes`);
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
function platformPath(platform = "native") {
|
||||||
|
return path_1.default.join(outputDirectory, `${platform}.${platform === "web" ? "css" : "js"}`);
|
||||||
|
}
|
||||||
|
function stringify(data) {
|
||||||
|
switch (typeof data) {
|
||||||
|
case "bigint":
|
||||||
|
case "symbol":
|
||||||
|
case "function":
|
||||||
|
throw new Error(`Cannot stringify ${typeof data}`);
|
||||||
|
case "string":
|
||||||
|
return `"${data}"`;
|
||||||
|
case "number":
|
||||||
|
return `${Math.round(data * 1000) / 1000}`;
|
||||||
|
case "boolean":
|
||||||
|
return `${data}`;
|
||||||
|
case "undefined":
|
||||||
|
return "null";
|
||||||
|
case "object": {
|
||||||
|
if (data === null) {
|
||||||
|
return "null";
|
||||||
|
}
|
||||||
|
else if (Array.isArray(data)) {
|
||||||
|
return `[${data
|
||||||
|
.map((value) => {
|
||||||
|
return value === null || value === undefined
|
||||||
|
? ""
|
||||||
|
: stringify(value);
|
||||||
|
})
|
||||||
|
.join(",")}]`;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
return `{${Object.entries(data)
|
||||||
|
.flatMap(([key, value]) => {
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
if (key.match(/[^a-zA-Z]/)) {
|
||||||
|
key = `"${key}"`;
|
||||||
|
}
|
||||||
|
value = stringify(value);
|
||||||
|
return [`${key}:${value}`];
|
||||||
|
})
|
||||||
|
.join(",")}}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
//# sourceMappingURL=index.js.map
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
/// <reference types="nativewind/types" />
|
||||||
|
|
||||||
|
// NOTE: This file should not be edited and should be committed with your source code. It is generated by NativeWind.
|
||||||
|
|
@ -11,42 +11,48 @@
|
||||||
"type-check": "tsc --noEmit"
|
"type-check": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@expo/vector-icons": "^14.0.0",
|
"@expo/metro-runtime": "~6.1.2",
|
||||||
"@react-native-async-storage/async-storage": "^2.1.0",
|
"@expo/vector-icons": "^15.0.3",
|
||||||
|
"@innungsapp/shared": "workspace:*",
|
||||||
|
"@react-native-async-storage/async-storage": "^2.2.0",
|
||||||
"@tanstack/react-query": "^5.59.0",
|
"@tanstack/react-query": "^5.59.0",
|
||||||
"@trpc/client": "^11.0.0",
|
"@trpc/client": "^11.0.0",
|
||||||
"@trpc/react-query": "^11.0.0",
|
"@trpc/react-query": "^11.0.0",
|
||||||
"better-auth": "^1.2.0",
|
"better-auth": "^1.2.0",
|
||||||
"expo": "~52.0.0",
|
"clsx": "^2.1.1",
|
||||||
"expo-calendar": "~13.0.0",
|
|
||||||
"expo-constants": "~17.0.0",
|
|
||||||
"expo-dev-client": "~5.0.0",
|
|
||||||
"expo-document-picker": "~13.0.0",
|
|
||||||
"expo-font": "~13.0.0",
|
|
||||||
"expo-haptics": "~14.0.0",
|
|
||||||
"expo-linking": "~7.0.0",
|
|
||||||
"expo-notifications": "~0.29.0",
|
|
||||||
"expo-router": "~4.0.0",
|
|
||||||
"expo-splash-screen": "~0.29.0",
|
|
||||||
"expo-status-bar": "~2.0.0",
|
|
||||||
"expo-system-ui": "~4.0.0",
|
|
||||||
"expo-web-browser": "~14.0.0",
|
|
||||||
"nativewind": "^4.1.0",
|
|
||||||
"react": "18.3.1",
|
|
||||||
"react-native": "0.76.1",
|
|
||||||
"react-native-safe-area-context": "4.12.0",
|
|
||||||
"react-native-screens": "~4.0.0",
|
|
||||||
"react-native-reanimated": "~3.16.0",
|
|
||||||
"react-native-gesture-handler": "~2.21.0",
|
|
||||||
"superjson": "^2.2.1",
|
|
||||||
"zustand": "^5.0.0",
|
|
||||||
"date-fns": "^3.6.0",
|
"date-fns": "^3.6.0",
|
||||||
"zod": "^3.23.0"
|
"expo": "~54.0.33",
|
||||||
|
"expo-calendar": "~15.0.8",
|
||||||
|
"expo-constants": "~18.0.13",
|
||||||
|
"expo-dev-client": "~6.0.20",
|
||||||
|
"expo-document-picker": "~14.0.8",
|
||||||
|
"expo-font": "~14.0.11",
|
||||||
|
"expo-haptics": "~15.0.8",
|
||||||
|
"expo-linking": "~8.0.11",
|
||||||
|
"expo-notifications": "~0.32.16",
|
||||||
|
"expo-router": "~6.0.23",
|
||||||
|
"expo-splash-screen": "~31.0.13",
|
||||||
|
"expo-status-bar": "~3.0.9",
|
||||||
|
"expo-system-ui": "~6.0.9",
|
||||||
|
"expo-web-browser": "~15.0.10",
|
||||||
|
"nativewind": "^4.1.0",
|
||||||
|
"react": "19.1.0",
|
||||||
|
"react-native": "0.81.5",
|
||||||
|
"react-native-gesture-handler": "~2.28.0",
|
||||||
|
"react-native-reanimated": "~4.1.6",
|
||||||
|
"react-native-safe-area-context": "5.6.2",
|
||||||
|
"react-native-screens": "~4.16.0",
|
||||||
|
"superjson": "^2.2.1",
|
||||||
|
"tailwind-merge": "^2.5.0",
|
||||||
|
"zod": "^3.23.0",
|
||||||
|
"zustand": "^5.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.25.0",
|
"@babel/core": "^7.25.0",
|
||||||
"@types/react": "~18.3.0",
|
"@innungsapp/admin": "workspace:*",
|
||||||
|
"@types/react": "~19.1.17",
|
||||||
|
"eslint-config-expo": "~10.0.0",
|
||||||
"tailwindcss": "^3.4.0",
|
"tailwindcss": "^3.4.0",
|
||||||
"typescript": "^5.6.0"
|
"typescript": "^5.9.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,58 +1,34 @@
|
||||||
import { create } from 'zustand'
|
import { create } from 'zustand'
|
||||||
import { authClient } from '@/lib/auth-client'
|
import { MOCK_MEMBER_ME } from '@/lib/mock-data'
|
||||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
|
||||||
|
|
||||||
interface Session {
|
interface Session {
|
||||||
user: {
|
user: { id: string; email: string; name: string }
|
||||||
id: string
|
|
||||||
email: string
|
|
||||||
name: string
|
|
||||||
}
|
|
||||||
token?: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AuthState {
|
interface AuthState {
|
||||||
session: Session | null
|
session: Session | null
|
||||||
orgId: string | null
|
|
||||||
role: 'admin' | 'member' | null
|
|
||||||
isInitialized: boolean
|
isInitialized: boolean
|
||||||
initialize: () => Promise<void>
|
initialize: () => Promise<void>
|
||||||
setSession: (session: Session | null) => void
|
|
||||||
signOut: () => Promise<void>
|
signOut: () => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useAuthStore = create<AuthState>((set) => ({
|
export const useAuthStore = create<AuthState>((set) => ({
|
||||||
session: null,
|
// Mock: direkt eingeloggt
|
||||||
orgId: null,
|
session: {
|
||||||
role: null,
|
user: {
|
||||||
isInitialized: false,
|
id: MOCK_MEMBER_ME.userId!,
|
||||||
|
email: MOCK_MEMBER_ME.email,
|
||||||
|
name: MOCK_MEMBER_ME.name,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
isInitialized: true,
|
||||||
|
|
||||||
initialize: async () => {
|
initialize: async () => {
|
||||||
try {
|
// Mock: nichts zu tun
|
||||||
const { data } = await authClient.getSession()
|
set({ isInitialized: true })
|
||||||
if (data?.session && data?.user) {
|
|
||||||
set({
|
|
||||||
session: { user: data.user },
|
|
||||||
isInitialized: true,
|
|
||||||
})
|
|
||||||
// Store token for API requests
|
|
||||||
if (data.session.token) {
|
|
||||||
await AsyncStorage.setItem('better-auth-session', data.session.token)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
await AsyncStorage.removeItem('better-auth-session')
|
|
||||||
set({ session: null, orgId: null, role: null, isInitialized: true })
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
set({ session: null, isInitialized: true })
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
setSession: (session) => set({ session }),
|
|
||||||
|
|
||||||
signOut: async () => {
|
signOut: async () => {
|
||||||
await authClient.signOut()
|
set({ session: null })
|
||||||
await AsyncStorage.removeItem('better-auth-session')
|
|
||||||
set({ session: null, orgId: null, role: null })
|
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
|
||||||
|
|
@ -3,16 +3,67 @@ module.exports = {
|
||||||
content: ['./app/**/*.{js,jsx,ts,tsx}', './components/**/*.{js,jsx,ts,tsx}'],
|
content: ['./app/**/*.{js,jsx,ts,tsx}', './components/**/*.{js,jsx,ts,tsx}'],
|
||||||
presets: [require('nativewind/preset')],
|
presets: [require('nativewind/preset')],
|
||||||
theme: {
|
theme: {
|
||||||
|
container: {
|
||||||
|
center: true,
|
||||||
|
padding: "2rem",
|
||||||
|
screens: {
|
||||||
|
"2xl": "1400px",
|
||||||
|
},
|
||||||
|
},
|
||||||
extend: {
|
extend: {
|
||||||
colors: {
|
colors: {
|
||||||
brand: {
|
border: "hsl(var(--border))",
|
||||||
50: '#fff1f1',
|
input: "hsl(var(--input))",
|
||||||
100: '#ffe1e1',
|
ring: "hsl(var(--ring))",
|
||||||
400: '#ff6b6b',
|
background: "hsl(var(--background))",
|
||||||
500: '#E63946',
|
foreground: "hsl(var(--foreground))",
|
||||||
600: '#d42535',
|
primary: {
|
||||||
700: '#b21e2c',
|
DEFAULT: "hsl(var(--primary))",
|
||||||
|
foreground: "hsl(var(--primary-foreground))",
|
||||||
},
|
},
|
||||||
|
secondary: {
|
||||||
|
DEFAULT: "hsl(var(--secondary))",
|
||||||
|
foreground: "hsl(var(--secondary-foreground))",
|
||||||
|
},
|
||||||
|
destructive: {
|
||||||
|
DEFAULT: "hsl(var(--destructive))",
|
||||||
|
foreground: "hsl(var(--destructive-foreground))",
|
||||||
|
},
|
||||||
|
muted: {
|
||||||
|
DEFAULT: "hsl(var(--muted))",
|
||||||
|
foreground: "hsl(var(--muted-foreground))",
|
||||||
|
},
|
||||||
|
accent: {
|
||||||
|
DEFAULT: "hsl(var(--accent))",
|
||||||
|
foreground: "hsl(var(--accent-foreground))",
|
||||||
|
},
|
||||||
|
popover: {
|
||||||
|
DEFAULT: "hsl(var(--popover))",
|
||||||
|
foreground: "hsl(var(--popover-foreground))",
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
DEFAULT: "hsl(var(--card))",
|
||||||
|
foreground: "hsl(var(--card-foreground))",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
borderRadius: {
|
||||||
|
lg: "var(--radius)",
|
||||||
|
md: "calc(var(--radius) - 2px)",
|
||||||
|
sm: "calc(var(--radius) - 4px)",
|
||||||
|
},
|
||||||
|
keyframes: {
|
||||||
|
"accordion-down": {
|
||||||
|
from: { height: "0" },
|
||||||
|
to: { height: "var(--radix-accordion-content-height)" },
|
||||||
|
},
|
||||||
|
"accordion-up": {
|
||||||
|
from: { height: "var(--radix-accordion-content-height)" },
|
||||||
|
to: { height: "0" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
animation: {
|
||||||
|
"accordion-down": "accordion-down 0.2s ease-out",
|
||||||
|
"accordion-up": "accordion-up 0.2s ease-out",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -0,0 +1,10 @@
|
||||||
|
../admin/server/context.ts(2,22): error TS2307: Cannot find module '@/lib/auth' or its corresponding type declarations.
|
||||||
|
../admin/server/routers/members.ts(3,22): error TS2307: Cannot find module '@/lib/auth' or its corresponding type declarations.
|
||||||
|
../admin/server/routers/members.ts(4,33): error TS2307: Cannot find module '@/lib/email' or its corresponding type declarations.
|
||||||
|
../admin/server/routers/news.ts(3,10): error TS2724: '"@/lib/notifications"' has no exported member named 'sendPushNotifications'. Did you mean 'setupPushNotifications'?
|
||||||
|
lib/notifications.ts(7,35): error TS2322: Type 'Promise<{ shouldShowAlert: true; shouldPlaySound: true; shouldSetBadge: true; }>' is not assignable to type 'Promise<NotificationBehavior>'.
|
||||||
|
Type '{ shouldShowAlert: true; shouldPlaySound: true; shouldSetBadge: true; }' is missing the following properties from type 'NotificationBehavior': shouldShowBanner, shouldShowList
|
||||||
|
lib/trpc.ts(41,5): error TS2769: No overload matches this call.
|
||||||
|
The last overload gave the following error.
|
||||||
|
Argument of type '{ client: TRPCClient<BuiltRouter<{ ctx: { req: Request; session: any; prisma: PrismaClient<Prisma.PrismaClientOptions, never, DefaultArgs>; }; meta: object; errorShape: { ...; }; transformer: true; }, DecorateCreateRouterOptions<...>>>; queryClient: QueryClient; }' is not assignable to parameter of type 'Attributes & TRPCProviderProps<BuiltRouter<{ ctx: { req: Request; session: any; prisma: PrismaClient<PrismaClientOptions, never, DefaultArgs>; }; meta: object; errorShape: { ...; }; transformer: true; }, DecorateCreateRouterOptions<...>>, unknown>'.
|
||||||
|
Property 'children' is missing in type '{ client: TRPCClient<BuiltRouter<{ ctx: { req: Request; session: any; prisma: PrismaClient<Prisma.PrismaClientOptions, never, DefaultArgs>; }; meta: object; errorShape: { ...; }; transformer: true; }, DecorateCreateRouterOptions<...>>>; queryClient: QueryClient; }' but required in type 'TRPCProviderProps<BuiltRouter<{ ctx: { req: Request; session: any; prisma: PrismaClient<PrismaClientOptions, never, DefaultArgs>; }; meta: object; errorShape: { ...; }; transformer: true; }, DecorateCreateRouterOptions<...>>, unknown>'.
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
../admin/server/context.ts(2,22): error TS2307: Cannot find module '@/lib/auth' or its corresponding type declarations.
|
||||||
|
../admin/server/routers/members.ts(3,22): error TS2307: Cannot find module '@/lib/auth' or its corresponding type declarations.
|
||||||
|
../admin/server/routers/members.ts(4,33): error TS2307: Cannot find module '@/lib/email' or its corresponding type declarations.
|
||||||
|
../admin/server/routers/news.ts(3,10): error TS2724: '"@/lib/notifications"' has no exported member named 'sendPushNotifications'. Did you mean 'setupPushNotifications'?
|
||||||
|
../admin/server/routers/news.ts(45,24): error TS7006: Parameter 'n' implicitly has an 'any' type.
|
||||||
|
../admin/server/routers/termine.ts(55,27): error TS7006: Parameter 't' implicitly has an 'any' type.
|
||||||
|
../admin/server/routers/termine.ts(58,33): error TS7006: Parameter 'a' implicitly has an 'any' type.
|
||||||
|
../admin/server/routers/termine.ts(87,36): error TS7006: Parameter 'a' implicitly has an 'any' type.
|
||||||
|
../admin/server/routers/termine.ts(115,10): error TS7006: Parameter 'a' implicitly has an 'any' type.
|
||||||
|
lib/notifications.ts(7,35): error TS2322: Type 'Promise<{ shouldShowAlert: true; shouldPlaySound: true; shouldSetBadge: true; }>' is not assignable to type 'Promise<NotificationBehavior>'.
|
||||||
|
Type '{ shouldShowAlert: true; shouldPlaySound: true; shouldSetBadge: true; }' is missing the following properties from type 'NotificationBehavior': shouldShowBanner, shouldShowList
|
||||||
|
lib/trpc.ts(41,5): error TS2769: No overload matches this call.
|
||||||
|
The last overload gave the following error.
|
||||||
|
Argument of type '{ client: TRPCClient<BuiltRouter<{ ctx: { req: Request; session: any; prisma: any; }; meta: object; errorShape: { data: { zodError: typeToFlattenedError<any, string> | null; code: TRPC_ERROR_CODE_KEY; httpStatus: number; path?: string; stack?: string; }; message: string; code: TRPC_ERROR_CODE_NUMBER; }; transformer:...' is not assignable to parameter of type 'Attributes & TRPCProviderProps<BuiltRouter<{ ctx: { req: Request; session: any; prisma: any; }; meta: object; errorShape: { data: { zodError: typeToFlattenedError<any, string> | null; code: "PARSE_ERROR" | ... 19 more ... | "CLIENT_CLOSED_REQUEST"; httpStatus: number; path?: string | undefined; stack?: string | unde...'.
|
||||||
|
Property 'children' is missing in type '{ client: TRPCClient<BuiltRouter<{ ctx: { req: Request; session: any; prisma: any; }; meta: object; errorShape: { data: { zodError: typeToFlattenedError<any, string> | null; code: TRPC_ERROR_CODE_KEY; httpStatus: number; path?: string; stack?: string; }; message: string; code: TRPC_ERROR_CODE_NUMBER; }; transformer:...' but required in type 'TRPCProviderProps<BuiltRouter<{ ctx: { req: Request; session: any; prisma: any; }; meta: object; errorShape: { data: { zodError: typeToFlattenedError<any, string> | null; code: "PARSE_ERROR" | ... 19 more ... | "CLIENT_CLOSED_REQUEST"; httpStatus: number; path?: string | undefined; stack?: string | undefined; }; mes...'.
|
||||||
|
../../packages/shared/src/types/index.ts(3,3): error TS2305: Module '"@prisma/client"' has no exported member 'User'.
|
||||||
|
../../packages/shared/src/types/index.ts(4,3): error TS2305: Module '"@prisma/client"' has no exported member 'Session'.
|
||||||
|
../../packages/shared/src/types/index.ts(5,3): error TS2305: Module '"@prisma/client"' has no exported member 'Account'.
|
||||||
|
../../packages/shared/src/types/index.ts(6,3): error TS2305: Module '"@prisma/client"' has no exported member 'Organization'.
|
||||||
|
../../packages/shared/src/types/index.ts(7,3): error TS2305: Module '"@prisma/client"' has no exported member 'Member'.
|
||||||
|
../../packages/shared/src/types/index.ts(8,3): error TS2305: Module '"@prisma/client"' has no exported member 'UserRole'.
|
||||||
|
../../packages/shared/src/types/index.ts(9,3): error TS2305: Module '"@prisma/client"' has no exported member 'News'.
|
||||||
|
../../packages/shared/src/types/index.ts(10,3): error TS2305: Module '"@prisma/client"' has no exported member 'NewsRead'.
|
||||||
|
../../packages/shared/src/types/index.ts(11,3): error TS2305: Module '"@prisma/client"' has no exported member 'NewsAttachment'.
|
||||||
|
../../packages/shared/src/types/index.ts(12,3): error TS2305: Module '"@prisma/client"' has no exported member 'Stelle'.
|
||||||
|
../../packages/shared/src/types/index.ts(13,3): error TS2305: Module '"@prisma/client"' has no exported member 'Termin'.
|
||||||
|
../../packages/shared/src/types/index.ts(14,3): error TS2305: Module '"@prisma/client"' has no exported member 'TerminAnmeldung'.
|
||||||
|
../../packages/shared/src/types/index.ts(15,3): error TS2305: Module '"@prisma/client"' has no exported member 'Plan'.
|
||||||
|
../../packages/shared/src/types/index.ts(16,3): error TS2305: Module '"@prisma/client"' has no exported member 'MemberStatus'.
|
||||||
|
../../packages/shared/src/types/index.ts(17,3): error TS2305: Module '"@prisma/client"' has no exported member 'OrgRole'.
|
||||||
|
../../packages/shared/src/types/index.ts(18,3): error TS2305: Module '"@prisma/client"' has no exported member 'NewsKategorie'.
|
||||||
|
../../packages/shared/src/types/index.ts(19,3): error TS2305: Module '"@prisma/client"' has no exported member 'TerminTyp'.
|
||||||
|
|
@ -4,8 +4,17 @@
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["./*"]
|
"@/*": [
|
||||||
|
"./*"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.d.ts", "expo-env.d.ts"]
|
"include": [
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".expo/types/**/*.d.ts",
|
||||||
|
".expo/types/**/*.ts",
|
||||||
|
"expo-env.d.ts",
|
||||||
|
"nativewind-env.d.ts"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,8 @@
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"turbo": "^2.3.0",
|
"turbo": "^2.3.0",
|
||||||
"typescript": "^5.6.0"
|
"typescript": "^5.6.0",
|
||||||
|
"undici": "6.23.0"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@9.12.0",
|
"packageManager": "pnpm@9.12.0",
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,54 @@
|
||||||
|
# Lead Quality Audit & Red Flags
|
||||||
|
|
||||||
|
## 🚩 Duplicate Emails (Potential Central Administration)
|
||||||
|
| Email | Count | Innungen |
|
||||||
|
|---|---|---|
|
||||||
|
| `khw-kg@t-online.de` | 4 | Bäckerinnung Bad Kissingen - Rhön-Grabfeld<br>Kreishandwerkerschaft Bad Kissingen<br>Maler- und Lackiererinnung Bad Kissingen... |
|
||||||
|
| `info@elektroinnung-hassberge.de` | 2 | Innung für Elektro- und Informationstechnik Haßberge<br>Kreishandwerkerschaft Haßberge |
|
||||||
|
| `rapp@kreishandwerkerschaft-sw.de` | 2 | Friseurinnung Main-Rhön<br>Metallinnung Schweinfurt - Haßberge |
|
||||||
|
|
||||||
|
## 🟡 Freemail Addresses (Check Professionality)
|
||||||
|
| Innung | Contact | Email |
|
||||||
|
|---|---|---|
|
||||||
|
| Buechsenmacher-Innung Nordrhein, RLP und Saarland | nan | `kliedl@t-online.de` |
|
||||||
|
| Bäckerinnung Bad Kissingen - Rhön-Grabfeld | Petra Schwab | `khw-kg@t-online.de` |
|
||||||
|
| Bäckerinnung Kitzingen | Tilo Brönner | `tilo-br@t-online.de` |
|
||||||
|
| Bäckerinnung Mainfranken | Marcel Scherg | `scherge.beck@t-online.de` |
|
||||||
|
| Bäckerinnung Schweinfurt - Haßberge | Gerhard Götz | `baeckerei-goetz@web.de` |
|
||||||
|
| Fleischer-Innung Main-Spessart | Sebastian Bumm | `fleischerinnungmsp@gmx.de` |
|
||||||
|
| Friseur-Innung Aschaffenburg Stadt und Land | nan | `friseurinnung-aschaffenburg@t-online.de` |
|
||||||
|
| Friseur-Innung Würzburg, Main-Spessart und Bad Kissingen | nan | `katharinawalker88@gmx.de` |
|
||||||
|
| Friseurinnung Kitzingen | nan | `sabine.hack71@web.de` |
|
||||||
|
| Innung des Massschneiderhandwerks Koeln / Textileiniger-Innung Koeln/Bonn | nan | `twp.koeln@gmail.com` |
|
||||||
|
| Innung für Sanitär-, Heizungs-, Klempner- und Klimatechnik Würzburg | Sandra Köller | `innung.shk@t-online.de` |
|
||||||
|
| Innung für Spengler-, Sanitär-, und Heizungstechnik Kitzingen | Christine Keppner-Siegert | `innung-kitzingen@freenet.de` |
|
||||||
|
| Kreishandwerkerschaft Bad Kissingen | Petra Schwab | `khw-kg@t-online.de` |
|
||||||
|
| Kreishandwerkerschaft Kitzingen | Monika Henneberger | `monika.henneberger@t-online.de` |
|
||||||
|
| Kreishandwerkerschaft Miltenberg | nan | `kreishandwerker.mil@gmail.com` |
|
||||||
|
| Maler -, Tüncher- und Lackierer Innung Alzenau | nan | `maler_trageser@gmx.de` |
|
||||||
|
| Maler- und Lackiererinnung Bad Kissingen | Mathias Stöth | `khw-kg@t-online.de` |
|
||||||
|
| Metzgerinnung Miltenberg | nan | `j.neuberger@t-online.de` |
|
||||||
|
| Schreinerinnung Bad Kissingen | Norbert Borst | `khw-kg@t-online.de` |
|
||||||
|
| Schreinerinnung Haßberge – Schweinfurt | Horst Zitterbart | `schreinerei.zitterbart@t-online.de` |
|
||||||
|
| Schuhmacherinnung Unterfranken | nan | `l.emge@t-online.de` |
|
||||||
|
| Spengler-, Sanitär- und Heizungstechnik Innung Aschaffenburg - Miltenberg | Michael Bramm | `shk-aschaffenburg@t-online.de` |
|
||||||
|
| Zimmerer-Innung Schweinfurt-Haßberge | Marion Reichhold | `zimmerei_klaus_treiber@gmx.de` |
|
||||||
|
|
||||||
|
## ℹ️ Kreishandwerkerschaft (KH) Generic Contacts
|
||||||
|
These emails likely reach the administrative office, not necessarily the specific trade representative directly.
|
||||||
|
| Innung | Email | Note |
|
||||||
|
|---|---|---|
|
||||||
|
| Bau-Innung Kreis Mettmann | `info@handwerk-me.de` | Generic KH Domain |
|
||||||
|
| Bau-Innung Mönchengladbach | `info@kh-mg.de` | Generic KH Domain |
|
||||||
|
| Bau-Innung Neuss-Viersen | `info@kh-niederrhein.de` | Generic KH Domain |
|
||||||
|
| Bau-Innung Remscheid | `info@handwerk-remscheid.de` | Generic KH Domain |
|
||||||
|
| Drucker- (Buchdrucker-)- und Buchbinder-Innung Duisburg | `info@handwerk-duisburg.de` | Generic KH Domain |
|
||||||
|
| Friseurinnung Main-Rhön | `rapp@kreishandwerkerschaft-sw.de` | Generic KH Domain |
|
||||||
|
| Glasapparatebauer-Innung Duisburg | `hippler@handwerk-duisburg.de` | Generic KH Domain |
|
||||||
|
| Graveur- und Metallbildner-Innung Rhein-Ruhr | `info@kh-mo.de` | Generic KH Domain |
|
||||||
|
| Innung der Metallhandwerke (Solingen) | `info@handwerk-sgw.de` | Generic KH Domain |
|
||||||
|
| Innung für Metallhandwerk des Kreises Kleve | `info@kh-kleve.de` | Generic KH Domain |
|
||||||
|
| Kreishandwerkerschaft Schweinfurt | `rapp@kreishandwerkerschaft-sw.de` | Generic KH Domain |
|
||||||
|
| Kreishandwerkerschaft Würzburg | `info@kreishandwerkerschaft-wuerzburg.de` | Generic KH Domain |
|
||||||
|
| Metall-Innung Essen | `info@metallhandwerk-essen.de` | Generic KH Domain |
|
||||||
|
| Metallinnung Schweinfurt - Haßberge | `rapp@kreishandwerkerschaft-sw.de` | Generic KH Domain |
|
||||||
|
|
@ -0,0 +1,62 @@
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"innung": "Baugewerks-Innung Duisburg",
|
||||||
|
"email": "info@baugewerksinnung-duisburg.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"innung": "Graveur- und Metallbildner-Innung Rhein-Ruhr",
|
||||||
|
"email": "info@kh-mo.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"innung": "Metall-Innung Essen",
|
||||||
|
"email": "info@metallhandwerk-essen.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"innung": "Innung für Metallhandwerk des Kreises Kleve",
|
||||||
|
"email": "info@kh-kleve.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"innung": "Bau-Innung Kreis Mettmann",
|
||||||
|
"email": "info@handwerk-me.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"innung": "Innung für Metalltechnik Kreis Mettmann",
|
||||||
|
"email": "info@handwerk-me.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"innung": "Bau-Innung Mönchengladbach",
|
||||||
|
"email": "info@kh-mg.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"innung": "Metall-Innung Mönchengladbach-Rheydt",
|
||||||
|
"email": "info@kh-mg.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"innung": "Baugewerks-Innung Mülheim an der Ruhr-Oberhausen",
|
||||||
|
"email": "info@kh-mo.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"innung": "Innung für Metallhandwerke Mülheim an der Ruhr-Oberhausen",
|
||||||
|
"email": "info@metallbauinnung-mh-ob.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"innung": "Bau-Innung Neuss-Viersen",
|
||||||
|
"email": "info@kh-niederrhein.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"innung": "Innung für Land- und Baumaschinentechnik Niederrhein",
|
||||||
|
"email": "info@kh-niederrhein.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"innung": "Metall-Innung Niederrhein",
|
||||||
|
"email": "info@kh-niederrhein.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"innung": "Bau-Innung Remscheid",
|
||||||
|
"email": "info@handwerk-remscheid.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"innung": "Fachinnung Metall- und Graviertechnik Remscheid",
|
||||||
|
"email": "info@handwerk-remscheid.de"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,62 @@
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"innung": "Bau-Innung Wuppertal-Solingen",
|
||||||
|
"email": "info@bauinnung-wuppertal.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"innung": "Innung der Metallhandwerke (Solingen)",
|
||||||
|
"email": "info@handwerk-sgw.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"innung": "Innung für Metallhandwerke Wuppertal",
|
||||||
|
"email": "info@handwerk-sgw.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"innung": "Baugewerks-Innung des Kreises Wesel",
|
||||||
|
"email": "info@khwesel.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"innung": "Metall-Innung des Kreises Wesel",
|
||||||
|
"email": "info@metallinnung-wesel.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"innung": "Fotografen-Innung Düsseldorf-Aachen-Köln",
|
||||||
|
"email": "info@Fotografen-DUS.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"innung": "Maßschneider-Innung Düsseldorf",
|
||||||
|
"email": "info@mass-schneider-innung.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"innung": "Musikinstrumentenmacher-Innung Nordrhein",
|
||||||
|
"email": "info@musikinstrumentenmacher-innung.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"innung": "Straßen- und Tiefbauer-Innung Düsseldorf",
|
||||||
|
"email": "info@strassenbauer-innung-duesseldorf.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"innung": "Drucker- (Buchdrucker-)- und Buchbinder-Innung Duisburg",
|
||||||
|
"email": "info@handwerk-duisburg.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"innung": "Glasapparatebauer-Innung Duisburg",
|
||||||
|
"email": "hippler@handwerk-duisburg.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"innung": "Innung des modeschaffenden Handwerks Duisburg",
|
||||||
|
"email": "info@handwerk-duisburg.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"innung": "Raumausstatter-Innung Rhein-Ruhr",
|
||||||
|
"email": "info@handwerk-duisburg.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"innung": "Zweiradmechaniker-Innung Rhein-Ruhr",
|
||||||
|
"email": "info@handwerk-duisburg.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"innung": "Verband der Berufsfotografen Ruhr",
|
||||||
|
"email": "ak@koehring-fotografie.de"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,152 @@
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"query": "Baugewerks-Innung Duisburg D\u00fcsseldorf Kontakt Email",
|
||||||
|
"innung": "Baugewerks-Innung Duisburg",
|
||||||
|
"person": "Volker Blastik"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"query": "Graveur- und Metallbildner-Innung Rhein-Ruhr D\u00fcsseldorf Kontakt Email",
|
||||||
|
"innung": "Graveur- und Metallbildner-Innung Rhein-Ruhr",
|
||||||
|
"person": "Till Esser"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"query": "Metall-Innung Essen D\u00fcsseldorf Kontakt Email",
|
||||||
|
"innung": "Metall-Innung Essen",
|
||||||
|
"person": "Bj\u00f6rn Bergmann"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"query": "Innung f\u00fcr Metallhandwerk des Kreises Kleve D\u00fcsseldorf Kontakt Email",
|
||||||
|
"innung": "Innung f\u00fcr Metallhandwerk des Kreises Kleve",
|
||||||
|
"person": "Johannes Flinterhoff"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"query": "Bau-Innung Kreis Mettmann D\u00fcsseldorf Kontakt Email",
|
||||||
|
"innung": "Bau-Innung Kreis Mettmann",
|
||||||
|
"person": "Thomas Gr\u00fcnendahl"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"query": "Innung f\u00fcr Metalltechnik Kreis Mettmann D\u00fcsseldorf Kontakt Email",
|
||||||
|
"innung": "Innung f\u00fcr Metalltechnik Kreis Mettmann",
|
||||||
|
"person": "Reiner Schumacher"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"query": "Bau-Innung M\u00f6nchengladbach D\u00fcsseldorf Kontakt Email",
|
||||||
|
"innung": "Bau-Innung M\u00f6nchengladbach",
|
||||||
|
"person": "Dipl.-Ing. Frank B\u00fchler"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"query": "Metall-Innung M\u00f6nchengladbach-Rheydt D\u00fcsseldorf Kontakt Email",
|
||||||
|
"innung": "Metall-Innung M\u00f6nchengladbach-Rheydt",
|
||||||
|
"person": "Adam Sautner"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"query": "Baugewerks-Innung M\u00fclheim an der Ruhr-Oberhausen D\u00fcsseldorf Kontakt Email",
|
||||||
|
"innung": "Baugewerks-Innung M\u00fclheim an der Ruhr-Oberhausen",
|
||||||
|
"person": "J\u00fcrgen Bunk"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"query": "Innung f\u00fcr Metallhandwerke M\u00fclheim an der Ruhr-Oberhausen D\u00fcsseldorf Kontakt Email",
|
||||||
|
"innung": "Innung f\u00fcr Metallhandwerke M\u00fclheim an der Ruhr-Oberhausen",
|
||||||
|
"person": "Johannes Arnzen"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"query": "Bau-Innung Neuss-Viersen D\u00fcsseldorf Kontakt Email",
|
||||||
|
"innung": "Bau-Innung Neuss-Viersen",
|
||||||
|
"person": "Thomas Goldmann"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"query": "Innung f\u00fcr Land- und Baumaschinentechnik Niederrhein D\u00fcsseldorf Kontakt Email",
|
||||||
|
"innung": "Innung f\u00fcr Land- und Baumaschinentechnik Niederrhein",
|
||||||
|
"person": "Franz-Josef Schulte"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"query": "Metall-Innung Niederrhein D\u00fcsseldorf Kontakt Email",
|
||||||
|
"innung": "Metall-Innung Niederrhein",
|
||||||
|
"person": "Klaus Caris"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"query": "Bau-Innung Remscheid D\u00fcsseldorf Kontakt Email",
|
||||||
|
"innung": "Bau-Innung Remscheid",
|
||||||
|
"person": "Carsten Hof"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"query": "Fachinnung Metall- und Graviertechnik Remscheid D\u00fcsseldorf Kontakt Email",
|
||||||
|
"innung": "Fachinnung Metall- und Graviertechnik Remscheid",
|
||||||
|
"person": "Uwe Wiegand"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"query": "Bau-Innung Wuppertal-Solingen D\u00fcsseldorf Kontakt Email",
|
||||||
|
"innung": "Bau-Innung Wuppertal-Solingen",
|
||||||
|
"person": "Marcus Koch"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"query": "Innung der Metallhandwerke (Solingen) D\u00fcsseldorf Kontakt Email",
|
||||||
|
"innung": "Innung der Metallhandwerke (Solingen)",
|
||||||
|
"person": "Thomas Blau"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"query": "Innung f\u00fcr Metallhandwerke Wuppertal D\u00fcsseldorf Kontakt Email",
|
||||||
|
"innung": "Innung f\u00fcr Metallhandwerke Wuppertal",
|
||||||
|
"person": "Christian Fl\u00fcss"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"query": "Baugewerks-Innung des Kreises Wesel D\u00fcsseldorf Kontakt Email",
|
||||||
|
"innung": "Baugewerks-Innung des Kreises Wesel",
|
||||||
|
"person": "Gerhard Landwehrs"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"query": "Metall-Innung des Kreises Wesel D\u00fcsseldorf Kontakt Email",
|
||||||
|
"innung": "Metall-Innung des Kreises Wesel",
|
||||||
|
"person": "Rainer Theunissen"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"query": "Fotografen-Innung D\u00fcsseldorf-Aachen-K\u00f6ln D\u00fcsseldorf Kontakt Email",
|
||||||
|
"innung": "Fotografen-Innung D\u00fcsseldorf-Aachen-K\u00f6ln",
|
||||||
|
"person": "Guido de Nardo"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"query": "Ma\u00dfschneider-Innung D\u00fcsseldorf D\u00fcsseldorf Kontakt Email",
|
||||||
|
"innung": "Ma\u00dfschneider-Innung D\u00fcsseldorf",
|
||||||
|
"person": "Sandra Gronemeier"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"query": "Musikinstrumentenmacher-Innung Nordrhein D\u00fcsseldorf Kontakt Email",
|
||||||
|
"innung": "Musikinstrumentenmacher-Innung Nordrhein",
|
||||||
|
"person": "Christoph B\u00f6ttcher"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"query": "Stra\u00dfen- und Tiefbauer-Innung D\u00fcsseldorf D\u00fcsseldorf Kontakt Email",
|
||||||
|
"innung": "Stra\u00dfen- und Tiefbauer-Innung D\u00fcsseldorf",
|
||||||
|
"person": "Andr\u00e9 Grimmert"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"query": "Drucker- (Buchdrucker-)- und Buchbinder-Innung Duisburg D\u00fcsseldorf Kontakt Email",
|
||||||
|
"innung": "Drucker- (Buchdrucker-)- und Buchbinder-Innung Duisburg",
|
||||||
|
"person": "Bodo H. Oppenberg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"query": "Glasapparatebauer-Innung Duisburg D\u00fcsseldorf Kontakt Email",
|
||||||
|
"innung": "Glasapparatebauer-Innung Duisburg",
|
||||||
|
"person": "Dieter Verhees"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"query": "Innung des modeschaffenden Handwerks Duisburg D\u00fcsseldorf Kontakt Email",
|
||||||
|
"innung": "Innung des modeschaffenden Handwerks Duisburg",
|
||||||
|
"person": "Charlotte No\u00e9"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"query": "Raumausstatter-Innung Rhein-Ruhr D\u00fcsseldorf Kontakt Email",
|
||||||
|
"innung": "Raumausstatter-Innung Rhein-Ruhr",
|
||||||
|
"person": "Kay Piller"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"query": "Zweiradmechaniker-Innung Rhein-Ruhr D\u00fcsseldorf Kontakt Email",
|
||||||
|
"innung": "Zweiradmechaniker-Innung Rhein-Ruhr",
|
||||||
|
"person": "Erwin Lohrmann"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"query": "Verband der Berufsfotografen Ruhr D\u00fcsseldorf Kontakt Email",
|
||||||
|
"innung": "Verband der Berufsfotografen Ruhr",
|
||||||
|
"person": "Andreas K\u00f6hring"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
Firm/Innung,Contact,Email,Phone,Region
|
||||||
|
Handwerkskammer zu Köln,N/A,e-mail@dachdecker-innung-koeln.de,N/A,Köln
|
||||||
|
Handwerkskammer zu Köln,N/A,e-mail@zimmerer-innung-koeln.de,N/A,Köln
|
||||||
|
shk-innung-koeln.de,N/A,info@shk-innung-koeln.de,N/A,Köln
|
||||||
|
Elektroinnung Köln,N/A,info@elektroinnungkoeln.de,N/A,Köln
|
||||||
|
Stuckateurinnung Köln,N/A,info@stuckateurinnung.koeln,N/A,Köln
|
||||||
|
Innung für Metalltechnik Köln,N/A,werle@handwerk.koeln,N/A,Köln
|
||||||
|
Innung des Gebäudereiniger-Handwerks Köln-Aachen,N/A,info@die-gebaeudedienstleister-koeln-aachen.de,N/A,Köln
|
||||||
|
Zahntechniker-Innung Köln,N/A,info@zik.de,N/A,Köln
|
||||||
|
Innung Köln Rollladen- und Sonnenschutz,N/A,fritsche@handwerk.koeln,N/A,Köln
|
||||||
|
|
|
@ -0,0 +1,6 @@
|
||||||
|
Firm/Innung,Contact,Email,Phone,Region
|
||||||
|
Baugewerbe Düsseldorf,N/A,buero@morick.com,N/A,Düsseldorf
|
||||||
|
Metall Düsseldorf,N/A,beate.schmidt@kh-duesseldorf.de,N/A,Düsseldorf
|
||||||
|
Dachdecker Düsseldorf,N/A,info@dachdeckerinnung.nrw,N/A,Düsseldorf
|
||||||
|
Elektro Düsseldorf,N/A,info@e-k-h.de,N/A,Düsseldorf
|
||||||
|
Sanitär Düsseldorf,N/A,info@shk-duesseldorf.de,N/A,Düsseldorf
|
||||||
|
|
|
@ -0,0 +1,6 @@
|
||||||
|
Firm/Innung,Contact,Email,Phone,Region
|
||||||
|
Tischler Düsseldorf,N/A,info@thomasklode.de,N/A,Düsseldorf
|
||||||
|
Maler Düsseldorf,N/A,roeckendorf@web.de,N/A,Düsseldorf
|
||||||
|
KFZ Düsseldorf,N/A,info@kfz-innung-duesseldorf.de,N/A,Düsseldorf
|
||||||
|
Friseur Düsseldorf,N/A,gnaki@web.de,N/A,Düsseldorf
|
||||||
|
Fleischer Düsseldorf,N/A,fleischer-innung-duesseldorf@t-online.de,N/A,Düsseldorf
|
||||||
|
|
|
@ -0,0 +1,11 @@
|
||||||
|
Firm/Innung,Contact,Email,Phone,Region
|
||||||
|
Zimmerer Düsseldorf,N/A,beate.schmidt@kh-duesseldorf.de,N/A,Düsseldorf
|
||||||
|
Glaser Düsseldorf,N/A,info@glaserinnung.de,N/A,Düsseldorf
|
||||||
|
Rollladen Düsseldorf,N/A,mm@rolladen-muellers.de,N/A,Düsseldorf
|
||||||
|
Gebäudereiniger Düsseldorf,N/A,kontakt@michael-kregel.de,N/A,Düsseldorf
|
||||||
|
Augenoptiker Düsseldorf,N/A,info@optikerinnung.de,N/A,Düsseldorf
|
||||||
|
Bäcker Düsseldorf,N/A,info@baecker-innung-rhein-ruhr.de,N/A,Düsseldorf
|
||||||
|
Konditoren Düsseldorf,N/A,beate.schmidt@kh-duesseldorf.de,N/A,Düsseldorf
|
||||||
|
Schornsteinfeger Düsseldorf,N/A,info@schornsteinfeger-duesseldorf.de,N/A,Düsseldorf
|
||||||
|
Steinmetz Düsseldorf,N/A,steinmetze@kh-duesseldorf.de,N/A,Düsseldorf
|
||||||
|
Straßenbauer Düsseldorf,N/A,andre@grimmert.de,N/A,Düsseldorf
|
||||||
|
|
|
@ -0,0 +1,13 @@
|
||||||
|
Firm/Innung,Contact,Email,Phone,Region
|
||||||
|
Goldschmiede Düsseldorf,N/A,info@stuckateur-innung-duesseldorf.de,N/A,Düsseldorf
|
||||||
|
IT Düsseldorf,N/A,beate.schmidt@kh-duesseldorf.de,N/A,Düsseldorf
|
||||||
|
Kachel Düsseldorf,N/A,susannekunzmann@googlemail.com,N/A,Düsseldorf
|
||||||
|
Karosserie Düsseldorf,N/A,info@ifit-duesseldorf.de,N/A,Düsseldorf
|
||||||
|
Schneider Düsseldorf,N/A,info@kachelofenbau-innung-nordrhein.de,N/A,Düsseldorf
|
||||||
|
Instrumenten Düsseldorf,N/A,beate.schmidt@kh-duesseldorf.de,N/A,Düsseldorf
|
||||||
|
Ortho-Technik Düsseldorf,N/A,info@mass-schneider-innung.de,N/A,Düsseldorf
|
||||||
|
Ortho-Schuh Düsseldorf,N/A,info@musikinstrumentenmacher-innung.de,N/A,Düsseldorf
|
||||||
|
Parkett Düsseldorf,N/A,info@ot-innung-dues.de,N/A,Düsseldorf
|
||||||
|
Sattler Düsseldorf,N/A,info@os-nrw.de,N/A,Düsseldorf
|
||||||
|
Werbe Düsseldorf,N/A,info@parkett-und-bodenleger.de,N/A,Düsseldorf
|
||||||
|
Zahn Düsseldorf,N/A,holgerhohmann@t-online.de,N/A,Düsseldorf
|
||||||
|
Binary file not shown.
|
|
@ -0,0 +1 @@
|
||||||
|
Firm/Innung,Contact,Email,Phone,Region
|
||||||
|
|
|
@ -0,0 +1,238 @@
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Innungen im Kammerbezirk
|
||||||
|
Innungen Kreishandwerkerschaft Düsseldorf
|
||||||
|
• Augenoptiker-Innung Düssel-Rhein-Ruhr/OM: Jens Schulz
|
||||||
|
• Bäcker-Innung Rhein-Ruhr/OM: Johannes Dackweiler
|
||||||
|
• Baugewerbe-Innung Düsseldorf/OM: Christoph Morick
|
||||||
|
• Boots- und Schiffbauer-Innung Düsseldorf/OM Marcus Rogozinski
|
||||||
|
• Dachdecker-Innung Düsseldorf/OM: Andreas Braun
|
||||||
|
• Elektro-Innung Düsseldorf/OM: Kai Hofmann
|
||||||
|
• Fleischer-Innung Düsseldorf-Mettmann-Solingen/OM: Lutz Kluke
|
||||||
|
• Fotografen-Innung Düsseldorf-Aachen-Köln/OM: Guido de Nardo
|
||||||
|
• Friseur-Innung Düsseldorf/OM: Christos Neofotistos
|
||||||
|
• Gebäudereiniger-Innung Düsseldorf/OM: Michael Kregel
|
||||||
|
• Glaser-Innung Düsseldorf/OM: Ralph Icks
|
||||||
|
• Gold- und Silberschmiede-Innung Düsseldorf/OM: Susanne Kunzmann
|
||||||
|
• Innung für Informationstechnik Düssel-Rhein-Ruhr/OM: Alvaro Millan
|
||||||
|
• Innung Nordrhein der Kachel- und Luftheizungsbauer/OM: Uwe Gobien
|
||||||
|
• Karosseriebauer-Innung Düsseldorf/OM: Detlev Thedens
|
||||||
|
• Konditoren-Innung Bergisches Land und Düsseldorf/OM: Thomas Pons
|
||||||
|
• Innung des Kraftfahrzeuggewerbes Düsseldorf/OM: Hermann Görtz
|
||||||
|
• Maler- und Lackierer-Innung Düsseldorf/OM: Jörg Schmitz
|
||||||
|
• Maßschneider-Innung Düsseldorf/OM: Sandra Gronemeier
|
||||||
|
• Musikinstrumentenmacher-Innung Nordrhein/OM: Christoph Böttcher
|
||||||
|
• Innung für Orthopädie-Technik für den Regierungsbezirk Düsseldorf/OM: Pierre Koppetsch
|
||||||
|
• Innung für Orthopädieschuhtechnik Rheinland-Westfalen/OM: Philipp Radtke
|
||||||
|
• Innung für Parkett- und Fußbodentechnik für den Regierungsbezirk Düsseldorf/
|
||||||
|
OM: Roland Hüsgen
|
||||||
|
• Innung Sanitär-Heizung-Klima Düsseldorf/OM: Hans Werner Eschrich
|
||||||
|
• Sattler- und Raumausstatter-Innung Düsseldorf/OM: Holger Hohmann
|
||||||
|
• Schornsteinfeger-Innung für den Regierungsbezirk Düsseldorf/OM: N.N.
|
||||||
|
• Fachinnung Stahl und Metall Düsseldorf/OM: Peter Maxisch
|
||||||
|
• Steinmetz- und Steinbildhauer-Innung Düsseldorf/OM: Jörg Hahn
|
||||||
|
• Straßen- und Tiefbauer-Innung Düsseldorf/OM: André Grimmert
|
||||||
|
• Stukkateur-Innung Düsseldorf-Neuss für Ausbau und Fassade/OM: Tobias Jülich
|
||||||
|
• Tischler-Innung Düsseldorf/OM: Thomas Klode
|
||||||
|
• Werbetechniker-Innung Düsseldorf/OM: Tim Rehse
|
||||||
|
• Zahntechniker-Innung Düsseldorf/OM: Dominik Kruchen
|
||||||
|
Innungen Kreishandwerkerschaft Duisburg
|
||||||
|
• Baugewerks-Innung Duisburg/OM: Volker Blastik
|
||||||
|
• Dachdecker- und Zimmerer-Innung Duisburg/OM: Udo Rosenstengel
|
||||||
|
• Drucker- (Buchdrucker-)- und Buchbinder-Innung Duisburg/OM: Bodo H. Oppenberg
|
||||||
|
• Elektro-Innung Duisburg/OM: Dipl.-Ing. Lothar Hellmann
|
||||||
|
• Fleischer-Innung Duisburg/OM: Franz-Josef van Bebber
|
||||||
|
• Friseur-Innung Duisburg/OM: Markus Lotze
|
||||||
|
• Gebäudereiniger-Innung Duisburg/OM: Jörg Hämmerling
|
||||||
|
• Glasapparatebauer-Innung Duisburg/OM: Dieter Verhees
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
• Konditoren-Innung Rhein-Ruhr/OM: Hubert Cordes
|
||||||
|
• Innung der Kraftfahrzeughandwerks Duisburg/OM: Thomas Dosoudil
|
||||||
|
• Maler- und Lackierer-Innung Duisburg/OM: Heinz-Jürgen Lobreyer
|
||||||
|
• Innung für Metall, Karosserie- und Fahrzeugbau Duisburg/Niederrhein/OM: Sebastian Christ
|
||||||
|
• Innung des modeschaffenden Handwerks Duisburg/OM: Charlotte Noé
|
||||||
|
• Innung für Informationstechnik Duisburg/OM: Thorsten Slizewski
|
||||||
|
• Raumausstatter-Innung Rhein-Ruhr/OM: Kay Piller
|
||||||
|
• Innung Sanitär-Heizung-Klima Duisburg/OM: Uwe Schöbel
|
||||||
|
• Steinmetz- und Steinbildhauer-Innung Duisburg, Mülheim, Oberhausen/OM: Ralf Pauschert
|
||||||
|
• Straßenbauer-Innung Duisburg/OM: Heiner Kühne
|
||||||
|
• Tischler-Innung Duisburg/OM: Frank Paschke
|
||||||
|
• Zweiradmechaniker-Innung Rhein-Ruhr/OM: Erwin Lohrmann
|
||||||
|
Innungen Kreishandwerkerschaft Essen
|
||||||
|
• Baugewerbe-Innung Essen/OM: Frank Schulte-Hubbert
|
||||||
|
• Dachdecker-Innung Essen/OM: Marc Sparrer
|
||||||
|
• Elektro-Innung Essen/OM: Frank Struck
|
||||||
|
• Verband der Berufsfotografen Ruhr/OM: Andreas Köhring
|
||||||
|
• Friseur-Innung Essen/OM: Markus Bredenbröcker
|
||||||
|
• Gebäudereiniger-Innung Essen-Mülheim an der Ruhr-Oberhausen/OM: Stefan Thielen
|
||||||
|
• Gold- und Silberschmiede-Innung Essen/OM: Zeno Ablass
|
||||||
|
• Graveur- und Metallbildner-Innung Rhein-Ruhr/OM: Till Esser
|
||||||
|
• Innung des Kraftfahrzeuggewerbes Essen-Mülheim-Oberhausen/OMs: Marcel Seyer und Ralf
|
||||||
|
Werner
|
||||||
|
• Maler- und Lackierer-Innung Essen/OM: Marc-Alexander Kecker
|
||||||
|
• Metall-Innung Essen/OM: Björn Bergmann
|
||||||
|
• Raumausstatter- und Sattler-Innung Essen/OM: Michel Gutberlet
|
||||||
|
• Innung für Sanitär- und Heizungstechnik Ruhr-West/OM: Thomas Weber
|
||||||
|
• Innung des Schilder- und Lichtreklameherstellerhandwerks Essen/OM: Sascha Schlüter
|
||||||
|
• Steinmetz- und Steinbildhauer-Innung Essen/OM: Stephan Peters
|
||||||
|
• Straßenbauer-Innung Essen-Mülheim/OM: Thorsten Potysch
|
||||||
|
• Tischler-Innung Essen/OM: Matthias Spehr
|
||||||
|
• Innung für Vulkaniseur- und Reifenmechanik-Handwerk Rhein-Ruhr/OM: Horst Kornetka
|
||||||
|
Innungen der Kreishandwerkerschaft Kreis Kleve
|
||||||
|
• Bäcker-Innung Niederrhein-Kleve-Wesel/OM: Johannes Gerhards
|
||||||
|
• Baugewerbe-Innung des Kreises Kleve/OM: Michael Köster
|
||||||
|
• Dachdecker-Innung des Kreises Kleve/OM: Markus Henkel
|
||||||
|
• Elektro-Innung des Kreises Kleve/OM: Jörg Weykamp
|
||||||
|
• Fleischer-Innung des Kreises Kleve/OM: Heinz Borghs
|
||||||
|
• Friseur-Innung des Kreises Kleve/OM: Karin Merlin Wrobel
|
||||||
|
• Gold- und Silberschmiede-Innung Niederrhein/OM: Martin Link
|
||||||
|
• Maler- und Lackierer-Innung des Kreises Kleve/OM: Franz-Theo Dirmeier
|
||||||
|
• Innung für Metallhandwerk des Kreises Kleve/OM: Johannes Flinterhoff
|
||||||
|
• Innung Sanitär-Heizung-Klima Kreis Kleve/OM: Michael Janßen
|
||||||
|
• Tischler-Innung des Kreises Kleve/OM: Stefan Meyer
|
||||||
|
• Zweiradmechaniker-Innung des Kreises Kleve/OM: Markus Daute
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Innungen der Kreishandwerkerschaft Kreis Mettmann
|
||||||
|
• Bau-Innung Kreis Mettmann/OM: Thomas Grünendahl
|
||||||
|
• Dachdecker- und Zimmerer-Innung Kreis Mettmann/OM: Renee Fügener
|
||||||
|
• Elektro-Innung Kreis Mettmann/OM: Andreas Schmidt
|
||||||
|
• Friseur-Innung Kreis Mettmann/OM: Uwe Ranke
|
||||||
|
• Karosserie- und Fahrzeugbauer-Innung Kreis Mettmann/OM: Max Witeczek
|
||||||
|
• Innung des Kraftfahrzeughandwerks Kreis Mettmann/OM: Alfons Kunz
|
||||||
|
• Maler- und Lackierer-Innung Kreis Mettmann/OM: Ralf Heinz Weber
|
||||||
|
• Innung für Metalltechnik Kreis Mettmann/OM: Reiner Schumacher
|
||||||
|
• Raumausstatter- und Sattler-Innung Kreis Mettmann/OM: Thomas Wittig
|
||||||
|
• Innung Rollladen- und Sonnenschutztechniker-Handwerk Regierungsbezirk Düsseldorf/
|
||||||
|
OM: Markus Müller
|
||||||
|
• Innung für Sanitär- und Heizungstechnik Kreis Mettmann/OM: Christian Möller
|
||||||
|
• Tischler-Innung Kreis Mettmann/OM: Michael Fischbach
|
||||||
|
Innungen Kreishandwerkerschaft Mönchengladbach
|
||||||
|
• Bäcker-Innung Mönchengladbach/OM: Gertie Riethmacher
|
||||||
|
• Bau-Innung Mönchengladbach/OM: Dipl.-Ing. Frank Bühler
|
||||||
|
• Dachdecker-Innung Mönchengladbach/OM: Reinhard Esser
|
||||||
|
• Elektro-Innung Mönchengladbach/OM: Heinz-Willi-Ober
|
||||||
|
• Fleischer-Innung Mönchengladbach/OM: Josef Baumanns
|
||||||
|
• Friseur-Innung Mönchengladbach/OM: Sabine Capan
|
||||||
|
• Informationstechniker-Innung Mönchengladbach-Neuss/OM: Dirk Weduwen
|
||||||
|
• Karosserie- und Fahrzeugbauer-Innung Mönchengladbach/OM: Reiner Brenner
|
||||||
|
• Konditoren-Innung Mönchengladbach/OM: Manfred Groth
|
||||||
|
• Innung des Kraftfahrzeuggewerbes Mönchengladbach/OM: Peter Fischer
|
||||||
|
• Maler- und Lackierer-Innung Mönchengladbach/OM: Udo Nösen
|
||||||
|
• Metall-Innung Mönchengladbach-Rheydt/OM: Adam Sautner
|
||||||
|
• Raumausstatter- und Sattler-Innung Mönchengladbach/OM: Joachim Rütten
|
||||||
|
• Innung Sanitär-Heizung-Klima Mönchengladbach/OM: Thorsten Caspers
|
||||||
|
• Schuhmacher-Innung linker Niederrhein/OM: Günther Schellenberger
|
||||||
|
• Tischler-Innung Mönchengladbach-Rheydt/OM: Hans Wilhelm Klomp
|
||||||
|
• Zimmerer-Innung Mönchengladbach/OM: Peter Röders
|
||||||
|
Innungen Kreishandwerkerschaft Mülheim a. d. Ruhr - Oberhausen
|
||||||
|
• Baugewerks-Innung Mülheim an der Ruhr-Oberhausen/OM: Jürgen Bunk
|
||||||
|
• Dachdecker- und Zimmerer-Innung Mülheim an der Ruhr/OM: Jens-Peter Richard
|
||||||
|
• Dachdecker-Innung Oberhausen-Rheinland/OM: Mark Notthoff
|
||||||
|
• Elektro-Innung Mülheim an der Ruhr/OM: Joachim Schweins
|
||||||
|
• Elektro-Innung Oberhausen-Rheinland/OM: Florian Menger
|
||||||
|
• Fleischer-Innung RheinRuhr/OM: Jörg Bischoff
|
||||||
|
• Friseur-Innung Mülheim an der Ruhr/OM: Ralf Wüstefeld
|
||||||
|
• Friseur-Innung Oberhausen-Rheinland/OM: Bernd Görg
|
||||||
|
• Karosserie- und Fahrzeugbauer-Innung Essen-Mülheim an der Ruhr-Oberhausen/
|
||||||
|
OM: Jörg Bergmann
|
||||||
|
• Maler- und Lackierer-Innung Mülheim an der Ruhr-Oberhausen/OM: Guido vom Ufer
|
||||||
|
• Innung für Metallhandwerke Mülheim an der Ruhr-Oberhausen/OM: Johannes Arnzen
|
||||||
|
• Innung für Sanitär- und Heizungstechnik Oberhausen-Rheinland/OM: Stefan Tögel
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
• Tischler-Innung Mülheim an der Ruhr-Oberhausen/OM: Michael Schmeling
|
||||||
|
Innungen der Kreishandwerkerschaft Niederrhein
|
||||||
|
• Niederrheinische Bäcker-Innung Krefeld-Viersen-Neuss/OM: Rudolf Weißert
|
||||||
|
• Bau-Innung Neuss-Viersen/OM: Thomas Goldmann
|
||||||
|
• Bau- und Straßenbauer-Innung Krefeld-Linker Niederrhein/OM: Dipl.-Ing. Joachim Selzer
|
||||||
|
• Bestatter-Innung Nordrhein-Westfalen/OM: Frank Wesemann
|
||||||
|
• Dachdecker-Innung Krefeld/OM: Andreas Pavel
|
||||||
|
• Dachdecker-Innung Kreis Viersen/OM: Ulrich Heurs
|
||||||
|
• Dachdecker-Innung Rhein-Kreis Neuss/OM: Marco Brüggen
|
||||||
|
• Drechsler-Innung Krefeld/OM: Klaus Reef
|
||||||
|
• Elektro-Innung Krefeld/OM: Peter Rath
|
||||||
|
• E-Handwerke Niederrhein-Kreis Viersen/OM: Martin Thomas Nowroth
|
||||||
|
• Elektro-Innung Rhein-Kreis Neuss/OM: Ernst Veiser
|
||||||
|
• Fleischer-Innung Niederrhein Krefeld-Viersen-Neuss/OM: Willi Schillings
|
||||||
|
• Friseur-Innung Krefeld-Viersen-Neuss/OM: Alexandra Houx-Brenner
|
||||||
|
• Gebäudereiniger-Innung Mittlerer Niederrhein/OM: Nadine Ludwigs
|
||||||
|
• Innung für Informationstechnik Niederrhein Krefeld-Viersen-Neuss/OM: Horst Rinsch
|
||||||
|
• Karosserie- und Fahrzeugbauer-Innung Krefeld-Viersen-Neuss/OM: Ralph Treeker
|
||||||
|
• Konditoren-Innung Niederrhein/OM: Andreas Achten
|
||||||
|
• Innung des Kraftfahrzeuggewerbes Krefeld-Viersen/OM: Dietmar Lassek
|
||||||
|
• Kraftfahrzeug-Innung Rhein-Kreis Neuss/OM: Johannes Brester
|
||||||
|
• Innung für Land- und Baumaschinentechnik Niederrhein/OM: Franz-Josef Schulte
|
||||||
|
• Maler- und Lackierer-Innung Niederrhein Krefeld-Neuss-Viersen/OM: Stephanie Jahrke
|
||||||
|
• Metall-Innung Niederrhein/OM: Klaus Caris
|
||||||
|
• Innung für das Modeschaffende Handwerk Niederrhein/OM: Angelika van Neerven
|
||||||
|
• Niederrheinische Raumausstatter- und Sattler-Innung Krefeld-Viersen-Neuss/
|
||||||
|
OM: Dittmar Posern
|
||||||
|
• Innung für Sanitär-Heizung-Klima-Apparatebau Krefeld/OM: Daniel Küppers
|
||||||
|
• Innung für Sanitär- und Heizungstechnik Kreis Viersen/OM: Michael Smeets
|
||||||
|
• Innung für Sanitär- und Heizungstechnik Rhein-Kreis Neuss/OM: Thomas Hanna
|
||||||
|
• Steinmetz- und Steinbildhauer-Innung Mittlerer Niederrhein/OM: Daniel Franzen
|
||||||
|
• Stuckateur-Innung Krefeld-Viersen/OM: Roland Gerhards
|
||||||
|
• Textilreiniger-Innung für den Regierungsbezirk Düsseldorf/OM: Dirk Querfurth
|
||||||
|
• Tischler-Innung Krefeld/OM: Dirk Kosanke
|
||||||
|
• Tischler-Innung Kreis Viersen/OM: Uwe Sötje
|
||||||
|
• Tischler-Innung Rhein-Kreis Neuss/OM: Philipp Schlang
|
||||||
|
• Zweiradmechaniker-Innung Niederrhein Krefeld-Viersen-Neuss/OM: Rolf Langer
|
||||||
|
Innungen der Kreishandwerkerschaft Remscheid
|
||||||
|
• Bau-Innung Remscheid/OM: Carsten Hof
|
||||||
|
• Dachdecker-Innung Remscheid/OM: Jörg Margies
|
||||||
|
• Innung für elektrotechnische Handwerke Remscheid/OM: Andreas Müller
|
||||||
|
• Friseur-Innung Remscheid/OM: Tanja Kürten-Rauhaus
|
||||||
|
• Innung für das Gebäudereiniger-Handwerk Remscheid-Solingen/OM: Oliver Knedlich
|
||||||
|
• Innung des Kraftfahrzeughandwerks Remscheid//OM: Thomas Bliß
|
||||||
|
• Maler- und Lackierer-Innung Remscheid/OM: Holger Schulz
|
||||||
|
• Fachinnung Metall- und Graviertechnik Remscheid/OM: Uwe Wiegand
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
• Innung der Nahrungsmittelhandwerke Remscheid/OM: Birgit Eppels
|
||||||
|
• Innung für Sanitär- und Heizungstechnik Remscheid/OM: Oliver Bergmann
|
||||||
|
• Steinmetz- und Steinbildhauer-Innung Bergisch Land/OM: Beate Globisch
|
||||||
|
• Tischler-Innung Remscheid/OM: Martin Stracke
|
||||||
|
Innungen der Kreishandwerkerschaft Solingen-Wuppertal
|
||||||
|
• Bäcker-Innung Solingen-Wuppertal (Sitz: Wuppertal)/OM: Dirk Polick
|
||||||
|
• Bau-Innung Wuppertal-Solingen/OM: Marcus Koch
|
||||||
|
• Dachdecker-Innung Solingen-Wuppertal/OM: Thomas Sobireg
|
||||||
|
• Elektro-Innung (Solingen)/OM: Frank Roth
|
||||||
|
• Elektro-Innung Wuppertal/OM: Ingo Kursawe
|
||||||
|
• Friseur-Innung Solingen-Wuppertal/OM: Pia Schneider
|
||||||
|
• Glaser-Innung Wuppertal/OM: Rafael Musik
|
||||||
|
• Graveur-Innung Solingen-Wuppertal/OM: Stefan Krüth
|
||||||
|
• Karosserie- und Fahrzeugbauer-Innung Wuppertal/OM: Martin Rosslan
|
||||||
|
• Innung des Kraftfahrzeughandwerks (Solingen)/OM: Uwe Stamm
|
||||||
|
• Innung des Kraftfahrzeughandwerks Wuppertal/OM: Wolfram Friedrich
|
||||||
|
• Maler- und Lackierer-Innung (Solingen)/OM: Oliver Conrads
|
||||||
|
• Maler- und Lackierer-Innung Wuppertal/OM: Oliver Conyn
|
||||||
|
• Innung der Metallhandwerke (Solingen)/OM: Thomas Blau
|
||||||
|
• Innung für Metallhandwerke Wuppertal/OM: Christian Flüss
|
||||||
|
• Raumausstatter-Innung Solingen-Wuppertal/OM: Frank Dembny
|
||||||
|
• Innung für Sanitär- und Heizungstechnik (Solingen)/OM: Andreas Glingener
|
||||||
|
• Innung für Sanitär-, Heizungs- und Klimatechnik Wuppertal/OM: Frank Karuc
|
||||||
|
• Straßen- und Tiefbau-Innung Wuppertal/OM: Martin Ehlhardt
|
||||||
|
• Stukkateur-Innung Wuppertal-Kreis Mettmann/OM: Wolfgang Wüstenhagen
|
||||||
|
• Tischler-Innung (Solingen)/OM: Paul-Gerhard Rössling
|
||||||
|
• Tischler-Innung Wuppertal/OM: Thomas Landsiedel
|
||||||
|
Innungen der Kreishandwerkerschaft Kreis Wesel
|
||||||
|
• Baugewerks-Innung des Kreises Wesel/OM: Gerhard Landwehrs
|
||||||
|
• Dachdecker-Innung des Kreises Wesel/OM: Marco Remy
|
||||||
|
• Innung für Elektrotechnik und Informationstechnik des Kreises Wesel/OM: Klemens Mues
|
||||||
|
• Friseur-Innung des Kreises Wesel/OM: Klaus Peter Neske
|
||||||
|
• Glaser-Innung Niederrhein/OM: Thomas Schulmeyer
|
||||||
|
• Innung des Kraftfahrzeuggewerbes Niederrhein Kleve-Wesel/OM: René Gravendyk
|
||||||
|
• Maler- und Lackierer-Innung des Kreises Wesel/OM: Günter Bode
|
||||||
|
• Metall-Innung des Kreises Wesel/OM: Rainer Theunissen
|
||||||
|
• Innung Sanitär-Heizung-Klima Kreis Wesel/OM: Norbert Buhl
|
||||||
|
• Innung für Schneid- und Schleiftechnik Nordrhein/OM: Uwe Peters
|
||||||
|
• Steinmetz- und Steinbildhauer-Innung Niederrhein/OM: Benedikt L. Kreusch
|
||||||
|
• Stuckateur-Innung Niederrhein/OM: Norbert Kehrbusch
|
||||||
|
• Tischler-Innung des Kreises Wesel/OM: Dirk Jockram
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,105 @@
|
||||||
|
import pandas as pd
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
|
||||||
|
def normalize(text):
|
||||||
|
if not isinstance(text, str):
|
||||||
|
return ""
|
||||||
|
return text.strip().lower()
|
||||||
|
|
||||||
|
def main():
|
||||||
|
# 1. Load the "Done" list
|
||||||
|
leads_csv_path = 'leads/leads.csv'
|
||||||
|
if os.path.exists(leads_csv_path):
|
||||||
|
leads_df = pd.read_csv(leads_csv_path)
|
||||||
|
done_names = set(leads_df['Firm/Innung'].apply(normalize))
|
||||||
|
done_emails = set(leads_df['Email'].apply(normalize))
|
||||||
|
else:
|
||||||
|
done_names = set()
|
||||||
|
done_emails = set()
|
||||||
|
|
||||||
|
missing_duesseldorf = []
|
||||||
|
missing_cologne = []
|
||||||
|
missing_unterfranken = []
|
||||||
|
|
||||||
|
# 2. Check Düsseldorf Targets
|
||||||
|
duesseldorf_path = 'leads/cologne_duesseldorf_data/duesseldorf_targets.json'
|
||||||
|
if os.path.exists(duesseldorf_path):
|
||||||
|
with open(duesseldorf_path, 'r', encoding='utf-8') as f:
|
||||||
|
targets = json.load(f)
|
||||||
|
seen_d = set()
|
||||||
|
for t in targets:
|
||||||
|
name = t.get('innung', '')
|
||||||
|
if normalize(name) not in done_names and normalize(name) not in seen_d:
|
||||||
|
missing_duesseldorf.append(t)
|
||||||
|
seen_d.add(normalize(name))
|
||||||
|
|
||||||
|
# 3. Check Cologne/Düsseldorf Raw CSV
|
||||||
|
cologne_raw_path = 'leads/raw/innungen_leads_koeln_duesseldorf.csv'
|
||||||
|
if os.path.exists(cologne_raw_path):
|
||||||
|
cologne_df = pd.read_csv(cologne_raw_path)
|
||||||
|
seen_c = set()
|
||||||
|
for _, row in cologne_df.iterrows():
|
||||||
|
name = row.get('organisation', '')
|
||||||
|
region = row.get('region', '')
|
||||||
|
email = row.get('email', '')
|
||||||
|
|
||||||
|
if str(region).lower() == 'koeln':
|
||||||
|
if normalize(name) in seen_c:
|
||||||
|
continue
|
||||||
|
seen_c.add(normalize(name))
|
||||||
|
|
||||||
|
if pd.isna(email) or email.strip() == '':
|
||||||
|
missing_cologne.append({'name': name, 'reason': 'No Email'})
|
||||||
|
else:
|
||||||
|
if normalize(email) not in done_emails and normalize(name) not in done_names:
|
||||||
|
missing_cologne.append({'name': name, 'email': email, 'reason': 'Not in final list'})
|
||||||
|
|
||||||
|
# 4. Check Unterfranken Raw CSV
|
||||||
|
unterfranken_raw_path = 'leads/raw/leads_unterfranken.csv'
|
||||||
|
if os.path.exists(unterfranken_raw_path):
|
||||||
|
uf_df = pd.read_csv(unterfranken_raw_path)
|
||||||
|
name_col = None
|
||||||
|
for col in uf_df.columns:
|
||||||
|
if 'innung' in col.lower() or 'firm' in col.lower() or 'name' in col.lower():
|
||||||
|
name_col = col
|
||||||
|
break
|
||||||
|
|
||||||
|
seen_u = set()
|
||||||
|
if name_col:
|
||||||
|
for _, row in uf_df.iterrows():
|
||||||
|
name = str(row[name_col]).strip()
|
||||||
|
# Filter garbage
|
||||||
|
if len(name) < 5: continue
|
||||||
|
if "regierungsbezirk" in name.lower() and "sitz" in name.lower(): continue # Garbage header line
|
||||||
|
|
||||||
|
if normalize(name) not in done_names and normalize(name) not in seen_u:
|
||||||
|
missing_unterfranken.append(name)
|
||||||
|
seen_u.add(normalize(name))
|
||||||
|
|
||||||
|
# 5. Generate Markdown
|
||||||
|
with open('missing_leads.md', 'w', encoding='utf-8') as f:
|
||||||
|
f.write('# Missing Leads Report\n\n')
|
||||||
|
|
||||||
|
f.write(f'## Düsseldorf (Missing: {len(missing_duesseldorf)})\n')
|
||||||
|
if not missing_duesseldorf:
|
||||||
|
f.write("No missing leads identified (or source file empty).\n")
|
||||||
|
for item in missing_duesseldorf:
|
||||||
|
f.write(f"- {item.get('innung')} (Contact: {item.get('person', 'N/A')})\n")
|
||||||
|
|
||||||
|
f.write(f'\n## Cologne (Missing: {len(missing_cologne)})\n')
|
||||||
|
if not missing_cologne:
|
||||||
|
f.write("No missing leads identified from raw source.\n")
|
||||||
|
for item in missing_cologne:
|
||||||
|
reason = item.get('reason', '')
|
||||||
|
email_part = f" (Email: {item['email']})" if 'email' in item else ""
|
||||||
|
f.write(f"- {item.get('name')}{email_part} [{reason}]\n")
|
||||||
|
|
||||||
|
f.write(f'\n## Unterfranken (Missing: {len(missing_unterfranken)})\n')
|
||||||
|
if not missing_unterfranken:
|
||||||
|
f.write("All raw Unterfranken leads seem to be in the final list.\n")
|
||||||
|
for name in missing_unterfranken:
|
||||||
|
f.write(f"- {name}\n")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
@ -0,0 +1,120 @@
|
||||||
|
Firm/Innung,Contact Person,Email,Region
|
||||||
|
Augenoptiker-Innung Koeln-Aachen,,info@optikerinnung.de,Köln
|
||||||
|
Bau-Innung Kreis Mettmann,Thomas Grünendahl,info@handwerk-me.de,Düsseldorf/Surrounding
|
||||||
|
Bau-Innung Mönchengladbach,Dipl.-Ing. Frank Bühler,info@kh-mg.de,Düsseldorf/Surrounding
|
||||||
|
Bau-Innung Neuss-Viersen,Thomas Goldmann,info@kh-niederrhein.de,Düsseldorf/Surrounding
|
||||||
|
Bau-Innung Remscheid,Carsten Hof,info@handwerk-remscheid.de,Düsseldorf/Surrounding
|
||||||
|
Bau-Innung Schweinfurt,Karl Böhner,info@bauinnung-schweinfurt.de,Unterfranken
|
||||||
|
Bau-Innung Wuppertal-Solingen,Marcus Koch,info@bauinnung-wuppertal.de,Düsseldorf/Surrounding
|
||||||
|
Baugewerks-Innung des Kreises Wesel,Gerhard Landwehrs,info@khwesel.de,Düsseldorf/Surrounding
|
||||||
|
Baugewerks-Innung Duisburg,Volker Blastik,info@baugewerksinnung-duisburg.de,Düsseldorf/Surrounding
|
||||||
|
Bauinnung Aschaffenburg,Felix Englert,felix.englert@bauinnung-aschaffenburg.de,Unterfranken
|
||||||
|
Bauinnung Bad Kissingen / Rhön-Grabfeld,Stefan Goos,stefan.goos@zehe-gmbh.de,Unterfranken
|
||||||
|
Bauinnung Mainfranken - Würzburg,Ralf Stegmeier,info@trend-bau.com,Unterfranken
|
||||||
|
Berufsfotografen-Innung für Unterfranken,,info@foto-alfen.de,Unterfranken
|
||||||
|
Brauer- und Mälzerinnung Unterfranken,,info@private-brauereien-bayern.de,Unterfranken
|
||||||
|
"Buechsenmacher-Innung Nordrhein, RLP und Saarland",,kliedl@t-online.de,Köln
|
||||||
|
Bundesinnung fuer das Geruestbauer-Handwerk,,info@geruestbauhandwerk.de,Köln
|
||||||
|
Bäckerinnung Bad Kissingen - Rhön-Grabfeld,Petra Schwab,khw-kg@t-online.de,Unterfranken
|
||||||
|
Bäckerinnung Bayerischer Untermain,,v.hench@baeckerinnung-bayerischer-untermain.de,Unterfranken
|
||||||
|
Bäckerinnung Kitzingen,Tilo Brönner,tilo-br@t-online.de,Unterfranken
|
||||||
|
Bäckerinnung Mainfranken,Marcel Scherg,scherge.beck@t-online.de,Unterfranken
|
||||||
|
Bäckerinnung Schweinfurt - Haßberge,Gerhard Götz,baeckerei-goetz@web.de,Unterfranken
|
||||||
|
Dachdecker- und Zimmerer-Innung Koeln,,e-mail@dachdecker-innung-koeln.de,Köln
|
||||||
|
Dachdeckerinnung Aschaffenburg - Miltenberg,,lukas.thalheimer@thalheimer.de,Unterfranken
|
||||||
|
Dachdeckerinnung Unterfranken,,info@dachdecker-unterfranken.de,Unterfranken
|
||||||
|
Drucker- (Buchdrucker-)- und Buchbinder-Innung Duisburg,Bodo H. Oppenberg,info@handwerk-duisburg.de,Düsseldorf/Surrounding
|
||||||
|
Elektroinnung Koeln,,info@elektroinnungkoeln.de,Köln
|
||||||
|
Fleischer-Innung Koeln,,obermeister@fleischer-koeln.de,Köln
|
||||||
|
Fleischer-Innung Main-Spessart,Sebastian Bumm,fleischerinnungMSP@gmx.de,Unterfranken
|
||||||
|
Fotografen-Innung Düsseldorf-Aachen-Köln,Guido de Nardo,info@Fotografen-DUS.de,Düsseldorf/Surrounding
|
||||||
|
Friseur-Innung Aschaffenburg Stadt und Land,,friseurinnung-aschaffenburg@t-online.de,Unterfranken
|
||||||
|
Friseur-Innung Haßberge,Heinz Göhr,info@team-art-of-hair.com,Unterfranken
|
||||||
|
Friseur-Innung Koeln,,info@kopfarbeit-koeln.de,Köln
|
||||||
|
"Friseur-Innung Würzburg, Main-Spessart und Bad Kissingen",,katharinawalker88@gmx.de,Unterfranken
|
||||||
|
Friseurinnung Kitzingen,,sabine.hack71@web.de,Unterfranken
|
||||||
|
Friseurinnung Main-Rhön,Brigitte Rapp,rapp@kreishandwerkerschaft-sw.de,Unterfranken
|
||||||
|
Friseurinnung Miltenberg,,info@haarmonique.de,Unterfranken
|
||||||
|
Glasapparatebauer-Innung Duisburg,Dieter Verhees,hippler@handwerk-duisburg.de,Düsseldorf/Surrounding
|
||||||
|
Glaser-Innung Koeln-Bonn-Aachen,,mail@glas-bong.de,Köln
|
||||||
|
Glaserinnung Unterfranken,,info@frank-bauglaserei.de,Unterfranken
|
||||||
|
Graveur- und Metallbildner-Innung Rhein-Ruhr,Till Esser,info@kh-mo.de,Düsseldorf/Surrounding
|
||||||
|
Innung der Metallhandwerke (Solingen),Thomas Blau,info@handwerk-sgw.de,Düsseldorf/Surrounding
|
||||||
|
Innung des Bekleidungshandwerks Unterfranken,Friedrun Schlagbauer-Werner,info@schneiderin-wuerzburg.de,Unterfranken
|
||||||
|
Innung des Gebaeudereiniger-Handwerks Koeln-Aachen,,info@gebaeudereiniger-koeln-aachen.de,Köln
|
||||||
|
Innung des Massschneiderhandwerks Koeln / Textileiniger-Innung Koeln/Bonn,,twp.koeln@gmail.com,Köln
|
||||||
|
Innung Farbe Koeln,,s.epe@epe-maler.de,Köln
|
||||||
|
Innung fuer Informationstechnik Koeln/Bonn/Rhein-Sieg/Rhein-Erft,,n.gassner@koenig-avt.de,Köln
|
||||||
|
Innung fuer Metalltechnik Koeln,,info@van-broek.de,Köln
|
||||||
|
Innung fuer Orthopaedie-Technik Koeln,,sebastian@malzkorn.at,Köln
|
||||||
|
Innung für Elektro- und Informationstechnik Bayerischer Untermain,Annett Kinzel,info@elektroinnung-bayerischeruntermain.de,Unterfranken
|
||||||
|
Innung für Elektro- und Informationstechnik Haßberge,Gitta Klopf,info@elektroinnung-hassberge.de,Unterfranken
|
||||||
|
Innung für Elektro- und Informationstechnik Schweinfurt,"Gaby Fröschel, Roland Klöffel, Ronald Niessner",info@elektroinnung-sw.de,Unterfranken
|
||||||
|
Innung für Elektro- und Informationstechnik Würzburg,Heike Langner,mailbox@elektro-innung-wuerzburg.de,Unterfranken
|
||||||
|
Innung für Land- und Baumaschinentechnik Unterfranken,Brigitte Rapp,info@innung-landbautechnik.de,Unterfranken
|
||||||
|
Innung für Metallhandwerk des Kreises Kleve,Johannes Flinterhoff,info@kh-kleve.de,Düsseldorf/Surrounding
|
||||||
|
Innung für Metallhandwerke Mülheim an der Ruhr-Oberhausen,Johannes Arnzen,info@metallbauinnung-mh-ob.de,Düsseldorf/Surrounding
|
||||||
|
"Innung für Sanitär-, Heizungs-, Klempner- und Klimatechnik Würzburg",Sandra Köller,innung.shk@t-online.de,Unterfranken
|
||||||
|
"Innung für Spengler-, Sanitär-, Heizungs- und Klimatechnik Schweinfurt - Main-Rhön",Stefan Köppe,info@shk-schweinfurt.de,Unterfranken
|
||||||
|
"Innung für Spengler-, Sanitär-, und Heizungstechnik Kitzingen",Christine Keppner-Siegert,innung-kitzingen@freenet.de,Unterfranken
|
||||||
|
Innung Koeln Rollladen und Sonnenschutz,,info@rhp-online.de,Köln
|
||||||
|
Innung Metallbau- und Feinwerktechnik Bayerischer Untermain,Matthias Kreß,m.kress@wassermannkress.de,Unterfranken
|
||||||
|
"Juwelier-, Gold- und Silberschmiede-Innung Koeln",,info@sotos-schmuck.de,Köln
|
||||||
|
Kaminkehrer-Innung Unterfranken,,info@kaminkehrerinnung-unterfranken.de,Unterfranken
|
||||||
|
Karosserie- und Fahrzeugtechnik Innung Unterfranken,Michael Seidel,info@seidel-karosserie.de,Unterfranken
|
||||||
|
Karosseriebauer-Innung Koeln,,info@karosserie-innungkoeln.de,Köln
|
||||||
|
Kfz-Innung Unterfranken,Michael Frank,info@kfz-innung-ufr.de,Unterfranken
|
||||||
|
Konditoren-Innung Koeln - Bonn,,info@cafe-schoener.de,Köln
|
||||||
|
Kreishandwerkerschaft Aschaffenburg,Claudia Find,info@khw-ab.de,Unterfranken
|
||||||
|
Kreishandwerkerschaft Bad Kissingen,Petra Schwab,khw-kg@t-online.de,Unterfranken
|
||||||
|
Kreishandwerkerschaft Haßberge,Gitta Klopf,info@elektroinnung-hassberge.de,Unterfranken
|
||||||
|
Kreishandwerkerschaft Kitzingen,Monika Henneberger,monika.henneberger@t-online.de,Unterfranken
|
||||||
|
Kreishandwerkerschaft Koeln,,lepore@handwerk.koeln,Köln
|
||||||
|
Kreishandwerkerschaft Main-Spessart,Thomas Heußlein,info@schreinerei-heusslein.de,Unterfranken
|
||||||
|
Kreishandwerkerschaft Miltenberg,,kreishandwerker.mil@gmail.com,Unterfranken
|
||||||
|
Kreishandwerkerschaft Rhön-Grabfeld,,khwsch-rhoen-grabfeld@mail.de,Unterfranken
|
||||||
|
Kreishandwerkerschaft Schweinfurt,Brigitte Rapp,rapp@Kreishandwerkerschaft-sw.de,Unterfranken
|
||||||
|
Kreishandwerkerschaft Würzburg,Sandra Köller,info@kreishandwerkerschaft-wuerzburg.de,Unterfranken
|
||||||
|
"Maler -, Tüncher- und Lackierer Innung Alzenau",,maler_trageser@gmx.de,Unterfranken
|
||||||
|
Maler- und Lackierer-Innung Aschaffenburg Stadt und Land,,uta.kern@kolb-kern.de,Unterfranken
|
||||||
|
Maler- und Lackiererinnung Bad Kissingen,Mathias Stöth,khw-kg@t-online.de,Unterfranken
|
||||||
|
Maler- und Lackiererinnung Kitzingen,Thomas Wandler,mail@maler-wandler.de,Unterfranken
|
||||||
|
Maler- und Lackiererinnung Miltenberg,Melitta Becker,info@farbe-miltenberg.de,Unterfranken
|
||||||
|
Maler- und Stuckateur-Innung Würzburg und Main-Spessart,Claudius Wolfrum,info@malerinnung-wuerzburg.de,Unterfranken
|
||||||
|
Maler- und Tüncherinnung Haßberge,,obermeister@malerinnung-hassberge.de,Unterfranken
|
||||||
|
"Maler-, Tüncher- und Lackierer-Innung Rhön-Grabfeld",Birgit Neuhöfer,info@malerinnung-rg.de,Unterfranken
|
||||||
|
Malerinnung Schweinfurt Stadt- und Land,Brigitte Rapp,info@malerinnung-schweinfurt.de,Unterfranken
|
||||||
|
Maßschneider-Innung Düsseldorf,Sandra Gronemeier,info@mass-schneider-innung.de,Düsseldorf/Surrounding
|
||||||
|
Metall-Innung Bad Kissingen/Rhön-Grabfeld,Klaus Engelmann,info@metallinnung-kg-nes.de,Unterfranken
|
||||||
|
Metall-Innung des Kreises Wesel,Rainer Theunissen,info@metallinnung-wesel.de,Düsseldorf/Surrounding
|
||||||
|
Metall-Innung Essen,Björn Bergmann,info@metallhandwerk-essen.de,Düsseldorf/Surrounding
|
||||||
|
Metallinnung Mainfranken - Mitte,Detlef Lurz,detlef.lurz@lurz-metalltec.de,Unterfranken
|
||||||
|
Metallinnung Schweinfurt - Haßberge,Brigitte Rapp,rapp@kreishandwerkerschaft-sw.de,Unterfranken
|
||||||
|
Metzger-Innung Würzburg,,horst@schoemig.eu,Unterfranken
|
||||||
|
Metzgerinnung Aschaffenburg,Marco Häuser,marco.haeuser@haeuser-hra.de,Unterfranken
|
||||||
|
Metzgerinnung Main-Rhön,Barbara Fink,metzgerei_dros_fladungen@outlook.de,Unterfranken
|
||||||
|
Metzgerinnung Miltenberg,,j.neuberger@t-online.de,Unterfranken
|
||||||
|
Musikinstrumentenmacher-Innung Nordrhein,Christoph Böttcher,info@musikinstrumentenmacher-innung.de,Düsseldorf/Surrounding
|
||||||
|
Raumausstatter- und Sattlerinnung Unterfranken,Hermann Noske,mail@sofa-shop.de,Unterfranken
|
||||||
|
Raumausstatter-Innung Koeln,,info@diana-breidenbach.de,Köln
|
||||||
|
Schreinerinnung Aschaffenburg Stadt und Land,,info@dellers-werkstatt.de,Unterfranken
|
||||||
|
Schreinerinnung Bad Kissingen,Norbert Borst,khw-kg@t-online.de,Unterfranken
|
||||||
|
Schreinerinnung Haßberge – Schweinfurt,Horst Zitterbart,schreinerei.zitterbart@t-online.de,Unterfranken
|
||||||
|
Schreinerinnung Mainfranken,Ramona Pfenning,info@schreinerinnung-mainfranken.de,Unterfranken
|
||||||
|
Schreinerinnung Miltenberg-Obernburg,,wt@reichert-betten.de,Unterfranken
|
||||||
|
Schreinerinnung Rhön-Grabfeld,,info@schreiner-rhoen-grabfeld.de,Unterfranken
|
||||||
|
Schuhmacherinnung Unterfranken,,l.emge@t-online.de,Unterfranken
|
||||||
|
"SHK-Innung Main-Spessart Innung Sanitär-, Heizungs- und Klimatechnik",,info@shk-main-spessart.de,Unterfranken
|
||||||
|
"Spengler-, Sanitär- und Heizungstechnik Innung Aschaffenburg - Miltenberg",Michael Bramm,shk-aschaffenburg@t-online.de,Unterfranken
|
||||||
|
Steinmetz- und Steinbildhauerinnung Unterfranken,Josef Hofmann,info@stein-welten.com,Unterfranken
|
||||||
|
Straßen- und Tiefbauer-Innung Düsseldorf,André Grimmert,info@strassenbauer-innung-duesseldorf.de,Düsseldorf/Surrounding
|
||||||
|
Stuckateur-Innung Koeln - Ausbau + Fassade,,s.rettig@hhhuerth.de,Köln
|
||||||
|
"Uhrmacher-, Gold- und Silberschmiedeinnung Unterfranken",Klaus Imhof,kontakt@juwelier-imhof.de,Unterfranken
|
||||||
|
Unterfränkische Ofen- und Luftheizungsbauer-Innung,Michael Heigel,heigel@heigel.de,Unterfranken
|
||||||
|
Verband der Berufsfotografen Ruhr,Andreas Köhring,ak@koehring-fotografie.de,Düsseldorf/Surrounding
|
||||||
|
Werbetechniker-Innung Koeln - Bonn - Aachen,,info@werbetechnik-baecker.de,Köln
|
||||||
|
Zimmerer-Innung Aschaffenburg - Miltenberg,Theresa Breunig,info@zimmerer-aschaffenburg-miltenberg.de,Unterfranken
|
||||||
|
Zimmerer-Innung Bad Neustadt,,info@holzbau-eyrich.de,Unterfranken
|
||||||
|
Zimmerer-Innung Main-Spessart,Volker Schäfer,info@schaefer-halsbach.de,Unterfranken
|
||||||
|
Zimmerer-Innung Schweinfurt-Haßberge,Marion Reichhold,zimmerei_klaus_treiber@gmx.de,Unterfranken
|
||||||
|
Zimmerer-Innung Würzburg - Kitzingen,Hermann Lang,lang@zimmerer-wuerzburg-kitzingen.de,Unterfranken
|
||||||
|
|
|
@ -0,0 +1,227 @@
|
||||||
|
# Missing Leads Report
|
||||||
|
|
||||||
|
## Düsseldorf (Missing: 180)
|
||||||
|
- Baugewerbe-Innung Düsseldorf (Contact: Christoph Morick)
|
||||||
|
- Dachdecker-Innung Düsseldorf (Contact: Andreas Braun)
|
||||||
|
- Elektro-Innung Düsseldorf (Contact: Kai Hofmann)
|
||||||
|
- Innung Sanitär-Heizung-Klima Düsseldorf (Contact: Hans Werner Eschrich)
|
||||||
|
- Fachinnung Stahl und Metall Düsseldorf (Contact: Peter Maxisch)
|
||||||
|
- Tischler-Innung Düsseldorf (Contact: Thomas Klode)
|
||||||
|
- Dachdecker- und Zimmerer-Innung Duisburg (Contact: Udo Rosenstengel)
|
||||||
|
- Elektro-Innung Duisburg (Contact: Dipl.-Ing. Lothar Hellmann)
|
||||||
|
- Innung für Metall, Karosserie- und Fahrzeugbau Duisburg/Niederrhein (Contact: Sebastian Christ)
|
||||||
|
- Innung Sanitär-Heizung-Klima Duisburg (Contact: Uwe Schöbel)
|
||||||
|
- Tischler-Innung Duisburg (Contact: Frank Paschke)
|
||||||
|
- Baugewerbe-Innung Essen (Contact: Frank Schulte-Hubbert)
|
||||||
|
- Dachdecker-Innung Essen (Contact: Marc Sparrer)
|
||||||
|
- Elektro-Innung Essen (Contact: Frank Struck)
|
||||||
|
- Innung für Sanitär- und Heizungstechnik Ruhr-West (Contact: Thomas Weber)
|
||||||
|
- Tischler-Innung Essen (Contact: Matthias Spehr)
|
||||||
|
- Baugewerbe-Innung des Kreises Kleve (Contact: Michael Köster)
|
||||||
|
- Dachdecker-Innung des Kreises Kleve (Contact: Markus Henkel)
|
||||||
|
- Elektro-Innung des Kreises Kleve (Contact: Jörg Weykamp)
|
||||||
|
- Innung Sanitär-Heizung-Klima Kreis Kleve (Contact: Michael Janßen)
|
||||||
|
- Tischler-Innung des Kreises Kleve (Contact: Stefan Meyer)
|
||||||
|
- Dachdecker- und Zimmerer-Innung Kreis Mettmann (Contact: Renee Fügener)
|
||||||
|
- Elektro-Innung Kreis Mettmann (Contact: Andreas Schmidt)
|
||||||
|
- Innung für Metalltechnik Kreis Mettmann (Contact: Reiner Schumacher)
|
||||||
|
- Innung für Sanitär- und Heizungstechnik Kreis Mettmann (Contact: Christian Möller)
|
||||||
|
- Tischler-Innung Kreis Mettmann (Contact: Michael Fischbach)
|
||||||
|
- Dachdecker-Innung Mönchengladbach (Contact: Reinhard Esser)
|
||||||
|
- Elektro-Innung Mönchengladbach (Contact: Heinz-Willi-Ober)
|
||||||
|
- Metall-Innung Mönchengladbach-Rheydt (Contact: Adam Sautner)
|
||||||
|
- Innung Sanitär-Heizung-Klima Mönchengladbach (Contact: Thorsten Caspers)
|
||||||
|
- Tischler-Innung Mönchengladbach-Rheydt (Contact: Hans Wilhelm Klomp)
|
||||||
|
- Baugewerks-Innung Mülheim an der Ruhr-Oberhausen (Contact: Jürgen Bunk)
|
||||||
|
- Dachdecker- und Zimmerer-Innung Mülheim an der Ruhr (Contact: Jens-Peter Richard)
|
||||||
|
- Dachdecker-Innung Oberhausen-Rheinland (Contact: Mark Notthoff)
|
||||||
|
- Elektro-Innung Mülheim an der Ruhr (Contact: Joachim Schweins)
|
||||||
|
- Elektro-Innung Oberhausen-Rheinland (Contact: Florian Menger)
|
||||||
|
- Innung für Sanitär- und Heizungstechnik Oberhausen-Rheinland (Contact: Stefan Tögel)
|
||||||
|
- Tischler-Innung Mülheim an der Ruhr-Oberhausen (Contact: Michael Schmeling)
|
||||||
|
- Bau- und Straßenbauer-Innung Krefeld-Linker Niederrhein (Contact: Dipl.-Ing. Joachim Selzer)
|
||||||
|
- Dachdecker-Innung Krefeld (Contact: Andreas Pavel)
|
||||||
|
- Dachdecker-Innung Kreis Viersen (Contact: Ulrich Heurs)
|
||||||
|
- Dachdecker-Innung Rhein-Kreis Neuss (Contact: Marco Brüggen)
|
||||||
|
- Elektro-Innung Krefeld (Contact: Peter Rath)
|
||||||
|
- Elektro-Innung Rhein-Kreis Neuss (Contact: Ernst Veiser)
|
||||||
|
- Innung für Land- und Baumaschinentechnik Niederrhein (Contact: Franz-Josef Schulte)
|
||||||
|
- Metall-Innung Niederrhein (Contact: Klaus Caris)
|
||||||
|
- Innung für Sanitär-Heizung-Klima-Apparatebau Krefeld (Contact: Daniel Küppers)
|
||||||
|
- Innung für Sanitär- und Heizungstechnik Kreis Viersen (Contact: Michael Smeets)
|
||||||
|
- Innung für Sanitär- und Heizungstechnik Rhein-Kreis Neuss (Contact: Thomas Hanna)
|
||||||
|
- Tischler-Innung Krefeld (Contact: Dirk Kosanke)
|
||||||
|
- Tischler-Innung Kreis Viersen (Contact: Uwe Sötje)
|
||||||
|
- Tischler-Innung Rhein-Kreis Neuss (Contact: Philipp Schlang)
|
||||||
|
- Dachdecker-Innung Remscheid (Contact: Jörg Margies)
|
||||||
|
- Fachinnung Metall- und Graviertechnik Remscheid (Contact: Uwe Wiegand)
|
||||||
|
- Innung für Sanitär- und Heizungstechnik Remscheid (Contact: Oliver Bergmann)
|
||||||
|
- Tischler-Innung Remscheid (Contact: Martin Stracke)
|
||||||
|
- Dachdecker-Innung Solingen-Wuppertal (Contact: Thomas Sobireg)
|
||||||
|
- Elektro-Innung (Solingen) (Contact: Frank Roth)
|
||||||
|
- Elektro-Innung Wuppertal (Contact: Ingo Kursawe)
|
||||||
|
- Innung für Metallhandwerke Wuppertal (Contact: Christian Flüss)
|
||||||
|
- Innung für Sanitär- und Heizungstechnik (Solingen) (Contact: Andreas Glingener)
|
||||||
|
- Innung für Sanitär-, Heizungs- und Klimatechnik Wuppertal (Contact: Frank Karuc)
|
||||||
|
- Tischler-Innung (Solingen) (Contact: Paul-Gerhard Rössling)
|
||||||
|
- Tischler-Innung Wuppertal (Contact: Thomas Landsiedel)
|
||||||
|
- Dachdecker-Innung des Kreises Wesel (Contact: Marco Remy)
|
||||||
|
- Innung für Elektrotechnik und Informationstechnik des Kreises Wesel (Contact: Klemens Mues)
|
||||||
|
- Innung Sanitär-Heizung-Klima Kreis Wesel (Contact: Norbert Buhl)
|
||||||
|
- Tischler-Innung des Kreises Wesel (Contact: Dirk Jockram)
|
||||||
|
- Augenoptiker-Innung Düssel-Rhein-Ruhr (Contact: Jens Schulz)
|
||||||
|
- Bäcker-Innung Rhein-Ruhr (Contact: Johannes Dackweiler)
|
||||||
|
- Fleischer-Innung Düsseldorf-Mettmann-Solingen (Contact: Lutz Kluke)
|
||||||
|
- Friseur-Innung Düsseldorf (Contact: Christos Neofotistos)
|
||||||
|
- Gebäudereiniger-Innung Düsseldorf (Contact: Michael Kregel)
|
||||||
|
- Glaser-Innung Düsseldorf (Contact: Ralph Icks)
|
||||||
|
- Gold- und Silberschmiede-Innung Düsseldorf (Contact: Susanne Kunzmann)
|
||||||
|
- Innung für Informationstechnik Düssel-Rhein-Ruhr (Contact: Alvaro Millan)
|
||||||
|
- Innung Nordrhein der Kachel- und Luftheizungsbauer (Contact: Uwe Gobien)
|
||||||
|
- Karosseriebauer-Innung Düsseldorf (Contact: Detlev Thedens)
|
||||||
|
- Konditoren-Innung Bergisches Land und Düsseldorf (Contact: Thomas Pons)
|
||||||
|
- Innung des Kraftfahrzeuggewerbes Düsseldorf (Contact: Hermann Görtz)
|
||||||
|
- Maler- und Lackierer-Innung Düsseldorf (Contact: Jörg Schmitz)
|
||||||
|
- Innung für Orthopädie-Technik für den Regierungsbezirk Düsseldorf (Contact: Pierre Koppetsch)
|
||||||
|
- Innung für Orthopädieschuhtechnik Rheinland-Westfalen (Contact: Philipp Radtke)
|
||||||
|
- Sattler- und Raumausstatter-Innung Düsseldorf (Contact: Holger Hohmann)
|
||||||
|
- Schornsteinfeger-Innung für den Regierungsbezirk Düsseldorf (Contact: N.N.)
|
||||||
|
- Steinmetz- und Steinbildhauer-Innung Düsseldorf (Contact: Jörg Hahn)
|
||||||
|
- Stukkateur-Innung Düsseldorf-Neuss für Ausbau und Fassade (Contact: Tobias Jülich)
|
||||||
|
- Werbetechniker-Innung Düsseldorf (Contact: Tim Rehse)
|
||||||
|
- Zahntechniker-Innung Düsseldorf (Contact: Dominik Kruchen)
|
||||||
|
- Fleischer-Innung Duisburg (Contact: Franz-Josef van Bebber)
|
||||||
|
- Friseur-Innung Duisburg (Contact: Markus Lotze)
|
||||||
|
- Gebäudereiniger-Innung Duisburg (Contact: Jörg Hämmerling)
|
||||||
|
- Konditoren-Innung Rhein-Ruhr (Contact: Hubert Cordes)
|
||||||
|
- Innung der Kraftfahrzeughandwerks Duisburg (Contact: Thomas Dosoudil)
|
||||||
|
- Maler- und Lackierer-Innung Duisburg (Contact: Heinz-Jürgen Lobreyer)
|
||||||
|
- Innung des modeschaffenden Handwerks Duisburg (Contact: Charlotte Noé)
|
||||||
|
- Innung für Informationstechnik Duisburg (Contact: Thorsten Slizewski)
|
||||||
|
- Raumausstatter-Innung Rhein-Ruhr (Contact: Kay Piller)
|
||||||
|
- Steinmetz- und Steinbildhauer-Innung Duisburg, Mülheim, Oberhausen (Contact: Ralf Pauschert)
|
||||||
|
- Straßenbauer-Innung Duisburg (Contact: Heiner Kühne)
|
||||||
|
- Zweiradmechaniker-Innung Rhein-Ruhr (Contact: Erwin Lohrmann)
|
||||||
|
- Friseur-Innung Essen (Contact: Markus Bredenbröcker)
|
||||||
|
- Gebäudereiniger-Innung Essen-Mülheim an der Ruhr-Oberhausen (Contact: Stefan Thielen)
|
||||||
|
- Gold- und Silberschmiede-Innung Essen (Contact: Zeno Ablass)
|
||||||
|
- Maler- und Lackierer-Innung Essen (Contact: Marc-Alexander Kecker)
|
||||||
|
- Raumausstatter- und Sattler-Innung Essen (Contact: Michel Gutberlet)
|
||||||
|
- Innung des Schilder- und Lichtreklameherstellerhandwerks Essen (Contact: Sascha Schlüter)
|
||||||
|
- Steinmetz- und Steinbildhauer-Innung Essen (Contact: Stephan Peters)
|
||||||
|
- Straßenbauer-Innung Essen-Mülheim (Contact: Thorsten Potysch)
|
||||||
|
- Innung für Vulkaniseur- und Reifenmechanik-Handwerk Rhein-Ruhr (Contact: Horst Kornetka)
|
||||||
|
- Bäcker-Innung Niederrhein-Kleve-Wesel (Contact: Johannes Gerhards)
|
||||||
|
- Fleischer-Innung des Kreises Kleve (Contact: Heinz Borghs)
|
||||||
|
- Friseur-Innung des Kreises Kleve (Contact: Karin Merlin Wrobel)
|
||||||
|
- Gold- und Silberschmiede-Innung Niederrhein (Contact: Martin Link)
|
||||||
|
- Maler- und Lackierer-Innung des Kreises Kleve (Contact: Franz-Theo Dirmeier)
|
||||||
|
- Zweiradmechaniker-Innung des Kreises Kleve (Contact: Markus Daute)
|
||||||
|
- Friseur-Innung Kreis Mettmann (Contact: Uwe Ranke)
|
||||||
|
- Karosserie- und Fahrzeugbauer-Innung Kreis Mettmann (Contact: Max Witeczek)
|
||||||
|
- Innung des Kraftfahrzeughandwerks Kreis Mettmann (Contact: Alfons Kunz)
|
||||||
|
- Maler- und Lackierer-Innung Kreis Mettmann (Contact: Ralf Heinz Weber)
|
||||||
|
- Raumausstatter- und Sattler-Innung Kreis Mettmann (Contact: Thomas Wittig)
|
||||||
|
- Bäcker-Innung Mönchengladbach (Contact: Gertie Riethmacher)
|
||||||
|
- Fleischer-Innung Mönchengladbach (Contact: Josef Baumanns)
|
||||||
|
- Friseur-Innung Mönchengladbach (Contact: Sabine Capan)
|
||||||
|
- Informationstechniker-Innung Mönchengladbach-Neuss (Contact: Dirk Weduwen)
|
||||||
|
- Karosserie- und Fahrzeugbauer-Innung Mönchengladbach (Contact: Reiner Brenner)
|
||||||
|
- Konditoren-Innung Mönchengladbach (Contact: Manfred Groth)
|
||||||
|
- Innung des Kraftfahrzeuggewerbes Mönchengladbach (Contact: Peter Fischer)
|
||||||
|
- Maler- und Lackierer-Innung Mönchengladbach (Contact: Udo Nösen)
|
||||||
|
- Raumausstatter- und Sattler-Innung Mönchengladbach (Contact: Joachim Rütten)
|
||||||
|
- Schuhmacher-Innung linker Niederrhein (Contact: Günther Schellenberger)
|
||||||
|
- Zimmerer-Innung Mönchengladbach (Contact: Peter Röders)
|
||||||
|
- Fleischer-Innung RheinRuhr (Contact: Jörg Bischoff)
|
||||||
|
- Friseur-Innung Mülheim an der Ruhr (Contact: Ralf Wüstefeld)
|
||||||
|
- Friseur-Innung Oberhausen-Rheinland (Contact: Bernd Görg)
|
||||||
|
- Maler- und Lackierer-Innung Mülheim an der Ruhr-Oberhausen (Contact: Guido vom Ufer)
|
||||||
|
- Niederrheinische Bäcker-Innung Krefeld-Viersen-Neuss (Contact: Rudolf Weißert)
|
||||||
|
- Bestatter-Innung Nordrhein-Westfalen (Contact: Frank Wesemann)
|
||||||
|
- Drechsler-Innung Krefeld (Contact: Klaus Reef)
|
||||||
|
- E-Handwerke Niederrhein-Kreis Viersen (Contact: Martin Thomas Nowroth)
|
||||||
|
- Fleischer-Innung Niederrhein Krefeld-Viersen-Neuss (Contact: Willi Schillings)
|
||||||
|
- Friseur-Innung Krefeld-Viersen-Neuss (Contact: Alexandra Houx-Brenner)
|
||||||
|
- Gebäudereiniger-Innung Mittlerer Niederrhein (Contact: Nadine Ludwigs)
|
||||||
|
- Innung für Informationstechnik Niederrhein Krefeld-Viersen-Neuss (Contact: Horst Rinsch)
|
||||||
|
- Karosserie- und Fahrzeugbauer-Innung Krefeld-Viersen-Neuss (Contact: Ralph Treeker)
|
||||||
|
- Konditoren-Innung Niederrhein (Contact: Andreas Achten)
|
||||||
|
- Innung des Kraftfahrzeuggewerbes Krefeld-Viersen (Contact: Dietmar Lassek)
|
||||||
|
- Kraftfahrzeug-Innung Rhein-Kreis Neuss (Contact: Johannes Brester)
|
||||||
|
- Maler- und Lackierer-Innung Niederrhein Krefeld-Neuss-Viersen (Contact: Stephanie Jahrke)
|
||||||
|
- Innung für das Modeschaffende Handwerk Niederrhein (Contact: Angelika van Neerven)
|
||||||
|
- Steinmetz- und Steinbildhauer-Innung Mittlerer Niederrhein (Contact: Daniel Franzen)
|
||||||
|
- Stuckateur-Innung Krefeld-Viersen (Contact: Roland Gerhards)
|
||||||
|
- Textilreiniger-Innung für den Regierungsbezirk Düsseldorf (Contact: Dirk Querfurth)
|
||||||
|
- Zweiradmechaniker-Innung Niederrhein Krefeld-Viersen-Neuss (Contact: Rolf Langer)
|
||||||
|
- Innung für elektrotechnische Handwerke Remscheid (Contact: Andreas Müller)
|
||||||
|
- Friseur-Innung Remscheid (Contact: Tanja Kürten-Rauhaus)
|
||||||
|
- Innung für das Gebäudereiniger-Handwerk Remscheid-Solingen (Contact: Oliver Knedlich)
|
||||||
|
- Innung des Kraftfahrzeughandwerks Remscheid/ (Contact: Thomas Bliß)
|
||||||
|
- Maler- und Lackierer-Innung Remscheid (Contact: Holger Schulz)
|
||||||
|
- Innung der Nahrungsmittelhandwerke Remscheid (Contact: Birgit Eppels)
|
||||||
|
- Steinmetz- und Steinbildhauer-Innung Bergisch Land (Contact: Beate Globisch)
|
||||||
|
- Bäcker-Innung Solingen-Wuppertal (Sitz: Wuppertal) (Contact: Dirk Polick)
|
||||||
|
- Friseur-Innung Solingen-Wuppertal (Contact: Pia Schneider)
|
||||||
|
- Glaser-Innung Wuppertal (Contact: Rafael Musik)
|
||||||
|
- Graveur-Innung Solingen-Wuppertal (Contact: Stefan Krüth)
|
||||||
|
- Karosserie- und Fahrzeugbauer-Innung Wuppertal (Contact: Martin Rosslan)
|
||||||
|
- Innung des Kraftfahrzeughandwerks (Solingen) (Contact: Uwe Stamm)
|
||||||
|
- Innung des Kraftfahrzeughandwerks Wuppertal (Contact: Wolfram Friedrich)
|
||||||
|
- Maler- und Lackierer-Innung (Solingen) (Contact: Oliver Conrads)
|
||||||
|
- Maler- und Lackierer-Innung Wuppertal (Contact: Oliver Conyn)
|
||||||
|
- Raumausstatter-Innung Solingen-Wuppertal (Contact: Frank Dembny)
|
||||||
|
- Straßen- und Tiefbau-Innung Wuppertal (Contact: Martin Ehlhardt)
|
||||||
|
- Stukkateur-Innung Wuppertal-Kreis Mettmann (Contact: Wolfgang Wüstenhagen)
|
||||||
|
- Friseur-Innung des Kreises Wesel (Contact: Klaus Peter Neske)
|
||||||
|
- Glaser-Innung Niederrhein (Contact: Thomas Schulmeyer)
|
||||||
|
- Innung des Kraftfahrzeuggewerbes Niederrhein Kleve-Wesel (Contact: René Gravendyk)
|
||||||
|
- Maler- und Lackierer-Innung des Kreises Wesel (Contact: Günter Bode)
|
||||||
|
- Innung für Schneid- und Schleiftechnik Nordrhein (Contact: Uwe Peters)
|
||||||
|
- Steinmetz- und Steinbildhauer-Innung Niederrhein (Contact: Benedikt L. Kreusch)
|
||||||
|
- Stuckateur-Innung Niederrhein (Contact: Norbert Kehrbusch)
|
||||||
|
|
||||||
|
## Cologne (Missing: 22)
|
||||||
|
- Kreishandwerkerschaft Koeln (Email: lepore@handwerk.koeln) [Not in final list]
|
||||||
|
- Buechsenmacher-Innung Nordrhein, RLP und Saarland (Email: kliedl@t-online.de) [Not in final list]
|
||||||
|
- Fleischer-Innung Koeln (Email: obermeister@fleischer-koeln.de) [Not in final list]
|
||||||
|
- Glaser-Innung Koeln-Bonn-Aachen (Email: mail@glas-bong.de) [Not in final list]
|
||||||
|
- Juwelier-, Gold- und Silberschmiede-Innung Koeln (Email: info@sotos-schmuck.de) [Not in final list]
|
||||||
|
- Innung Farbe Koeln (Email: s.epe@epe-maler.de) [Not in final list]
|
||||||
|
- Innung des Massschneiderhandwerks Koeln / Textileiniger-Innung Koeln/Bonn (Email: twp.koeln@gmail.com) [Not in final list]
|
||||||
|
- Innung fuer Metalltechnik Koeln (Email: info@van-broek.de) [Not in final list]
|
||||||
|
- Innung fuer Orthopaedie-Technik Koeln (Email: sebastian@malzkorn.at) [Not in final list]
|
||||||
|
- Raumausstatter-Innung Koeln (Email: info@diana-breidenbach.de) [Not in final list]
|
||||||
|
- Innung Koeln Rollladen und Sonnenschutz (Email: info@rhp-online.de) [Not in final list]
|
||||||
|
- Stuckateur-Innung Koeln - Ausbau + Fassade (Email: s.rettig@hhhuerth.de) [Not in final list]
|
||||||
|
- Werbetechniker-Innung Koeln - Bonn - Aachen (Email: info@werbetechnik-baecker.de) [Not in final list]
|
||||||
|
- Augenoptiker-Innung Koeln-Aachen (Email: info@optikerinnung.de) [Not in final list]
|
||||||
|
- Dachdecker- und Zimmerer-Innung Koeln (Email: e-mail@dachdecker-innung-koeln.de) [Not in final list]
|
||||||
|
- Elektroinnung Koeln (Email: info@elektroinnungkoeln.de) [Not in final list]
|
||||||
|
- Friseur-Innung Koeln (Email: info@kopfarbeit-koeln.de) [Not in final list]
|
||||||
|
- Innung des Gebaeudereiniger-Handwerks Koeln-Aachen (Email: info@gebaeudereiniger-koeln-aachen.de) [Not in final list]
|
||||||
|
- Bundesinnung fuer das Geruestbauer-Handwerk (Email: info@geruestbauhandwerk.de) [Not in final list]
|
||||||
|
- Innung fuer Informationstechnik Koeln/Bonn/Rhein-Sieg/Rhein-Erft (Email: n.gassner@koenig-avt.de) [Not in final list]
|
||||||
|
- Karosseriebauer-Innung Koeln (Email: info@karosserie-innungkoeln.de) [Not in final list]
|
||||||
|
- Konditoren-Innung Koeln - Bonn (Email: info@cafe-schoener.de) [Not in final list]
|
||||||
|
|
||||||
|
## Unterfranken (Missing: 18)
|
||||||
|
- Innung für Elektro- und Informationstechnik Bayerischer Untermain
|
||||||
|
- Innung für Elektro- und Informationstechnik Haßberge
|
||||||
|
- Innung für Elektro- und Informationstechnik Schweinfurt
|
||||||
|
- Innung für Elektro- und Informationstechnik Würzburg
|
||||||
|
- Friseur-Innung Würzburg, Main-Spessart und Bad Kissingen
|
||||||
|
- Karosserie- und Fahrzeugtechnik Innung Unterfranken
|
||||||
|
- Innung für Land- und Baumaschinentechnik Unterfranken
|
||||||
|
- Maler -, Tüncher- und Lackierer Innung Alzenau
|
||||||
|
- Maler- und Lackierer-Innung Aschaffenburg Stadt und Land
|
||||||
|
- Maler-, Tüncher- und Lackierer-Innung Rhön-Grabfeld
|
||||||
|
- Maler- und Stuckateur-Innung Würzburg und Main-Spessart
|
||||||
|
- Innung Metallbau- und Feinwerktechnik Bayerischer Untermain
|
||||||
|
- Unterfränkische Ofen- und Luftheizungsbauer-Innung
|
||||||
|
- Spengler-, Sanitär- und Heizungstechnik Innung Aschaffenburg - Miltenberg
|
||||||
|
- Innung für Spengler-, Sanitär-, und Heizungstechnik Kitzingen
|
||||||
|
- SHK-Innung Main-Spessart Innung Sanitär-, Heizungs- und Klimatechnik
|
||||||
|
- Innung für Spengler-, Sanitär-, Heizungs- und Klimatechnik Schweinfurt - Main-Rhön
|
||||||
|
- Innung für Sanitär-, Heizungs-, Klempner- und Klimatechnik Würzburg
|
||||||
|
|
@ -0,0 +1,143 @@
|
||||||
|
Firm/Innung,Contact Person,Email,Region
|
||||||
|
Bäckerinnung Bayerischer Untermain,N/A,v.hench@baeckerinnung-bayerischer-untermain.de,Unterfranken
|
||||||
|
Bäckerinnung Bad Kissingen - Rhön-Grabfeld,Petra Schwab,khw-kg@t-online.de,Unterfranken
|
||||||
|
Bäckerinnung Bad Kissingen - Rhön-Grabfeld,Ullrich Amthor,info@baeckerei-amthor.com,Unterfranken
|
||||||
|
Bäckerinnung Kitzingen,Tilo Brönner,tilo-br@t-online.de,Unterfranken
|
||||||
|
Bäckerinnung Schweinfurt - Haßberge,Brigitte Rapp,rapp@kreishandwerkerschaft-sw.de,Unterfranken
|
||||||
|
Bäckerinnung Schweinfurt - Haßberge,Gerhard Götz,baeckerei-goetz@web.de,Unterfranken
|
||||||
|
Bäckerinnung Mainfranken,Christine Winterbauer,christine.winterbauer@baeckerbuchstelle.de,Unterfranken
|
||||||
|
Bäckerinnung Mainfranken,Marcel Scherg,scherge.beck@t-online.de,Unterfranken
|
||||||
|
Bauinnung Aschaffenburg,Nina Emeneth,info@bauinnung-aschaffenburg.de,Unterfranken
|
||||||
|
Bauinnung Aschaffenburg,Felix Englert,felix.englert@bauinnung-aschaffenburg.de,Unterfranken
|
||||||
|
Bauinnung Bad Kissingen / Rhön-Grabfeld,Stefan Goos,stefan.goos@zehe-gmbh.de,Unterfranken
|
||||||
|
Bau-Innung Schweinfurt,Ramona Ziegler,info@bauinnung-schweinfurt.de,Unterfranken
|
||||||
|
Bau-Innung Schweinfurt,Karl Böhner,boehner-bau@t-online.de,Unterfranken
|
||||||
|
Bauinnung Mainfranken - Würzburg,Manfred Dallner,baugewerbe@lbb-unterfranken.de,Unterfranken
|
||||||
|
Bauinnung Mainfranken - Würzburg,Ralf Stegmeier,trendbaugmbh@aol.com,Unterfranken
|
||||||
|
Innung des Bekleidungshandwerks Unterfranken,Nicole Brandler,info@innung-unterfranken.de,Unterfranken
|
||||||
|
Innung des Bekleidungshandwerks Unterfranken,Friedrun Schlagbauer-Werner,info@schneiderin-wuerzburg.de,Unterfranken
|
||||||
|
Innung des Bekleidungshandwerks Unterfranken,Friedrun Schlagbauer-Werner,info@private-brauereien-bayern.de,Unterfranken
|
||||||
|
Innung des Bekleidungshandwerks Unterfranken,Josef Göller,geschaeftsleitung@brauerei-goeller.de,Unterfranken
|
||||||
|
Innung des Bekleidungshandwerks Unterfranken,Josef Göller,lukas.thalheimer@thalheimer.de,Unterfranken
|
||||||
|
Innung des Bekleidungshandwerks Unterfranken,Lukas Thalheimer,info@mein-dachdecker.com,Unterfranken
|
||||||
|
Innung des Bekleidungshandwerks Unterfranken,Timo Markert,info@elektroinnung-bayerischeruntermain.de,Unterfranken
|
||||||
|
Innung des Bekleidungshandwerks Unterfranken,Edwin Palzer,info@elektroinnung-hassberge.de,Unterfranken
|
||||||
|
Innung des Bekleidungshandwerks Unterfranken,Ralf Jooß,info@elektro-jooss.de,Unterfranken
|
||||||
|
Innung des Bekleidungshandwerks Unterfranken,Ralf Jooß,info@elektroinnung-sw.de,Unterfranken
|
||||||
|
Innung des Bekleidungshandwerks Unterfranken,Rainer Walter-Helk,rwalter-helk@innotech-solar.de,Unterfranken
|
||||||
|
Innung des Bekleidungshandwerks Unterfranken,Rainer Walter-Helk,mailbox@elektro-innung-wuerzburg.de,Unterfranken
|
||||||
|
Innung des Bekleidungshandwerks Unterfranken,Sebastian Seynstahl,se@elektro-seynstahl.de,Unterfranken
|
||||||
|
Berufsfotografen-Innung für Unterfranken,N/A,info@foto-alfen.de,Unterfranken
|
||||||
|
Friseur-Innung Aschaffenburg Stadt und Land,N/A,friseurinnung-aschaffenburg@t-online.de,Unterfranken
|
||||||
|
Friseur-Innung Aschaffenburg Stadt und Land,Corina Bayer,cbm.bayer@t-online.de,Unterfranken
|
||||||
|
Friseur-Innung Haßberge,Heinz Göhr,info@team-art-of-hair.com,Unterfranken
|
||||||
|
Friseurinnung Kitzingen,N/A,sabine.hack71@web.de,Unterfranken
|
||||||
|
Friseurinnung Miltenberg,N/A,info@haarmonique.de,Unterfranken
|
||||||
|
Friseurinnung Main-Rhön,Margit Rosentritt,katharinawalker88@gmx.de,Unterfranken
|
||||||
|
Friseurinnung Main-Rhön,Katharina Walker,info@frank-bauglaserei.de,Unterfranken
|
||||||
|
Kaminkehrer-Innung Unterfranken,N/A,info@kaminkehrerinnung-unterfranken.de,Unterfranken
|
||||||
|
Kaminkehrer-Innung Unterfranken,Benjamin Schreck,benjamin.schreck@t-online.de,Unterfranken
|
||||||
|
Kaminkehrer-Innung Unterfranken,Benjamin Schreck,info@khw-nuernberg.de,Unterfranken
|
||||||
|
Kaminkehrer-Innung Unterfranken,Michael Seidel,info@seidel-karosserie.de,Unterfranken
|
||||||
|
Kfz-Innung Unterfranken,Michael Frank,info@kfz-innung-ufr.de,Unterfranken
|
||||||
|
Kfz-Innung Unterfranken,Roland Hoier,roland.hoier@autohaus-keller.de,Unterfranken
|
||||||
|
Kfz-Innung Unterfranken,Bertram Muth,bertram.muth@outlook.de,Unterfranken
|
||||||
|
Maler,N/A,maler_trageser@gmx.de,Unterfranken
|
||||||
|
Maler,Karlheinz Trageser,uta.kern@kolb-kern.de,Unterfranken
|
||||||
|
Maler,Ansgar Kern,ansgar.kern@kolb-kern.de,Unterfranken
|
||||||
|
Maler,Mathias Stöth,mathias@stoeth-fuchsstadt.de,Unterfranken
|
||||||
|
Maler,Mathias Stöth,obermeister@malerinnung-hassberge.de,Unterfranken
|
||||||
|
Maler,Michael Ott,info@malerinnung-kitzingen.de,Unterfranken
|
||||||
|
Maler,Thomas Wandler,mail@maler-wandler.de,Unterfranken
|
||||||
|
Maler,Thomas Wandler,info@farbe-miltenberg.de,Unterfranken
|
||||||
|
Maler,Jan Becker,info@malerinnung-rg.de,Unterfranken
|
||||||
|
Malerinnung Schweinfurt Stadt- und Land,Andreas Spath,info@spath-tuencher.de,Unterfranken
|
||||||
|
Malerinnung Schweinfurt Stadt- und Land,Andreas Spath,info@malerinnung-wuerzburg.de,Unterfranken
|
||||||
|
Malerinnung Schweinfurt Stadt- und Land,Peter Killinger,info@manufatture-colori.de,Unterfranken
|
||||||
|
Malerinnung Schweinfurt Stadt- und Land,Peter Killinger,info@innung-metallbau-feinwerktechnik.de,Unterfranken
|
||||||
|
Malerinnung Schweinfurt Stadt- und Land,Matthias Kreß,m.kress@wassermannkress.de,Unterfranken
|
||||||
|
Metall-Innung Bad Kissingen/Rhön-Grabfeld,Klaus Engelmann,info@metallbau-engelmann.de,Unterfranken
|
||||||
|
Metall-Innung Bad Kissingen/Rhön-Grabfeld,René Dauelsberg,rd@mpi-dauelsberg.de,Unterfranken
|
||||||
|
Metall-Innung Bad Kissingen/Rhön-Grabfeld,René Dauelsberg,info@metallinnung-mainfranken.de,Unterfranken
|
||||||
|
Metall-Innung Bad Kissingen/Rhön-Grabfeld,Detlef Lurz,detlef.lurz@lurz-metalltec.de,Unterfranken
|
||||||
|
Metzgerinnung Aschaffenburg,Dagobert Pfarr,metzgerei-pfarr@t-online.de,Unterfranken
|
||||||
|
Metzgerinnung Aschaffenburg,Marco Häuser,marco.haeuser@haeuser-hra.de,Unterfranken
|
||||||
|
Fleischer-Innung Main-Spessart,Sebastian Bumm,fleischerinnungMSP@gmx.de,Unterfranken
|
||||||
|
Fleischer-Innung Main-Spessart,Sebastian Bumm,metzgerei-bumm@t-online.de,Unterfranken
|
||||||
|
Metzgerinnung Miltenberg,N/A,j.neuberger@t-online.de,Unterfranken
|
||||||
|
Metzgerinnung Main-Rhön,"Jürgen Straub, Sonja Grob",innung@fleischerring.de,Unterfranken
|
||||||
|
Metzgerinnung Main-Rhön,Barbara Fink,metzgerei_dros_fladungen@outlook.de,Unterfranken
|
||||||
|
Metzger-Innung Würzburg,N/A,horst@schoemig.eu,Unterfranken
|
||||||
|
Metzger-Innung Würzburg,Horst Schömig,horst.schoemig@arcor.de,Unterfranken
|
||||||
|
Metzger-Innung Würzburg,Horst Schömig,josef.bock60@gmail.com,Unterfranken
|
||||||
|
Metzger-Innung Würzburg,Michael Heigel,heigel@heigel.de,Unterfranken
|
||||||
|
Metzger-Innung Würzburg,Hermann Noske,mail@sofa-shop.de,Unterfranken
|
||||||
|
Metzger-Innung Würzburg,Hermann Noske,shk-aschaffenburg@t-online.de,Unterfranken
|
||||||
|
Metzger-Innung Würzburg,Christoph Winkler,c.winkler@cw-haustechnik.de,Unterfranken
|
||||||
|
Metzger-Innung Würzburg,Christoph Winkler,innung-kitzingen@freenet.de,Unterfranken
|
||||||
|
Metzger-Innung Würzburg,Thomas Lößlein,thomas_loesslein@t-online.de,Unterfranken
|
||||||
|
Metzger-Innung Würzburg,Thomas Lößlein,info@shk-main-spessart.de,Unterfranken
|
||||||
|
Metzger-Innung Würzburg,Johannes Reber,info@shk-schweinfurt.de,Unterfranken
|
||||||
|
Metzger-Innung Würzburg,Heinz Schuchbauer,innung.shk@t-online.de,Unterfranken
|
||||||
|
Metzger-Innung Würzburg,Werner Rath,info@dellers-werkstatt.de,Unterfranken
|
||||||
|
Metzger-Innung Würzburg,Norbert Borst,service@norbert-borst.de,Unterfranken
|
||||||
|
Metzger-Innung Würzburg,Norbert Borst,wt@reichert-betten.de,Unterfranken
|
||||||
|
Metzger-Innung Würzburg,Werner Tausch,info@schreiner-rhoen-grabfeld.de,Unterfranken
|
||||||
|
Metzger-Innung Würzburg,Michael Werner,michael@werner-objekteinrichtungen.de,Unterfranken
|
||||||
|
Metzger-Innung Würzburg,Horst Zitterbart,schreinerei.zitterbart@t-online.de,Unterfranken
|
||||||
|
Metzger-Innung Würzburg,Horst Zitterbart,info@schreinerinnung-mainfranken.de,Unterfranken
|
||||||
|
Metzger-Innung Würzburg,Thomas Heußlein,info@schreinerei-heusslein.de,Unterfranken
|
||||||
|
Metzger-Innung Würzburg,Thomas Heußlein,l.emge@t-online.de,Unterfranken
|
||||||
|
Metzger-Innung Würzburg,Sebastian Ludwig,info@geisendoerfer-online.de,Unterfranken
|
||||||
|
Metzger-Innung Würzburg,Sebastian Ludwig,m.graf@hwk-ufr.de,Unterfranken
|
||||||
|
Metzger-Innung Würzburg,Klaus Imhof,kontakt@juwelier-imhof.de,Unterfranken
|
||||||
|
Zimmerer-Innung Aschaffenburg - Miltenberg,Theresa Breunig,info@zimmerer-aschaffenburg-miltenberg.de,Unterfranken
|
||||||
|
Zimmerer-Innung Aschaffenburg - Miltenberg,Jürgen Pfarr,info@zimmerei-juergen-pfarr.de,Unterfranken
|
||||||
|
Zimmerer-Innung Bad Neustadt,N/A,info@holzbau-eyrich.de,Unterfranken
|
||||||
|
Zimmerer-Innung Main-Spessart,Ulrike Feser,info@zimmerer-wuerzburg-kitzingen.de,Unterfranken
|
||||||
|
Zimmerer-Innung Main-Spessart,Volker Schäfer,info@schaefer-halsbach.de,Unterfranken
|
||||||
|
Zimmerer-Innung Schweinfurt-Haßberge,Marion Reichhold,zimmerei_klaus_treiber@gmx.de,Unterfranken
|
||||||
|
Zimmerer-Innung Würzburg - Kitzingen,Hermann Lang,lang@zimmerer-wuerzburg-kitzingen.de,Unterfranken
|
||||||
|
Kreishandwerkerschaft Aschaffenburg,Claudia Find,info@khw-ab.de,Unterfranken
|
||||||
|
Kreishandwerkerschaft Bad Kissingen,Ulrike Lochner-Erhard,lochner-baudekoration-gmbh@t-online.de,Unterfranken
|
||||||
|
Kreishandwerkerschaft Haßberge,Udo Merz,u.merz@haustechnik-merz.de,Unterfranken
|
||||||
|
Kreishandwerkerschaft Kitzingen,Elisabeth Hofmann,khw.kitzingen@gmail.com,Unterfranken
|
||||||
|
Kreishandwerkerschaft Kitzingen,Monika Henneberger,monika.henneberger@t-online.de,Unterfranken
|
||||||
|
Kreishandwerkerschaft Main-Spessart,Petra Stegerwald,petra@stegerwald.de,Unterfranken
|
||||||
|
Kreishandwerkerschaft Miltenberg,N/A,kreishandwerker.mil@gmail.com,Unterfranken
|
||||||
|
Kreishandwerkerschaft Miltenberg,Monique Haas,monique3003@arcor.de,Unterfranken
|
||||||
|
Kreishandwerkerschaft Rhön-Grabfeld,N/A,khwsch-rhoen-grabfeld@mail.de,Unterfranken
|
||||||
|
Kreishandwerkerschaft Rhön-Grabfeld,Bruno Werner,info@werner-objekteinrichtungen.de,Unterfranken
|
||||||
|
E-Mail: rapp@Kreishandwerkerschaft-sw.de,N/A,rapp@Kreishandwerkerschaft-sw.de,Unterfranken
|
||||||
|
Kreishandwerkerschaft Würzburg,Sandra Köller,info@kreishandwerkerschaft-wuerzburg.de,Unterfranken
|
||||||
|
Kreishandwerkerschaft Würzburg,Martin Strobl,innung@elektro-strobl.de,Unterfranken
|
||||||
|
Baugewerks-Innung Duisburg,Volker Blastik,info@baugewerksinnung-duisburg.de,Düsseldorf/Surrounding
|
||||||
|
Graveur- und Metallbildner-Innung Rhein-Ruhr,Till Esser,info@kh-mo.de,Düsseldorf/Surrounding
|
||||||
|
Metall-Innung Essen,Björn Bergmann,info@metallhandwerk-essen.de,Düsseldorf/Surrounding
|
||||||
|
Innung für Metallhandwerk des Kreises Kleve,Johannes Flinterhoff,info@kh-kleve.de,Düsseldorf/Surrounding
|
||||||
|
Bau-Innung Kreis Mettmann,Thomas Grünendahl,info@handwerk-me.de,Düsseldorf/Surrounding
|
||||||
|
Innung für Metalltechnik Kreis Mettmann,Reiner Schumacher,info@handwerk-me.de,Düsseldorf/Surrounding
|
||||||
|
Bau-Innung Mönchengladbach,Dipl.-Ing. Frank Bühler,info@kh-mg.de,Düsseldorf/Surrounding
|
||||||
|
Metall-Innung Mönchengladbach-Rheydt,Adam Sautner,info@kh-mg.de,Düsseldorf/Surrounding
|
||||||
|
Baugewerks-Innung Mülheim an der Ruhr-Oberhausen,Jürgen Bunk,info@kh-mo.de,Düsseldorf/Surrounding
|
||||||
|
Innung für Metallhandwerke Mülheim an der Ruhr-Oberhausen,Johannes Arnzen,info@metallbauinnung-mh-ob.de,Düsseldorf/Surrounding
|
||||||
|
Bau-Innung Neuss-Viersen,Thomas Goldmann,info@kh-niederrhein.de,Düsseldorf/Surrounding
|
||||||
|
Innung für Land- und Baumaschinentechnik Niederrhein,Franz-Josef Schulte,info@kh-niederrhein.de,Düsseldorf/Surrounding
|
||||||
|
Metall-Innung Niederrhein,Klaus Caris,info@kh-niederrhein.de,Düsseldorf/Surrounding
|
||||||
|
Bau-Innung Remscheid,Carsten Hof,info@handwerk-remscheid.de,Düsseldorf/Surrounding
|
||||||
|
Fachinnung Metall- und Graviertechnik Remscheid,Uwe Wiegand,info@handwerk-remscheid.de,Düsseldorf/Surrounding
|
||||||
|
Bau-Innung Wuppertal-Solingen,Marcus Koch,info@bauinnung-wuppertal.de,Düsseldorf/Surrounding
|
||||||
|
Innung der Metallhandwerke (Solingen),Thomas Blau,info@handwerk-sgw.de,Düsseldorf/Surrounding
|
||||||
|
Innung für Metallhandwerke Wuppertal,Christian Flüss,info@handwerk-sgw.de,Düsseldorf/Surrounding
|
||||||
|
Baugewerks-Innung des Kreises Wesel,Gerhard Landwehrs,info@khwesel.de,Düsseldorf/Surrounding
|
||||||
|
Metall-Innung des Kreises Wesel,Rainer Theunissen,info@metallinnung-wesel.de,Düsseldorf/Surrounding
|
||||||
|
Fotografen-Innung Düsseldorf-Aachen-Köln,Guido de Nardo,info@Fotografen-DUS.de,Düsseldorf/Surrounding
|
||||||
|
Maßschneider-Innung Düsseldorf,Sandra Gronemeier,info@mass-schneider-innung.de,Düsseldorf/Surrounding
|
||||||
|
Musikinstrumentenmacher-Innung Nordrhein,Christoph Böttcher,info@musikinstrumentenmacher-innung.de,Düsseldorf/Surrounding
|
||||||
|
Straßen- und Tiefbauer-Innung Düsseldorf,André Grimmert,info@strassenbauer-innung-duesseldorf.de,Düsseldorf/Surrounding
|
||||||
|
Drucker- (Buchdrucker-)- und Buchbinder-Innung Duisburg,Bodo H. Oppenberg,info@handwerk-duisburg.de,Düsseldorf/Surrounding
|
||||||
|
Glasapparatebauer-Innung Duisburg,Dieter Verhees,hippler@handwerk-duisburg.de,Düsseldorf/Surrounding
|
||||||
|
Innung des modeschaffenden Handwerks Duisburg,Charlotte Noé,info@handwerk-duisburg.de,Düsseldorf/Surrounding
|
||||||
|
Raumausstatter-Innung Rhein-Ruhr,Kay Piller,info@handwerk-duisburg.de,Düsseldorf/Surrounding
|
||||||
|
Zweiradmechaniker-Innung Rhein-Ruhr,Erwin Lohrmann,info@handwerk-duisburg.de,Düsseldorf/Surrounding
|
||||||
|
Verband der Berufsfotografen Ruhr,Andreas Köhring,ak@koehring-fotografie.de,Düsseldorf/Surrounding
|
||||||
|
|
|
@ -0,0 +1,34 @@
|
||||||
|
region,organisation,url,kontaktperson,email,facebook,instagram,linkedin,twitter
|
||||||
|
Koeln,Kreishandwerkerschaft Koeln,www.handwerk.koeln,Roberto Lepore (Hauptgeschaeftsfuehrer) / Nicolai Lucks (Kreishandwerksmeister),lepore@handwerk.koeln,,,,
|
||||||
|
Koeln,"Buechsenmacher-Innung Nordrhein, RLP und Saarland",,Klaus-Bernd Liedl (Obermeister),kliedl@t-online.de,,,,
|
||||||
|
Koeln,Fleischer-Innung Koeln,,Astrid Schmitz (Obermeisterin),obermeister@fleischer-koeln.de,,,,
|
||||||
|
Koeln,Glaser-Innung Koeln-Bonn-Aachen,,Anne Bong (Obermeisterin),mail@glas-bong.de,,,,
|
||||||
|
Koeln,"Juwelier-, Gold- und Silberschmiede-Innung Koeln",,Ingo Telkmann (Obermeister),info@sotos-schmuck.de,,,,
|
||||||
|
Koeln,Innung Farbe Koeln,,Sebastian Epe (Obermeister),s.epe@epe-maler.de,,,,
|
||||||
|
Koeln,Innung des Massschneiderhandwerks Koeln / Textileiniger-Innung Koeln/Bonn,,Thomas Wien-Pegelow (Obermeister),twp.koeln@gmail.com,,,,
|
||||||
|
Koeln,Innung fuer Metalltechnik Koeln,,Sascha Franke (Obermeister),info@van-broek.de,,,,
|
||||||
|
Koeln,Innung fuer Orthopaedie-Technik Koeln,,Sebastian Malzkorn (Obermeister),sebastian@malzkorn.at,,,,
|
||||||
|
Koeln,Raumausstatter-Innung Koeln,,Diana Goeddertz (Obermeisterin),info@diana-breidenbach.de,,,,
|
||||||
|
Koeln,Innung Koeln Rollladen und Sonnenschutz,,Andre Urban (Obermeister),info@rhp-online.de,,,,
|
||||||
|
Koeln,Stuckateur-Innung Koeln - Ausbau + Fassade,,Sarah M. Rettig (Obermeisterin),s.rettig@hhhuerth.de,,,,
|
||||||
|
Koeln,Werbetechniker-Innung Koeln - Bonn - Aachen,,Markus Boecker (Obermeister),info@werbetechnik-baecker.de,,,,
|
||||||
|
Koeln,Augenoptiker-Innung Koeln-Aachen,www.optikerinnung.de/aoi/,Hans Josef Schuemmer (Obermeister),info@optikerinnung.de,,,https://www.linkedin.com/company/aov-nrw,
|
||||||
|
Koeln,Dachdecker- und Zimmerer-Innung Koeln,www.dachdecker-innung-koeln.de,Oliver Miesen (Obermeister) / Bettina Dietrich (Geschaeftsfuehrerin),e-mail@dachdecker-innung-koeln.de,,,,
|
||||||
|
Koeln,Elektroinnung Koeln,www.elektroinnungkoeln.de,Ralf Janowski (Obermeister),info@elektroinnungkoeln.de,https://www.facebook.com/ELEKTROINNUNG-K,https://www.instagram.com/elektroinnungkoeln/,,
|
||||||
|
Koeln,Friseur-Innung Koeln,www.kopfarbeit-koeln.de,Mike Engels (Obermeister) / Julia Barth (Geschaeftsfuehrerin),info@kopfarbeit-koeln.de,,,,
|
||||||
|
Koeln,Innung des Gebaeudereiniger-Handwerks Koeln-Aachen,www.gebaeudereiniger-koeln-aachen.de,Detlef Ptak (Obermeister) / Jennifer Schramm (Geschaeftsfuehrerin),info@gebaeudereiniger-koeln-aachen.de,,,,
|
||||||
|
Koeln,Bundesinnung fuer das Geruestbauer-Handwerk,www.geruestbauhandwerk.de,Marcus Nachbauer (Bundesinnungsmeister) / Sabrina Luther (Geschaeftsfuehrerin),info@geruestbauhandwerk.de,,,,
|
||||||
|
Koeln,Innung fuer Informationstechnik Koeln/Bonn/Rhein-Sieg/Rhein-Erft,,Nicolay Gassner (Obermeister),n.gassner@koenig-avt.de,,,,
|
||||||
|
Koeln,Karosseriebauer-Innung Koeln,www.karosserie-innungkoeln.de,Oliver Nienhaus (Obermeister) / Claudia Weiler (Geschaeftsfuehrerin),info@karosserie-innungkoeln.de,https://www.facebook.com/KarosseriebauerKoeln/,,,
|
||||||
|
Koeln,Konditoren-Innung Koeln - Bonn,,Rudolf Schoener (Obermeister),info@cafe-schoener.de,,,,
|
||||||
|
Duesseldorf,Augenoptiker-Innung Duessel-Rhein-Ruhr,,Jens Schulz (Obermeister),,,,,
|
||||||
|
Duesseldorf,Verband des Rheinischen Baeckerhandwerks,,Henning Funke (GF) / Johannes Dackweiler (Obermeister),,,,,
|
||||||
|
Duesseldorf,Baugewerbe-Innung Duesseldorf,,Peter Szemenyei (GF) / Christoph Morick (Obermeister),,,,,
|
||||||
|
Duesseldorf,Bestatter-Innung NRW,,Christian Jaeger (GF) / Frank Wesemann (Obermeister),,,,,
|
||||||
|
Duesseldorf,Fleischer-Innung Duesseldorf-Mettmann-Solingen,,Daniela van der Valk (GF) / Lutz Kluke (Obermeister),,,,,
|
||||||
|
Duesseldorf,Innung des Kraftfahrzeuggewerbes Duesseldorf,,Sven Gustavson (GF) / Hermann Goertz (Obermeister),,https://www.facebook.com/kfzgewerbenrw/,https://www.instagram.com/kfznrw/,,
|
||||||
|
Duesseldorf,Innung fuer Orthopaedie-Schuhtechnik Rheinland/Westfalen,,Irene Zamponi (GF) / Philipp Radtke (Obermeister),,,,,
|
||||||
|
Duesseldorf,Innung fuer Sanitaer- und Heizungstechnik Duesseldorf,,Horst Jansen (GF) / Hans Werner Eschrich (Obermeister),,,,,
|
||||||
|
Duesseldorf,Schornsteinfeger-Innung Regierungsbezirk Duesseldorf,,Marcus Doerenkamp (GF),,,,,
|
||||||
|
Duesseldorf,Stukkatuer-Innung Wuppertal und Kreis Mettmann,,Hermann Schulte-Hiltrop (HGF) / Wolfgang Wuestenhagen (Obermeister),,,,,
|
||||||
|
Duesseldorf,Zahntechniker-Innung Duesseldorf,,Michael Knittel (GF) / Dominik Kruchen (Obermeister),,,,,
|
||||||
|
|
|
@ -0,0 +1,143 @@
|
||||||
|
Firm/Innung,Contact Person,Email,Region
|
||||||
|
Bäckerinnung Bayerischer Untermain,N/A,v.hench@baeckerinnung-bayerischer-untermain.de,Unterfranken
|
||||||
|
Bäckerinnung Bad Kissingen - Rhön-Grabfeld,Petra Schwab,khw-kg@t-online.de,Unterfranken
|
||||||
|
Bäckerinnung Bad Kissingen - Rhön-Grabfeld,Ullrich Amthor,info@baeckerei-amthor.com,Unterfranken
|
||||||
|
Bäckerinnung Kitzingen,Tilo Brönner,tilo-br@t-online.de,Unterfranken
|
||||||
|
Bäckerinnung Schweinfurt - Haßberge,Brigitte Rapp,rapp@kreishandwerkerschaft-sw.de,Unterfranken
|
||||||
|
Bäckerinnung Schweinfurt - Haßberge,Gerhard Götz,baeckerei-goetz@web.de,Unterfranken
|
||||||
|
Bäckerinnung Mainfranken,Christine Winterbauer,christine.winterbauer@baeckerbuchstelle.de,Unterfranken
|
||||||
|
Bäckerinnung Mainfranken,Marcel Scherg,scherge.beck@t-online.de,Unterfranken
|
||||||
|
Bauinnung Aschaffenburg,Nina Emeneth,info@bauinnung-aschaffenburg.de,Unterfranken
|
||||||
|
Bauinnung Aschaffenburg,Felix Englert,felix.englert@bauinnung-aschaffenburg.de,Unterfranken
|
||||||
|
Bauinnung Bad Kissingen / Rhön-Grabfeld,Stefan Goos,stefan.goos@zehe-gmbh.de,Unterfranken
|
||||||
|
Bau-Innung Schweinfurt,Ramona Ziegler,info@bauinnung-schweinfurt.de,Unterfranken
|
||||||
|
Bau-Innung Schweinfurt,Karl Böhner,boehner-bau@t-online.de,Unterfranken
|
||||||
|
Bauinnung Mainfranken - Würzburg,Manfred Dallner,baugewerbe@lbb-unterfranken.de,Unterfranken
|
||||||
|
Bauinnung Mainfranken - Würzburg,Ralf Stegmeier,trendbaugmbh@aol.com,Unterfranken
|
||||||
|
Innung des Bekleidungshandwerks Unterfranken,Nicole Brandler,info@innung-unterfranken.de,Unterfranken
|
||||||
|
Innung des Bekleidungshandwerks Unterfranken,Friedrun Schlagbauer-Werner,info@schneiderin-wuerzburg.de,Unterfranken
|
||||||
|
Innung des Bekleidungshandwerks Unterfranken,Friedrun Schlagbauer-Werner,info@private-brauereien-bayern.de,Unterfranken
|
||||||
|
Innung des Bekleidungshandwerks Unterfranken,Josef Göller,geschaeftsleitung@brauerei-goeller.de,Unterfranken
|
||||||
|
Innung des Bekleidungshandwerks Unterfranken,Josef Göller,lukas.thalheimer@thalheimer.de,Unterfranken
|
||||||
|
Innung des Bekleidungshandwerks Unterfranken,Lukas Thalheimer,info@mein-dachdecker.com,Unterfranken
|
||||||
|
Innung des Bekleidungshandwerks Unterfranken,Timo Markert,info@elektroinnung-bayerischeruntermain.de,Unterfranken
|
||||||
|
Innung des Bekleidungshandwerks Unterfranken,Edwin Palzer,info@elektroinnung-hassberge.de,Unterfranken
|
||||||
|
Innung des Bekleidungshandwerks Unterfranken,Ralf Jooß,info@elektro-jooss.de,Unterfranken
|
||||||
|
Innung des Bekleidungshandwerks Unterfranken,Ralf Jooß,info@elektroinnung-sw.de,Unterfranken
|
||||||
|
Innung des Bekleidungshandwerks Unterfranken,Rainer Walter-Helk,rwalter-helk@innotech-solar.de,Unterfranken
|
||||||
|
Innung des Bekleidungshandwerks Unterfranken,Rainer Walter-Helk,mailbox@elektro-innung-wuerzburg.de,Unterfranken
|
||||||
|
Innung des Bekleidungshandwerks Unterfranken,Sebastian Seynstahl,se@elektro-seynstahl.de,Unterfranken
|
||||||
|
Berufsfotografen-Innung für Unterfranken,N/A,info@foto-alfen.de,Unterfranken
|
||||||
|
Friseur-Innung Aschaffenburg Stadt und Land,N/A,friseurinnung-aschaffenburg@t-online.de,Unterfranken
|
||||||
|
Friseur-Innung Aschaffenburg Stadt und Land,Corina Bayer,cbm.bayer@t-online.de,Unterfranken
|
||||||
|
Friseur-Innung Haßberge,Heinz Göhr,info@team-art-of-hair.com,Unterfranken
|
||||||
|
Friseurinnung Kitzingen,N/A,sabine.hack71@web.de,Unterfranken
|
||||||
|
Friseurinnung Miltenberg,N/A,info@haarmonique.de,Unterfranken
|
||||||
|
Friseurinnung Main-Rhön,Margit Rosentritt,katharinawalker88@gmx.de,Unterfranken
|
||||||
|
Friseurinnung Main-Rhön,Katharina Walker,info@frank-bauglaserei.de,Unterfranken
|
||||||
|
Kaminkehrer-Innung Unterfranken,N/A,info@kaminkehrerinnung-unterfranken.de,Unterfranken
|
||||||
|
Kaminkehrer-Innung Unterfranken,Benjamin Schreck,benjamin.schreck@t-online.de,Unterfranken
|
||||||
|
Kaminkehrer-Innung Unterfranken,Benjamin Schreck,info@khw-nuernberg.de,Unterfranken
|
||||||
|
Kaminkehrer-Innung Unterfranken,Michael Seidel,info@seidel-karosserie.de,Unterfranken
|
||||||
|
Kfz-Innung Unterfranken,Michael Frank,info@kfz-innung-ufr.de,Unterfranken
|
||||||
|
Kfz-Innung Unterfranken,Roland Hoier,roland.hoier@autohaus-keller.de,Unterfranken
|
||||||
|
Kfz-Innung Unterfranken,Bertram Muth,bertram.muth@outlook.de,Unterfranken
|
||||||
|
Maler,N/A,maler_trageser@gmx.de,Unterfranken
|
||||||
|
Maler,Karlheinz Trageser,uta.kern@kolb-kern.de,Unterfranken
|
||||||
|
Maler,Ansgar Kern,ansgar.kern@kolb-kern.de,Unterfranken
|
||||||
|
Maler,Mathias Stöth,mathias@stoeth-fuchsstadt.de,Unterfranken
|
||||||
|
Maler,Mathias Stöth,obermeister@malerinnung-hassberge.de,Unterfranken
|
||||||
|
Maler,Michael Ott,info@malerinnung-kitzingen.de,Unterfranken
|
||||||
|
Maler,Thomas Wandler,mail@maler-wandler.de,Unterfranken
|
||||||
|
Maler,Thomas Wandler,info@farbe-miltenberg.de,Unterfranken
|
||||||
|
Maler,Jan Becker,info@malerinnung-rg.de,Unterfranken
|
||||||
|
Malerinnung Schweinfurt Stadt- und Land,Andreas Spath,info@spath-tuencher.de,Unterfranken
|
||||||
|
Malerinnung Schweinfurt Stadt- und Land,Andreas Spath,info@malerinnung-wuerzburg.de,Unterfranken
|
||||||
|
Malerinnung Schweinfurt Stadt- und Land,Peter Killinger,info@manufatture-colori.de,Unterfranken
|
||||||
|
Malerinnung Schweinfurt Stadt- und Land,Peter Killinger,info@innung-metallbau-feinwerktechnik.de,Unterfranken
|
||||||
|
Malerinnung Schweinfurt Stadt- und Land,Matthias Kreß,m.kress@wassermannkress.de,Unterfranken
|
||||||
|
Metall-Innung Bad Kissingen/Rhön-Grabfeld,Klaus Engelmann,info@metallbau-engelmann.de,Unterfranken
|
||||||
|
Metall-Innung Bad Kissingen/Rhön-Grabfeld,René Dauelsberg,rd@mpi-dauelsberg.de,Unterfranken
|
||||||
|
Metall-Innung Bad Kissingen/Rhön-Grabfeld,René Dauelsberg,info@metallinnung-mainfranken.de,Unterfranken
|
||||||
|
Metall-Innung Bad Kissingen/Rhön-Grabfeld,Detlef Lurz,detlef.lurz@lurz-metalltec.de,Unterfranken
|
||||||
|
Metzgerinnung Aschaffenburg,Dagobert Pfarr,metzgerei-pfarr@t-online.de,Unterfranken
|
||||||
|
Metzgerinnung Aschaffenburg,Marco Häuser,marco.haeuser@haeuser-hra.de,Unterfranken
|
||||||
|
Fleischer-Innung Main-Spessart,Sebastian Bumm,fleischerinnungMSP@gmx.de,Unterfranken
|
||||||
|
Fleischer-Innung Main-Spessart,Sebastian Bumm,metzgerei-bumm@t-online.de,Unterfranken
|
||||||
|
Metzgerinnung Miltenberg,N/A,j.neuberger@t-online.de,Unterfranken
|
||||||
|
Metzgerinnung Main-Rhön,"Jürgen Straub, Sonja Grob",innung@fleischerring.de,Unterfranken
|
||||||
|
Metzgerinnung Main-Rhön,Barbara Fink,metzgerei_dros_fladungen@outlook.de,Unterfranken
|
||||||
|
Metzger-Innung Würzburg,N/A,horst@schoemig.eu,Unterfranken
|
||||||
|
Metzger-Innung Würzburg,Horst Schömig,horst.schoemig@arcor.de,Unterfranken
|
||||||
|
Metzger-Innung Würzburg,Horst Schömig,josef.bock60@gmail.com,Unterfranken
|
||||||
|
Metzger-Innung Würzburg,Michael Heigel,heigel@heigel.de,Unterfranken
|
||||||
|
Metzger-Innung Würzburg,Hermann Noske,mail@sofa-shop.de,Unterfranken
|
||||||
|
Metzger-Innung Würzburg,Hermann Noske,shk-aschaffenburg@t-online.de,Unterfranken
|
||||||
|
Metzger-Innung Würzburg,Christoph Winkler,c.winkler@cw-haustechnik.de,Unterfranken
|
||||||
|
Metzger-Innung Würzburg,Christoph Winkler,innung-kitzingen@freenet.de,Unterfranken
|
||||||
|
Metzger-Innung Würzburg,Thomas Lößlein,thomas_loesslein@t-online.de,Unterfranken
|
||||||
|
Metzger-Innung Würzburg,Thomas Lößlein,info@shk-main-spessart.de,Unterfranken
|
||||||
|
Metzger-Innung Würzburg,Johannes Reber,info@shk-schweinfurt.de,Unterfranken
|
||||||
|
Metzger-Innung Würzburg,Heinz Schuchbauer,innung.shk@t-online.de,Unterfranken
|
||||||
|
Metzger-Innung Würzburg,Werner Rath,info@dellers-werkstatt.de,Unterfranken
|
||||||
|
Metzger-Innung Würzburg,Norbert Borst,service@norbert-borst.de,Unterfranken
|
||||||
|
Metzger-Innung Würzburg,Norbert Borst,wt@reichert-betten.de,Unterfranken
|
||||||
|
Metzger-Innung Würzburg,Werner Tausch,info@schreiner-rhoen-grabfeld.de,Unterfranken
|
||||||
|
Metzger-Innung Würzburg,Michael Werner,michael@werner-objekteinrichtungen.de,Unterfranken
|
||||||
|
Metzger-Innung Würzburg,Horst Zitterbart,schreinerei.zitterbart@t-online.de,Unterfranken
|
||||||
|
Metzger-Innung Würzburg,Horst Zitterbart,info@schreinerinnung-mainfranken.de,Unterfranken
|
||||||
|
Metzger-Innung Würzburg,Thomas Heußlein,info@schreinerei-heusslein.de,Unterfranken
|
||||||
|
Metzger-Innung Würzburg,Thomas Heußlein,l.emge@t-online.de,Unterfranken
|
||||||
|
Metzger-Innung Würzburg,Sebastian Ludwig,info@geisendoerfer-online.de,Unterfranken
|
||||||
|
Metzger-Innung Würzburg,Sebastian Ludwig,m.graf@hwk-ufr.de,Unterfranken
|
||||||
|
Metzger-Innung Würzburg,Klaus Imhof,kontakt@juwelier-imhof.de,Unterfranken
|
||||||
|
Zimmerer-Innung Aschaffenburg - Miltenberg,Theresa Breunig,info@zimmerer-aschaffenburg-miltenberg.de,Unterfranken
|
||||||
|
Zimmerer-Innung Aschaffenburg - Miltenberg,Jürgen Pfarr,info@zimmerei-juergen-pfarr.de,Unterfranken
|
||||||
|
Zimmerer-Innung Bad Neustadt,N/A,info@holzbau-eyrich.de,Unterfranken
|
||||||
|
Zimmerer-Innung Main-Spessart,Ulrike Feser,info@zimmerer-wuerzburg-kitzingen.de,Unterfranken
|
||||||
|
Zimmerer-Innung Main-Spessart,Volker Schäfer,info@schaefer-halsbach.de,Unterfranken
|
||||||
|
Zimmerer-Innung Schweinfurt-Haßberge,Marion Reichhold,zimmerei_klaus_treiber@gmx.de,Unterfranken
|
||||||
|
Zimmerer-Innung Würzburg - Kitzingen,Hermann Lang,lang@zimmerer-wuerzburg-kitzingen.de,Unterfranken
|
||||||
|
Kreishandwerkerschaft Aschaffenburg,Claudia Find,info@khw-ab.de,Unterfranken
|
||||||
|
Kreishandwerkerschaft Bad Kissingen,Ulrike Lochner-Erhard,lochner-baudekoration-gmbh@t-online.de,Unterfranken
|
||||||
|
Kreishandwerkerschaft Haßberge,Udo Merz,u.merz@haustechnik-merz.de,Unterfranken
|
||||||
|
Kreishandwerkerschaft Kitzingen,Elisabeth Hofmann,khw.kitzingen@gmail.com,Unterfranken
|
||||||
|
Kreishandwerkerschaft Kitzingen,Monika Henneberger,monika.henneberger@t-online.de,Unterfranken
|
||||||
|
Kreishandwerkerschaft Main-Spessart,Petra Stegerwald,petra@stegerwald.de,Unterfranken
|
||||||
|
Kreishandwerkerschaft Miltenberg,N/A,kreishandwerker.mil@gmail.com,Unterfranken
|
||||||
|
Kreishandwerkerschaft Miltenberg,Monique Haas,monique3003@arcor.de,Unterfranken
|
||||||
|
Kreishandwerkerschaft Rhön-Grabfeld,N/A,khwsch-rhoen-grabfeld@mail.de,Unterfranken
|
||||||
|
Kreishandwerkerschaft Rhön-Grabfeld,Bruno Werner,info@werner-objekteinrichtungen.de,Unterfranken
|
||||||
|
E-Mail: rapp@Kreishandwerkerschaft-sw.de,N/A,rapp@Kreishandwerkerschaft-sw.de,Unterfranken
|
||||||
|
Kreishandwerkerschaft Würzburg,Sandra Köller,info@kreishandwerkerschaft-wuerzburg.de,Unterfranken
|
||||||
|
Kreishandwerkerschaft Würzburg,Martin Strobl,innung@elektro-strobl.de,Unterfranken
|
||||||
|
Baugewerks-Innung Duisburg,Volker Blastik,info@baugewerksinnung-duisburg.de,Düsseldorf/Surrounding
|
||||||
|
Graveur- und Metallbildner-Innung Rhein-Ruhr,Till Esser,info@kh-mo.de,Düsseldorf/Surrounding
|
||||||
|
Metall-Innung Essen,Björn Bergmann,info@metallhandwerk-essen.de,Düsseldorf/Surrounding
|
||||||
|
Innung für Metallhandwerk des Kreises Kleve,Johannes Flinterhoff,info@kh-kleve.de,Düsseldorf/Surrounding
|
||||||
|
Bau-Innung Kreis Mettmann,Thomas Grünendahl,info@handwerk-me.de,Düsseldorf/Surrounding
|
||||||
|
Innung für Metalltechnik Kreis Mettmann,Reiner Schumacher,info@handwerk-me.de,Düsseldorf/Surrounding
|
||||||
|
Bau-Innung Mönchengladbach,Dipl.-Ing. Frank Bühler,info@kh-mg.de,Düsseldorf/Surrounding
|
||||||
|
Metall-Innung Mönchengladbach-Rheydt,Adam Sautner,info@kh-mg.de,Düsseldorf/Surrounding
|
||||||
|
Baugewerks-Innung Mülheim an der Ruhr-Oberhausen,Jürgen Bunk,info@kh-mo.de,Düsseldorf/Surrounding
|
||||||
|
Innung für Metallhandwerke Mülheim an der Ruhr-Oberhausen,Johannes Arnzen,info@metallbauinnung-mh-ob.de,Düsseldorf/Surrounding
|
||||||
|
Bau-Innung Neuss-Viersen,Thomas Goldmann,info@kh-niederrhein.de,Düsseldorf/Surrounding
|
||||||
|
Innung für Land- und Baumaschinentechnik Niederrhein,Franz-Josef Schulte,info@kh-niederrhein.de,Düsseldorf/Surrounding
|
||||||
|
Metall-Innung Niederrhein,Klaus Caris,info@kh-niederrhein.de,Düsseldorf/Surrounding
|
||||||
|
Bau-Innung Remscheid,Carsten Hof,info@handwerk-remscheid.de,Düsseldorf/Surrounding
|
||||||
|
Fachinnung Metall- und Graviertechnik Remscheid,Uwe Wiegand,info@handwerk-remscheid.de,Düsseldorf/Surrounding
|
||||||
|
Bau-Innung Wuppertal-Solingen,Marcus Koch,info@bauinnung-wuppertal.de,Düsseldorf/Surrounding
|
||||||
|
Innung der Metallhandwerke (Solingen),Thomas Blau,info@handwerk-sgw.de,Düsseldorf/Surrounding
|
||||||
|
Innung für Metallhandwerke Wuppertal,Christian Flüss,info@handwerk-sgw.de,Düsseldorf/Surrounding
|
||||||
|
Baugewerks-Innung des Kreises Wesel,Gerhard Landwehrs,info@khwesel.de,Düsseldorf/Surrounding
|
||||||
|
Metall-Innung des Kreises Wesel,Rainer Theunissen,info@metallinnung-wesel.de,Düsseldorf/Surrounding
|
||||||
|
Fotografen-Innung Düsseldorf-Aachen-Köln,Guido de Nardo,info@Fotografen-DUS.de,Düsseldorf/Surrounding
|
||||||
|
Maßschneider-Innung Düsseldorf,Sandra Gronemeier,info@mass-schneider-innung.de,Düsseldorf/Surrounding
|
||||||
|
Musikinstrumentenmacher-Innung Nordrhein,Christoph Böttcher,info@musikinstrumentenmacher-innung.de,Düsseldorf/Surrounding
|
||||||
|
Straßen- und Tiefbauer-Innung Düsseldorf,André Grimmert,info@strassenbauer-innung-duesseldorf.de,Düsseldorf/Surrounding
|
||||||
|
Drucker- (Buchdrucker-)- und Buchbinder-Innung Duisburg,Bodo H. Oppenberg,info@handwerk-duisburg.de,Düsseldorf/Surrounding
|
||||||
|
Glasapparatebauer-Innung Duisburg,Dieter Verhees,hippler@handwerk-duisburg.de,Düsseldorf/Surrounding
|
||||||
|
Innung des modeschaffenden Handwerks Duisburg,Charlotte Noé,info@handwerk-duisburg.de,Düsseldorf/Surrounding
|
||||||
|
Raumausstatter-Innung Rhein-Ruhr,Kay Piller,info@handwerk-duisburg.de,Düsseldorf/Surrounding
|
||||||
|
Zweiradmechaniker-Innung Rhein-Ruhr,Erwin Lohrmann,info@handwerk-duisburg.de,Düsseldorf/Surrounding
|
||||||
|
Verband der Berufsfotografen Ruhr,Andreas Köhring,ak@koehring-fotografie.de,Düsseldorf/Surrounding
|
||||||
|
|
|
@ -0,0 +1,146 @@
|
||||||
|
Innung,Name,Email,Region
|
||||||
|
"gen und Kreishandwerkerschaften, die ihren Sitz im Regierungsbezirk Unter-",Veronika Hench,v.hench@baeckerinnung-bayerischer-untermain.de,Unterfranken
|
||||||
|
"gen und Kreishandwerkerschaften, die ihren Sitz im Regierungsbezirk Unter-",Veronika Hench,khw-kg@t-online.de,Unterfranken
|
||||||
|
"gen und Kreishandwerkerschaften, die ihren Sitz im Regierungsbezirk Unter-",Ullrich Amthor,info@baeckerei-amthor.com,Unterfranken
|
||||||
|
"gen und Kreishandwerkerschaften, die ihren Sitz im Regierungsbezirk Unter-",Tilo Brönner,tilo-br@t-online.de,Unterfranken
|
||||||
|
"gen und Kreishandwerkerschaften, die ihren Sitz im Regierungsbezirk Unter-",Tilo Brönner,rapp@kreishandwerkerschaft-sw.de,Unterfranken
|
||||||
|
"gen und Kreishandwerkerschaften, die ihren Sitz im Regierungsbezirk Unter-",Gerhard Götz,baeckerei-goetz@web.de,Unterfranken
|
||||||
|
"gen und Kreishandwerkerschaften, die ihren Sitz im Regierungsbezirk Unter-",Gerhard Götz,christine.winterbauer@baeckerbuchstelle.de,Unterfranken
|
||||||
|
"gen und Kreishandwerkerschaften, die ihren Sitz im Regierungsbezirk Unter-",Marcel Scherg,scherge.beck@t-online.de,Unterfranken
|
||||||
|
"gen und Kreishandwerkerschaften, die ihren Sitz im Regierungsbezirk Unter-",Marcel Scherg,info@bauinnung-aschaffenburg.de,Unterfranken
|
||||||
|
"gen und Kreishandwerkerschaften, die ihren Sitz im Regierungsbezirk Unter-",Felix Englert,felix.englert@bauinnung-aschaffenburg.de,Unterfranken
|
||||||
|
"gen und Kreishandwerkerschaften, die ihren Sitz im Regierungsbezirk Unter-",Felix Englert,khw-kg@t-online.de,Unterfranken
|
||||||
|
"gen und Kreishandwerkerschaften, die ihren Sitz im Regierungsbezirk Unter-",Stefan Goos,stefan.goos@zehe-gmbh.de,Unterfranken
|
||||||
|
"gen und Kreishandwerkerschaften, die ihren Sitz im Regierungsbezirk Unter-",Stefan Goos,info@bauinnung-schweinfurt.de,Unterfranken
|
||||||
|
Bau-Innung Schweinfurt,Karl Böhner,boehner-bau@t-online.de,Unterfranken
|
||||||
|
Bau-Innung Schweinfurt,Karl Böhner,baugewerbe@lbb-unterfranken.de,Unterfranken
|
||||||
|
Bau-Innung Schweinfurt,Ralf Stegmeier,trendbaugmbh@aol.com,Unterfranken
|
||||||
|
Bau-Innung Schweinfurt,Ralf Stegmeier,info@innung-unterfranken.de,Unterfranken
|
||||||
|
Innung des Bekleidungshandwerks Unterfranken,Friedrun Schlagbauer-Werner,info@schneiderin-wuerzburg.de,Unterfranken
|
||||||
|
Innung des Bekleidungshandwerks Unterfranken,Friedrun Schlagbauer-Werner,info@private-brauereien-bayern.de,Unterfranken
|
||||||
|
Innung des Bekleidungshandwerks Unterfranken,Josef Göller,geschaeftsleitung@brauerei-goeller.de,Unterfranken
|
||||||
|
Innung des Bekleidungshandwerks Unterfranken,Josef Göller,lukas.thalheimer@thalheimer.de,Unterfranken
|
||||||
|
Innung des Bekleidungshandwerks Unterfranken,Lukas Thalheimer,lukas.thalheimer@thalheimer.de,Unterfranken
|
||||||
|
Innung des Bekleidungshandwerks Unterfranken,Lukas Thalheimer,info@mein-dachdecker.com,Unterfranken
|
||||||
|
Innung des Bekleidungshandwerks Unterfranken,Timo Markert,info@mein-dachdecker.com,Unterfranken
|
||||||
|
Innung des Bekleidungshandwerks Unterfranken,Timo Markert,info@elektroinnung-bayerischeruntermain.de,Unterfranken
|
||||||
|
Innung für Elektro- und Informationstechnik Bayerischer Untermain,Edwin Palzer,info@elektroinnung-hassberge.de,Unterfranken
|
||||||
|
Innung für Elektro- und Informationstechnik Haßberge,Ralf Jooß,info@elektro-jooss.de,Unterfranken
|
||||||
|
Innung für Elektro- und Informationstechnik Haßberge,Ralf Jooß,info@elektroinnung-sw.de,Unterfranken
|
||||||
|
Innung für Elektro- und Informationstechnik Schweinfurt,Rainer Walter-Helk,rwalter-helk@innotech-solar.de,Unterfranken
|
||||||
|
Innung für Elektro- und Informationstechnik Schweinfurt,Rainer Walter-Helk,mailbox@elektro-innung-wuerzburg.de,Unterfranken
|
||||||
|
Innung für Elektro- und Informationstechnik Würzburg,Sebastian Seynstahl,se@elektro-seynstahl.de,Unterfranken
|
||||||
|
Innung für Elektro- und Informationstechnik Würzburg,Sebastian Seynstahl,info@foto-alfen.de,Unterfranken
|
||||||
|
Berufsfotografen-Innung für Unterfranken,Michael Alfen,info@foto-alfen.de,Unterfranken
|
||||||
|
Berufsfotografen-Innung für Unterfranken,Michael Alfen,friseurinnung-aschaffenburg@t-online.de,Unterfranken
|
||||||
|
Friseur-Innung Aschaffenburg Stadt und Land,Corina Bayer,cbm.bayer@t-online.de,Unterfranken
|
||||||
|
Friseur-Innung Aschaffenburg Stadt und Land,Corina Bayer,info@team-art-of-hair.com,Unterfranken
|
||||||
|
Friseur-Innung Haßberge,Oliver Merkl,info@team-art-of-hair.com,Unterfranken
|
||||||
|
Friseur-Innung Haßberge,Oliver Merkl,sabine.hack71@web.de,Unterfranken
|
||||||
|
Friseur-Innung Haßberge,Sabine Hack,sabine.hack71@web.de,Unterfranken
|
||||||
|
Friseur-Innung Haßberge,Sabine Hack,info@haarmonique.de,Unterfranken
|
||||||
|
Friseur-Innung Haßberge,Monique Haas,info@haarmonique.de,Unterfranken
|
||||||
|
Friseur-Innung Haßberge,Monique Haas,rapp@kreishandwerkerschaft-sw.de,Unterfranken
|
||||||
|
Friseur-Innung Haßberge,Margit Rosentritt,katharinawalker88@gmx.de,Unterfranken
|
||||||
|
"Friseur-Innung Würzburg, Main-Spessart und Bad Kissingen",Katharina Walker,katharinawalker88@gmx.de,Unterfranken
|
||||||
|
"Friseur-Innung Würzburg, Main-Spessart und Bad Kissingen",Katharina Walker,info@frank-bauglaserei.de,Unterfranken
|
||||||
|
"Friseur-Innung Würzburg, Main-Spessart und Bad Kissingen",Siegfried Frank,info@frank-bauglaserei.de,Unterfranken
|
||||||
|
"Friseur-Innung Würzburg, Main-Spessart und Bad Kissingen",Siegfried Frank,info@kaminkehrerinnung-unterfranken.de,Unterfranken
|
||||||
|
Kaminkehrer-Innung Unterfranken,Benjamin Schreck,benjamin.schreck@t-online.de,Unterfranken
|
||||||
|
Kaminkehrer-Innung Unterfranken,Benjamin Schreck,info@khw-nuernberg.de,Unterfranken
|
||||||
|
Karosserie- und Fahrzeugtechnik Innung Unterfranken,Michael Seidel,info@seidel-karosserie.de,Unterfranken
|
||||||
|
Karosserie- und Fahrzeugtechnik Innung Unterfranken,Michael Seidel,info@kfz-innung-ufr.de,Unterfranken
|
||||||
|
Kfz-Innung Unterfranken,Roland Hoier,roland.hoier@autohaus-keller.de,Unterfranken
|
||||||
|
Kfz-Innung Unterfranken,Roland Hoier,rapp@kreishandwerkerschaft-sw.de,Unterfranken
|
||||||
|
Innung für Land- und Baumaschinentechnik Unterfranken,Bertram Muth,bertram.muth@outlook.de,Unterfranken
|
||||||
|
Innung für Land- und Baumaschinentechnik Unterfranken,Bertram Muth,maler_trageser@gmx.de,Unterfranken
|
||||||
|
"Maler -, Tüncher- und Lackierer Innung Alzenau",Karlheinz Trageser,maler_trageser@gmx.de,Unterfranken
|
||||||
|
"Maler -, Tüncher- und Lackierer Innung Alzenau",Karlheinz Trageser,uta.kern@kolb-kern.de,Unterfranken
|
||||||
|
Maler- und Lackierer-Innung Aschaffenburg Stadt und Land,Ansgar Kern,ansgar.kern@kolb-kern.de,Unterfranken
|
||||||
|
Maler- und Lackierer-Innung Aschaffenburg Stadt und Land,Ansgar Kern,khw-kg@t-online.de,Unterfranken
|
||||||
|
Maler- und Lackierer-Innung Aschaffenburg Stadt und Land,Mathias Stöth,mathias@stoeth-fuchsstadt.de,Unterfranken
|
||||||
|
Maler- und Lackierer-Innung Aschaffenburg Stadt und Land,Mathias Stöth,obermeister@malerinnung-hassberge.de,Unterfranken
|
||||||
|
Maler- und Lackierer-Innung Aschaffenburg Stadt und Land,Michael Ott,info@malerinnung-kitzingen.de,Unterfranken
|
||||||
|
Maler- und Lackierer-Innung Aschaffenburg Stadt und Land,Thomas Wandler,mail@maler-wandler.de,Unterfranken
|
||||||
|
Maler- und Lackierer-Innung Aschaffenburg Stadt und Land,Thomas Wandler,info@farbe-miltenberg.de,Unterfranken
|
||||||
|
Maler- und Lackierer-Innung Aschaffenburg Stadt und Land,Jan Becker,info@malerinnung-rg.de,Unterfranken
|
||||||
|
"Maler-, Tüncher- und Lackierer-Innung Rhön-Grabfeld",Stefan Neuhöfer,info@malerinnung-rg.de,Unterfranken
|
||||||
|
"Maler-, Tüncher- und Lackierer-Innung Rhön-Grabfeld",Stefan Neuhöfer,rapp@kreishandwerkerschaft-sw.de,Unterfranken
|
||||||
|
"Maler-, Tüncher- und Lackierer-Innung Rhön-Grabfeld",Andreas Spath,info@spath-tuencher.de,Unterfranken
|
||||||
|
"Maler-, Tüncher- und Lackierer-Innung Rhön-Grabfeld",Andreas Spath,info@malerinnung-wuerzburg.de,Unterfranken
|
||||||
|
Maler- und Stuckateur-Innung Würzburg und Main-Spessart,Peter Killinger,info@manufatture-colori.de,Unterfranken
|
||||||
|
Maler- und Stuckateur-Innung Würzburg und Main-Spessart,Peter Killinger,info@innung-metallbau-feinwerktechnik.de,Unterfranken
|
||||||
|
Innung Metallbau- und Feinwerktechnik Bayerischer Untermain,Matthias Kreß,m.kress@wassermannkress.de,Unterfranken
|
||||||
|
Innung Metallbau- und Feinwerktechnik Bayerischer Untermain,Matthias Kreß,khw-kg@t-online.de,Unterfranken
|
||||||
|
Metall-Innung Bad Kissingen/Rhön-Grabfeld,Klaus Engelmann,info@metallbau-engelmann.de,Unterfranken
|
||||||
|
Metall-Innung Bad Kissingen/Rhön-Grabfeld,Klaus Engelmann,rapp@kreishandwerkerschaft-sw.de,Unterfranken
|
||||||
|
Metall-Innung Bad Kissingen/Rhön-Grabfeld,René Dauelsberg,rd@mpi-dauelsberg.de,Unterfranken
|
||||||
|
Metall-Innung Bad Kissingen/Rhön-Grabfeld,René Dauelsberg,info@metallinnung-mainfranken.de,Unterfranken
|
||||||
|
Metall-Innung Bad Kissingen/Rhön-Grabfeld,Detlef Lurz,detlef.lurz@lurz-metalltec.de,Unterfranken
|
||||||
|
Metall-Innung Bad Kissingen/Rhön-Grabfeld,Detlef Lurz,metzgerei-pfarr@t-online.de,Unterfranken
|
||||||
|
Metall-Innung Bad Kissingen/Rhön-Grabfeld,Marco Häuser,marco.haeuser@haeuser-hra.de,Unterfranken
|
||||||
|
Metall-Innung Bad Kissingen/Rhön-Grabfeld,Marco Häuser,fleischerinnungMSP@gmx.de,Unterfranken
|
||||||
|
Metall-Innung Bad Kissingen/Rhön-Grabfeld,Marco Häuser,metzgerei-bumm@t-online.de,Unterfranken
|
||||||
|
Metall-Innung Bad Kissingen/Rhön-Grabfeld,Marco Häuser,j.neuberger@t-online.de,Unterfranken
|
||||||
|
Fleischer-Innung Main-Spessart,Josef Neuberger,j.neuberger@t-online.de,Unterfranken
|
||||||
|
Fleischer-Innung Main-Spessart,Josef Neuberger,innung@fleischerring.de,Unterfranken
|
||||||
|
Fleischer-Innung Main-Spessart,Barbara Fink,metzgerei_dros_fladungen@outlook.de,Unterfranken
|
||||||
|
Fleischer-Innung Main-Spessart,Barbara Fink,horst@schoemig.eu,Unterfranken
|
||||||
|
Metzger-Innung Würzburg,Horst Schömig,horst.schoemig@arcor.de,Unterfranken
|
||||||
|
Metzger-Innung Würzburg,Horst Schömig,josef.bock60@gmail.com,Unterfranken
|
||||||
|
Unterfränkische Ofen- und Luftheizungsbauer-Innung,Michael Heigel,heigel@heigel.de,Unterfranken
|
||||||
|
Unterfränkische Ofen- und Luftheizungsbauer-Innung,Michael Heigel,rapp@kreishandwerkerschaft-sw.de,Unterfranken
|
||||||
|
Unterfränkische Ofen- und Luftheizungsbauer-Innung,Hermann Noske,mail@sofa-shop.de,Unterfranken
|
||||||
|
Unterfränkische Ofen- und Luftheizungsbauer-Innung,Hermann Noske,shk-aschaffenburg@t-online.de,Unterfranken
|
||||||
|
"Spengler-, Sanitär- und Heizungstechnik Innung Aschaffenburg - Miltenberg",Christoph Winkler,c.winkler@cw-haustechnik.de,Unterfranken
|
||||||
|
"Spengler-, Sanitär- und Heizungstechnik Innung Aschaffenburg - Miltenberg",Christoph Winkler,innung-kitzingen@freenet.de,Unterfranken
|
||||||
|
"Innung für Spengler-, Sanitär-, und Heizungstechnik Kitzingen",Thomas Lößlein,thomas_loesslein@t-online.de,Unterfranken
|
||||||
|
"Innung für Spengler-, Sanitär-, und Heizungstechnik Kitzingen",Thomas Lößlein,info@shk-main-spessart.de,Unterfranken
|
||||||
|
"SHK-Innung Main-Spessart Innung Sanitär-, Heizungs- und Klimatechnik",Johannes Reber,info@shk-main-spessart.de,Unterfranken
|
||||||
|
"SHK-Innung Main-Spessart Innung Sanitär-, Heizungs- und Klimatechnik",Johannes Reber,info@shk-schweinfurt.de,Unterfranken
|
||||||
|
"Innung für Spengler-, Sanitär-, Heizungs- und Klimatechnik Schweinfurt - Main-Rhön",Heinz Schuchbauer,innung.shk@t-online.de,Unterfranken
|
||||||
|
"Innung für Sanitär-, Heizungs-, Klempner- und Klimatechnik Würzburg",Werner Rath,info@dellers-werkstatt.de,Unterfranken
|
||||||
|
"Innung für Sanitär-, Heizungs-, Klempner- und Klimatechnik Würzburg",Michael Deller,info@dellers-werkstatt.de,Unterfranken
|
||||||
|
"Innung für Sanitär-, Heizungs-, Klempner- und Klimatechnik Würzburg",Michael Deller,khw-kg@t-online.de,Unterfranken
|
||||||
|
"Innung für Sanitär-, Heizungs-, Klempner- und Klimatechnik Würzburg",Norbert Borst,service@norbert-borst.de,Unterfranken
|
||||||
|
"Innung für Sanitär-, Heizungs-, Klempner- und Klimatechnik Würzburg",Norbert Borst,wt@reichert-betten.de,Unterfranken
|
||||||
|
"Innung für Sanitär-, Heizungs-, Klempner- und Klimatechnik Würzburg",Werner Tausch,wt@reichert-betten.de,Unterfranken
|
||||||
|
"Innung für Sanitär-, Heizungs-, Klempner- und Klimatechnik Würzburg",Werner Tausch,info@schreiner-rhoen-grabfeld.de,Unterfranken
|
||||||
|
"Innung für Sanitär-, Heizungs-, Klempner- und Klimatechnik Würzburg",Michael Werner,michael@werner-objekteinrichtungen.de,Unterfranken
|
||||||
|
"Innung für Sanitär-, Heizungs-, Klempner- und Klimatechnik Würzburg",Michael Werner,rapp@kreishandwerkerschaft-sw.de,Unterfranken
|
||||||
|
"Innung für Sanitär-, Heizungs-, Klempner- und Klimatechnik Würzburg",Horst Zitterbart,schreinerei.zitterbart@t-online.de,Unterfranken
|
||||||
|
"Innung für Sanitär-, Heizungs-, Klempner- und Klimatechnik Würzburg",Horst Zitterbart,info@schreinerinnung-mainfranken.de,Unterfranken
|
||||||
|
"Innung für Sanitär-, Heizungs-, Klempner- und Klimatechnik Würzburg",Thomas Heußlein,info@schreinerei-heusslein.de,Unterfranken
|
||||||
|
"Innung für Sanitär-, Heizungs-, Klempner- und Klimatechnik Würzburg",Thomas Heußlein,l.emge@t-online.de,Unterfranken
|
||||||
|
"Innung für Sanitär-, Heizungs-, Klempner- und Klimatechnik Würzburg",Leo Emge,l.emge@t-online.de,Unterfranken
|
||||||
|
"Innung für Sanitär-, Heizungs-, Klempner- und Klimatechnik Würzburg",Leo Emge,rapp@kreishandwerkerschaft-sw.de,Unterfranken
|
||||||
|
"Innung für Sanitär-, Heizungs-, Klempner- und Klimatechnik Würzburg",Sebastian Ludwig,info@geisendoerfer-online.de,Unterfranken
|
||||||
|
"Innung für Sanitär-, Heizungs-, Klempner- und Klimatechnik Würzburg",Sebastian Ludwig,m.graf@hwk-ufr.de,Unterfranken
|
||||||
|
"Innung für Sanitär-, Heizungs-, Klempner- und Klimatechnik Würzburg",Klaus Imhof,kontakt@juwelier-imhof.de,Unterfranken
|
||||||
|
"Innung für Sanitär-, Heizungs-, Klempner- und Klimatechnik Würzburg",Klaus Imhof,info@zimmerer-aschaffenburg-miltenberg.de,Unterfranken
|
||||||
|
Zimmerer-Innung Aschaffenburg - Miltenberg,Jürgen Pfarr,info@zimmerei-juergen-pfarr.de,Unterfranken
|
||||||
|
Zimmerer-Innung Aschaffenburg - Miltenberg,Jürgen Pfarr,info@holzbau-eyrich.de,Unterfranken
|
||||||
|
Zimmerer-Innung Bad Neustadt,Michael Eyrich-Halbig,info@holzbau-eyrich.de,Unterfranken
|
||||||
|
Zimmerer-Innung Bad Neustadt,Michael Eyrich-Halbig,info@zimmerer-wuerzburg-kitzingen.de,Unterfranken
|
||||||
|
Zimmerer-Innung Main-Spessart,Volker Schäfer,info@schaefer-halsbach.de,Unterfranken
|
||||||
|
Zimmerer-Innung Main-Spessart,Volker Schäfer,rapp@kreishandwerkerschaft-sw.de,Unterfranken
|
||||||
|
Zimmerer-Innung Schweinfurt-Haßberge,Marion Reichhold,zimmerei_klaus_treiber@gmx.de,Unterfranken
|
||||||
|
Zimmerer-Innung Schweinfurt-Haßberge,Marion Reichhold,info@zimmerer-wuerzburg-kitzingen.de,Unterfranken
|
||||||
|
Zimmerer-Innung Würzburg - Kitzingen,Hermann Lang,lang@zimmerer-wuerzburg-kitzingen.de,Unterfranken
|
||||||
|
Zimmerer-Innung Würzburg - Kitzingen,Hermann Lang,info@khw-ab.de,Unterfranken
|
||||||
|
Zimmerer-Innung Würzburg - Kitzingen,Hermann Lang,m.kress@wassermannkress.de,Unterfranken
|
||||||
|
Zimmerer-Innung Würzburg - Kitzingen,Hermann Lang,khw-kg@t-online.de,Unterfranken
|
||||||
|
Zimmerer-Innung Würzburg - Kitzingen,Hermann Lang,lochner-baudekoration-gmbh@t-online.de,Unterfranken
|
||||||
|
Zimmerer-Innung Würzburg - Kitzingen,Hermann Lang,info@elektroinnung-hassberge.de,Unterfranken
|
||||||
|
Zimmerer-Innung Würzburg - Kitzingen,Hermann Lang,u.merz@haustechnik-merz.de,Unterfranken
|
||||||
|
Zimmerer-Innung Würzburg - Kitzingen,Hermann Lang,khw.kitzingen@gmail.com,Unterfranken
|
||||||
|
Zimmerer-Innung Würzburg - Kitzingen,Hermann Lang,monika.henneberger@t-online.de,Unterfranken
|
||||||
|
Zimmerer-Innung Würzburg - Kitzingen,Hermann Lang,petra@stegerwald.de,Unterfranken
|
||||||
|
Zimmerer-Innung Würzburg - Kitzingen,Hermann Lang,info@schreinerei-heusslein.de,Unterfranken
|
||||||
|
Zimmerer-Innung Würzburg - Kitzingen,Hermann Lang,kreishandwerker.mil@gmail.com,Unterfranken
|
||||||
|
Zimmerer-Innung Würzburg - Kitzingen,Hermann Lang,monique3003@arcor.de,Unterfranken
|
||||||
|
Zimmerer-Innung Würzburg - Kitzingen,Hermann Lang,khwsch-rhoen-grabfeld@mail.de,Unterfranken
|
||||||
|
Zimmerer-Innung Würzburg - Kitzingen,Hermann Lang,info@werner-objekteinrichtungen.de,Unterfranken
|
||||||
|
Zimmerer-Innung Würzburg - Kitzingen,Hermann Lang,rapp@Kreishandwerkerschaft-sw.de,Unterfranken
|
||||||
|
Zimmerer-Innung Würzburg - Kitzingen,Hermann Lang,info@kreishandwerkerschaft-wuerzburg.de,Unterfranken
|
||||||
|
Zimmerer-Innung Würzburg - Kitzingen,Hermann Lang,innung@elektro-strobl.de,Unterfranken
|
||||||
|
|
|
@ -0,0 +1,130 @@
|
||||||
|
Firm/Innung,Contact Person,Email,Region
|
||||||
|
Bäckerinnung Bayerischer Untermain,N/A,v.hench@baeckerinnung-bayerischer-untermain.de,Unterfranken
|
||||||
|
Bäckerinnung Bad Kissingen - Rhön-Grabfeld,Petra Schwab,khw-kg@t-online.de,Unterfranken
|
||||||
|
Bäckerinnung Bad Kissingen - Rhön-Grabfeld,Ullrich Amthor,info@baeckerei-amthor.com,Unterfranken
|
||||||
|
Bäckerinnung Kitzingen,Tilo Brönner,tilo-br@t-online.de,Unterfranken
|
||||||
|
Bäckerinnung Schweinfurt - Haßberge,Brigitte Rapp,rapp@kreishandwerkerschaft-sw.de,Unterfranken
|
||||||
|
Bäckerinnung Schweinfurt - Haßberge,Gerhard Götz,baeckerei-goetz@web.de,Unterfranken
|
||||||
|
Bäckerinnung Mainfranken,Christine Winterbauer,christine.winterbauer@baeckerbuchstelle.de,Unterfranken
|
||||||
|
Bäckerinnung Mainfranken,Marcel Scherg,scherge.beck@t-online.de,Unterfranken
|
||||||
|
Bauinnung Aschaffenburg,Nina Emeneth,info@bauinnung-aschaffenburg.de,Unterfranken
|
||||||
|
Bauinnung Aschaffenburg,Felix Englert,felix.englert@bauinnung-aschaffenburg.de,Unterfranken
|
||||||
|
Bauinnung Bad Kissingen / Rhön-Grabfeld,Petra Schwab,khw-kg@t-online.de,Unterfranken
|
||||||
|
Bauinnung Bad Kissingen / Rhön-Grabfeld,Stefan Goos,stefan.goos@zehe-gmbh.de,Unterfranken
|
||||||
|
Bau-Innung Schweinfurt,Ramona Ziegler,info@bauinnung-schweinfurt.de,Unterfranken
|
||||||
|
Bau-Innung Schweinfurt,Karl Böhner,boehner-bau@t-online.de,Unterfranken
|
||||||
|
Bauinnung Mainfranken - Würzburg,Manfred Dallner,baugewerbe@lbb-unterfranken.de,Unterfranken
|
||||||
|
Bauinnung Mainfranken - Würzburg,Ralf Stegmeier,trendbaugmbh@aol.com,Unterfranken
|
||||||
|
Innung des Bekleidungshandwerks Unterfranken,Nicole Brandler,info@innung-unterfranken.de,Unterfranken
|
||||||
|
Innung des Bekleidungshandwerks Unterfranken,Friedrun Schlagbauer-Werner,info@schneiderin-wuerzburg.de,Unterfranken
|
||||||
|
Brauer- und Mälzerinnung Unterfranken,N/A,info@private-brauereien-bayern.de,Unterfranken
|
||||||
|
Brauer- und Mälzerinnung Unterfranken,Josef Göller,geschaeftsleitung@brauerei-goeller.de,Unterfranken
|
||||||
|
Dachdeckerinnung Aschaffenburg - Miltenberg,N/A,lukas.thalheimer@thalheimer.de,Unterfranken
|
||||||
|
Dachdeckerinnung Unterfranken,N/A,info@mein-dachdecker.com,Unterfranken
|
||||||
|
Innung für Elektro- und Informationstechnik Bayerischer Untermain,Annett Kinzel,info@elektroinnung-bayerischeruntermain.de,Unterfranken
|
||||||
|
Innung für Elektro- und Informationstechnik Haßberge,Gitta Klopf,info@elektroinnung-hassberge.de,Unterfranken
|
||||||
|
Innung für Elektro- und Informationstechnik Haßberge,Ralf Jooß,info@elektro-jooss.de,Unterfranken
|
||||||
|
Innung für Elektro- und Informationstechnik Schweinfurt,"Gaby Fröschel, Roland Klöffel, Ronald Niessner",info@elektroinnung-sw.de,Unterfranken
|
||||||
|
Innung für Elektro- und Informationstechnik Schweinfurt,Rainer Walter-Helk,rwalter-helk@innotech-solar.de,Unterfranken
|
||||||
|
Innung für Elektro- und Informationstechnik Würzburg,Heike Langner,mailbox@elektro-innung-wuerzburg.de,Unterfranken
|
||||||
|
Innung für Elektro- und Informationstechnik Würzburg,Sebastian Seynstahl,se@elektro-seynstahl.de,Unterfranken
|
||||||
|
Berufsfotografen-Innung für Unterfranken,N/A,info@foto-alfen.de,Unterfranken
|
||||||
|
Friseur-Innung Aschaffenburg Stadt und Land,N/A,friseurinnung-aschaffenburg@t-online.de,Unterfranken
|
||||||
|
Friseur-Innung Aschaffenburg Stadt und Land,Corina Bayer,cbm.bayer@t-online.de,Unterfranken
|
||||||
|
Friseur-Innung Haßberge,Heinz Göhr,info@team-art-of-hair.com,Unterfranken
|
||||||
|
Friseurinnung Kitzingen,N/A,sabine.hack71@web.de,Unterfranken
|
||||||
|
Friseurinnung Miltenberg,N/A,info@haarmonique.de,Unterfranken
|
||||||
|
Friseurinnung Main-Rhön,Brigitte Rapp,rapp@kreishandwerkerschaft-sw.de,Unterfranken
|
||||||
|
"Friseur-Innung Würzburg, Main-Spessart und Bad Kissingen",N/A,katharinawalker88@gmx.de,Unterfranken
|
||||||
|
Glaserinnung Unterfranken,N/A,info@frank-bauglaserei.de,Unterfranken
|
||||||
|
Kaminkehrer-Innung Unterfranken,N/A,info@kaminkehrerinnung-unterfranken.de,Unterfranken
|
||||||
|
Kaminkehrer-Innung Unterfranken,Benjamin Schreck,benjamin.schreck@t-online.de,Unterfranken
|
||||||
|
Karosserie- und Fahrzeugtechnik Innung Unterfranken,Manuela Wohlert,info@khw-nuernberg.de,Unterfranken
|
||||||
|
Karosserie- und Fahrzeugtechnik Innung Unterfranken,Michael Seidel,info@seidel-karosserie.de,Unterfranken
|
||||||
|
Kfz-Innung Unterfranken,Michael Frank,info@kfz-innung-ufr.de,Unterfranken
|
||||||
|
Kfz-Innung Unterfranken,Roland Hoier,roland.hoier@autohaus-keller.de,Unterfranken
|
||||||
|
Innung für Land- und Baumaschinentechnik Unterfranken,Brigitte Rapp,rapp@kreishandwerkerschaft-sw.de,Unterfranken
|
||||||
|
Innung für Land- und Baumaschinentechnik Unterfranken,Bertram Muth,bertram.muth@outlook.de,Unterfranken
|
||||||
|
"Maler -, Tüncher- und Lackierer Innung Alzenau",N/A,maler_trageser@gmx.de,Unterfranken
|
||||||
|
Maler- und Lackierer-Innung Aschaffenburg Stadt und Land,N/A,uta.kern@kolb-kern.de,Unterfranken
|
||||||
|
Maler- und Lackierer-Innung Aschaffenburg Stadt und Land,Ansgar Kern,ansgar.kern@kolb-kern.de,Unterfranken
|
||||||
|
Maler- und Lackiererinnung Bad Kissingen,Petra Schwab,khw-kg@t-online.de,Unterfranken
|
||||||
|
Maler- und Lackiererinnung Bad Kissingen,Mathias Stöth,mathias@stoeth-fuchsstadt.de,Unterfranken
|
||||||
|
Maler- und Tüncherinnung Haßberge,N/A,obermeister@malerinnung-hassberge.de,Unterfranken
|
||||||
|
Maler- und Lackiererinnung Kitzingen,Sandra und Andreas Zobel,info@malerinnung-kitzingen.de,Unterfranken
|
||||||
|
Maler- und Lackiererinnung Kitzingen,Thomas Wandler,mail@maler-wandler.de,Unterfranken
|
||||||
|
Maler- und Lackiererinnung Miltenberg,Melitta Becker,info@farbe-miltenberg.de,Unterfranken
|
||||||
|
"Maler-, Tüncher- und Lackierer-Innung Rhön-Grabfeld",Birgit Neuhöfer,info@malerinnung-rg.de,Unterfranken
|
||||||
|
Malerinnung Schweinfurt Stadt- und Land,Brigitte Rapp,rapp@kreishandwerkerschaft-sw.de,Unterfranken
|
||||||
|
Malerinnung Schweinfurt Stadt- und Land,Andreas Spath,info@spath-tuencher.de,Unterfranken
|
||||||
|
Maler- und Stuckateur-Innung Würzburg und Main-Spessart,Claudius Wolfrum,info@malerinnung-wuerzburg.de,Unterfranken
|
||||||
|
Maler- und Stuckateur-Innung Würzburg und Main-Spessart,Peter Killinger,info@manufatture-colori.de,Unterfranken
|
||||||
|
Innung Metallbau- und Feinwerktechnik Bayerischer Untermain,N/A,info@innung-metallbau-feinwerktechnik.de,Unterfranken
|
||||||
|
Innung Metallbau- und Feinwerktechnik Bayerischer Untermain,Matthias Kreß,m.kress@wassermannkress.de,Unterfranken
|
||||||
|
Metall-Innung Bad Kissingen/Rhön-Grabfeld,Petra Schwab,khw-kg@t-online.de,Unterfranken
|
||||||
|
Metall-Innung Bad Kissingen/Rhön-Grabfeld,Klaus Engelmann,info@metallbau-engelmann.de,Unterfranken
|
||||||
|
Metallinnung Schweinfurt - Haßberge,Brigitte Rapp,rapp@kreishandwerkerschaft-sw.de,Unterfranken
|
||||||
|
Metallinnung Schweinfurt - Haßberge,René Dauelsberg,rd@mpi-dauelsberg.de,Unterfranken
|
||||||
|
Metallinnung Mainfranken - Mitte,Birgit Beckmann,info@metallinnung-mainfranken.de,Unterfranken
|
||||||
|
Metallinnung Mainfranken - Mitte,Detlef Lurz,detlef.lurz@lurz-metalltec.de,Unterfranken
|
||||||
|
Metzgerinnung Aschaffenburg,Dagobert Pfarr,metzgerei-pfarr@t-online.de,Unterfranken
|
||||||
|
Metzgerinnung Aschaffenburg,Marco Häuser,marco.haeuser@haeuser-hra.de,Unterfranken
|
||||||
|
Fleischer-Innung Main-Spessart,Sebastian Bumm,fleischerinnungMSP@gmx.de,Unterfranken
|
||||||
|
Fleischer-Innung Main-Spessart,Sebastian Bumm,metzgerei-bumm@t-online.de,Unterfranken
|
||||||
|
Metzgerinnung Miltenberg,N/A,j.neuberger@t-online.de,Unterfranken
|
||||||
|
Metzgerinnung Main-Rhön,"Jürgen Straub, Sonja Grob",innung@fleischerring.de,Unterfranken
|
||||||
|
Metzgerinnung Main-Rhön,Barbara Fink,metzgerei_dros_fladungen@outlook.de,Unterfranken
|
||||||
|
Metzger-Innung Würzburg,N/A,horst@schoemig.eu,Unterfranken
|
||||||
|
Metzger-Innung Würzburg,Horst Schömig,horst.schoemig@arcor.de,Unterfranken
|
||||||
|
Unterfränkische Ofen- und Luftheizungsbauer-Innung,Josef Bock,josef.bock60@gmail.com,Unterfranken
|
||||||
|
Unterfränkische Ofen- und Luftheizungsbauer-Innung,Michael Heigel,heigel@heigel.de,Unterfranken
|
||||||
|
Raumausstatter- und Sattlerinnung Unterfranken,Brigitte Rapp,rapp@kreishandwerkerschaft-sw.de,Unterfranken
|
||||||
|
Raumausstatter- und Sattlerinnung Unterfranken,Hermann Noske,mail@sofa-shop.de,Unterfranken
|
||||||
|
"Spengler-, Sanitär- und Heizungstechnik Innung Aschaffenburg - Miltenberg",Michael Bramm,shk-aschaffenburg@t-online.de,Unterfranken
|
||||||
|
"Spengler-, Sanitär- und Heizungstechnik Innung Aschaffenburg - Miltenberg",Christoph Winkler,c.winkler@cw-haustechnik.de,Unterfranken
|
||||||
|
"Innung für Spengler-, Sanitär-, und Heizungstechnik Kitzingen",Christine Keppner-Siegert,innung-kitzingen@freenet.de,Unterfranken
|
||||||
|
"Innung für Spengler-, Sanitär-, und Heizungstechnik Kitzingen",Thomas Lößlein,thomas_loesslein@t-online.de,Unterfranken
|
||||||
|
"SHK-Innung Main-Spessart Innung Sanitär-, Heizungs- und Klimatechnik",N/A,info@shk-main-spessart.de,Unterfranken
|
||||||
|
"Innung für Spengler-, Sanitär-, Heizungs- und Klimatechnik Schweinfurt - Main-Rhön",Stefan Köppe,info@shk-schweinfurt.de,Unterfranken
|
||||||
|
"Innung für Sanitär-, Heizungs-, Klempner- und Klimatechnik Würzburg",Sandra Köller,innung.shk@t-online.de,Unterfranken
|
||||||
|
Schreinerinnung Aschaffenburg Stadt und Land,N/A,info@dellers-werkstatt.de,Unterfranken
|
||||||
|
Schreinerinnung Bad Kissingen,Petra Schwab,khw-kg@t-online.de,Unterfranken
|
||||||
|
Schreinerinnung Bad Kissingen,Norbert Borst,service@norbert-borst.de,Unterfranken
|
||||||
|
Schreinerinnung Miltenberg-Obernburg,N/A,wt@reichert-betten.de,Unterfranken
|
||||||
|
Schreinerinnung Rhön-Grabfeld,N/A,info@schreiner-rhoen-grabfeld.de,Unterfranken
|
||||||
|
Schreinerinnung Rhön-Grabfeld,Michael Werner,michael@werner-objekteinrichtungen.de,Unterfranken
|
||||||
|
Schreinerinnung Haßberge – Schweinfurt,Brigitte Rapp,rapp@kreishandwerkerschaft-sw.de,Unterfranken
|
||||||
|
Schreinerinnung Haßberge – Schweinfurt,Horst Zitterbart,schreinerei.zitterbart@t-online.de,Unterfranken
|
||||||
|
Schreinerinnung Mainfranken,Ramona Pfenning,info@schreinerinnung-mainfranken.de,Unterfranken
|
||||||
|
Schreinerinnung Mainfranken,Thomas Heußlein,info@schreinerei-heusslein.de,Unterfranken
|
||||||
|
Schuhmacherinnung Unterfranken,N/A,l.emge@t-online.de,Unterfranken
|
||||||
|
Steinmetz- und Steinbildhauerinnung Unterfranken,Brigitte Rapp,rapp@kreishandwerkerschaft-sw.de,Unterfranken
|
||||||
|
Steinmetz- und Steinbildhauerinnung Unterfranken,Sebastian Ludwig,info@geisendoerfer-online.de,Unterfranken
|
||||||
|
"Uhrmacher-, Gold- und Silberschmiedeinnung Unterfranken",Markus Graf,m.graf@hwk-ufr.de,Unterfranken
|
||||||
|
"Uhrmacher-, Gold- und Silberschmiedeinnung Unterfranken",Klaus Imhof,kontakt@juwelier-imhof.de,Unterfranken
|
||||||
|
Zimmerer-Innung Aschaffenburg - Miltenberg,Theresa Breunig,info@zimmerer-aschaffenburg-miltenberg.de,Unterfranken
|
||||||
|
Zimmerer-Innung Aschaffenburg - Miltenberg,Jürgen Pfarr,info@zimmerei-juergen-pfarr.de,Unterfranken
|
||||||
|
Zimmerer-Innung Bad Neustadt,N/A,info@holzbau-eyrich.de,Unterfranken
|
||||||
|
Zimmerer-Innung Main-Spessart,Ulrike Feser,info@zimmerer-wuerzburg-kitzingen.de,Unterfranken
|
||||||
|
Zimmerer-Innung Main-Spessart,Volker Schäfer,info@schaefer-halsbach.de,Unterfranken
|
||||||
|
Zimmerer-Innung Schweinfurt-Haßberge,Brigitte Rapp,rapp@kreishandwerkerschaft-sw.de,Unterfranken
|
||||||
|
Zimmerer-Innung Schweinfurt-Haßberge,Marion Reichhold,zimmerei_klaus_treiber@gmx.de,Unterfranken
|
||||||
|
Zimmerer-Innung Würzburg - Kitzingen,Ulrike Feser,info@zimmerer-wuerzburg-kitzingen.de,Unterfranken
|
||||||
|
Zimmerer-Innung Würzburg - Kitzingen,Hermann Lang,lang@zimmerer-wuerzburg-kitzingen.de,Unterfranken
|
||||||
|
Kreishandwerkerschaft Aschaffenburg,Claudia Find,info@khw-ab.de,Unterfranken
|
||||||
|
Kreishandwerkerschaft Aschaffenburg,Matthias Kreß,m.kress@wassermannkress.de,Unterfranken
|
||||||
|
Kreishandwerkerschaft Bad Kissingen,Petra Schwab,khw-kg@t-online.de,Unterfranken
|
||||||
|
Kreishandwerkerschaft Bad Kissingen,Ulrike Lochner-Erhard,lochner-baudekoration-gmbh@t-online.de,Unterfranken
|
||||||
|
Kreishandwerkerschaft Haßberge,Gitta Klopf,info@elektroinnung-hassberge.de,Unterfranken
|
||||||
|
Kreishandwerkerschaft Haßberge,Udo Merz,u.merz@haustechnik-merz.de,Unterfranken
|
||||||
|
Kreishandwerkerschaft Kitzingen,Elisabeth Hofmann,khw.kitzingen@gmail.com,Unterfranken
|
||||||
|
Kreishandwerkerschaft Kitzingen,Monika Henneberger,monika.henneberger@t-online.de,Unterfranken
|
||||||
|
Kreishandwerkerschaft Main-Spessart,Petra Stegerwald,petra@stegerwald.de,Unterfranken
|
||||||
|
Kreishandwerkerschaft Main-Spessart,Thomas Heußlein,info@schreinerei-heusslein.de,Unterfranken
|
||||||
|
Kreishandwerkerschaft Miltenberg,N/A,kreishandwerker.mil@gmail.com,Unterfranken
|
||||||
|
Kreishandwerkerschaft Miltenberg,Monique Haas,monique3003@arcor.de,Unterfranken
|
||||||
|
Kreishandwerkerschaft Rhön-Grabfeld,N/A,khwsch-rhoen-grabfeld@mail.de,Unterfranken
|
||||||
|
Kreishandwerkerschaft Rhön-Grabfeld,Bruno Werner,info@werner-objekteinrichtungen.de,Unterfranken
|
||||||
|
Kreishandwerkerschaft Schweinfurt,Brigitte Rapp,rapp@Kreishandwerkerschaft-sw.de,Unterfranken
|
||||||
|
Kreishandwerkerschaft Würzburg,Sandra Köller,info@kreishandwerkerschaft-wuerzburg.de,Unterfranken
|
||||||
|
Kreishandwerkerschaft Würzburg,Martin Strobl,innung@elektro-strobl.de,Unterfranken
|
||||||
|
Binary file not shown.
|
|
@ -0,0 +1,67 @@
|
||||||
|
import pandas as pd
|
||||||
|
import os
|
||||||
|
|
||||||
|
def normalize(text):
|
||||||
|
if not isinstance(text, str):
|
||||||
|
return ""
|
||||||
|
return text.strip().lower()
|
||||||
|
|
||||||
|
def recover_cologne():
|
||||||
|
leads_csv_path = 'leads/leads.csv'
|
||||||
|
cologne_raw_path = 'leads/raw/innungen_leads_koeln_duesseldorf.csv'
|
||||||
|
|
||||||
|
if not os.path.exists(leads_csv_path) or not os.path.exists(cologne_raw_path):
|
||||||
|
print("Files not found.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Load existing leads
|
||||||
|
leads_df = pd.read_csv(leads_csv_path)
|
||||||
|
done_emails = set(leads_df['Email'].apply(normalize))
|
||||||
|
done_names = set(leads_df['Firm/Innung'].apply(normalize))
|
||||||
|
|
||||||
|
# Load raw Cologne data
|
||||||
|
cologne_df = pd.read_csv(cologne_raw_path)
|
||||||
|
|
||||||
|
new_rows = []
|
||||||
|
|
||||||
|
print(f"Scanning {len(cologne_df)} raw entries...")
|
||||||
|
|
||||||
|
for _, row in cologne_df.iterrows():
|
||||||
|
name = row.get('organisation', '')
|
||||||
|
region = row.get('region', '')
|
||||||
|
email = row.get('email', '')
|
||||||
|
|
||||||
|
# We only care about Cologne for this recovery
|
||||||
|
if str(region).lower() != 'koeln':
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Must have a valid email
|
||||||
|
if pd.isna(email) or email.strip() == '':
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check if already in leads.csv
|
||||||
|
if normalize(email) in done_emails or normalize(name) in done_names:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# It's a valid new lead!
|
||||||
|
new_row = {
|
||||||
|
'Firm/Innung': name,
|
||||||
|
'Contact Person': 'N/A', # Raw data might not have person, or we need to check columns
|
||||||
|
'Email': email,
|
||||||
|
'Region': 'Köln'
|
||||||
|
}
|
||||||
|
new_rows.append(new_row)
|
||||||
|
done_emails.add(normalize(email)) # Prevent dupes within batch
|
||||||
|
|
||||||
|
print(f"Found {len(new_rows)} new Cologne leads to add.")
|
||||||
|
|
||||||
|
if new_rows:
|
||||||
|
new_df = pd.DataFrame(new_rows)
|
||||||
|
# Append to CSV
|
||||||
|
new_df.to_csv(leads_csv_path, mode='a', header=False, index=False)
|
||||||
|
print("Successfully appended to leads.csv")
|
||||||
|
else:
|
||||||
|
print("No new leads found.")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
recover_cologne()
|
||||||
|
|
@ -0,0 +1,93 @@
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
import re
|
||||||
|
|
||||||
|
def analyze_leads():
|
||||||
|
input_csv = 'leads/leads.csv'
|
||||||
|
output_report = 'leads/analysis/red_flags.md'
|
||||||
|
|
||||||
|
df = pd.read_csv(input_csv)
|
||||||
|
|
||||||
|
# 1. Apply Specific Fixes
|
||||||
|
# Dachdeckerinnung Unterfranken -> info@dachdecker-unterfranken.de
|
||||||
|
mask = df['Firm/Innung'].str.contains('Dachdeckerinnung Unterfranken', case=False, na=False)
|
||||||
|
if mask.any():
|
||||||
|
print("Fixing Dachdeckerinnung Unterfranken email...")
|
||||||
|
df.loc[mask, 'Email'] = 'info@dachdecker-unterfranken.de'
|
||||||
|
|
||||||
|
# Save the fixes back to CSV
|
||||||
|
df.to_csv(input_csv, index=False)
|
||||||
|
|
||||||
|
# 2. Red Flag Analysis
|
||||||
|
red_flags = []
|
||||||
|
|
||||||
|
# Patterns
|
||||||
|
freemail_domains = ['t-online.de', 'web.de', 'gmx.de', 'gmail.com', 'hotmail.com', 'yahoo.de', 'aol.com', 'freenet.de', 'arcor.de']
|
||||||
|
kh_patterns = ['kh-', 'handwerk-', 'kreishandwerkerschaft', '-kh']
|
||||||
|
|
||||||
|
# Check for Duplicates
|
||||||
|
email_counts = df['Email'].value_counts()
|
||||||
|
duplicate_emails = email_counts[email_counts > 1]
|
||||||
|
|
||||||
|
if not duplicate_emails.empty:
|
||||||
|
red_flags.append("## 🚩 Duplicate Emails (Potential Central Administration)")
|
||||||
|
red_flags.append("| Email | Count | Innungen |")
|
||||||
|
red_flags.append("|---|---|---|")
|
||||||
|
for email, count in duplicate_emails.items():
|
||||||
|
innungen = df[df['Email'] == email]['Firm/Innung'].tolist()
|
||||||
|
innungen_str = "<br>".join(innungen[:3]) + ("..." if len(innungen) > 3 else "")
|
||||||
|
red_flags.append(f"| `{email}` | {count} | {innungen_str} |")
|
||||||
|
red_flags.append("")
|
||||||
|
|
||||||
|
# Check for Freemail Addresses
|
||||||
|
red_flags.append("## 🟡 Freemail Addresses (Check Professionality)")
|
||||||
|
red_flags.append("| Innung | Contact | Email |")
|
||||||
|
red_flags.append("|---|---|---|")
|
||||||
|
|
||||||
|
found_freemail = False
|
||||||
|
for idx, row in df.iterrows():
|
||||||
|
email = str(row['Email']).lower()
|
||||||
|
domain = email.split('@')[-1] if '@' in email else ''
|
||||||
|
|
||||||
|
if domain in freemail_domains:
|
||||||
|
red_flags.append(f"| {row['Firm/Innung']} | {row['Contact Person']} | `{email}` |")
|
||||||
|
found_freemail = True
|
||||||
|
|
||||||
|
if not found_freemail:
|
||||||
|
red_flags.append("No freemail addresses found.")
|
||||||
|
red_flags.append("")
|
||||||
|
|
||||||
|
# Check for Generic KH Domains vs Specific Innung Name
|
||||||
|
# Heuristic: If email has 'kh-' or 'handwerk' but Innung name is specific (like "Bäckerinnung")
|
||||||
|
# This might indicate the email goes to the KH office, not the Obermeister directly.
|
||||||
|
red_flags.append("## ℹ️ Kreishandwerkerschaft (KH) Generic Contacts")
|
||||||
|
red_flags.append("These emails likely reach the administrative office, not necessarily the specific trade representative directly.")
|
||||||
|
red_flags.append("| Innung | Email | Note |")
|
||||||
|
red_flags.append("|---|---|---|")
|
||||||
|
|
||||||
|
for idx, row in df.iterrows():
|
||||||
|
email = str(row['Email']).lower()
|
||||||
|
innung = str(row['Firm/Innung'])
|
||||||
|
|
||||||
|
is_kh_email = any(p in email for p in kh_patterns)
|
||||||
|
|
||||||
|
# If it's a specific guild but uses a generic KH email
|
||||||
|
if is_kh_email:
|
||||||
|
red_flags.append(f"| {innung} | `{email}` | Generic KH Domain |")
|
||||||
|
|
||||||
|
# Domain Mismatch (Simple)
|
||||||
|
# Check if the domain is totally unrelated to the Innung name
|
||||||
|
# Difficult to do reliably without extensive lists, but we can look for "shop", "portal", etc.
|
||||||
|
|
||||||
|
# Save Report
|
||||||
|
import os
|
||||||
|
os.makedirs(os.path.dirname(output_report), exist_ok=True)
|
||||||
|
|
||||||
|
with open(output_report, 'w', encoding='utf-8') as f:
|
||||||
|
f.write("# Lead Quality Audit & Red Flags\n\n")
|
||||||
|
f.write("\n".join(red_flags))
|
||||||
|
|
||||||
|
print(f"Report generated at {output_report}")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
analyze_leads()
|
||||||
|
|
@ -0,0 +1,76 @@
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
def apply_verification_fixes():
|
||||||
|
csv_path = 'leads/leads.csv'
|
||||||
|
df = pd.read_csv(csv_path)
|
||||||
|
|
||||||
|
# Helper to update row based on Innung name
|
||||||
|
def update_lead(innung, new_email=None, new_contact=None, new_phone=None, new_address=None):
|
||||||
|
mask = df['Firm/Innung'].str.contains(innung, case=False, na=False)
|
||||||
|
if mask.any():
|
||||||
|
if new_email: df.loc[mask, 'Email'] = new_email
|
||||||
|
if new_contact: df.loc[mask, 'Contact Person'] = new_contact
|
||||||
|
if new_phone: df.loc[mask, 'Phone'] = new_phone
|
||||||
|
if new_address: df.loc[mask, 'Address'] = new_address
|
||||||
|
print(f"Updated {innung}")
|
||||||
|
|
||||||
|
# --- Schweinfurt Fixes ---
|
||||||
|
# Bäckerinnung Schweinfurt - Haßberge -> Fusioniert? Keeping KH contact but noting it might be Mainfranken now.
|
||||||
|
# Actually, let's keep the KH contact if it's the only one, but maybe update the name if we found a better one?
|
||||||
|
# Search said: "Bäckerinnung Schweinfurt-Haßberge... fusioniert... zur neuen Bäcker-Innung Mainfranken"
|
||||||
|
# So we should probably rename it or at least acknowledge it. checking Mainfranken entry?
|
||||||
|
# For now, let's Stick to the specific Obermeister emails we found.
|
||||||
|
|
||||||
|
# Friseurinnung Main-Rhön -> Margit Rosentritt? Search said website is www.friseurinnung-main-rhoen.de
|
||||||
|
# Email: info@friseurinnung-main-rhoen.de (Guess? Website didn't explicitly say email, but likely).
|
||||||
|
# Re-reading search: "Adresse: Galgenleite 3... E-Mail: rapp@kreishandwerkerschaft-sw.de".
|
||||||
|
# So the KH email IS the official one for the Innung.
|
||||||
|
|
||||||
|
# Innung für Land- und Baumaschinentechnik Unterfranken
|
||||||
|
update_lead("Innung für Land- und Baumaschinentechnik Unterfranken", new_email="info@innung-landbautechnik.de")
|
||||||
|
|
||||||
|
# Malerinnung Schweinfurt Stadt- und Land
|
||||||
|
update_lead("Malerinnung Schweinfurt Stadt- und Land", new_email="info@malerinnung-schweinfurt.de")
|
||||||
|
|
||||||
|
# Metallinnung Schweinfurt - Haßberge -> rapp@... confirmed.
|
||||||
|
|
||||||
|
# Zimmerer-Innung Schweinfurt-Haßberge -> Reichhold.
|
||||||
|
# Search confirms KH manages it. But maybe add Reichhold's email if available?
|
||||||
|
# Only found phone. Keeping KH email but maybe adding Obermeister name if missing.
|
||||||
|
update_lead("Zimmerer-Innung Schweinfurt-Haßberge", new_contact="Marion Reichhold")
|
||||||
|
|
||||||
|
# Steinmetz- und Steinbildhauerinnung Unterfranken
|
||||||
|
update_lead("Steinmetz- und Steinbildhauerinnung Unterfranken", new_contact="Josef Hofmann", new_email="info@stein-welten.com")
|
||||||
|
|
||||||
|
# Schreinerinnung Haßberge – Schweinfurt
|
||||||
|
update_lead("Schreinerinnung Haßberge – Schweinfurt", new_contact="Horst Zitterbart", new_email="schreinerei.zitterbart@t-online.de")
|
||||||
|
|
||||||
|
# --- Bad Kissingen Fixes ---
|
||||||
|
# Bauinnung Bad Kissingen / Rhön-Grabfeld
|
||||||
|
# Stefan Goos. Email not explicitly found, but "info@bauunternehmen-zehe.de" is likely for his company.
|
||||||
|
# Safe bet: Keep KH or try to find his specific one?
|
||||||
|
# Let's stick to KH if we are unsure, but maybe update Contact Person.
|
||||||
|
update_lead("Bauinnung Bad Kissingen / Rhön-Grabfeld", new_contact="Stefan Goos")
|
||||||
|
|
||||||
|
# Metall-Innung Bad Kissingen/Rhön-Grabfeld
|
||||||
|
update_lead("Metall-Innung Bad Kissingen/Rhön-Grabfeld", new_contact="Klaus Engelmann", new_email="info@metallinnung-kg-nes.de")
|
||||||
|
|
||||||
|
# Schreinerinnung Bad Kissingen
|
||||||
|
update_lead("Schreinerinnung Bad Kissingen", new_contact="Norbert Borst", new_email="khw-kg@t-online.de") # Confirmed
|
||||||
|
|
||||||
|
# Maler- und Lackiererinnung Bad Kissingen
|
||||||
|
update_lead("Maler- und Lackiererinnung Bad Kissingen", new_contact="Mathias Stöth", new_email="khw-kg@t-online.de") # Confirmed
|
||||||
|
|
||||||
|
# --- Freemail Fixes ---
|
||||||
|
# Bau-Innung Schweinfurt -> Karl Böhner
|
||||||
|
update_lead("Bau-Innung Schweinfurt", new_email="info@bauinnung-schweinfurt.de", new_contact="Karl Böhner")
|
||||||
|
|
||||||
|
# Bauinnung Mainfranken - Würzburg -> Ralf Stegmeier
|
||||||
|
update_lead("Bauinnung Mainfranken - Würzburg", new_email="info@trend-bau.com", new_contact="Ralf Stegmeier")
|
||||||
|
|
||||||
|
df.to_csv(csv_path, index=False)
|
||||||
|
print("Verification fixes applied.")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
apply_verification_fixes()
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
import pypdf
|
||||||
|
|
||||||
|
pdf_path = 'cologne_duesseldorf_data/duesseldorf_innungen.pdf'
|
||||||
|
|
||||||
|
def debug_pdf():
|
||||||
|
try:
|
||||||
|
reader = pypdf.PdfReader(pdf_path)
|
||||||
|
text = ""
|
||||||
|
for page in reader.pages:
|
||||||
|
text += page.extract_text() + "\n"
|
||||||
|
|
||||||
|
# Search for known name
|
||||||
|
target = "Jens Schulz"
|
||||||
|
idx = text.find(target)
|
||||||
|
if idx != -1:
|
||||||
|
print(f"Found '{target}' at index {idx}")
|
||||||
|
context = text[max(0, idx-200):min(len(text), idx+500)]
|
||||||
|
print("--- CONTEXT AROUND JENS SCHULZ ---")
|
||||||
|
print(context)
|
||||||
|
print("--- END CONTEXT ---")
|
||||||
|
else:
|
||||||
|
print(f"'{target}' not found!")
|
||||||
|
|
||||||
|
# Search for @
|
||||||
|
at_indices = [i for i, c in enumerate(text) if c == '@']
|
||||||
|
print(f"Found {len(at_indices)} '@' symbols.")
|
||||||
|
if at_indices:
|
||||||
|
first_at = at_indices[0]
|
||||||
|
print(f"Context around first '@':")
|
||||||
|
print(text[max(0, first_at-50):min(len(text), first_at+50)])
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error: {e}")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
debug_pdf()
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
def deduplicate_leads():
|
||||||
|
filepath = 'leads/leads.csv'
|
||||||
|
df = pd.read_csv(filepath)
|
||||||
|
|
||||||
|
initial_count = len(df)
|
||||||
|
|
||||||
|
# Remove duplicates based on 'Firm/Innung' column, keeping the first occurrence
|
||||||
|
# (Assuming first occurrence is valid or same as others since they were duplicates)
|
||||||
|
df_dedup = df.drop_duplicates(subset=['Firm/Innung'], keep='first')
|
||||||
|
|
||||||
|
final_count = len(df_dedup)
|
||||||
|
|
||||||
|
print(f"Removed {initial_count - final_count} duplicates.")
|
||||||
|
|
||||||
|
df_dedup.to_csv(filepath, index=False)
|
||||||
|
print("Deduplication complete.")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
deduplicate_leads()
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
import requests
|
||||||
|
|
||||||
|
url = "https://www.handwerk.koeln/innungen/innungen-kh/"
|
||||||
|
headers = {
|
||||||
|
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36"
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.get(url, headers=headers)
|
||||||
|
response.raise_for_status()
|
||||||
|
with open("cologne_duesseldorf_data/cologne_innungen.html", "w", encoding="utf-8") as f:
|
||||||
|
f.write(response.text)
|
||||||
|
print(f"Successfully downloaded {len(response.text)} characters.")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error downloading: {e}")
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
import pypdf
|
||||||
|
|
||||||
|
pdf_path = 'cologne_duesseldorf_data/duesseldorf_innungen.pdf'
|
||||||
|
|
||||||
|
try:
|
||||||
|
reader = pypdf.PdfReader(pdf_path)
|
||||||
|
text = ""
|
||||||
|
for page in reader.pages:
|
||||||
|
text += page.extract_text() + "\n"
|
||||||
|
|
||||||
|
with open('cologne_duesseldorf_data/duesseldorf_raw.txt', 'w', encoding='utf-8') as f:
|
||||||
|
f.write(text)
|
||||||
|
print(f"Dumped {len(text)} characters to duesseldorf_raw.txt")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error: {e}")
|
||||||
|
|
@ -0,0 +1,62 @@
|
||||||
|
import pypdf
|
||||||
|
import re
|
||||||
|
import csv
|
||||||
|
|
||||||
|
pdf_path = 'cologne_duesseldorf_data/duesseldorf_innungen.pdf'
|
||||||
|
output_csv = 'cologne_duesseldorf_data/duesseldorf_leads.csv'
|
||||||
|
|
||||||
|
def extract_duesseldorf_leads():
|
||||||
|
try:
|
||||||
|
reader = pypdf.PdfReader(pdf_path)
|
||||||
|
text = ""
|
||||||
|
for page in reader.pages:
|
||||||
|
text += page.extract_text() + "\n"
|
||||||
|
|
||||||
|
lines = text.split('\n')
|
||||||
|
leads = []
|
||||||
|
current_innung = "Unknown Innung"
|
||||||
|
|
||||||
|
# Regex for email
|
||||||
|
email_regex = re.compile(r'[\w\.-]+@[\w\.-]+\.\w+')
|
||||||
|
|
||||||
|
for i, line in enumerate(lines):
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Update current Innung if line looks like a title (pure text, no email, short-ish)
|
||||||
|
# This is still heuristic but let's try to capture lines with "Innung" OR "Verband"
|
||||||
|
if ("Innung" in line or "Verband" in line) and "@" not in line and len(line) < 100:
|
||||||
|
current_innung = line
|
||||||
|
|
||||||
|
emails = email_regex.findall(line)
|
||||||
|
for email in emails:
|
||||||
|
email = email.rstrip('.')
|
||||||
|
|
||||||
|
if any(l['Email'] == email for l in leads):
|
||||||
|
continue
|
||||||
|
|
||||||
|
leads.append({
|
||||||
|
'Firm/Innung': current_innung,
|
||||||
|
'Contact': "N/A",
|
||||||
|
'Email': email,
|
||||||
|
'Phone': "N/A",
|
||||||
|
'Region': 'Düsseldorf'
|
||||||
|
})
|
||||||
|
|
||||||
|
# Write to CSV
|
||||||
|
with open(output_csv, 'w', newline='', encoding='utf-8') as f:
|
||||||
|
writer = csv.DictWriter(f, fieldnames=['Firm/Innung', 'Contact', 'Email', 'Phone', 'Region'])
|
||||||
|
writer.writeheader()
|
||||||
|
writer.writerows(leads)
|
||||||
|
|
||||||
|
print(f"Extracted {len(leads)} leads from Düsseldorf PDF.")
|
||||||
|
# Print first 5 for verification
|
||||||
|
for l in leads[:5]:
|
||||||
|
print(f"- {l['Firm/Innung']}: {l['Email']}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error extracting Düsseldorf leads: {e}")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
extract_duesseldorf_leads()
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
import pypdf
|
||||||
|
import re
|
||||||
|
|
||||||
|
pdf_path = 'cologne_duesseldorf_data/duesseldorf_innungen.pdf'
|
||||||
|
|
||||||
|
def extract_emails_direct():
|
||||||
|
try:
|
||||||
|
reader = pypdf.PdfReader(pdf_path)
|
||||||
|
print(f"PDF matches {len(reader.pages)} pages.")
|
||||||
|
|
||||||
|
full_text = ""
|
||||||
|
for i, page in enumerate(reader.pages):
|
||||||
|
text = page.extract_text()
|
||||||
|
full_text += text + "\n"
|
||||||
|
print(f"--- Page {i+1} Text Sample (First 200 chars) ---")
|
||||||
|
print(text[:200])
|
||||||
|
print("------------------------------------------------")
|
||||||
|
|
||||||
|
emails = re.findall(r'[\w\.-]+@[\w\.-]+\.\w+', full_text)
|
||||||
|
print(f"Total extracted text length: {len(full_text)}")
|
||||||
|
print(f"Found {len(emails)} emails.")
|
||||||
|
|
||||||
|
for email in emails:
|
||||||
|
print(f"Email: {email}")
|
||||||
|
# Find context
|
||||||
|
idx = full_text.find(email)
|
||||||
|
start = max(0, idx - 50)
|
||||||
|
end = min(len(full_text), idx + 50 + len(email))
|
||||||
|
print(f"Context: {full_text[start:end].replace(chr(10), ' ')}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error: {e}")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
extract_emails_direct()
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue