Init commit

This commit is contained in:
Jerrit Fritzsche 2025-11-12 17:16:01 +01:00
commit c221830705
128 changed files with 20670 additions and 0 deletions

View File

@ -0,0 +1,32 @@
{
"permissions": {
"allow": [
"Bash(npx create-next-app:*)",
"Bash(dir)",
"Bash(printf:*)",
"Bash(npm init:*)",
"Bash(npm install:*)",
"Bash(npx shadcn@latest add:*)",
"Bash(npx prisma init)",
"Bash(echo:*)",
"Bash(.env)",
"Bash(npx prisma migrate dev:*)",
"Bash(npx prisma db seed:*)",
"Bash(npx prisma generate)",
"Bash(public/uploads/.gitignore)",
"Bash(\"c:\\Users\\Weapie\\Desktop\\taskmanager\\src\\app\\(dashboard)\\calendar\\page.tsx\")",
"Bash(\"c:\\Users\\Weapie\\Desktop\\taskmanager\\src\\app\\(dashboard)\\tasks\\loading.tsx\")",
"Bash(\"c:\\Users\\Weapie\\Desktop\\taskmanager\\src\\app\\(dashboard)\\tasks\\[id]\\loading.tsx\")",
"Bash(\"c:\\Users\\Weapie\\Desktop\\taskmanager\\src\\app\\(dashboard)\\calendar\\loading.tsx\")",
"Bash(if exist .next rmdir /s /q .next)",
"Bash(npm run build:*)",
"Bash(npm uninstall:*)",
"Bash(npx ts-node:*)",
"Bash(npm run dev:*)",
"Bash(dir /b)",
"Bash(ls:*)"
],
"deny": [],
"ask": []
}
}

22
.dockerignore Normal file
View File

@ -0,0 +1,22 @@
node_modules
.next
.env
.env.local
.env.development
.env.test
.git
.gitignore
README.md
*.md
.vscode
.idea
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.DS_Store
*.pem
.vercel
.turbo
coverage
dist
build

48
.gitignore vendored Normal file
View File

@ -0,0 +1,48 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# env files
.env
.env*.local
.env.production
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
# prisma
/prisma/*.db
/prisma/*.db-journal
/src/generated/prisma

212
API_DOCUMENTATION.md Normal file
View File

@ -0,0 +1,212 @@
# API Dokumentation - Aufgabenplaner
## Authentifizierung
Alle API-Endpunkte erfordern eine authentifizierte Session über NextAuth.
## Tasks API
### GET /api/tasks
Gibt alle Tasks zurück, basierend auf Berechtigungen.
**Berechtigung:**
- Bearbeiter: Nur eigene Tasks (assigneeId === userId)
- Pfleger+: Alle Tasks
**Query Parameter:**
- `status` (optional): Filtert nach TaskStatus (UNERLEDIGT, IN_BEARBEITUNG, ERLEDIGT)
**Response:** `200 OK`
```json
[
{
"id": "string",
"wo": "string",
"was": "string",
"bisWann": "datetime",
"status": "TaskStatus",
"beauftragtAm": "datetime?",
"firmaBeauftragt": "string?",
"createdAt": "datetime",
"updatedAt": "datetime",
"creator": { "id": "string", "name": "string", "email": "string" },
"assignee": { "id": "string", "name": "string", "email": "string" },
"comments": [...],
"files": [...]
}
]
```
### POST /api/tasks
Erstellt eine neue Task.
**Berechtigung:** Pfleger+
**Request Body:**
```json
{
"wo": "string",
"was": "string",
"bisWann": "datetime",
"assigneeId": "string",
"beauftragtAm": "datetime?" (optional),
"firmaBeauftragt": "string?" (optional)
}
```
**Response:** `201 Created`
---
### GET /api/tasks/[id]
Gibt eine einzelne Task zurück.
**Berechtigung:**
- User muss Task sehen dürfen (Bearbeiter: nur eigene, andere: alle)
**Response:** `200 OK`
### PATCH /api/tasks/[id]
Aktualisiert eine Task.
**Berechtigung:**
- Bearbeiter: Nur Status ihrer eigenen Tasks
- Pfleger+: Alle Felder aller Tasks
**Request Body (Bearbeiter):**
```json
{
"status": "TaskStatus"
}
```
**Request Body (Pfleger+):**
```json
{
"wo": "string?" (optional),
"was": "string?" (optional),
"bisWann": "datetime?" (optional),
"status": "TaskStatus?" (optional),
"assigneeId": "string?" (optional),
"beauftragtAm": "datetime?" (optional),
"firmaBeauftragt": "string?" (optional)
}
```
**Response:** `200 OK`
### DELETE /api/tasks/[id]
Löscht eine Task.
**Berechtigung:** Vorgesetzter+
**Response:** `200 OK`
---
## Comments API
### GET /api/tasks/[id]/comments
Gibt alle Kommentare einer Task zurück.
**Berechtigung:** User muss Task sehen dürfen
**Response:** `200 OK`
```json
[
{
"id": "string",
"content": "string",
"createdAt": "datetime",
"author": { "id": "string", "name": "string", "email": "string" }
}
]
```
### POST /api/tasks/[id]/comments
Erstellt einen Kommentar für eine Task.
**Berechtigung:** User muss Task sehen dürfen
**Request Body:**
```json
{
"content": "string"
}
```
**Response:** `201 Created`
---
## Files API
### GET /api/tasks/[id]/files
Gibt alle Dateien einer Task zurück.
**Berechtigung:** User muss Task sehen dürfen
**Response:** `200 OK`
```json
[
{
"id": "string",
"name": "string",
"path": "string",
"mimeType": "string",
"size": number,
"createdAt": "datetime"
}
]
```
### POST /api/tasks/[id]/files
Lädt eine Datei für eine Task hoch.
**Berechtigung:** User muss Task bearbeiten dürfen (Pfleger+)
**Request:** `multipart/form-data`
- `file`: File (max 10MB)
**Erlaubte Dateitypen:**
- PDF (.pdf)
- JPEG/JPG (.jpg, .jpeg)
- PNG (.png)
- Word (.docx)
- Excel (.xlsx)
**Response:** `201 Created`
### DELETE /api/tasks/[id]/files/[fileId]
Löscht eine Datei.
**Berechtigung:** User muss Task bearbeiten dürfen (Pfleger+)
**Response:** `200 OK`
---
## HTTP Status Codes
- `200 OK`: Erfolgreiche Anfrage
- `201 Created`: Ressource wurde erstellt
- `400 Bad Request`: Validierungsfehler
- `401 Unauthorized`: Nicht authentifiziert
- `403 Forbidden`: Keine Berechtigung
- `404 Not Found`: Ressource nicht gefunden
- `500 Internal Server Error`: Serverfehler
## Berechtigungen
### Rollenmatrix
| Aktion | BEARBEITER | PFLEGER | VORGESETZTER | ADMIN |
|--------|-----------|---------|--------------|-------|
| Eigene Tasks sehen | ✅ | ✅ | ✅ | ✅ |
| Alle Tasks sehen | ❌ | ✅ | ✅ | ✅ |
| Task erstellen | ❌ | ✅ | ✅ | ✅ |
| Task bearbeiten | Status eigener Tasks | ✅ | ✅ | ✅ |
| Task löschen | ❌ | ❌ | ✅ | ✅ |
| Kommentar hinzufügen | Eigene Tasks | ✅ | ✅ | ✅ |
| Datei hochladen | ❌ | ✅ | ✅ | ✅ |
| Datei löschen | ❌ | ✅ | ✅ | ✅ |

54
Dockerfile Normal file
View File

@ -0,0 +1,54 @@
FROM node:20-alpine AS base
# Dependencies
FROM base AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci
# Builder
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED 1
RUN npx prisma generate
RUN npm run build
# Runner
FROM base AS runner
WORKDIR /app
ENV NODE_ENV production
ENV NEXT_TELEMETRY_DISABLED 1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
# Kopiere App-Dateien
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
# Kopiere Prisma-Dateien und Node-Modules für Migration & Seeding
COPY --from=builder /app/prisma ./prisma
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./package.json
# Kopiere Start-Script
COPY --chown=nextjs:nodejs start.sh ./start.sh
RUN chmod +x ./start.sh
USER nextjs
EXPOSE 3000
ENV PORT 3000
ENV HOSTNAME "0.0.0.0"
# Nutze Start-Script statt direktem node-Befehl
CMD ["./start.sh"]

176
README.Docker.md Normal file
View File

@ -0,0 +1,176 @@
# Task Manager - Docker Deployment
Anleitung zum Starten des Task Managers mit Docker und Docker Compose.
## Voraussetzungen
- Docker installiert
- Docker Compose installiert
## Schnellstart
### 1. Repository klonen / Dateien herunterladen
```bash
cd taskmanager
```
### 2. Mit Docker Compose starten
```bash
docker-compose up -d
```
Dieser Befehl:
- Erstellt die PostgreSQL Datenbank
- Baut das Docker-Image der Anwendung
- Führt automatisch Datenbankmigrationen durch
- Erstellt einen Admin-Benutzer
- Startet die Anwendung auf Port 3000
### 3. Anwendung öffnen
Öffne im Browser: http://localhost:3000
## Standard-Login
Nach dem ersten Start wurde automatisch ein Admin-Benutzer angelegt:
- **Email:** admin@taskmanager.de
- **Passwort:** admin123
**WICHTIG:** Ändere das Passwort nach dem ersten Login!
## Logs anzeigen
```bash
# Alle Logs anzeigen
docker-compose logs -f
# Nur App-Logs
docker-compose logs -f app
# Nur Datenbank-Logs
docker-compose logs -f db
```
## Container stoppen
```bash
docker-compose down
```
## Container stoppen und Daten löschen
**ACHTUNG:** Löscht alle Daten in der Datenbank!
```bash
docker-compose down -v
```
## Container neu bauen
Falls du Änderungen am Code gemacht hast:
```bash
docker-compose down
docker-compose build --no-cache
docker-compose up -d
```
## Umgebungsvariablen
Die wichtigsten Umgebungsvariablen sind in der `docker-compose.yml` bereits konfiguriert:
- `DATABASE_URL`: Verbindung zur PostgreSQL Datenbank
- `NEXTAUTH_URL`: URL der Anwendung (für NextAuth)
- `NEXTAUTH_SECRET`: Secret für NextAuth Session-Verschlüsselung
**Für Produktion** solltest du diese Werte ändern:
- Ein sicheres `NEXTAUTH_SECRET` generieren
- `NEXTAUTH_URL` auf deine Domain setzen
- Datenbank-Passwort in `docker-compose.yml` ändern
## Datenbank-Verbindung
Die PostgreSQL Datenbank läuft auf:
- **Host:** localhost (von außen) / db (intern)
- **Port:** 5432
- **User:** taskmanager
- **Passwort:** taskmanager123
- **Datenbank:** taskmanager
## Persistente Daten
Zwei Docker Volumes werden erstellt:
- `postgres_data`: PostgreSQL Datenbank
- `uploads`: Hochgeladene Dateien
Diese bleiben auch nach `docker-compose down` erhalten.
## Troubleshooting
### Port 3000 bereits belegt
Ändere in `docker-compose.yml` den Port:
```yaml
ports:
- "3001:3000" # Nutze Port 3001 statt 3000
```
### Port 5432 bereits belegt
Falls du bereits eine PostgreSQL-Instanz laufen hast:
```yaml
ports:
- "5433:5432" # Nutze Port 5433 statt 5432
```
### Migration schlägt fehl
```bash
# Container neu starten
docker-compose restart app
# Logs prüfen
docker-compose logs app
```
### Admin-User wurde nicht erstellt
```bash
# Seeding manuell ausführen
docker-compose exec app npx prisma db seed
```
## Manuelle Befehle
```bash
# In App-Container einsteigen
docker-compose exec app sh
# Prisma Studio starten (Datenbank-GUI)
docker-compose exec app npx prisma studio
# Migration manuell ausführen
docker-compose exec app npx prisma migrate deploy
# Seeding manuell ausführen
docker-compose exec app npx prisma db seed
```
## Sicherheitshinweise für Produktion
1. **Ändere das NEXTAUTH_SECRET** in `docker-compose.yml`
2. **Ändere das Datenbank-Passwort** in `docker-compose.yml`
3. **Ändere das Admin-Passwort** nach dem ersten Login
4. **Verwende HTTPS** mit einem Reverse Proxy (z.B. Nginx, Traefik)
5. **Setze NEXTAUTH_URL** auf deine echte Domain
6. **Verwende Environment-Files** statt hardcodierter Secrets
## Support
Bei Problemen prüfe:
1. Docker-Logs: `docker-compose logs -f`
2. Sind alle Container gestartet? `docker-compose ps`
3. Ist die Datenbank erreichbar? `docker-compose exec app npx prisma db seed`

448
README.md Normal file
View File

@ -0,0 +1,448 @@
# Aufgabenplaner
Ein modernes Task-Management-System gebaut mit Next.js 14, TypeScript, Prisma, PostgreSQL und NextAuth.js.
## Features
- ✅ **Rollenbasierte Zugriffskontrolle**: BEARBEITER, PFLEGER, VORGESETZTER, ADMIN
- ✅ **Task-Management**: Erstellen, Bearbeiten, Löschen von Aufgaben
- ✅ **Kommentare**: Kommunikation zu Aufgaben
- ✅ **Datei-Uploads**: Anhänge zu Aufgaben (PDF, JPG, PNG, DOCX, XLSX)
- ✅ **Kalender-Ansicht**: Visualisierung der Aufgaben mit react-big-calendar
- ✅ **Benutzerverwaltung**: Verwaltung von Benutzern (nur VORGESETZTER & ADMIN)
- ✅ **Dark/Light Mode**: Theme-Unterstützung
- ✅ **Responsive Design**: Desktop & Mobile optimiert
- ✅ **shadcn/ui Components**: Moderne UI-Komponenten
## Tech Stack
- **Framework**: Next.js 14 (App Router)
- **Sprache**: TypeScript
- **Datenbank**: PostgreSQL
- **ORM**: Prisma
- **Authentifizierung**: NextAuth.js
- **UI**: shadcn/ui + Tailwind CSS
- **Animationen**: Framer Motion
- **Formulare**: React Hook Form + Zod
- **State Management**: TanStack Query
- **Kalender**: react-big-calendar
## Voraussetzungen
- Node.js 20 oder höher
- PostgreSQL 15 oder höher (oder Docker)
- npm oder yarn
## Lokale Entwicklung
### 1. Repository klonen und Dependencies installieren
```bash
# Dependencies installieren
npm install
```
### 2. Environment Variables einrichten
Erstelle eine `.env` Datei im Root-Verzeichnis:
```env
DATABASE_URL="postgresql://user:password@localhost:5432/aufgabenplaner_db"
NEXTAUTH_SECRET="dein-geheimer-schluessel-hier"
NEXTAUTH_URL="http://localhost:3000"
```
Generiere `NEXTAUTH_SECRET`:
```bash
openssl rand -base64 32
```
### 3. Datenbank Setup
```bash
# Prisma Client generieren
npx prisma generate
# Datenbank-Migrationen ausführen
npx prisma migrate dev
# Datenbank mit Beispieldaten füllen
npx prisma db seed
```
### 4. Development Server starten
```bash
npm run dev
```
Die Anwendung läuft nun auf [http://localhost:3000](http://localhost:3000)
### Standard-Zugangsdaten
Nach dem Seeding ist folgender Admin-Account verfügbar:
- **Email**: admin@taskmanager.de
- **Passwort**: admin123
⚠️ **Wichtig**: Ändern Sie diese Zugangsdaten in Production!
## Production Deployment mit Docker
### 1. Environment Variables vorbereiten
```bash
# Template kopieren
cp .env.production.example .env.production
# .env.production bearbeiten und folgende Werte anpassen:
# - DB_PASSWORD: Sicheres Passwort für PostgreSQL
# - NEXTAUTH_SECRET: Generieren mit openssl rand -base64 32
# - NEXTAUTH_URL: Deine Production URL (z.B. https://aufgabenplaner.example.com)
```
### 2. Container bauen und starten
```bash
# Container im Hintergrund starten
docker-compose up -d
# Logs anschauen
docker-compose logs -f app
```
### 3. Datenbank initialisieren
```bash
# Migrations ausführen
docker-compose exec app npx prisma migrate deploy
# Datenbank seeden (erstellt Admin-User)
docker-compose exec app npx prisma db seed
```
Die Anwendung läuft nun auf [http://localhost:3000](http://localhost:3000)
### Container-Management
```bash
# Container stoppen
docker-compose down
# Container stoppen und Volumes löschen (⚠️ löscht alle Daten!)
docker-compose down -v
# Container neu bauen
docker-compose build --no-cache
# Logs anschauen
docker-compose logs -f app
docker-compose logs -f db
# Shell im Container öffnen
docker-compose exec app sh
```
## Prisma Studio
Prisma Studio ist ein visuelles Tool zur Verwaltung der Datenbank:
```bash
# Lokal
npx prisma studio
# In Docker
docker-compose exec app npx prisma studio
```
Studio läuft auf [http://localhost:5555](http://localhost:5555)
## Datenbank-Migrations
### Neue Migration erstellen
```bash
# Lokal
npx prisma migrate dev --name migration_name
# In Docker (nicht empfohlen, besser lokal entwickeln)
docker-compose exec app npx prisma migrate dev --name migration_name
```
### Migrations in Production anwenden
```bash
docker-compose exec app npx prisma migrate deploy
```
## Projekt-Struktur
```
src/
├── app/ # Next.js App Router
│ ├── (auth)/ # Auth-Gruppe (Login)
│ ├── (dashboard)/ # Dashboard-Gruppe (protected)
│ │ ├── page.tsx # Dashboard Startseite
│ │ ├── tasks/ # Aufgaben-Seiten
│ │ ├── calendar/ # Kalender-Seite
│ │ └── users/ # Benutzerverwaltung
│ └── api/ # API Routes
│ ├── auth/ # NextAuth API
│ ├── tasks/ # Task CRUD
│ └── users/ # User CRUD
├── components/ # React Komponenten
│ ├── calendar/ # Kalender-Komponenten
│ ├── layout/ # Layout-Komponenten
│ ├── shared/ # Gemeinsame Komponenten
│ ├── tasks/ # Task-Komponenten
│ ├── ui/ # shadcn/ui Komponenten
│ └── users/ # User-Komponenten
├── hooks/ # Custom React Hooks
├── lib/ # Utility-Funktionen
│ ├── auth.ts # NextAuth Konfiguration
│ ├── permissions.ts # Permission-System
│ ├── prisma.ts # Prisma Client
│ └── validations/ # Zod Schemas
├── types/ # TypeScript Types
└── middleware.ts # Next.js Middleware (Auth & Permissions)
prisma/
├── schema.prisma # Datenbank-Schema
└── seed.ts # Seed-Daten
```
## Berechtigungssystem
### Rollen
1. **BEARBEITER**: Kann nur eigene zugewiesene Aufgaben sehen und Status ändern
2. **PFLEGER**: Kann alle Aufgaben sehen, erstellen, bearbeiten und löschen
3. **VORGESETZTER**: Wie PFLEGER + Benutzerverwaltung
4. **ADMIN**: Volle Berechtigung + Benutzer löschen
### Benutzerrollen im Detail
#### Bearbeiter
- Kann nur **zugewiesene Aufgaben** sehen (keine Übersicht aller Aufgaben)
- Kann **Status eigener Aufgaben** ändern (UNERLEDIGT → IN_BEARBEITUNG → ERLEDIGT)
- Kann **Kommentare** zu eigenen Aufgaben schreiben
- Kann **Dateien** zu eigenen Aufgaben hochladen
- **Kein Zugriff** auf:
- Aufgaben erstellen
- Aufgaben bearbeiten (außer Status)
- Aufgaben löschen
- Benutzerverwaltung
#### Pfleger
- Kann **alle Aufgaben** sehen (Dashboard-Übersicht)
- Kann **Aufgaben erstellen** und allen Benutzern zuweisen
- Kann **Aufgaben bearbeiten** (Ort, Beschreibung, Frist, Zuständigkeit, Status)
- Kann **Aufgaben löschen**
- Kann **Kommentare** schreiben
- Kann **Dateien** hochladen
- **Kein Zugriff** auf:
- Benutzerverwaltung
#### Vorgesetzter
- **Alle Rechte von Pfleger**
- Kann **Benutzerverwaltung** aufrufen
- Kann **neue Benutzer** anlegen
- Kann **Benutzer bearbeiten** (Name, Email, Passwort, Rolle)
- **Kein Zugriff** auf:
- Benutzer löschen
#### Admin
- **Alle Rechte von Vorgesetzter**
- Kann **Benutzer löschen**
- Hat **volle System-Rechte**
### Berechtigungen
| Aktion | BEARBEITER | PFLEGER | VORGESETZTER | ADMIN |
|--------|------------|---------|--------------|-------|
| Eigene Tasks sehen | ✅ | ✅ | ✅ | ✅ |
| Alle Tasks sehen | ❌ | ✅ | ✅ | ✅ |
| Task erstellen | ❌ | ✅ | ✅ | ✅ |
| Task bearbeiten | ❌ | ✅ | ✅ | ✅ |
| Task-Status ändern | ✅ | ✅ | ✅ | ✅ |
| Task löschen | ❌ | ✅ | ✅ | ✅ |
| Kommentare hinzufügen | ✅ | ✅ | ✅ | ✅ |
| Dateien hochladen | ✅ | ✅ | ✅ | ✅ |
| Benutzer verwalten | ❌ | ❌ | ✅ | ✅ |
| Benutzer löschen | ❌ | ❌ | ❌ | ✅ |
## Sicherheit
- ✅ Passwörter werden mit bcrypt gehashed (12 Rounds)
- ✅ JWT-basierte Session mit NextAuth.js
- ✅ CSRF-Schutz durch NextAuth.js
- ✅ Role-basierte Zugriffskontrolle auf API- und UI-Ebene
- ✅ Input-Validierung mit Zod
- ✅ SQL-Injection-Schutz durch Prisma
- ✅ XSS-Schutz durch React
- ✅ File-Upload-Validierung (Dateityp & Größe)
## Performance-Optimierungen
- ✅ Server-Side Rendering (SSR)
- ✅ Static Site Generation (SSG) wo möglich
- ✅ Optimistic Updates mit TanStack Query
- ✅ Image Optimization mit next/image
- ✅ Code Splitting
- ✅ Database Indexing (Prisma)
- ✅ Standalone Output für Docker (minimale Image-Größe)
## Error Handling & Loading States
### Error Boundaries
Die Anwendung verwendet Next.js Error Boundaries für robuste Fehlerbehandlung:
- **Global Error Boundary** (`app/error.tsx`): Fängt unerwartete Fehler auf
- **404 Page** (`app/not-found.tsx`): Zeigt freundliche Fehlerseite für nicht gefundene Routen
- **Toast Notifications**: Benutzerfreundliche Fehler- und Erfolgsmeldungen mit sonner
### Loading States
Skeleton Components sorgen für bessere UX während Ladezeiten:
- **Global Loading** (`app/loading.tsx`): Layout-Skeleton mit Header, Sidebar, Content
- **Task List Loading** (`app/(dashboard)/tasks/loading.tsx`): TaskListSkeleton
- **Task Detail Loading** (`app/(dashboard)/tasks/[id]/loading.tsx`): TaskDetailSkeleton
- **Calendar Loading** (`app/(dashboard)/calendar/loading.tsx`): CalendarSkeleton
### File Upload Validation
- **Max Dateigröße**: 10MB
- **Erlaubte Typen**: PDF, JPG, PNG, DOCX, XLSX
- **Filename Sanitization**: Entfernt gefährliche Zeichen
- **Client & Server Validierung**: Doppelte Absicherung
## Testing Checklist
### Authentifizierung
- [ ] Login funktioniert mit korrekten Credentials
- [ ] Login schlägt fehl mit falschen Credentials
- [ ] Logout funktioniert und leitet zu Login weiter
- [ ] Session bleibt erhalten beim Reload
- [ ] Nicht-authentifizierte Benutzer werden zu /login weitergeleitet
### Rollenbasierte Berechtigungen
- [ ] **Bearbeiter** sieht nur zugewiesene Aufgaben
- [ ] **Bearbeiter** kann nur Status ändern (keine vollständige Bearbeitung)
- [ ] **Pfleger** sieht alle Aufgaben
- [ ] **Pfleger** kann Aufgaben erstellen, bearbeiten, löschen
- [ ] **Vorgesetzter** hat Zugriff auf Benutzerverwaltung
- [ ] **Vorgesetzter** kann Benutzer erstellen und bearbeiten (nicht löschen)
- [ ] **Admin** kann Benutzer löschen
- [ ] Middleware blockiert unautorisierte Zugriffe auf /users
### Task Management
- [ ] **Erstellen**: Neue Tasks können erstellt werden (Pfleger+)
- [ ] **Ansehen**: Tasks werden korrekt angezeigt
- [ ] **Bearbeiten**: Task-Details können geändert werden (Pfleger+)
- [ ] **Status ändern**: Status kann geändert werden (alle Rollen)
- [ ] **Löschen**: Tasks können gelöscht werden (Pfleger+)
- [ ] **Überfällige Tasks**: Werden rot markiert bei überschrittener Frist
### Kommentare
- [ ] Kommentare können hinzugefügt werden
- [ ] Kommentare werden mit Avatar und Timestamp angezeigt
- [ ] Relative Zeitangaben (z.B. "vor 3 Stunden") funktionieren
### File Upload
- [ ] Dateien können hochgeladen werden
- [ ] Validierung funktioniert (Dateityp, Größe)
- [ ] Dateien werden korrekt angezeigt
- [ ] Download-Links funktionieren
- [ ] Fehlermeldungen bei ungültigen Dateien
### Kalender
- [ ] Tasks werden im Kalender angezeigt
- [ ] Status-Farben sind korrekt (Rot/Gelb/Grün)
- [ ] Filter nach Status funktioniert
- [ ] Filter nach Zuständigkeit funktioniert
- [ ] Click auf Event öffnet Task-Detail-Dialog
- [ ] Navigation zur Task-Detailseite funktioniert
### Benutzerverwaltung
- [ ] Benutzerliste wird angezeigt (nur für Vorgesetzte/Admins)
- [ ] Neue Benutzer können erstellt werden
- [ ] Benutzer können bearbeitet werden
- [ ] Benutzer können gelöscht werden (nur Admin)
- [ ] E-Mail-Duplikate werden verhindert
- [ ] Passwort wird korrekt gehashed
### Responsive Design
- [ ] Desktop: Sidebar links, Content rechts
- [ ] Mobile: Sheet Sidebar, Hamburger Menu
- [ ] Alle Seiten sind mobile-optimiert
- [ ] Touch-Gesten funktionieren
### Dark/Light Mode
- [ ] Theme-Toggle funktioniert
- [ ] Theme bleibt erhalten beim Reload
- [ ] Alle Komponenten sind in beiden Modi lesbar
### Loading & Error States
- [ ] Loading Skeletons werden angezeigt
- [ ] Error Boundaries fangen Fehler ab
- [ ] 404 Page wird bei ungültigen Routes angezeigt
- [ ] Toast Notifications zeigen Fehler/Erfolg
## Troubleshooting
### Datenbank-Verbindungsfehler
```bash
# Prüfe ob PostgreSQL läuft
docker-compose ps
# Prüfe Logs
docker-compose logs db
# Restart Container
docker-compose restart db
```
### Migration-Fehler
```bash
# Reset Datenbank (⚠️ löscht alle Daten!)
npx prisma migrate reset
# Oder in Docker:
docker-compose exec app npx prisma migrate reset
```
### Port bereits belegt
```bash
# Port 3000 oder 5432 bereits belegt?
# Ändere die Ports in docker-compose.yml:
ports:
- "3001:3000" # Für App
- "5433:5432" # Für DB
```
## Lizenz
Dieses Projekt ist für interne Nutzung bestimmt.
## Support
Bei Fragen oder Problemen öffne ein Issue im Repository.

20
components.json Normal file
View File

@ -0,0 +1,20 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "src/app/globals.css",
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
}
}

51
docker-compose.yml Normal file
View File

@ -0,0 +1,51 @@
version: '3.8'
services:
# PostgreSQL Datenbank
db:
image: postgres:16-alpine
container_name: taskmanager-db
restart: unless-stopped
environment:
POSTGRES_USER: taskmanager
POSTGRES_PASSWORD: taskmanager123
POSTGRES_DB: taskmanager
volumes:
- postgres_data:/var/lib/postgresql/data
ports:
- "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U taskmanager"]
interval: 10s
timeout: 5s
retries: 5
# Task Manager App
app:
build:
context: .
dockerfile: Dockerfile
container_name: taskmanager-app
restart: unless-stopped
depends_on:
db:
condition: service_healthy
environment:
# Datenbank-Verbindung
DATABASE_URL: postgresql://taskmanager:taskmanager123@db:5432/taskmanager
# NextAuth Konfiguration
NEXTAUTH_URL: http://localhost:3000
NEXTAUTH_SECRET: super-geheimes-secret-bitte-in-produktion-aendern
# Node Environment
NODE_ENV: production
volumes:
# Upload-Ordner persistent machen
- uploads:/app/public/uploads
ports:
- "3000:3000"
volumes:
postgres_data:
uploads:

12
next.config.ts Normal file
View File

@ -0,0 +1,12 @@
import type { NextConfig } from "next";
/**
* Next.js Konfiguration
* - output: 'standalone' für Docker/Production Deployment
* - Optimiert für minimale Image-Größe
*/
const nextConfig: NextConfig = {
output: 'standalone',
};
export default nextConfig;

9288
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

73
package.json Normal file
View File

@ -0,0 +1,73 @@
{
"name": "taskmanager",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate deploy",
"prisma:seed": "ts-node prisma/seed.ts"
},
"prisma": {
"seed": "ts-node prisma/seed.ts"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"@auth/prisma-adapter": "^2.11.1",
"@hookform/resolvers": "^5.2.2",
"@prisma/client": "^6.19.0",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-query": "^5.90.7",
"@types/node": "^24.10.0",
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2",
"bcrypt": "^6.0.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"dotenv": "^17.2.3",
"eslint": "^9.39.1",
"eslint-config-next": "^16.0.1",
"framer-motion": "^12.23.24",
"lucide-react": "^0.553.0",
"next": "^16.0.1",
"next-auth": "^4.24.13",
"next-themes": "^0.4.6",
"react": "^19.2.0",
"react-big-calendar": "^1.19.4",
"react-day-picker": "^9.11.1",
"react-dom": "^19.2.0",
"react-hook-form": "^7.66.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"tailwindcss-animate": "^1.0.7",
"typescript": "^5.9.3",
"zod": "^4.1.12"
},
"devDependencies": {
"@types/bcrypt": "^6.0.0",
"autoprefixer": "^10.4.17",
"postcss": "^8.4.33",
"prisma": "^6.19.0",
"tailwindcss": "^3.4.1",
"ts-node": "^10.9.2"
}
}

9
postcss.config.mjs Normal file
View File

@ -0,0 +1,9 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
export default config;

23
prisma.config.ts Normal file
View File

@ -0,0 +1,23 @@
import "dotenv/config";
import { defineConfig, env } from "prisma/config";
/**
* Prisma Konfiguration für Aufgabenplaner
* - Schema-Pfad
* - Migrations-Pfad
* - Datasource URL
* - Seed-Kommando
*/
export default defineConfig({
schema: "prisma/schema.prisma",
migrations: {
path: "prisma/migrations",
},
engine: "classic",
datasource: {
url: env("DATABASE_URL"),
},
seed: {
command: 'ts-node --compiler-options {"module":"CommonJS"} prisma/seed.ts',
},
});

View File

@ -0,0 +1,98 @@
-- CreateEnum
CREATE TYPE "Role" AS ENUM ('BEARBEITER', 'PFLEGER', 'VORGESETZTER', 'ADMIN');
-- CreateEnum
CREATE TYPE "TaskStatus" AS ENUM ('UNERLEDIGT', 'IN_BEARBEITUNG', 'ERLEDIGT');
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL,
"email" TEXT NOT NULL,
"name" TEXT NOT NULL,
"password" TEXT NOT NULL,
"role" "Role" NOT NULL DEFAULT 'BEARBEITER',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Task" (
"id" TEXT NOT NULL,
"wo" TEXT NOT NULL,
"was" TEXT NOT NULL,
"bisWann" TIMESTAMP(3) NOT NULL,
"status" "TaskStatus" NOT NULL DEFAULT 'UNERLEDIGT',
"beauftragtAm" TIMESTAMP(3),
"firmaBeauftragt" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"creatorId" TEXT NOT NULL,
"assigneeId" TEXT,
CONSTRAINT "Task_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Comment" (
"id" TEXT NOT NULL,
"content" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"taskId" TEXT NOT NULL,
"authorId" TEXT NOT NULL,
CONSTRAINT "Comment_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "File" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"path" TEXT NOT NULL,
"mimeType" TEXT NOT NULL,
"size" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"taskId" TEXT NOT NULL,
CONSTRAINT "File_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
-- CreateIndex
CREATE INDEX "User_email_idx" ON "User"("email");
-- CreateIndex
CREATE INDEX "Task_status_idx" ON "Task"("status");
-- CreateIndex
CREATE INDEX "Task_bisWann_idx" ON "Task"("bisWann");
-- CreateIndex
CREATE INDEX "Task_assigneeId_idx" ON "Task"("assigneeId");
-- CreateIndex
CREATE INDEX "Task_creatorId_idx" ON "Task"("creatorId");
-- CreateIndex
CREATE INDEX "Comment_taskId_idx" ON "Comment"("taskId");
-- CreateIndex
CREATE INDEX "File_taskId_idx" ON "File"("taskId");
-- AddForeignKey
ALTER TABLE "Task" ADD CONSTRAINT "Task_creatorId_fkey" FOREIGN KEY ("creatorId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Task" ADD CONSTRAINT "Task_assigneeId_fkey" FOREIGN KEY ("assigneeId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Comment" ADD CONSTRAINT "Comment_taskId_fkey" FOREIGN KEY ("taskId") REFERENCES "Task"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Comment" ADD CONSTRAINT "Comment_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "File" ADD CONSTRAINT "File_taskId_fkey" FOREIGN KEY ("taskId") REFERENCES "Task"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"

113
prisma/schema.prisma Normal file
View File

@ -0,0 +1,113 @@
// Prisma Schema für Aufgabenplaner
// Dokumentation: https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// Benutzerrollen im System
enum Role {
BEARBEITER
PFLEGER
VORGESETZTER
ADMIN
}
// Aufgaben-Status
enum TaskStatus {
UNERLEDIGT
IN_BEARBEITUNG
ERLEDIGT
}
// Benutzer-Modell
model User {
id String @id @default(cuid())
email String @unique
name String
password String
role Role @default(BEARBEITER)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations: Aufgaben die der Benutzer erstellt hat
createdTasks Task[] @relation("TaskCreator")
// Relations: Aufgaben die dem Benutzer zugewiesen sind
assignedTasks Task[] @relation("TaskAssignee")
// Relations: Kommentare des Benutzers
comments Comment[]
@@index([email])
}
// Aufgaben-Modell
model Task {
id String @id @default(cuid())
wo String
was String @db.Text
bisWann DateTime
status TaskStatus @default(UNERLEDIGT)
beauftragtAm DateTime?
firmaBeauftragt String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations: Ersteller der Aufgabe
creatorId String
creator User @relation("TaskCreator", fields: [creatorId], references: [id])
// Relations: Zugewiesener Bearbeiter
assigneeId String?
assignee User? @relation("TaskAssignee", fields: [assigneeId], references: [id])
// Relations: Kommentare zur Aufgabe
comments Comment[]
// Relations: Dateien der Aufgabe
files File[]
@@index([status])
@@index([bisWann])
@@index([assigneeId])
@@index([creatorId])
}
// Kommentar-Modell
model Comment {
id String @id @default(cuid())
content String @db.Text
createdAt DateTime @default(now())
// Relations: Zugehörige Aufgabe
taskId String
task Task @relation(fields: [taskId], references: [id], onDelete: Cascade)
// Relations: Autor des Kommentars
authorId String
author User @relation(fields: [authorId], references: [id])
@@index([taskId])
}
// Datei-Modell
model File {
id String @id @default(cuid())
name String
path String
mimeType String
size Int
createdAt DateTime @default(now())
// Relations: Zugehörige Aufgabe
taskId String
task Task @relation(fields: [taskId], references: [id], onDelete: Cascade)
@@index([taskId])
}

42
prisma/seed.ts Normal file
View File

@ -0,0 +1,42 @@
import { PrismaClient } from '@prisma/client';
import * as bcrypt from 'bcrypt';
// Prisma Client Instanz
const prisma = new PrismaClient();
/**
* Seed-Funktion zum Initialisieren der Datenbank mit einem Admin-Benutzer
*/
async function main() {
console.log('Starte Datenbank-Seeding...');
// Passwort hashen mit bcrypt (10 Runden)
const hashedPassword = await bcrypt.hash('admin123', 10);
// Admin-Benutzer erstellen oder aktualisieren
const admin = await prisma.user.upsert({
where: { email: 'admin@taskmanager.de' },
update: {},
create: {
email: 'admin@taskmanager.de',
name: 'Administrator',
password: hashedPassword,
role: 'ADMIN',
},
});
console.log('Admin-Benutzer erstellt:', admin);
console.log('Email:', admin.email);
console.log('Passwort: admin123');
}
// Seed-Funktion ausführen
main()
.then(async () => {
await prisma.$disconnect();
})
.catch(async (e) => {
console.error('Fehler beim Seeding:', e);
await prisma.$disconnect();
process.exit(1);
});

2
public/uploads/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
*
!.gitignore

View File

@ -0,0 +1,120 @@
"use client";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { signIn } from "next-auth/react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { toast } from "sonner";
/**
* Login-Seite
* - Formular für Email und Passwort
* - Authentifizierung über NextAuth
* - Redirect zu /dashboard nach erfolgreichem Login
* - Toast-Benachrichtigungen für Fehler/Erfolg
*/
export default function LoginPage() {
// Router für Navigation
const router = useRouter();
// Loading-State für Button
const [isLoading, setIsLoading] = useState(false);
// Form-State für Email und Passwort
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
/**
* Handle Form Submit
* Führt Login mit NextAuth durch
*/
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);
try {
// Login mit NextAuth
const result = await signIn("credentials", {
email,
password,
redirect: false,
});
// Login fehlgeschlagen
if (result?.error) {
toast.error("Login fehlgeschlagen", {
description: "Ungültige Email oder Passwort",
});
return;
}
// Login erfolgreich
toast.success("Login erfolgreich", {
description: "Sie werden weitergeleitet...",
});
// Redirect zu Dashboard
router.push("/dashboard");
router.refresh();
} catch (error) {
toast.error("Fehler beim Login", {
description: "Ein unerwarteter Fehler ist aufgetreten",
});
} finally {
setIsLoading(false);
}
};
return (
<div className="flex min-h-screen items-center justify-center">
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle>Anmelden</CardTitle>
<CardDescription>
Melden Sie sich mit Ihrem Account an
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="ihre.email@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
disabled={isLoading}
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Passwort</Label>
<Input
id="password"
type="password"
placeholder="••••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
disabled={isLoading}
/>
</div>
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? "Wird angemeldet..." : "Anmelden"}
</Button>
</form>
</CardContent>
</Card>
</div>
);
}

View File

@ -0,0 +1,13 @@
import NextAuth from "next-auth";
import { authOptions } from "@/lib/auth";
/**
* NextAuth API Route Handler
* Behandelt alle NextAuth-bezogenen API-Anfragen (/api/auth/*)
* - GET: Session-Informationen abrufen
* - POST: Sign-in, Sign-out, etc.
*/
const handler = NextAuth(authOptions);
// Exportiere Handler für GET und POST Requests
export { handler as GET, handler as POST };

View File

@ -0,0 +1,156 @@
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { canAddComment, canViewAllTasks } from "@/lib/permissions";
import { z } from "zod";
/**
* Validierungsschema für Kommentar-Erstellung
*/
const createCommentSchema = z.object({
content: z.string().min(1, "Kommentar-Text ist erforderlich"),
});
/**
* GET /api/tasks/[id]/comments
* Gibt alle Kommentare einer Task zurück
*/
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
// Session prüfen
const session = await getServerSession(authOptions);
if (!session?.user) {
return NextResponse.json({ error: "Nicht autorisiert" }, { status: 401 });
}
// Task existiert prüfen
const task = await prisma.task.findUnique({
where: { id },
});
if (!task) {
return NextResponse.json({ error: "Task nicht gefunden" }, { status: 404 });
}
// Berechtigung prüfen - User muss Task sehen dürfen
if (
!canViewAllTasks(session.user.role) &&
task.assigneeId !== session.user.id
) {
return NextResponse.json(
{ error: "Keine Berechtigung" },
{ status: 403 }
);
}
// Kommentare abrufen (neueste zuerst)
const comments = await prisma.comment.findMany({
where: {
taskId: id,
},
include: {
author: {
select: {
id: true,
name: true,
email: true,
},
},
},
orderBy: {
createdAt: "desc",
},
});
return NextResponse.json(comments);
} catch (error) {
console.error("Fehler beim Abrufen der Kommentare:", error);
return NextResponse.json(
{ error: "Interner Serverfehler" },
{ status: 500 }
);
}
}
/**
* POST /api/tasks/[id]/comments
* Erstellt einen neuen Kommentar für eine Task
*/
export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
// Session prüfen
const session = await getServerSession(authOptions);
if (!session?.user) {
return NextResponse.json({ error: "Nicht autorisiert" }, { status: 401 });
}
// Task abrufen
const task = await prisma.task.findUnique({
where: { id },
});
if (!task) {
return NextResponse.json({ error: "Task nicht gefunden" }, { status: 404 });
}
// Berechtigung prüfen - User muss Kommentar hinzufügen dürfen
if (!canAddComment(session.user.role, task, session.user.id)) {
return NextResponse.json(
{ error: "Keine Berechtigung zum Kommentieren" },
{ status: 403 }
);
}
// Request Body parsen
const body = await req.json();
// Input validieren
const validation = createCommentSchema.safeParse(body);
if (!validation.success) {
return NextResponse.json(
{ error: "Validierungsfehler", details: validation.error.errors },
{ status: 400 }
);
}
// Kommentar erstellen
const comment = await prisma.comment.create({
data: {
content: validation.data.content,
taskId: id,
authorId: session.user.id,
},
include: {
author: {
select: {
id: true,
name: true,
email: true,
},
},
},
});
return NextResponse.json(comment, { status: 201 });
} catch (error) {
console.error("Fehler beim Erstellen des Kommentars:", error);
return NextResponse.json(
{ error: "Interner Serverfehler" },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,87 @@
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { canEditTask } from "@/lib/permissions";
import { unlink } from "fs/promises";
import { existsSync } from "fs";
import path from "path";
/**
* DELETE /api/tasks/[id]/files/[fileId]
* Löscht eine Datei einer Task
*/
export async function DELETE(
req: NextRequest,
{ params }: { params: { id: string; fileId: string } }
) {
try {
// Session prüfen
const session = await getServerSession(authOptions);
if (!session?.user) {
return NextResponse.json({ error: "Nicht autorisiert" }, { status: 401 });
}
// Task abrufen
const task = await prisma.task.findUnique({
where: { id: params.id },
});
if (!task) {
return NextResponse.json({ error: "Task nicht gefunden" }, { status: 404 });
}
// Berechtigung prüfen - User muss Task bearbeiten dürfen
if (!canEditTask(session.user.role, task, session.user.id)) {
return NextResponse.json(
{ error: "Keine Berechtigung zum Löschen von Dateien" },
{ status: 403 }
);
}
// Datei-Record abrufen
const file = await prisma.file.findUnique({
where: { id: params.fileId },
});
if (!file) {
return NextResponse.json(
{ error: "Datei nicht gefunden" },
{ status: 404 }
);
}
// Prüfen ob Datei zur Task gehört
if (file.taskId !== params.id) {
return NextResponse.json(
{ error: "Datei gehört nicht zu dieser Task" },
{ status: 400 }
);
}
// Datei von Disk löschen
const filePath = path.join(process.cwd(), "public", file.path);
if (existsSync(filePath)) {
try {
await unlink(filePath);
} catch (error) {
console.error("Fehler beim Löschen der Datei von Disk:", error);
// Fahre trotzdem fort, um den DB-Record zu löschen
}
}
// File-Record aus DB löschen
await prisma.file.delete({
where: { id: params.fileId },
});
return NextResponse.json({ message: "Datei erfolgreich gelöscht" });
} catch (error) {
console.error("Fehler beim Löschen der Datei:", error);
return NextResponse.json(
{ error: "Interner Serverfehler" },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,195 @@
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { canViewAllTasks, canEditTask } from "@/lib/permissions";
import { writeFile, mkdir } from "fs/promises";
import { existsSync } from "fs";
import path from "path";
/**
* Erlaubte Datei-Typen und maximale Dateigröße
*/
const ALLOWED_MIME_TYPES = [
"application/pdf",
"image/jpeg",
"image/jpg",
"image/png",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document", // .docx
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", // .xlsx
];
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
/**
* GET /api/tasks/[id]/files
* Gibt alle Dateien einer Task zurück
*/
export async function GET(
_req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
// Session prüfen
const session = await getServerSession(authOptions);
if (!session?.user) {
return NextResponse.json({ error: "Nicht autorisiert" }, { status: 401 });
}
// Task existiert prüfen
const task = await prisma.task.findUnique({
where: { id },
});
if (!task) {
return NextResponse.json({ error: "Task nicht gefunden" }, { status: 404 });
}
// Berechtigung prüfen - User muss Task sehen dürfen
if (
!canViewAllTasks(session.user.role) &&
task.assigneeId !== session.user.id
) {
return NextResponse.json(
{ error: "Keine Berechtigung" },
{ status: 403 }
);
}
// Dateien abrufen
const files = await prisma.file.findMany({
where: {
taskId: id,
},
orderBy: {
createdAt: "desc",
},
});
return NextResponse.json(files);
} catch (error) {
console.error("Fehler beim Abrufen der Dateien:", error);
return NextResponse.json(
{ error: "Interner Serverfehler" },
{ status: 500 }
);
}
}
/**
* POST /api/tasks/[id]/files
* Lädt eine neue Datei für eine Task hoch
*/
export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
// Session prüfen
const session = await getServerSession(authOptions);
if (!session?.user) {
return NextResponse.json({ error: "Nicht autorisiert" }, { status: 401 });
}
// Task abrufen
const task = await prisma.task.findUnique({
where: { id },
});
if (!task) {
return NextResponse.json({ error: "Task nicht gefunden" }, { status: 404 });
}
// Berechtigung prüfen - User muss Task sehen und bearbeiten dürfen
// Pfleger+ können immer Dateien hochladen
// Bearbeiter können hochladen, wenn sie der Aufgabe zugewiesen sind und diese nicht erledigt ist
if (!canViewAllTasks(session.user.role)) {
if (
task.assigneeId !== session.user.id ||
task.status === "ERLEDIGT"
) {
return NextResponse.json(
{ error: "Keine Berechtigung zum Hochladen von Dateien" },
{ status: 403 }
);
}
}
// FormData parsen
const formData = await req.formData();
const file = formData.get("file") as File;
if (!file) {
return NextResponse.json(
{ error: "Keine Datei hochgeladen" },
{ status: 400 }
);
}
// Dateigröße prüfen
if (file.size > MAX_FILE_SIZE) {
return NextResponse.json(
{ error: "Datei ist zu groß. Maximale Größe: 10MB" },
{ status: 400 }
);
}
// MIME-Type prüfen
if (!ALLOWED_MIME_TYPES.includes(file.type)) {
return NextResponse.json(
{
error:
"Dateityp nicht erlaubt. Erlaubte Typen: PDF, JPG, PNG, DOCX, XLSX",
},
{ status: 400 }
);
}
// Datei-Buffer lesen
const bytes = await file.arrayBuffer();
const buffer = Buffer.from(bytes);
// Upload-Verzeichnis erstellen falls nicht vorhanden
const uploadDir = path.join(process.cwd(), "public", "uploads", id);
if (!existsSync(uploadDir)) {
await mkdir(uploadDir, { recursive: true });
}
// Eindeutigen Dateinamen generieren
const timestamp = Date.now();
const sanitizedFileName = file.name.replace(/[^a-zA-Z0-9.-]/g, "_");
const fileName = `${timestamp}-${sanitizedFileName}`;
const filePath = path.join(uploadDir, fileName);
// Datei speichern
await writeFile(filePath, buffer);
// Relativer Pfad für DB
const relativePath = `/uploads/${id}/${fileName}`;
// File-Record in DB erstellen
const fileRecord = await prisma.file.create({
data: {
name: file.name,
path: relativePath,
mimeType: file.type,
size: file.size,
taskId: id,
},
});
return NextResponse.json(fileRecord, { status: 201 });
} catch (error) {
console.error("Fehler beim Hochladen der Datei:", error);
return NextResponse.json(
{ error: "Interner Serverfehler" },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,265 @@
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import {
canViewAllTasks,
canEditTask,
canChangeStatus,
canDeleteTask,
} from "@/lib/permissions";
import { updateTaskSchema, updateTaskStatusSchema } from "@/lib/validations";
/**
* GET /api/tasks/[id]
* Gibt eine einzelne Task zurück
*/
export async function GET(
_req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
// Session prüfen
const session = await getServerSession(authOptions);
if (!session?.user) {
return NextResponse.json({ error: "Nicht autorisiert" }, { status: 401 });
}
// Task abrufen
const task = await prisma.task.findUnique({
where: { id },
include: {
creator: {
select: {
id: true,
name: true,
email: true,
},
},
assignee: {
select: {
id: true,
name: true,
email: true,
},
},
comments: {
include: {
author: {
select: {
id: true,
name: true,
email: true,
},
},
},
orderBy: {
createdAt: "desc",
},
},
files: true,
},
});
if (!task) {
return NextResponse.json({ error: "Task nicht gefunden" }, { status: 404 });
}
// Berechtigung prüfen - User muss Task sehen dürfen
if (!canViewAllTasks(session.user.role)) {
// Bearbeiter dürfen nur ihre eigenen, nicht erledigten Tasks sehen
if (
task.assigneeId !== session.user.id ||
task.status === "ERLEDIGT"
) {
return NextResponse.json(
{ error: "Keine Berechtigung" },
{ status: 403 }
);
}
}
return NextResponse.json(task);
} catch (error) {
console.error("Fehler beim Abrufen der Task:", error);
return NextResponse.json(
{ error: "Interner Serverfehler" },
{ status: 500 }
);
}
}
/**
* PATCH /api/tasks/[id]
* Aktualisiert eine Task
* - Bearbeiter: Nur Status-Update erlaubt
* - Pfleger+: Alle Felder erlaubt
*/
export async function PATCH(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
// Session prüfen
const session = await getServerSession(authOptions);
if (!session?.user) {
return NextResponse.json({ error: "Nicht autorisiert" }, { status: 401 });
}
// Task abrufen
const task = await prisma.task.findUnique({
where: { id },
});
if (!task) {
return NextResponse.json({ error: "Task nicht gefunden" }, { status: 404 });
}
// Request Body parsen
const body = await req.json();
// Berechtigung prüfen und entsprechendes Schema verwenden
let validation;
// Pfleger+ können alle Felder bearbeiten
if (canEditTask(session.user.role, task, session.user.id)) {
validation = updateTaskSchema.safeParse({
...body,
bisWann: body.bisWann ? new Date(body.bisWann) : undefined,
beauftragtAm: body.beauftragtAm ? new Date(body.beauftragtAm) : undefined,
});
}
// Bearbeiter können nur Status ändern (ihrer eigenen Tasks)
else if (canChangeStatus(session.user.role, task, session.user.id)) {
// Nur Status-Feld erlaubt
if (Object.keys(body).length === 1 && "status" in body) {
validation = updateTaskStatusSchema.safeParse(body);
} else {
return NextResponse.json(
{ error: "Bearbeiter können nur den Status ändern" },
{ status: 403 }
);
}
} else {
return NextResponse.json(
{ error: "Keine Berechtigung" },
{ status: 403 }
);
}
if (!validation.success) {
return NextResponse.json(
{ error: "Validierungsfehler", details: validation.error.issues },
{ status: 400 }
);
}
// Task aktualisieren
const updatedTask = await prisma.task.update({
where: { id },
data: validation.data,
include: {
creator: {
select: {
id: true,
name: true,
email: true,
},
},
assignee: {
select: {
id: true,
name: true,
email: true,
},
},
comments: {
include: {
author: {
select: {
id: true,
name: true,
email: true,
},
},
},
},
files: true,
},
});
return NextResponse.json(updatedTask);
} catch (error) {
console.error("Fehler beim Aktualisieren der Task:", error);
return NextResponse.json(
{ error: "Interner Serverfehler" },
{ status: 500 }
);
}
}
/**
* DELETE /api/tasks/[id]
* Löscht eine Task
* - Nur Vorgesetzte+ dürfen Tasks löschen
* - Task muss Status ERLEDIGT haben
*/
export async function DELETE(
_req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
// Session prüfen
const session = await getServerSession(authOptions);
if (!session?.user) {
return NextResponse.json({ error: "Nicht autorisiert" }, { status: 401 });
}
// Berechtigung prüfen
if (!canDeleteTask(session.user.role)) {
return NextResponse.json(
{ error: "Keine Berechtigung zum Löschen von Tasks" },
{ status: 403 }
);
}
// Task existiert prüfen
const task = await prisma.task.findUnique({
where: { id },
});
if (!task) {
return NextResponse.json({ error: "Task nicht gefunden" }, { status: 404 });
}
// Prüfen ob Task erledigt ist
if (task.status !== "ERLEDIGT") {
return NextResponse.json(
{ error: "Nur erledigte Tasks können gelöscht werden" },
{ status: 403 }
);
}
// Task löschen (Comments und Files werden automatisch durch onDelete: Cascade gelöscht)
await prisma.task.delete({
where: { id },
});
return NextResponse.json({ message: "Task erfolgreich gelöscht" });
} catch (error) {
console.error("Fehler beim Löschen der Task:", error);
return NextResponse.json(
{ error: "Interner Serverfehler" },
{ status: 500 }
);
}
}

172
src/app/api/tasks/route.ts Normal file
View File

@ -0,0 +1,172 @@
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { canCreateTask, canViewAllTasks } from "@/lib/permissions";
import { createTaskSchema } from "@/lib/validations";
/**
* GET /api/tasks
* Gibt alle Tasks zurück, basierend auf den Berechtigungen des Benutzers
* - Bearbeiter: Nur eigene, nicht erledigte Tasks (assigneeId === userId && status !== ERLEDIGT)
* - Andere: Alle Tasks (erledigte nur wenn explizit gefiltert)
*/
export async function GET(req: NextRequest) {
try {
// Session prüfen
const session = await getServerSession(authOptions);
if (!session?.user) {
return NextResponse.json({ error: "Nicht autorisiert" }, { status: 401 });
}
// Query-Parameter für Filterung
const searchParams = req.nextUrl.searchParams;
const status = searchParams.get("status");
// Basis-Where-Clause basierend auf Berechtigung
const where: any = {};
// Bearbeiter können nur ihre eigenen, nicht erledigten Tasks sehen
if (!canViewAllTasks(session.user.role)) {
where.assigneeId = session.user.id;
where.status = { not: "ERLEDIGT" };
} else {
// Pfleger+: Erledigte Tasks standardmäßig ausblenden (außer explizit gefiltert)
if (!status) {
where.status = { not: "ERLEDIGT" };
} else if (status !== "ALL") {
// Status-Filter anwenden wenn vorhanden und nicht "ALL"
where.status = status;
}
// Bei status === "ALL": keine Status-Filterung für Pfleger+
}
// Für Bearbeiter Status-Filter ignorieren (immer nur nicht-erledigte)
if (!canViewAllTasks(session.user.role) && status && status !== "ALL") {
// Bearbeiter-Filter bleibt bestehen
}
// Tasks abrufen mit allen Relations
const tasks = await prisma.task.findMany({
where,
include: {
creator: {
select: {
id: true,
name: true,
email: true,
},
},
assignee: {
select: {
id: true,
name: true,
email: true,
},
},
comments: {
include: {
author: {
select: {
id: true,
name: true,
email: true,
},
},
},
orderBy: {
createdAt: "desc",
},
},
files: true,
},
orderBy: {
bisWann: "asc",
},
});
return NextResponse.json(tasks);
} catch (error) {
console.error("Fehler beim Abrufen der Tasks:", error);
return NextResponse.json(
{ error: "Interner Serverfehler" },
{ status: 500 }
);
}
}
/**
* POST /api/tasks
* Erstellt eine neue Task
* - Nur Pfleger+ dürfen Tasks erstellen
*/
export async function POST(req: NextRequest) {
try {
// Session prüfen
const session = await getServerSession(authOptions);
if (!session?.user) {
return NextResponse.json({ error: "Nicht autorisiert" }, { status: 401 });
}
// Berechtigung prüfen
if (!canCreateTask(session.user.role)) {
return NextResponse.json(
{ error: "Keine Berechtigung zum Erstellen von Tasks" },
{ status: 403 }
);
}
// Request Body parsen
const body = await req.json();
// Input validieren mit Zod
const validation = createTaskSchema.safeParse({
...body,
bisWann: new Date(body.bisWann),
beauftragtAm: body.beauftragtAm ? new Date(body.beauftragtAm) : undefined,
});
if (!validation.success) {
return NextResponse.json(
{ error: "Validierungsfehler", details: validation.error.errors },
{ status: 400 }
);
}
// Task erstellen
const task = await prisma.task.create({
data: {
...validation.data,
creatorId: session.user.id,
},
include: {
creator: {
select: {
id: true,
name: true,
email: true,
},
},
assignee: {
select: {
id: true,
name: true,
email: true,
},
},
comments: true,
files: true,
},
});
return NextResponse.json(task, { status: 201 });
} catch (error) {
console.error("Fehler beim Erstellen der Task:", error);
return NextResponse.json(
{ error: "Interner Serverfehler" },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,170 @@
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { hash } from "bcrypt";
import { authOptions } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { canManageUsers, canDeleteUser } from "@/lib/permissions";
import { updateUserSchema } from "@/lib/validations/user";
/**
* PATCH /api/users/[id]
* Aktualisiert einen Benutzer
* Benötigt: canManageUsers Berechtigung (VORGESETZTER oder ADMIN)
*/
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
// Session prüfen
const session = await getServerSession(authOptions);
if (!session?.user) {
return NextResponse.json({ error: "Nicht authentifiziert" }, { status: 401 });
}
// Berechtigung prüfen
if (!canManageUsers(session.user.role)) {
return NextResponse.json(
{ error: "Keine Berechtigung zum Aktualisieren von Benutzern" },
{ status: 403 }
);
}
// Request Body parsen
const body = await request.json();
// Validierung mit Zod
const validationResult = updateUserSchema.safeParse(body);
if (!validationResult.success) {
return NextResponse.json(
{ error: "Validierungsfehler", details: validationResult.error.errors },
{ status: 400 }
);
}
const data = validationResult.data;
// Prüfen ob Benutzer existiert
const existingUser = await prisma.user.findUnique({
where: { id },
});
if (!existingUser) {
return NextResponse.json(
{ error: "Benutzer nicht gefunden" },
{ status: 404 }
);
}
// Falls E-Mail geändert wird, prüfen ob bereits vergeben
if (data.email && data.email !== existingUser.email) {
const emailTaken = await prisma.user.findUnique({
where: { email: data.email },
});
if (emailTaken) {
return NextResponse.json(
{ error: "E-Mail-Adresse wird bereits verwendet" },
{ status: 400 }
);
}
}
// Update-Daten vorbereiten
const updateData: any = {};
if (data.name) updateData.name = data.name;
if (data.email) updateData.email = data.email;
if (data.role) updateData.role = data.role;
// Falls Passwort geändert wird, hashen
if (data.password) {
updateData.password = await hash(data.password, 12);
}
// Benutzer aktualisieren
const user = await prisma.user.update({
where: { id },
data: updateData,
select: {
id: true,
name: true,
email: true,
role: true,
createdAt: true,
updatedAt: true,
},
});
return NextResponse.json(user);
} catch (error) {
console.error("PATCH /api/users/[id] error:", error);
return NextResponse.json(
{ error: "Fehler beim Aktualisieren des Benutzers" },
{ status: 500 }
);
}
}
/**
* DELETE /api/users/[id]
* Löscht einen Benutzer
* Benötigt: canDeleteUser Berechtigung (nur ADMIN)
*/
export async function DELETE(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
// Session prüfen
const session = await getServerSession(authOptions);
if (!session?.user) {
return NextResponse.json({ error: "Nicht authentifiziert" }, { status: 401 });
}
// Berechtigung prüfen (nur Admins dürfen löschen)
if (!canDeleteUser(session.user.role)) {
return NextResponse.json(
{ error: "Keine Berechtigung zum Löschen von Benutzern" },
{ status: 403 }
);
}
// Prüfen ob Benutzer existiert
const existingUser = await prisma.user.findUnique({
where: { id },
});
if (!existingUser) {
return NextResponse.json(
{ error: "Benutzer nicht gefunden" },
{ status: 404 }
);
}
// Prüfen ob Benutzer sich selbst löschen will
if (id === session.user.id) {
return NextResponse.json(
{ error: "Sie können sich nicht selbst löschen" },
{ status: 400 }
);
}
// Benutzer löschen
await prisma.user.delete({
where: { id },
});
return NextResponse.json({ message: "Benutzer erfolgreich gelöscht" });
} catch (error) {
console.error("DELETE /api/users/[id] error:", error);
return NextResponse.json(
{ error: "Fehler beim Löschen des Benutzers" },
{ status: 500 }
);
}
}

131
src/app/api/users/route.ts Normal file
View File

@ -0,0 +1,131 @@
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { hash } from "bcrypt";
import { authOptions } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { canManageUsers } from "@/lib/permissions";
import { createUserSchema } from "@/lib/validations/user";
/**
* GET /api/users
* Gibt alle Benutzer zurück (ohne Passwort)
* Benötigt: canManageUsers Berechtigung (VORGESETZTER oder ADMIN)
*/
export async function GET(request: NextRequest) {
try {
// Session prüfen
const session = await getServerSession(authOptions);
if (!session?.user) {
return NextResponse.json({ error: "Nicht authentifiziert" }, { status: 401 });
}
// Berechtigung prüfen
if (!canManageUsers(session.user.role)) {
return NextResponse.json(
{ error: "Keine Berechtigung zum Verwalten von Benutzern" },
{ status: 403 }
);
}
// Alle Benutzer abrufen (ohne Passwort)
const users = await prisma.user.findMany({
select: {
id: true,
name: true,
email: true,
role: true,
createdAt: true,
updatedAt: true,
},
orderBy: {
createdAt: "desc",
},
});
return NextResponse.json(users);
} catch (error) {
console.error("GET /api/users error:", error);
return NextResponse.json(
{ error: "Fehler beim Abrufen der Benutzer" },
{ status: 500 }
);
}
}
/**
* POST /api/users
* Erstellt einen neuen Benutzer
* Benötigt: canManageUsers Berechtigung (VORGESETZTER oder ADMIN)
*/
export async function POST(request: NextRequest) {
try {
// Session prüfen
const session = await getServerSession(authOptions);
if (!session?.user) {
return NextResponse.json({ error: "Nicht authentifiziert" }, { status: 401 });
}
// Berechtigung prüfen
if (!canManageUsers(session.user.role)) {
return NextResponse.json(
{ error: "Keine Berechtigung zum Erstellen von Benutzern" },
{ status: 403 }
);
}
// Request Body parsen
const body = await request.json();
// Validierung mit Zod
const validationResult = createUserSchema.safeParse(body);
if (!validationResult.success) {
return NextResponse.json(
{ error: "Validierungsfehler", details: validationResult.error.errors },
{ status: 400 }
);
}
const { name, email, password, role } = validationResult.data;
// Prüfen ob E-Mail bereits existiert
const existingUser = await prisma.user.findUnique({
where: { email },
});
if (existingUser) {
return NextResponse.json(
{ error: "E-Mail-Adresse wird bereits verwendet" },
{ status: 400 }
);
}
// Passwort hashen
const hashedPassword = await hash(password, 12);
// Benutzer erstellen
const user = await prisma.user.create({
data: {
name,
email,
password: hashedPassword,
role,
},
select: {
id: true,
name: true,
email: true,
role: true,
createdAt: true,
updatedAt: true,
},
});
return NextResponse.json(user, { status: 201 });
} catch (error) {
console.error("POST /api/users error:", error);
return NextResponse.json(
{ error: "Fehler beim Erstellen des Benutzers" },
{ status: 500 }
);
}
}

45
src/app/calendar.css Normal file
View File

@ -0,0 +1,45 @@
/**
* Minimal CSS Overrides für react-big-calendar
* Nur Layout-Anpassungen, keine Farb-Änderungen
*/
@import 'react-big-calendar/lib/css/react-big-calendar.css';
/* Event-Höhe anpassen */
.rbc-event {
padding: 2px 5px;
font-size: 0.875rem;
border-radius: 4px;
}
/* Event-Label Styling */
.rbc-event-label {
font-size: 0.75rem;
}
/* Agenda View anpassen */
.rbc-agenda-view {
font-size: 0.875rem;
}
.rbc-agenda-date-cell,
.rbc-agenda-time-cell {
white-space: nowrap;
}
/* Responsive: Kleinere Schrift auf Mobile */
@media (max-width: 768px) {
.rbc-event {
font-size: 0.75rem;
padding: 1px 3px;
}
.rbc-toolbar {
flex-direction: column;
gap: 0.5rem;
}
.rbc-toolbar-label {
margin: 0.5rem 0;
}
}

View File

@ -0,0 +1,25 @@
import { TaskListSkeleton } from "@/components/shared/task-list-skeleton";
/**
* Loading State für Kalender-Seite
*/
export default function CalendarLoading() {
return (
<div className="flex flex-1 flex-col gap-4">
<div className="flex items-center justify-between">
<div className="space-y-2">
<div className="h-9 w-48 bg-muted animate-pulse rounded" />
<div className="h-5 w-64 bg-muted animate-pulse rounded" />
</div>
<div className="flex items-center gap-2">
<div className="h-9 w-20 bg-muted animate-pulse rounded" />
<div className="h-9 w-9 bg-muted animate-pulse rounded" />
<div className="h-9 w-48 bg-muted animate-pulse rounded" />
<div className="h-9 w-9 bg-muted animate-pulse rounded" />
</div>
</div>
<TaskListSkeleton />
</div>
);
}

View File

@ -0,0 +1,270 @@
"use client";
import { useEffect, useState } from "react";
import { useSession } from "next-auth/react";
import { useRouter } from "next/navigation";
import {
startOfMonth,
endOfMonth,
startOfWeek,
endOfWeek,
eachDayOfInterval,
format,
isSameMonth,
isSameDay,
addMonths,
subMonths,
isToday,
} from "date-fns";
import { de } from "date-fns/locale";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { useTasks } from "@/hooks/use-tasks";
import { TaskListSkeleton } from "@/components/shared/task-list-skeleton";
import { canViewAllTasks } from "@/lib/permissions";
import { TaskStatus } from "@prisma/client";
import { TaskWithRelations } from "@/types";
/**
* Kalender-Seite
* - Bearbeiter sehen nur offene Tasks (nicht ERLEDIGT)
* - Pfleger+ sehen alle Tasks inklusive erledigte
* - Monatliche Kalenderansicht mit Tasks an Fälligkeitsdatum
* - Farbcodierung nach Status
*/
export default function CalendarPage() {
const { data: session } = useSession();
const router = useRouter();
/**
* Aktueller Monat
*/
const [currentMonth, setCurrentMonth] = useState(new Date());
/**
* Prüfe ob User alle Tasks sehen darf
*/
const canViewAll = session?.user?.role
? canViewAllTasks(session.user.role)
: false;
/**
* Tasks abrufen
* - Bearbeiter: nur eigene, nicht erledigte (kein Parameter)
* - Pfleger+: alle Tasks inklusive erledigte (status="ALL")
*/
const { data: tasks, isLoading, error } = useTasks(canViewAll ? "ALL" : undefined);
/**
* Vorheriger Monat
*/
const handlePreviousMonth = () => {
setCurrentMonth(subMonths(currentMonth, 1));
};
/**
* Nächster Monat
*/
const handleNextMonth = () => {
setCurrentMonth(addMonths(currentMonth, 1));
};
/**
* Heute
*/
const handleToday = () => {
setCurrentMonth(new Date());
};
/**
* Kalendertage für aktuellen Monat generieren
*/
const monthStart = startOfMonth(currentMonth);
const monthEnd = endOfMonth(currentMonth);
const calendarStart = startOfWeek(monthStart, { locale: de });
const calendarEnd = endOfWeek(monthEnd, { locale: de });
const calendarDays = eachDayOfInterval({ start: calendarStart, end: calendarEnd });
/**
* Tasks für einen bestimmten Tag filtern
*/
const getTasksForDay = (day: Date): TaskWithRelations[] => {
if (!tasks) return [];
return tasks.filter((task) => {
const taskDate = new Date(task.bisWann);
return isSameDay(taskDate, day);
});
};
/**
* Badge Variant basierend auf Status
*/
const getStatusVariant = (status: TaskStatus) => {
switch (status) {
case "UNERLEDIGT":
return "destructive" as const;
case "IN_BEARBEITUNG":
return "secondary" as const;
case "ERLEDIGT":
return "default" as const;
default:
return "outline" as const;
}
};
/**
* Badge Name basierend auf Status
*/
const getStatusName = (status: TaskStatus) => {
switch (status) {
case "UNERLEDIGT":
return "U";
case "IN_BEARBEITUNG":
return "B";
case "ERLEDIGT":
return "E";
default:
return "?";
}
};
/**
* Task Click Handler - Navigiere zu Task-Details
*/
const handleTaskClick = (taskId: string) => {
router.push(`/dashboard/tasks/${taskId}`);
};
/**
* Loading
*/
if (!session) {
return null;
}
return (
<div className="flex flex-1 flex-col gap-4">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">Kalender</h1>
<p className="text-muted-foreground">
Aufgaben nach Fälligkeitsdatum
</p>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={handleToday}>
Heute
</Button>
<Button variant="outline" size="icon" onClick={handlePreviousMonth}>
<ChevronLeft className="h-4 w-4" />
</Button>
<div className="min-w-[200px] text-center font-semibold">
{format(currentMonth, "MMMM yyyy", { locale: de })}
</div>
<Button variant="outline" size="icon" onClick={handleNextMonth}>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
{/* Kalender Grid */}
{isLoading ? (
<TaskListSkeleton />
) : error ? (
<div className="text-center text-destructive">
Fehler beim Laden der Aufgaben
</div>
) : (
<Card>
<CardContent className="p-4">
{/* Wochentage Header */}
<div className="grid grid-cols-7 gap-2 mb-2">
{["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"].map((day) => (
<div
key={day}
className="text-center text-sm font-semibold text-muted-foreground py-2"
>
{day}
</div>
))}
</div>
{/* Kalendertage Grid */}
<div className="grid grid-cols-7 gap-2">
{calendarDays.map((day) => {
const dayTasks = getTasksForDay(day);
const isCurrentMonth = isSameMonth(day, currentMonth);
const isCurrentDay = isToday(day);
return (
<div
key={day.toISOString()}
className={`
min-h-[120px] border rounded-lg p-2
${isCurrentMonth ? "bg-card" : "bg-muted/50"}
${isCurrentDay ? "border-primary border-2" : ""}
`}
>
{/* Datum */}
<div
className={`
text-sm font-medium mb-2
${isCurrentMonth ? "" : "text-muted-foreground"}
${isCurrentDay ? "text-primary font-bold" : ""}
`}
>
{format(day, "d")}
</div>
{/* Tasks für diesen Tag */}
<div className="space-y-1">
{dayTasks.map((task) => (
<div
key={task.id}
onClick={() => handleTaskClick(task.id)}
className="cursor-pointer hover:opacity-80 transition-opacity"
>
<Badge
variant={getStatusVariant(task.status)}
className="w-full text-xs truncate justify-start px-1"
>
<span className="font-bold mr-1">
{getStatusName(task.status)}
</span>
<span className="truncate">{task.was}</span>
</Badge>
</div>
))}
</div>
</div>
);
})}
</div>
{/* Legende */}
<div className="mt-4 pt-4 border-t flex items-center gap-4 text-sm">
<div className="font-semibold text-muted-foreground">Legende:</div>
<div className="flex items-center gap-2">
<Badge variant="destructive" className="text-xs">U</Badge>
<span className="text-muted-foreground">Unerledigt</span>
</div>
<div className="flex items-center gap-2">
<Badge variant="secondary" className="text-xs">B</Badge>
<span className="text-muted-foreground">In Bearbeitung</span>
</div>
<div className="flex items-center gap-2">
<Badge variant="default" className="text-xs">E</Badge>
<span className="text-muted-foreground">Erledigt</span>
</div>
</div>
</CardContent>
</Card>
)}
</div>
);
}

View File

@ -0,0 +1,33 @@
import { ReactNode } from "react";
import { AppSidebar } from "@/components/layout/app-sidebar";
import { Header } from "@/components/layout/header";
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
/**
* Props für das Dashboard Layout
*/
interface DashboardLayoutProps {
children: ReactNode;
}
/**
* Dashboard Layout
* Modernes Layout mit shadcn/ui Sidebar-Komponenten
* - SidebarProvider für state management
* - AppSidebar mit collapsible navigation
* - SidebarInset für den Hauptinhalt
* - Header mit Breadcrumbs und Theme Toggle
*/
export default function DashboardLayout({ children }: DashboardLayoutProps) {
return (
<SidebarProvider>
<AppSidebar />
<SidebarInset>
<Header />
<div className="flex flex-1 flex-col gap-4 p-4 md:p-6 pt-6">
{children}
</div>
</SidebarInset>
</SidebarProvider>
);
}

236
src/app/dashboard/page.tsx Normal file
View File

@ -0,0 +1,236 @@
"use client";
import { useMemo } from "react";
import { useSession } from "next-auth/react";
import { useRouter } from "next/navigation";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { Button } from "@/components/ui/button";
import { CheckCircle2, Clock, ListTodo, AlertCircle, Plus, ArrowRight } from "lucide-react";
import { useTasks } from "@/hooks/use-tasks";
import { canCreateTask } from "@/lib/permissions";
import { TaskModal } from "@/components/tasks/task-modal";
import { isPast } from "date-fns";
/**
* Dashboard-Seite (Hauptseite)
* - Zeigt echte Aufgaben-Statistiken an
* - Übersicht der neuesten Aufgaben
* - Schnellzugriff für neue Aufgaben
*/
export default function DashboardPage() {
const { data: session } = useSession();
const router = useRouter();
const { data: tasks, isLoading } = useTasks();
/**
* Berechne Statistiken aus echten Tasks
*/
const stats = useMemo(() => {
if (!tasks) return null;
const unerledigt = tasks.filter((t) => t.status === "UNERLEDIGT").length;
const inBearbeitung = tasks.filter((t) => t.status === "IN_BEARBEITUNG").length;
const erledigt = tasks.filter((t) => t.status === "ERLEDIGT").length;
const ueberfaellig = tasks.filter(
(t) => t.status !== "ERLEDIGT" && isPast(new Date(t.bisWann))
).length;
return {
gesamt: tasks.length,
unerledigt,
inBearbeitung,
erledigt,
ueberfaellig,
};
}, [tasks]);
/**
* Prüfe ob User Tasks erstellen darf
*/
const canCreate = session?.user?.role
? canCreateTask(session.user.role)
: false;
/**
* Letzte 5 Aufgaben
*/
const recentTasks = useMemo(() => {
if (!tasks) return [];
return tasks.slice(0, 5);
}, [tasks]);
return (
<div className="flex flex-1 flex-col gap-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">Dashboard</h1>
<p className="text-muted-foreground">
Willkommen zurück, {session?.user?.name}!
</p>
</div>
{canCreate && (
<TaskModal
mode="create"
trigger={
<Button size="sm">
<Plus className="mr-2 h-4 w-4" />
Neue Aufgabe
</Button>
}
/>
)}
</div>
{/* Statistik Cards */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{isLoading ? (
<>
<Skeleton className="h-32" />
<Skeleton className="h-32" />
<Skeleton className="h-32" />
<Skeleton className="h-32" />
</>
) : (
<>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Gesamt</CardTitle>
<ListTodo className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats?.gesamt || 0}</div>
<p className="text-xs text-muted-foreground">Alle Aufgaben</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Unerledigt</CardTitle>
<Clock className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats?.unerledigt || 0}</div>
<p className="text-xs text-muted-foreground">Offene Aufgaben</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">In Bearbeitung</CardTitle>
<Clock className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats?.inBearbeitung || 0}</div>
<p className="text-xs text-muted-foreground">Aktive Aufgaben</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Überfällig</CardTitle>
<AlertCircle className="h-4 w-4 text-destructive" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-destructive">
{stats?.ueberfaellig || 0}
</div>
<p className="text-xs text-muted-foreground">Frist überschritten</p>
</CardContent>
</Card>
</>
)}
</div>
{/* Aktuelle Aufgaben */}
<div className="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Neueste Aufgaben</CardTitle>
<CardDescription>Ihre letzten 5 Aufgaben</CardDescription>
</div>
<Button variant="ghost" size="sm" onClick={() => router.push("/dashboard/tasks")}>
Alle anzeigen
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="space-y-2">
<Skeleton className="h-16" />
<Skeleton className="h-16" />
<Skeleton className="h-16" />
</div>
) : recentTasks.length > 0 ? (
<div className="space-y-4">
{recentTasks.map((task) => (
<div
key={task.id}
className="flex items-start justify-between border-b pb-3 last:border-0 last:pb-0 cursor-pointer hover:bg-muted/50 rounded-md p-2 -m-2"
onClick={() => router.push(`/dashboard/tasks/${task.id}`)}
>
<div className="space-y-1">
<p className="text-sm font-medium leading-none">{task.wo}</p>
<p className="text-sm text-muted-foreground line-clamp-1">{task.was}</p>
</div>
<Badge
variant={
task.status === "ERLEDIGT"
? "default"
: task.status === "IN_BEARBEITUNG"
? "secondary"
: "outline"
}
>
{task.status === "UNERLEDIGT"
? "Offen"
: task.status === "IN_BEARBEITUNG"
? "In Arbeit"
: "Erledigt"}
</Badge>
</div>
))}
</div>
) : (
<p className="text-sm text-muted-foreground">Keine Aufgaben vorhanden</p>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Ihre Account-Details</CardTitle>
<CardDescription>Benutzerinformationen</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Name:</span>
<span className="text-sm text-muted-foreground">{session?.user?.name}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Email:</span>
<span className="text-sm text-muted-foreground">{session?.user?.email}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Rolle:</span>
<Badge variant="outline">{session?.user?.role}</Badge>
</div>
</div>
</CardContent>
</Card>
</div>
</div>
);
}

View File

@ -0,0 +1,13 @@
import { TaskDetailSkeleton } from "@/components/shared/task-detail-skeleton";
/**
* Task Detail Page Loading State
* Zeigt TaskDetailSkeleton während die Task-Details laden
*/
export default function TaskDetailLoading() {
return (
<div className="p-4 md:p-8">
<TaskDetailSkeleton />
</div>
);
}

View File

@ -0,0 +1,136 @@
"use client";
import { useState } from "react";
import { useParams, useRouter } from "next/navigation";
import { ArrowLeft } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { TaskInfoCard } from "@/components/tasks/task-info-card";
import { CommentSection } from "@/components/tasks/comment-section";
import { FileSection } from "@/components/tasks/file-section";
import { FileUploadDialog } from "@/components/tasks/file-upload-dialog";
import { TaskActions } from "@/components/tasks/task-actions";
import { useTask } from "@/hooks";
import { Badge } from "@/components/ui/badge";
/**
* Task Detail Page
* Zeigt alle Details einer einzelnen Task an
* - Task-Info-Card mit allen Basis-Informationen
* - Comment-Section für Kommentare
* - File-Section für Datei-Anhänge
* - Nur shadcn/ui Komponenten
*/
export default function TaskDetailPage() {
const params = useParams();
const router = useRouter();
const taskId = params.id as string;
/**
* State für File-Upload-Dialog
*/
const [uploadDialogOpen, setUploadDialogOpen] = useState(false);
/**
* Hook zum Abrufen der Task-Daten
*/
const { data: task, isLoading, error } = useTask(taskId);
/**
* Handler für Zurück-Navigation
*/
const handleBack = () => {
router.push("/dashboard/tasks");
};
/**
* Loading State
*/
if (isLoading) {
return (
<div className="space-y-6">
<Skeleton className="h-10 w-32" />
<Skeleton className="h-40 w-full" />
<div className="grid gap-6 md:grid-cols-2">
<Skeleton className="h-96" />
<div className="space-y-6">
<Skeleton className="h-96" />
<Skeleton className="h-40" />
</div>
</div>
</div>
);
}
/**
* Error State
*/
if (error || !task) {
return (
<div className="space-y-4">
<Button variant="ghost" onClick={handleBack}>
<ArrowLeft className="h-4 w-4 mr-2" />
Zurück
</Button>
<Card>
<CardContent className="pt-6">
<p className="text-destructive">
Fehler beim Laden der Task. Bitte versuchen Sie es erneut.
</p>
</CardContent>
</Card>
</div>
);
}
/**
* Main Content
*/
return (
<div className="space-y-6">
{/* Zurück-Button und Actions */}
<div className="flex items-center justify-between">
<Button variant="ghost" onClick={handleBack}>
<ArrowLeft className="h-4 w-4 mr-2" />
Zurück
</Button>
<TaskActions task={task} />
</div>
{/* Task Titel und Beschreibung */}
<Card>
<CardHeader>
<CardTitle>
{task.was}
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm whitespace-pre-wrap">{task.wo}</p>
</CardContent>
</Card>
{/* Grid Layout: Info links, Comments und Files rechts */}
<div className="grid gap-6 md:grid-cols-2">
{/* Linke Spalte: Task Info */}
<TaskInfoCard task={task} />
{/* Rechte Spalte: Comments und Files */}
<div className="space-y-6">
<CommentSection task={task} />
<FileSection
task={task}
onUploadClick={() => setUploadDialogOpen(true)}
/>
</div>
</div>
{/* File Upload Dialog */}
<FileUploadDialog
taskId={taskId}
open={uploadDialogOpen}
onOpenChange={setUploadDialogOpen}
/>
</div>
);
}

View File

@ -0,0 +1,88 @@
"use client";
import { useEffect } from "react";
import { useSession } from "next-auth/react";
import { useRouter } from "next/navigation";
import { ArrowLeft } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useTasks } from "@/hooks/use-tasks";
import { TaskList } from "@/components/tasks/task-list";
import { TaskListSkeleton } from "@/components/shared/task-list-skeleton";
import { canViewAllTasks } from "@/lib/permissions";
/**
* Abgeschlossene Aufgaben-Seite
* - Nur für Pfleger+ zugänglich
* - Zeigt nur Aufgaben mit Status ERLEDIGT
* - Erlaubt Vorgesetzten+ das Löschen erledigter Aufgaben
*/
export default function CompletedTasksPage() {
const { data: session } = useSession();
const router = useRouter();
// Nur erledigte Tasks abrufen
const { data: tasks, isLoading, error } = useTasks("ERLEDIGT");
/**
* Prüfe ob User Pfleger+ ist (kann abgeschlossene Tasks sehen)
*/
const canView = session?.user?.role
? canViewAllTasks(session.user.role)
: false;
/**
* Wenn keine Berechtigung: zurück zur Übersicht (in useEffect)
*/
useEffect(() => {
if (session && !canView) {
router.push("/dashboard/tasks");
}
}, [session, canView, router]);
/**
* Loading oder keine Berechtigung: nichts anzeigen
*/
if (!session || !canView) {
return null;
}
return (
<div className="flex flex-1 flex-col gap-4">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => router.push("/dashboard/tasks")}
>
<ArrowLeft className="h-4 w-4 mr-2" />
Zurück
</Button>
</div>
<h1 className="text-3xl font-bold tracking-tight mt-2">Abgeschlossene Aufgaben</h1>
<p className="text-muted-foreground">
Erledigte Aufgaben zur Prüfung und Archivierung
</p>
</div>
</div>
{/* Task List */}
{isLoading ? (
<TaskListSkeleton />
) : error ? (
<div className="text-center text-destructive">
Fehler beim Laden der Aufgaben
</div>
) : tasks && tasks.length > 0 ? (
<TaskList tasks={tasks} />
) : (
<div className="text-center text-muted-foreground py-12">
Keine abgeschlossenen Aufgaben vorhanden
</div>
)}
</div>
);
}

View File

@ -0,0 +1,15 @@
import { TaskListSkeleton } from "@/components/shared/task-list-skeleton";
/**
* Tasks Page Loading State
* Zeigt TaskListSkeleton während die Tasks laden
*/
export default function TasksLoading() {
return (
<div className="space-y-6 p-4 md:p-8">
<div className="space-y-4">
<TaskListSkeleton />
</div>
</div>
);
}

View File

@ -0,0 +1,110 @@
"use client";
import { useState } from "react";
import { Plus, CheckCircle2 } from "lucide-react";
import { useSession } from "next-auth/react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { useTasks } from "@/hooks/use-tasks";
import { TaskFilters } from "@/components/tasks/task-filters";
import { TaskList } from "@/components/tasks/task-list";
import { TaskListSkeleton } from "@/components/shared/task-list-skeleton";
import { TaskModal } from "@/components/tasks/task-modal";
import { canCreateTask } from "@/lib/permissions";
import { Role } from "@prisma/client";
/**
* Aufgaben-Übersicht-Seite
* - TanStack Query für Datenabruf
* - Filter-Bereich mit Select Komponenten
* - "Neue Aufgabe" Button (nur für Pfleger+) mit TaskModal
* - TaskList mit Skeleton während Loading
* - Nur shadcn/ui Komponenten
*/
export default function TasksPage() {
const { data: session } = useSession();
const router = useRouter();
const [statusFilter, setStatusFilter] = useState<string | undefined>(
undefined
);
// Tasks mit TanStack Query abrufen
const { data: tasks, isLoading, error } = useTasks(statusFilter);
/**
* Handle Filter Reset
*/
const handleResetFilters = () => {
setStatusFilter(undefined);
};
/**
* Prüfe ob User Tasks erstellen darf
*/
const canCreate = session?.user?.role
? canCreateTask(session.user.role)
: false;
/**
* Prüfe ob User Pfleger+ ist (kann abgeschlossene Tasks sehen)
*/
const canViewCompleted = session?.user?.role
? [Role.PFLEGER, Role.VORGESETZTER, Role.ADMIN].includes(session.user.role as Role)
: false;
return (
<div className="flex flex-1 flex-col gap-4">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">Aufgaben</h1>
<p className="text-muted-foreground">
Alle Aufgaben verwalten und bearbeiten
</p>
</div>
<div className="flex items-center gap-2">
{canViewCompleted && (
<Button
variant="outline"
onClick={() => router.push("/dashboard/tasks/completed")}
>
<CheckCircle2 className="mr-2 h-4 w-4" />
Abgeschlossene
</Button>
)}
{canCreate && (
<TaskModal
mode="create"
trigger={
<Button>
<Plus className="mr-2 h-4 w-4" />
Neue Aufgabe
</Button>
}
/>
)}
</div>
</div>
{/* Filters */}
<TaskFilters
statusFilter={statusFilter}
onStatusChange={setStatusFilter}
onReset={handleResetFilters}
/>
{/* Task List */}
{isLoading ? (
<TaskListSkeleton />
) : error ? (
<div className="text-center text-destructive">
Fehler beim Laden der Aufgaben
</div>
) : (
<TaskList tasks={tasks || []} />
)}
</div>
);
}

View File

@ -0,0 +1,62 @@
"use client";
import { useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { UserTable } from "@/components/users/user-table";
import { UserModal } from "@/components/users/user-modal";
import { useUsers } from "@/hooks";
/**
* Users Page
* Benutzerverwaltung
* - Nur für VORGESETZTER und ADMIN zugänglich (Middleware-Check)
* - Card mit UserTable
* - Button "Neuer Benutzer" öffnet UserModal
* - Nur shadcn/ui Komponenten
*/
export default function UsersPage() {
/**
* State für Create Modal
*/
const [createModalOpen, setCreateModalOpen] = useState(false);
/**
* Hook zum Abrufen aller Benutzer
*/
const { data: users, isLoading } = useUsers();
/**
* Loading State
*/
if (isLoading) {
return (
<div className="space-y-6">
<Skeleton className="h-10 w-40" />
<Skeleton className="h-96 w-full" />
</div>
);
}
return (
<div className="space-y-6">
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>Benutzer</CardTitle>
<Button onClick={() => setCreateModalOpen(true)}>
Neuer Benutzer
</Button>
</div>
</CardHeader>
<CardContent>
<UserTable users={users || []} />
</CardContent>
</Card>
{/* Create Modal */}
<UserModal open={createModalOpen} onOpenChange={setCreateModalOpen} />
</div>
);
}

58
src/app/error.tsx Normal file
View File

@ -0,0 +1,58 @@
"use client";
import { useEffect } from "react";
import { AlertCircle } from "lucide-react";
import {
Card,
CardContent,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
/**
* Error Boundary Component
* Fängt Fehler auf und zeigt eine Fehlerseite an
* - Alert Komponente für Fehleranzeige
* - Button zum Erneuten Versuchen
* - Nur shadcn/ui Komponenten
*/
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
// Log error to console in development
console.error("Error caught by error boundary:", error);
}, [error]);
return (
<div className="flex min-h-screen items-center justify-center p-4">
<Card className="w-full max-w-lg">
<CardHeader>
<CardTitle>Ein Fehler ist aufgetreten</CardTitle>
</CardHeader>
<CardContent>
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Fehler</AlertTitle>
<AlertDescription>
{error.message || "Ein unerwarteter Fehler ist aufgetreten."}
</AlertDescription>
</Alert>
</CardContent>
<CardFooter className="flex gap-2">
<Button onClick={reset}>Erneut versuchen</Button>
<Button variant="outline" onClick={() => (window.location.href = "/dashboard")}>
Zur Startseite
</Button>
</CardFooter>
</Card>
</div>
);
}

83
src/app/globals.css Normal file
View File

@ -0,0 +1,83 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--radius: 0.65rem;
--background: 0 0% 100%;
--foreground: 240 5.9% 10%;
--card: 0 0% 100%;
--card-foreground: 240 5.9% 10%;
--popover: 0 0% 100%;
--popover-foreground: 240 5.9% 10%;
--primary: 142.1 76.2% 36.3%;
--primary-foreground: 355.7 100% 97.3%;
--secondary: 240 4.8% 95.9%;
--secondary-foreground: 240 5.9% 10%;
--muted: 240 4.8% 95.9%;
--muted-foreground: 240 3.8% 46.1%;
--accent: 240 4.8% 95.9%;
--accent-foreground: 240 5.9% 10%;
--destructive: 0 84.2% 60.2%;
--border: 240 5.9% 90%;
--input: 240 5.9% 90%;
--ring: 142.1 76.2% 36.3%;
--chart-1: 12 76% 61%;
--chart-2: 173 58% 39%;
--chart-3: 197 37% 24%;
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
--sidebar-background: 0 0% 98%;
--sidebar-foreground: 240 5.3% 26.1%;
--sidebar-primary: 240 5.9% 10%;
--sidebar-primary-foreground: 0 0% 98%;
--sidebar-accent: 240 4.8% 95.9%;
--sidebar-accent-foreground: 240 5.9% 10%;
--sidebar-border: 220 13% 91%;
--sidebar-ring: 217.2 91.2% 59.8%;
}
.dark {
--background: 0 0% 3.9%;
--foreground: 0 0% 98%;
--card: 0 0% 3.9%;
--card-foreground: 0 0% 98%;
--popover: 0 0% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: 142.1 70.6% 45.3%;
--primary-foreground: 144.9 80.4% 10%;
--secondary: 240 3.7% 15.9%;
--secondary-foreground: 0 0% 98%;
--muted: 240 3.7% 15.9%;
--muted-foreground: 240 5% 64.9%;
--accent: 240 3.7% 15.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--border: 240 3.7% 15.9%;
--input: 240 3.7% 15.9%;
--ring: 142.4 71.8% 29.2%;
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
--sidebar-background: 0 0% 3.9%;
--sidebar-foreground: 240 4.8% 95.9%;
--sidebar-primary: 224.3 76.3% 48%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 240 3.7% 15.9%;
--sidebar-accent-foreground: 240 4.8% 95.9%;
--sidebar-border: 240 3.7% 15.9%;
--sidebar-ring: 217.2 91.2% 59.8%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}

50
src/app/layout.tsx Normal file
View File

@ -0,0 +1,50 @@
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
import { SessionProvider } from "@/components/providers/session-provider";
import { ThemeProvider } from "@/components/providers/theme-provider";
import { QueryProvider } from "@/components/providers/query-provider";
import { Toaster } from "@/components/ui/sonner";
// Schriftart initialisieren
const inter = Inter({ subsets: ["latin"] });
// Metadata für die Anwendung
export const metadata: Metadata = {
title: "Aufgabenplaner",
description: "Ein moderner Aufgabenplaner mit Next.js 14+",
};
/**
* Root Layout Component
* - Bindet ThemeProvider für Dark/Light Mode ein
* - Bindet SessionProvider für Authentifizierung ein
* - Bindet QueryProvider für TanStack Query ein
* - Bindet Toaster für Toast-Benachrichtigungen ein
* - Setzt Schriftart und Sprache
*/
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="de" suppressHydrationWarning>
<body className={inter.className}>
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
<SessionProvider>
<QueryProvider>
{children}
<Toaster />
</QueryProvider>
</SessionProvider>
</ThemeProvider>
</body>
</html>
);
}

43
src/app/loading.tsx Normal file
View File

@ -0,0 +1,43 @@
import { Skeleton } from "@/components/ui/skeleton";
/**
* Global Loading State
* Zeigt einen Layout-Skeleton während die Seite lädt
* - Header Skeleton
* - Sidebar Skeleton (Desktop)
* - Content Skeleton
* - Nur shadcn/ui Komponenten
*/
export default function Loading() {
return (
<div className="flex min-h-screen flex-col">
{/* Navbar Skeleton */}
<div className="border-b">
<div className="flex h-16 items-center justify-between px-4">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-8 w-8 rounded-full" />
</div>
</div>
<div className="flex flex-1">
{/* Sidebar Skeleton (Desktop) */}
<aside className="hidden w-64 border-r p-4 md:block">
<div className="space-y-4">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
</div>
</aside>
{/* Content Skeleton */}
<main className="flex-1 p-4 md:p-8">
<div className="space-y-6">
<Skeleton className="h-10 w-64" />
<Skeleton className="h-96 w-full" />
</div>
</main>
</div>
</div>
);
}

39
src/app/not-found.tsx Normal file
View File

@ -0,0 +1,39 @@
import Link from "next/link";
import {
Card,
CardContent,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
/**
* 404 Not Found Page
* Zeigt eine Fehlerseite für nicht gefundene Routen
* - Card Komponente zentriert
* - Button zur Startseite
* - Nur shadcn/ui Komponenten
*/
export default function NotFound() {
return (
<div className="flex min-h-screen items-center justify-center p-4">
<Card className="w-full max-w-lg">
<CardHeader>
<CardTitle>404 - Seite nicht gefunden</CardTitle>
</CardHeader>
<CardContent>
<p className="text-muted-foreground">
Die angeforderte Seite konnte nicht gefunden werden. Möglicherweise
wurde sie verschoben oder gelöscht.
</p>
</CardContent>
<CardFooter>
<Button asChild>
<Link href="/dashboard">Zur Startseite</Link>
</Button>
</CardFooter>
</Card>
</div>
);
}

28
src/app/page.tsx Normal file
View File

@ -0,0 +1,28 @@
import { Button } from "@/components/ui/button";
import Link from "next/link";
/**
* Startseite
* - Zeigt Willkommensnachricht
* - Link zur Login-Seite
*/
export default function Home() {
return (
<div className="flex min-h-screen items-center justify-center">
<div className="text-center space-y-6">
<h1 className="text-4xl font-bold">Aufgabenplaner</h1>
<p className="text-muted-foreground">
Willkommen beim Aufgabenplaner
</p>
<div className="flex gap-4 justify-center">
<Button asChild>
<Link href="/login">Zum Login</Link>
</Button>
<Button asChild variant="outline">
<Link href="/dashboard">Zum Dashboard</Link>
</Button>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,25 @@
import { Badge } from "@/components/ui/badge";
/**
* Calendar Legend Component
* Zeigt eine Legende der Status-Farben
* - Badge für jeden Status mit Label
* - Flex Layout ohne custom classes
* - Nur shadcn/ui Komponenten (Badge)
*/
export function CalendarLegend() {
return (
<div className="flex items-center gap-4 flex-wrap">
<span className="text-sm font-medium">Legende:</span>
<div className="flex items-center gap-2">
<Badge variant="destructive">UNERLEDIGT</Badge>
</div>
<div className="flex items-center gap-2">
<Badge variant="secondary">IN_BEARBEITUNG</Badge>
</div>
<div className="flex items-center gap-2">
<Badge variant="default">ERLEDIGT</Badge>
</div>
</div>
);
}

View File

@ -0,0 +1,182 @@
"use client";
import { useState, useMemo, useCallback } from "react";
import { Calendar, dateFnsLocalizer, View } from "react-big-calendar";
import { format, parse, startOfWeek, getDay } from "date-fns";
import { de } from "date-fns/locale";
import { TaskWithRelations } from "@/types";
import { TaskEvent } from "./task-event";
import { TaskDetailDialog } from "./task-detail-dialog";
import { TaskStatus } from "@prisma/client";
/**
* date-fns Localizer für react-big-calendar
* Deutsche Lokalisierung
*/
const localizer = dateFnsLocalizer({
format,
parse,
startOfWeek: () => startOfWeek(new Date(), { weekStartsOn: 1 }),
getDay,
locales: { de },
});
/**
* Props für CalendarView Component
*/
interface CalendarViewProps {
tasks: TaskWithRelations[];
}
/**
* Calendar View Component
* Kalender-Ansicht mit react-big-calendar
* - Deutsche Lokalisierung mit date-fns
* - Tasks als Events (event.start = event.end = task.bisWann)
* - Event-Styling nach Status
* - Click auf Event öffnet TaskDetailDialog
* - Responsive: Desktop Monats-Ansicht, Mobile Agenda-Ansicht
*/
export function CalendarView({ tasks }: CalendarViewProps) {
/**
* State für ausgewählte Task (für Dialog)
*/
const [selectedTask, setSelectedTask] = useState<TaskWithRelations | null>(
null
);
/**
* State für Dialog
*/
const [dialogOpen, setDialogOpen] = useState(false);
/**
* State für aktuelle View (responsive)
*/
const [view, setView] = useState<View>("month");
/**
* Konvertiere Tasks zu Calendar Events
*/
const events = useMemo(() => {
return tasks.map((task) => ({
id: task.id,
title: task.wo,
start: new Date(task.bisWann),
end: new Date(task.bisWann),
resource: task,
status: task.status,
assigneeName: task.assignee?.name,
}));
}, [tasks]);
/**
* Event-Styling basierend auf Status
* Nutze CSS-Variablen für Farben
*/
const eventPropGetter = useCallback(
(event: any) => {
let style: React.CSSProperties = {};
switch (event.status as TaskStatus) {
case "UNERLEDIGT":
// Destructive Badge Farbe
style = {
backgroundColor: "hsl(var(--destructive))",
color: "hsl(var(--destructive-foreground))",
border: "none",
};
break;
case "IN_BEARBEITUNG":
// Secondary Badge Farbe
style = {
backgroundColor: "hsl(var(--secondary))",
color: "hsl(var(--secondary-foreground))",
border: "none",
};
break;
case "ERLEDIGT":
// Default Badge Farbe
style = {
backgroundColor: "hsl(var(--primary))",
color: "hsl(var(--primary-foreground))",
border: "none",
};
break;
default:
style = {
backgroundColor: "hsl(var(--muted))",
color: "hsl(var(--muted-foreground))",
border: "none",
};
}
return { style };
},
[]
);
/**
* Handler für Event-Click
*/
const handleSelectEvent = useCallback((event: any) => {
setSelectedTask(event.resource);
setDialogOpen(true);
}, []);
/**
* Custom Event-Komponente
*/
const EventComponent = useCallback(
({ event }: any) => <TaskEvent event={event} />,
[]
);
/**
* Deutsche Nachrichten für Kalender
*/
const messages = {
allDay: "Ganztägig",
previous: "Zurück",
next: "Weiter",
today: "Heute",
month: "Monat",
week: "Woche",
day: "Tag",
agenda: "Agenda",
date: "Datum",
time: "Zeit",
event: "Ereignis",
noEventsInRange: "Keine Aufgaben in diesem Zeitraum.",
showMore: (total: number) => `+ ${total} weitere`,
};
return (
<>
<div style={{ height: "600px" }}>
<Calendar
localizer={localizer}
events={events}
startAccessor="start"
endAccessor="end"
view={view}
onView={setView}
views={["month", "week", "day", "agenda"]}
messages={messages}
culture="de"
eventPropGetter={eventPropGetter}
onSelectEvent={handleSelectEvent}
components={{
event: EventComponent,
}}
/>
</div>
<TaskDetailDialog
task={selectedTask}
open={dialogOpen}
onOpenChange={setDialogOpen}
/>
</>
);
}

View File

@ -0,0 +1,9 @@
/**
* Zentrale Export-Datei für Calendar-Komponenten
* Ermöglicht einfache Imports: import { CalendarView, CalendarLegend } from "@/components/calendar"
*/
export { CalendarView } from "./calendar-view";
export { CalendarLegend } from "./calendar-legend";
export { TaskEvent } from "./task-event";
export { TaskDetailDialog } from "./task-detail-dialog";

View File

@ -0,0 +1,168 @@
"use client";
import { useRouter } from "next/navigation";
import { format } from "date-fns";
import { de } from "date-fns/locale";
import { Calendar, MapPin, User } from "lucide-react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator";
import { TaskWithRelations } from "@/types";
import { TaskStatus } from "@prisma/client";
/**
* Props für TaskDetailDialog Component
*/
interface TaskDetailDialogProps {
task: TaskWithRelations | null;
open: boolean;
onOpenChange: (open: boolean) => void;
}
/**
* Task Detail Dialog Component
* Dialog für Event-Click im Kalender
* - Zeigt Task-Details (ähnlich TaskInfoCard)
* - Button "Zur Aufgabe" navigiert zu task/[id]
* - Button "Schließen"
* - Nur shadcn/ui Komponenten
*/
export function TaskDetailDialog({
task,
open,
onOpenChange,
}: TaskDetailDialogProps) {
const router = useRouter();
/**
* Initialen für Avatar generieren
*/
const getInitials = (name: string) => {
return name
.split(" ")
.map((n) => n[0])
.join("")
.toUpperCase()
.slice(0, 2);
};
/**
* Badge Variant basierend auf Status
*/
const getStatusVariant = (status: TaskStatus) => {
switch (status) {
case "UNERLEDIGT":
return "destructive" as const;
case "IN_BEARBEITUNG":
return "secondary" as const;
case "ERLEDIGT":
return "default" as const;
default:
return "outline" as const;
}
};
/**
* Handler für Navigation zur Task-Detailseite
*/
const handleNavigateToTask = () => {
if (!task) return;
router.push(`/tasks/${task.id}`);
onOpenChange(false);
};
if (!task) return null;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{task.wo}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
{/* Status */}
<div>
<Badge variant={getStatusVariant(task.status)}>{task.status}</Badge>
</div>
<Separator />
{/* Beschreibung */}
<div className="space-y-2">
<Label>Beschreibung</Label>
<p className="text-sm whitespace-pre-wrap">{task.was}</p>
</div>
<Separator />
{/* Ort */}
<div className="space-y-2">
<Label className="flex items-center gap-2">
<MapPin className="h-4 w-4" />
Ort
</Label>
<p className="text-sm">{task.wo}</p>
</div>
<Separator />
{/* Frist */}
<div className="space-y-2">
<Label className="flex items-center gap-2">
<Calendar className="h-4 w-4" />
Frist
</Label>
<p className="text-sm">
{format(new Date(task.bisWann), "dd.MM.yyyy HH:mm", {
locale: de,
})}
</p>
</div>
{/* Zuständig */}
{task.assignee && (
<>
<Separator />
<div className="space-y-2">
<Label className="flex items-center gap-2">
<User className="h-4 w-4" />
Zuständig
</Label>
<div className="flex items-center gap-2">
<Avatar>
<AvatarFallback>
{getInitials(task.assignee.name)}
</AvatarFallback>
</Avatar>
<div>
<p className="text-sm font-medium">{task.assignee.name}</p>
<p className="text-xs text-muted-foreground">
{task.assignee.email}
</p>
</div>
</div>
</div>
</>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Schließen
</Button>
<Button onClick={handleNavigateToTask}>Zur Aufgabe</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,51 @@
import { Badge } from "@/components/ui/badge";
import { TaskStatus } from "@prisma/client";
/**
* Props für TaskEvent Component
*/
interface TaskEventProps {
event: {
title: string;
status: TaskStatus;
assigneeName?: string;
};
}
/**
* Task Event Component
* Custom Event-Komponente für react-big-calendar
* - Zeigt Ort (wo) und Zuständigen-Namen
* - Badge für Status
* - Text-Truncate bei langem Text
* - Nur shadcn/ui Komponenten (Badge)
*/
export function TaskEvent({ event }: TaskEventProps) {
/**
* Badge Variant basierend auf Status
*/
const getStatusVariant = (status: TaskStatus) => {
switch (status) {
case "UNERLEDIGT":
return "destructive" as const;
case "IN_BEARBEITUNG":
return "secondary" as const;
case "ERLEDIGT":
return "default" as const;
default:
return "outline" as const;
}
};
return (
<div className="flex items-center gap-1 overflow-hidden">
<Badge variant={getStatusVariant(event.status)} className="flex-shrink-0">
{event.status}
</Badge>
<span className="truncate text-xs">
{event.title}
{event.assigneeName && ` • ${event.assigneeName}`}
</span>
</div>
);
}

View File

@ -0,0 +1,187 @@
"use client";
import {
Home,
LogOut,
Users,
User,
ChevronUp,
ClipboardList,
Calendar,
} from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useSession, signOut } from "next-auth/react";
import { useRouter } from "next/navigation";
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarHeader,
} from "@/components/ui/sidebar";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { canManageUsers } from "@/lib/permissions";
/**
* Navigation Item Type
*/
interface NavItem {
title: string;
href: string;
icon: React.ComponentType<{ className?: string }>;
requiresPermission?: (role: string) => boolean;
}
/**
* Navigation Items Definition
*/
const navItems: NavItem[] = [
{
title: "Aufgaben",
href: "/dashboard/tasks",
icon: ClipboardList,
},
{
title: "Kalender",
href: "/dashboard/calendar",
icon: Calendar,
},
{
title: "Benutzerverwaltung",
href: "/dashboard/users",
icon: Users,
requiresPermission: (role) => canManageUsers(role as any),
},
];
/**
* App Sidebar Component
* Moderne Sidebar mit shadcn/ui Sidebar Komponenten
*/
export function AppSidebar() {
const pathname = usePathname();
const { data: session } = useSession();
const router = useRouter();
/**
* Logout Handler
*/
const handleLogout = async () => {
await signOut({ redirect: false });
router.push("/login");
};
/**
* Initialen für Avatar generieren
*/
const getInitials = (name: string) => {
return name
.split(" ")
.map((n) => n[0])
.join("")
.toUpperCase()
.slice(0, 2);
};
// Filtere Navigation Items nach Berechtigung
const filteredNavItems = navItems.filter((item) => {
if (!item.requiresPermission) return true;
if (!session?.user?.role) return false;
return item.requiresPermission(session.user.role);
});
return (
<Sidebar variant="inset">
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton size="lg" asChild>
<Link href="/dashboard">
<div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground">
<Home className="size-4" />
</div>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">Aufgabenplaner</span>
<span className="truncate text-xs">Task Management</span>
</div>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
<SidebarContent>
<SidebarGroup>
<SidebarGroupLabel>Navigation</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{filteredNavItems.map((item) => {
const Icon = item.icon;
const isActive = pathname === item.href || pathname.startsWith(item.href + "/");
return (
<SidebarMenuItem key={item.href}>
<SidebarMenuButton asChild isActive={isActive}>
<Link href={item.href}>
<Icon className="h-4 w-4" />
<span>{item.title}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
);
})}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
<SidebarFooter>
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton
size="lg"
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
>
<Avatar className="h-8 w-8 rounded-lg">
<AvatarFallback className="rounded-lg">
{session?.user?.name ? getInitials(session.user.name) : "U"}
</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">{session?.user?.name}</span>
<span className="truncate text-xs">{session?.user?.email}</span>
</div>
<ChevronUp className="ml-auto size-4" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-[--radix-dropdown-menu-trigger-width] min-w-56 rounded-lg"
side="bottom"
align="end"
sideOffset={4}
>
<DropdownMenuItem onClick={handleLogout}>
<LogOut className="mr-2 h-4 w-4" />
<span>Abmelden</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
</SidebarFooter>
</Sidebar>
);
}

View File

@ -0,0 +1,95 @@
"use client";
import { SidebarTrigger } from "@/components/ui/sidebar";
import { Separator } from "@/components/ui/separator";
import { ThemeToggle } from "./theme-toggle";
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@/components/ui/breadcrumb";
import { usePathname } from "next/navigation";
import { Fragment } from "react";
/**
* Header Component
* Moderne Header-Navigation mit Breadcrumbs und Theme Toggle
*/
export function Header() {
const pathname = usePathname();
/**
* Generiert Breadcrumbs basierend auf dem aktuellen Pfad
*/
const getBreadcrumbs = () => {
const paths = pathname.split("/").filter(Boolean);
const breadcrumbs = [];
// Dashboard root
if (paths.length === 1 && paths[0] === "dashboard") {
return [{ label: "Dashboard", href: "/dashboard", isLast: true }];
}
breadcrumbs.push({ label: "Dashboard", href: "/dashboard", isLast: false });
// Weitere Pfade
for (let i = 1; i < paths.length; i++) {
const path = paths[i];
const href = "/" + paths.slice(0, i + 1).join("/");
const isLast = i === paths.length - 1;
let label = path;
switch (path) {
case "calendar":
label = "Kalender";
break;
case "users":
label = "Benutzerverwaltung";
break;
case "tasks":
label = "Aufgaben";
break;
default:
// Für IDs: zeige nur "Details"
if (path.match(/^[a-zA-Z0-9-_]+$/)) {
label = "Details";
}
}
breadcrumbs.push({ label, href, isLast });
}
return breadcrumbs;
};
const breadcrumbs = getBreadcrumbs();
return (
<header className="flex h-16 shrink-0 items-center gap-2 border-b px-4">
<SidebarTrigger className="-ml-1" />
<Separator orientation="vertical" className="mr-2 h-4" />
<Breadcrumb>
<BreadcrumbList>
{breadcrumbs.map((crumb, index) => (
<Fragment key={crumb.href}>
<BreadcrumbItem>
{crumb.isLast ? (
<BreadcrumbPage>{crumb.label}</BreadcrumbPage>
) : (
<BreadcrumbLink href={crumb.href}>{crumb.label}</BreadcrumbLink>
)}
</BreadcrumbItem>
{!crumb.isLast && <BreadcrumbSeparator />}
</Fragment>
))}
</BreadcrumbList>
</Breadcrumb>
<div className="ml-auto">
<ThemeToggle />
</div>
</header>
);
}

View File

@ -0,0 +1,9 @@
/**
* Zentrale Export-Datei für Layout-Komponenten
* Ermöglicht einfache Imports: import { Navbar, Sidebar } from "@/components/layout"
*/
export { Navbar } from "./navbar";
export { Sidebar } from "./sidebar";
export { MobileSidebar } from "./mobile-sidebar";
export { ThemeToggle } from "./theme-toggle";

View File

@ -0,0 +1,37 @@
"use client";
import { Menu } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
Sheet,
SheetContent,
SheetTrigger,
} from "@/components/ui/sheet";
import { Sidebar } from "./sidebar";
/**
* Mobile Sidebar Component
* Zeigt die Sidebar in einem Sheet für mobile Geräte
* Schließt automatisch nach Navigation
*/
export function MobileSidebar() {
const [open, setOpen] = useState(false);
return (
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>
<Button variant="ghost" size="icon" className="md:hidden">
<Menu className="h-5 w-5" />
<span className="sr-only">Menü öffnen</span>
</Button>
</SheetTrigger>
<SheetContent side="left" className="w-64 p-0">
<div onClick={() => setOpen(false)}>
<Sidebar />
</div>
</SheetContent>
</Sheet>
);
}

View File

@ -0,0 +1,102 @@
"use client";
import { LogOut, User } from "lucide-react";
import { signOut, useSession } from "next-auth/react";
import { useRouter } from "next/navigation";
import {
Avatar,
AvatarFallback,
} from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { ThemeToggle } from "./theme-toggle";
import { MobileSidebar } from "./mobile-sidebar";
/**
* Navbar Component
* Header-Navigation mit Logo, Theme Toggle und User Dropdown
*/
export function Navbar() {
const { data: session } = useSession();
const router = useRouter();
/**
* Logout Handler
*/
const handleLogout = async () => {
await signOut({ redirect: false });
router.push("/login");
};
/**
* Initialen für Avatar generieren
*/
const getInitials = (name: string) => {
return name
.split(" ")
.map((n) => n[0])
.join("")
.toUpperCase()
.slice(0, 2);
};
return (
<header className="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="container flex h-14 items-center">
<MobileSidebar />
<div className="flex flex-1 items-center justify-between space-x-2 md:justify-end">
<div className="w-full flex-1 md:w-auto md:flex-none">
<span className="font-semibold md:hidden">Aufgabenplaner</span>
</div>
<div className="flex items-center gap-2">
<ThemeToggle />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="relative h-8 w-8 rounded-full">
<Avatar className="h-8 w-8">
<AvatarFallback>
{session?.user?.name ? getInitials(session.user.name) : "U"}
</AvatarFallback>
</Avatar>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>
<div className="flex flex-col space-y-1">
<p className="text-sm font-medium leading-none">
{session?.user?.name}
</p>
<p className="text-xs leading-none text-muted-foreground">
{session?.user?.email}
</p>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem>
<User className="mr-2 h-4 w-4" />
<span>Profil</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleLogout}>
<LogOut className="mr-2 h-4 w-4" />
<span>Abmelden</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
</header>
);
}

View File

@ -0,0 +1,92 @@
"use client";
import { Home, Calendar, Users } from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useSession } from "next-auth/react";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { canManageUsers } from "@/lib/permissions";
/**
* Navigation Item Type
*/
interface NavItem {
title: string;
href: string;
icon: React.ComponentType<{ className?: string }>;
requiresPermission?: (role: string) => boolean;
}
/**
* Navigation Items Definition
*/
const navItems: NavItem[] = [
{
title: "Aufgaben",
href: "/dashboard",
icon: Home,
},
{
title: "Kalender",
href: "/dashboard/calendar",
icon: Calendar,
},
{
title: "Benutzer",
href: "/dashboard/users",
icon: Users,
requiresPermission: (role) => canManageUsers(role as any),
},
];
/**
* Sidebar Component
* Desktop-Sidebar mit Navigation
* Zeigt aktiven State und filtert Items nach Berechtigung
*/
export function Sidebar() {
const pathname = usePathname();
const { data: session } = useSession();
// Filtere Navigation Items nach Berechtigung
const filteredNavItems = navItems.filter((item) => {
if (!item.requiresPermission) return true;
if (!session?.user?.role) return false;
return item.requiresPermission(session.user.role);
});
return (
<div className="flex h-full flex-col gap-2">
<div className="flex h-14 items-center border-b px-4">
<Link href="/dashboard" className="flex items-center gap-2 font-semibold">
<span>Aufgabenplaner</span>
</Link>
</div>
<Separator />
<div className="flex-1 px-3 py-2">
<nav className="grid gap-1">
{filteredNavItems.map((item) => {
const Icon = item.icon;
const isActive = pathname === item.href;
return (
<Button
key={item.href}
variant={isActive ? "secondary" : "ghost"}
className="justify-start"
asChild
>
<Link href={item.href}>
<Icon className="mr-2 h-4 w-4" />
{item.title}
</Link>
</Button>
);
})}
</nav>
</div>
</div>
);
}

View File

@ -0,0 +1,45 @@
"use client";
import * as React from "react";
import { Moon, Sun } from "lucide-react";
import { useTheme } from "next-themes";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
/**
* Theme Toggle Component
* Ermöglicht Wechsel zwischen Light, Dark und System Theme
* Nutzt next-themes für Theme-Management
*/
export function ThemeToggle() {
const { setTheme } = useTheme();
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span className="sr-only">Theme wechseln</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setTheme("light")}>
Hell
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("dark")}>
Dunkel
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("system")}>
System
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@ -0,0 +1,35 @@
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactNode, useState } from "react";
/**
* Props für den QueryProvider
*/
interface QueryProviderProps {
children: ReactNode;
}
/**
* Query Provider Wrapper
* Wraps die Anwendung mit dem TanStack Query Provider
* Muss ein Client Component sein ("use client")
*/
export function QueryProvider({ children }: QueryProviderProps) {
// Query Client Instanz erstellen (nur einmal pro Component-Lebenszyklus)
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000, // 1 Minute
refetchOnWindowFocus: false,
},
},
})
);
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
}

View File

@ -0,0 +1,20 @@
"use client";
import { SessionProvider as NextAuthSessionProvider } from "next-auth/react";
import { ReactNode } from "react";
/**
* Props für den SessionProvider
*/
interface SessionProviderProps {
children: ReactNode;
}
/**
* Session Provider Wrapper
* Wraps die Anwendung mit dem NextAuth SessionProvider
* Muss ein Client Component sein ("use client")
*/
export function SessionProvider({ children }: SessionProviderProps) {
return <NextAuthSessionProvider>{children}</NextAuthSessionProvider>;
}

View File

@ -0,0 +1,14 @@
"use client";
import * as React from "react";
import { ThemeProvider as NextThemesProvider } from "next-themes";
import { type ThemeProviderProps } from "next-themes/dist/types";
/**
* Theme Provider Wrapper
* Ermöglicht Light/Dark Mode Switching
* Wraps die Anwendung mit dem next-themes ThemeProvider
*/
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
}

View File

@ -0,0 +1,66 @@
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
/**
* Calendar Skeleton Component
* Loading State für die Kalender-Ansicht
* - Skeleton für Kalender-Grid
* - Nur shadcn/ui Komponenten
*/
export function CalendarSkeleton() {
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<Skeleton className="h-8 w-32" />
<Skeleton className="h-10 w-48" />
</div>
</CardHeader>
<CardContent className="space-y-6">
{/* Filter Skeletons */}
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Skeleton className="h-4 w-16" />
<Skeleton className="h-10 w-full" />
</div>
<div className="space-y-2">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-10 w-full" />
</div>
</div>
<Skeleton className="h-px w-full" />
{/* Legende Skeleton */}
<div className="flex items-center gap-4 flex-wrap">
<Skeleton className="h-4 w-16" />
<Skeleton className="h-6 w-24" />
<Skeleton className="h-6 w-32" />
<Skeleton className="h-6 w-24" />
</div>
<Skeleton className="h-px w-full" />
{/* Calendar Grid Skeleton */}
<div className="space-y-4">
{/* Calendar Header */}
<div className="flex items-center justify-between">
<Skeleton className="h-8 w-32" />
<div className="flex gap-2">
<Skeleton className="h-8 w-20" />
<Skeleton className="h-8 w-20" />
<Skeleton className="h-8 w-20" />
</div>
</div>
{/* Calendar Grid */}
<div className="grid grid-cols-7 gap-2">
{Array.from({ length: 35 }).map((_, i) => (
<Skeleton key={i} className="h-24 w-full" />
))}
</div>
</div>
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,53 @@
import { formatDistanceToNow } from "date-fns";
import { de } from "date-fns/locale";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { CommentWithAuthor } from "@/types";
/**
* Props für Comment Component
*/
interface CommentProps {
comment: CommentWithAuthor;
}
/**
* Comment Component
* Zeigt einen einzelnen Kommentar an
* - Avatar des Autors links
* - Name, Zeitstempel und Text rechts
* - Nur shadcn/ui Komponenten (Avatar)
*/
export function Comment({ comment }: CommentProps) {
/**
* Initialen für Avatar generieren
*/
const getInitials = (name: string) => {
return name
.split(" ")
.map((n) => n[0])
.join("")
.toUpperCase()
.slice(0, 2);
};
return (
<div className="flex gap-3">
<Avatar>
<AvatarFallback>{getInitials(comment.author.name)}</AvatarFallback>
</Avatar>
<div className="flex-1 space-y-1">
<div className="flex items-center gap-2">
<span className="font-semibold text-sm">{comment.author.name}</span>
<span className="text-xs text-muted-foreground">
{formatDistanceToNow(new Date(comment.createdAt), {
addSuffix: true,
locale: de,
})}
</span>
</div>
<p className="text-sm">{comment.content}</p>
</div>
</div>
);
}

View File

@ -0,0 +1,9 @@
/**
* Zentrale Export-Datei für Shared Components
* Ermöglicht einfache Imports: import { Comment, TaskListSkeleton } from "@/components/shared"
*/
export { Comment } from "./comment";
export { TaskListSkeleton } from "./task-list-skeleton";
export { TaskDetailSkeleton } from "./task-detail-skeleton";
export { CalendarSkeleton } from "./calendar-skeleton";

View File

@ -0,0 +1,92 @@
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
/**
* Task Detail Skeleton Component
* Loading State für die Task-Detailseite
* - Skeleton für Header, Info, Kommentare, Dateien
* - Nur shadcn/ui Komponenten
*/
export function TaskDetailSkeleton() {
return (
<div className="space-y-6">
{/* Zurück-Button Skeleton */}
<Skeleton className="h-10 w-32" />
{/* Task Titel und Beschreibung Skeleton */}
<Card>
<CardHeader>
<Skeleton className="h-8 w-3/4" />
</CardHeader>
<CardContent>
<Skeleton className="h-20 w-full" />
</CardContent>
</Card>
{/* Grid Layout */}
<div className="grid gap-6 md:grid-cols-2">
{/* Info Card Skeleton */}
<Card>
<CardHeader>
<Skeleton className="h-6 w-32" />
</CardHeader>
<CardContent className="space-y-4">
<Skeleton className="h-5 w-20" />
<Skeleton className="h-px w-full" />
<div className="space-y-2">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-4 w-full" />
</div>
<Skeleton className="h-px w-full" />
<div className="space-y-2">
<Skeleton className="h-4 w-16" />
<Skeleton className="h-4 w-32" />
</div>
</CardContent>
</Card>
{/* Comments & Files Skeleton */}
<div className="space-y-6">
{/* Comments Skeleton */}
<Card>
<CardHeader>
<Skeleton className="h-6 w-32" />
</CardHeader>
<CardContent className="space-y-4">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="flex gap-3">
<Skeleton className="h-10 w-10 rounded-full" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-4 w-full" />
</div>
</div>
))}
</CardContent>
</Card>
{/* Files Skeleton */}
<Card>
<CardHeader>
<Skeleton className="h-6 w-24" />
</CardHeader>
<CardContent className="space-y-3">
{Array.from({ length: 2 }).map((_, i) => (
<div key={i} className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Skeleton className="h-5 w-5" />
<div className="space-y-1">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-3 w-24" />
</div>
</div>
<Skeleton className="h-8 w-8" />
</div>
))}
</CardContent>
</Card>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,38 @@
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
/**
* Task List Skeleton Component
* Loading State für die Task-Liste
* - Grid Layout
* - Card-Skeletons mit Avatar, Text, Badge
* - Nur shadcn/ui Komponenten
*/
export function TaskListSkeleton() {
return (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<Card key={i}>
<CardHeader>
<Skeleton className="h-6 w-3/4" />
</CardHeader>
<CardContent className="space-y-3">
<Skeleton className="h-5 w-20" />
<div className="flex items-center gap-2">
<Skeleton className="h-4 w-4" />
<Skeleton className="h-4 w-32" />
</div>
<div className="flex items-center gap-2">
<Skeleton className="h-4 w-4" />
<Skeleton className="h-4 w-24" />
</div>
<div className="flex items-center gap-2">
<Skeleton className="h-8 w-8 rounded-full" />
<Skeleton className="h-4 w-24" />
</div>
</CardContent>
</Card>
))}
</div>
);
}

View File

@ -0,0 +1,95 @@
"use client";
import { useState } from "react";
import {
Card,
CardContent,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator";
import { Textarea } from "@/components/ui/textarea";
import { Button } from "@/components/ui/button";
import { Comment } from "@/components/shared/comment";
import { TaskWithRelations } from "@/types";
import { useAddComment } from "@/hooks/use-comments";
/**
* Props für CommentSection Component
*/
interface CommentSectionProps {
task: TaskWithRelations;
}
/**
* Comment Section Component
* Zeigt alle Kommentare einer Task an und ermöglicht das Hinzufügen neuer Kommentare
* - Nur shadcn/ui Komponenten (Card, ScrollArea, Separator, Textarea, Button)
* - Comment Component für einzelne Kommentare
*/
export function CommentSection({ task }: CommentSectionProps) {
/**
* State für neuen Kommentar-Text
*/
const [newComment, setNewComment] = useState("");
/**
* Hook zum Hinzufügen eines Kommentars
*/
const addComment = useAddComment(task.id);
/**
* Handler für Kommentar hinzufügen
*/
const handleAddComment = () => {
if (!newComment.trim()) return;
addComment.mutate(newComment, {
onSuccess: () => {
setNewComment("");
},
});
};
return (
<Card>
<CardHeader>
<CardTitle>Kommentare</CardTitle>
</CardHeader>
<CardContent>
<ScrollArea className="h-[400px] pr-4">
{task.comments.length === 0 ? (
<p className="text-sm text-muted-foreground">
Noch keine Kommentare vorhanden
</p>
) : (
<div className="space-y-4">
{task.comments.map((comment, index) => (
<div key={comment.id}>
<Comment comment={comment} />
{index < task.comments.length - 1 && <Separator className="mt-4" />}
</div>
))}
</div>
)}
</ScrollArea>
</CardContent>
<CardFooter className="flex-col gap-2">
<Textarea
placeholder="Kommentar hinzufügen..."
value={newComment}
onChange={(e) => setNewComment(e.target.value)}
/>
<Button
onClick={handleAddComment}
disabled={!newComment.trim() || addComment.isPending}
className="w-full"
>
{addComment.isPending ? "Wird hinzugefügt..." : "Kommentar hinzufügen"}
</Button>
</CardFooter>
</Card>
);
}

View File

@ -0,0 +1,118 @@
"use client";
import { format } from "date-fns";
import { de } from "date-fns/locale";
import { Download, FileText } from "lucide-react";
import { useSession } from "next-auth/react";
import {
Card,
CardContent,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { TaskWithRelations } from "@/types";
import { canViewAllTasks } from "@/lib/permissions";
/**
* Props für FileSection Component
*/
interface FileSectionProps {
task: TaskWithRelations;
onUploadClick: () => void;
}
/**
* File Section Component
* Zeigt alle hochgeladenen Dateien einer Task an
* - Nur shadcn/ui Komponenten (Card, Button, Separator)
* - Download-Links für jede Datei
* - Upload-Button im Footer (nur für berechtigte Benutzer)
*/
export function FileSection({ task, onUploadClick }: FileSectionProps) {
const { data: session } = useSession();
/**
* Formatiert Dateigröße in lesbares Format
*/
const formatFileSize = (bytes: number) => {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
};
/**
* Prüfe ob User Dateien hochladen darf
* Pfleger+ können immer hochladen
* Bearbeiter können hochladen, wenn sie zugewiesen sind und Task nicht erledigt
*/
const canUpload = session?.user
? canViewAllTasks(session.user.role) ||
(task.assigneeId === session.user.id && task.status !== "ERLEDIGT")
: false;
/**
* Handler für Datei-Download
*/
const handleDownload = (filePath: string, fileName: string) => {
const link = document.createElement("a");
link.href = filePath;
link.download = fileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
return (
<Card>
<CardHeader>
<CardTitle>Dateien</CardTitle>
</CardHeader>
<CardContent>
{task.files.length === 0 ? (
<p className="text-sm text-muted-foreground">
Noch keine Dateien hochgeladen
</p>
) : (
<div className="space-y-3">
{task.files.map((file, index) => (
<div key={file.id}>
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-3 flex-1 min-w-0">
<FileText className="h-5 w-5 text-muted-foreground flex-shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{file.name}</p>
<p className="text-xs text-muted-foreground">
{formatFileSize(file.size)} •{" "}
{format(new Date(file.createdAt), "dd.MM.yyyy", {
locale: de,
})}
</p>
</div>
</div>
<Button
variant="outline"
size="sm"
onClick={() => handleDownload(file.path, file.name)}
>
<Download className="h-4 w-4" />
</Button>
</div>
{index < task.files.length - 1 && <Separator className="mt-3" />}
</div>
))}
</div>
)}
</CardContent>
{canUpload && (
<CardFooter>
<Button onClick={onUploadClick} variant="outline" className="w-full">
Datei hochladen
</Button>
</CardFooter>
)}
</Card>
);
}

View File

@ -0,0 +1,142 @@
"use client";
import { useState } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useUploadFile } from "@/hooks/use-comments";
/**
* Props für FileUploadDialog Component
*/
interface FileUploadDialogProps {
taskId: string;
open: boolean;
onOpenChange: (open: boolean) => void;
}
/**
* File Upload Dialog Component
* Dialog zum Hochladen von Dateien zu einer Task
* - Nur shadcn/ui Komponenten (Dialog, Input, Button, Label)
* - Validierung: Max 10MB, erlaubte Typen: PDF, JPG, PNG, DOCX, XLSX
* - useUploadFile Hook für Upload-Mutation
*/
export function FileUploadDialog({
taskId,
open,
onOpenChange,
}: FileUploadDialogProps) {
/**
* State für ausgewählte Datei
*/
const [selectedFile, setSelectedFile] = useState<File | null>(null);
/**
* Hook zum Hochladen der Datei
*/
const uploadFile = useUploadFile(taskId);
/**
* Handler für Dateiauswahl
*/
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
// Validierung: Max 10MB
if (file.size > 10 * 1024 * 1024) {
alert("Datei ist zu groß. Maximal 10MB erlaubt.");
return;
}
// Validierung: Erlaubte Dateitypen
const allowedTypes = [
"application/pdf",
"image/jpeg",
"image/png",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
];
if (!allowedTypes.includes(file.type)) {
alert("Dateityp nicht erlaubt. Erlaubt: PDF, JPG, PNG, DOCX, XLSX");
return;
}
setSelectedFile(file);
};
/**
* Handler für Upload
*/
const handleUpload = () => {
if (!selectedFile) return;
uploadFile.mutate(selectedFile, {
onSuccess: () => {
setSelectedFile(null);
onOpenChange(false);
},
});
};
/**
* Handler für Dialog-Schließen
*/
const handleClose = () => {
setSelectedFile(null);
onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent>
<DialogHeader>
<DialogTitle>Datei hochladen</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="file">Datei auswählen</Label>
<Input
id="file"
type="file"
accept=".pdf,.jpg,.jpeg,.png,.docx,.xlsx"
onChange={handleFileChange}
/>
<p className="text-xs text-muted-foreground">
Erlaubt: PDF, JPG, PNG, DOCX, XLSX (max. 10MB)
</p>
</div>
{selectedFile && (
<div className="text-sm">
<p className="font-medium">Ausgewählte Datei:</p>
<p className="text-muted-foreground">{selectedFile.name}</p>
<p className="text-muted-foreground">
{(selectedFile.size / 1024 / 1024).toFixed(2)} MB
</p>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={handleClose}>
Abbrechen
</Button>
<Button
onClick={handleUpload}
disabled={!selectedFile || uploadFile.isPending}
>
{uploadFile.isPending ? "Wird hochgeladen..." : "Hochladen"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,9 @@
/**
* Zentrale Export-Datei für Task-Komponenten
* Ermöglicht einfache Imports: import { TaskCard, TaskList } from "@/components/tasks"
*/
export { TaskCard } from "./task-card";
export { TaskList } from "./task-list";
export { TaskFilters } from "./task-filters";
export { TaskModal } from "./task-modal";

View File

@ -0,0 +1,205 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Pencil, Trash2, PlayCircle, CheckCircle } from "lucide-react";
import { useSession } from "next-auth/react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { TaskModal } from "./task-modal";
import { useDeleteTask, useUpdateTask } from "@/hooks/use-tasks";
import { canEditTask, canDeleteTask } from "@/lib/permissions";
import { TaskWithRelations } from "@/types";
/**
* Props für TaskActions Component
*/
interface TaskActionsProps {
task: TaskWithRelations;
}
/**
* Task Actions Component
* Zeigt Edit- und Delete-Buttons für Tasks an
* - Edit: Nur für Pfleger+
* - Delete: Nur für Vorgesetzte+ und nur bei erledigten Tasks
* - Status-Buttons: Für Bearbeiter (In Bearbeitung, Erledigt)
*/
export function TaskActions({ task }: TaskActionsProps) {
const { data: session } = useSession();
const router = useRouter();
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const deleteTask = useDeleteTask();
const updateTask = useUpdateTask();
/**
* Prüfe Berechtigungen
*/
const canEdit = session?.user
? canEditTask(session.user.role, task, session.user.id)
: false;
const canDelete = session?.user
? canDeleteTask(session.user.role) && task.status === "ERLEDIGT"
: false;
/**
* Prüfe ob User der Assignee ist (für Status-Buttons)
*/
const isAssignee = session?.user?.id === task.assigneeId;
/**
* Zeige Status-Buttons nur für Bearbeiter die assigned sind
*/
const showInBearbeitungButton =
isAssignee && task.status === "UNERLEDIGT";
const showErledigtButton =
isAssignee && task.status === "IN_BEARBEITUNG";
/**
* Handle Status Change to IN_BEARBEITUNG
*/
const handleStartWork = async () => {
try {
await updateTask.mutateAsync({
id: task.id,
data: { status: "IN_BEARBEITUNG" },
});
toast.success("Status aktualisiert", {
description: "Die Aufgabe wurde auf 'In Bearbeitung' gesetzt.",
});
} catch (error: any) {
toast.error("Fehler", {
description: error?.message || "Der Status konnte nicht geändert werden.",
});
}
};
/**
* Handle Status Change to ERLEDIGT
*/
const handleComplete = async () => {
try {
await updateTask.mutateAsync({
id: task.id,
data: { status: "ERLEDIGT" },
});
toast.success("Aufgabe erledigt", {
description: "Die Aufgabe wurde erfolgreich abgeschlossen.",
});
// Zurück zur Tasks-Liste navigieren
router.push("/dashboard/tasks");
} catch (error: any) {
toast.error("Fehler", {
description: error?.message || "Der Status konnte nicht geändert werden.",
});
}
};
/**
* Handle Delete
*/
const handleDelete = async () => {
try {
await deleteTask.mutateAsync(task.id);
toast.success("Aufgabe gelöscht", {
description: "Die Aufgabe wurde erfolgreich gelöscht.",
});
router.push("/dashboard/tasks");
} catch (error: any) {
toast.error("Fehler", {
description: error?.message || "Die Aufgabe konnte nicht gelöscht werden.",
});
}
};
// Wenn keine Berechtigung: nichts anzeigen
if (!canEdit && !canDelete && !showInBearbeitungButton && !showErledigtButton) {
return null;
}
return (
<div className="flex items-center gap-2">
{/* Status-Buttons für Bearbeiter */}
{showInBearbeitungButton && (
<Button
variant="default"
size="sm"
onClick={handleStartWork}
disabled={updateTask.isPending}
>
<PlayCircle className="mr-2 h-4 w-4" />
In Bearbeitung
</Button>
)}
{showErledigtButton && (
<Button
variant="default"
size="sm"
onClick={handleComplete}
disabled={updateTask.isPending}
>
<CheckCircle className="mr-2 h-4 w-4" />
Erledigt
</Button>
)}
{/* Edit-Button für Pfleger+ */}
{canEdit && (
<TaskModal
mode="edit"
taskId={task.id}
trigger={
<Button variant="outline" size="sm">
<Pencil className="mr-2 h-4 w-4" />
Bearbeiten
</Button>
}
/>
)}
{canDelete && (
<>
<Button
variant="destructive"
size="sm"
onClick={() => setDeleteDialogOpen(true)}
>
<Trash2 className="mr-2 h-4 w-4" />
Löschen
</Button>
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Aufgabe löschen?</AlertDialogTitle>
<AlertDialogDescription>
Möchten Sie die Aufgabe "{task.wo}" wirklich löschen? Diese Aktion kann
nicht rückgängig gemacht werden.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Abbrechen</AlertDialogCancel>
<AlertDialogAction onClick={handleDelete}>
Löschen
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)}
</div>
);
}

View File

@ -0,0 +1,142 @@
"use client";
import { motion } from "framer-motion";
import { format, isPast } from "date-fns";
import { de } from "date-fns/locale";
import { useRouter } from "next/navigation";
import { Calendar, MapPin, AlertCircle } from "lucide-react";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { TaskWithRelations } from "@/types";
import { TaskStatus } from "@prisma/client";
/**
* Props für TaskCard Component
*/
interface TaskCardProps {
task: TaskWithRelations;
}
/**
* Task Card Component
* Zeigt eine einzelne Task in einer Card an
* - Nur shadcn/ui Komponenten (Card, Badge, Avatar)
* - Framer Motion für Hover-Animation
* - onClick öffnet Task-Detail-Page
*/
export function TaskCard({ task }: TaskCardProps) {
const router = useRouter();
/**
* Badge Name basierend auf Status
*/
const getStatusName = (status: TaskStatus) => {
switch (status) {
case "UNERLEDIGT":
return "Unerledigt" as const;
case "IN_BEARBEITUNG":
return "In Bearbeitung" as const;
case "ERLEDIGT":
return "Erledigt" as const;
default:
return "Unerledigt" as const;
}
};
/**
* Badge Variant basierend auf Status
*/
const getStatusVariant = (status: TaskStatus) => {
switch (status) {
case "UNERLEDIGT":
return "destructive" as const;
case "IN_BEARBEITUNG":
return "secondary" as const;
case "ERLEDIGT":
return "default" as const;
default:
return "outline" as const;
}
};
/**
* Initialen für Avatar generieren
*/
const getInitials = (name: string) => {
return name
.split(" ")
.map((n) => n[0])
.join("")
.toUpperCase()
.slice(0, 2);
};
/**
* Prüfe ob Frist überschritten ist
*/
const isOverdue = isPast(new Date(task.bisWann)) && task.status !== "ERLEDIGT";
/**
* Handle Card Click - Navigiere zu Task-Details
*/
const handleClick = () => {
router.push(`/dashboard/tasks/${task.id}`);
};
return (
<motion.div
whileHover={{ scale: 1.02 }}
transition={{ duration: 0.2 }}
onClick={handleClick}
style={{ cursor: "pointer" }}
>
<Card>
<CardHeader>
<CardTitle>{task.was}</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div>
<Badge variant={getStatusVariant(task.status)}>
{getStatusName(task.status)}
</Badge>
</div>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<MapPin className="h-4 w-4" />
<span>{task.wo}</span>
</div>
<div className={`flex items-center gap-2 text-sm ${isOverdue ? "text-destructive font-semibold" : "text-muted-foreground"}`}>
{isOverdue ? (
<AlertCircle className="h-4 w-4" />
) : (
<Calendar className="h-4 w-4" />
)}
<span>
Frist: {format(new Date(task.bisWann), "dd.MM.yyyy", { locale: de })}
{isOverdue && " (überfällig)"}
</span>
</div>
{task.assignee && (
<div className="flex items-center gap-2">
<Avatar>
<AvatarFallback>
{getInitials(task.assignee.name)}
</AvatarFallback>
</Avatar>
<span className="text-sm">{task.assignee.name}</span>
</div>
)}
</CardContent>
</Card>
</motion.div>
);
}

View File

@ -0,0 +1,65 @@
"use client";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { TaskStatus } from "@prisma/client";
/**
* Props für TaskFilters Component
*/
interface TaskFiltersProps {
statusFilter: string | undefined;
onStatusChange: (value: string | undefined) => void;
onReset: () => void;
}
/**
* Task Filters Component
* Filter-Bereich für Tasks mit shadcn/ui Select Komponenten
* - Status-Filter (UNERLEDIGT, IN_BEARBEITUNG, ERLEDIGT)
* - Reset-Button
*/
export function TaskFilters({
statusFilter,
onStatusChange,
onReset,
}: TaskFiltersProps) {
return (
<div className="flex flex-col gap-4 sm:flex-row sm:items-center">
<div className="flex-1">
<Select
value={statusFilter}
onValueChange={(value) => onStatusChange(value === "all" ? undefined : value)}
>
<SelectTrigger>
<SelectValue placeholder="Alle Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Alle Status</SelectItem>
<SelectItem value={TaskStatus.UNERLEDIGT}>
Unerledigt
</SelectItem>
<SelectItem value={TaskStatus.IN_BEARBEITUNG}>
In Bearbeitung
</SelectItem>
{/*
<SelectItem value={TaskStatus.ERLEDIGT}>
Erledigt
</SelectItem>
*/}
</SelectContent>
</Select>
</div>
<Button variant="outline" onClick={onReset}>
Filter zurücksetzen
</Button>
</div>
);
}

View File

@ -0,0 +1,208 @@
import { format, isPast } from "date-fns";
import { de } from "date-fns/locale";
import { Calendar, MapPin, User } from "lucide-react";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Separator } from "@/components/ui/separator";
import { Label } from "@/components/ui/label";
import { TaskStatus, TaskWithRelations } from "@/types";
import { Badge } from "../ui/badge";
/**
* Props für TaskInfoCard Component
*/
interface TaskInfoCardProps {
task: TaskWithRelations;
}
/**
* Task Info Card Component
* Zeigt alle Details einer Task an
* - Nur shadcn/ui Komponenten (Card, Avatar, Separator, Label)
* - Formatierung mit date-fns
*/
export function TaskInfoCard({ task }: TaskInfoCardProps) {
/**
* Badge Name basierend auf Status
*/
const getStatusName = (status: TaskStatus) => {
switch (status) {
case "UNERLEDIGT":
return "Unerledigt" as const;
case "IN_BEARBEITUNG":
return "In Bearbeitung" as const;
case "ERLEDIGT":
return "Erledigt" as const;
default:
return "Unerledigt" as const;
}
};
/**
* Badge Variant basierend auf Status
*/
const getStatusVariant = (status: TaskStatus) => {
switch (status) {
case "UNERLEDIGT":
return "destructive" as const;
case "IN_BEARBEITUNG":
return "secondary" as const;
case "ERLEDIGT":
return "default" as const;
default:
return "outline" as const;
}
};
/**
* Initialen für Avatar generieren
*/
const getInitials = (name: string) => {
return name
.split(" ")
.map((n) => n[0])
.join("")
.toUpperCase()
.slice(0, 2);
};
/**
* Prüfe ob Frist überschritten ist
*/
const isOverdue = isPast(new Date(task.bisWann)) && task.status !== "ERLEDIGT";
return (
<Card>
<CardHeader>
<CardTitle>Details</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Status */}
<div className="space-y-2">
<Label className="flex items-center gap-2">
<Calendar className="h-4 w-4" />
Status
</Label>
<div>
<Badge variant={getStatusVariant(task.status)}>
{getStatusName(task.status)}
</Badge>
</div>
</div>
<Separator />
{/* Ort */}
<div className="space-y-2">
<Label className="flex items-center gap-2">
<MapPin className="h-4 w-4" />
Ort
</Label>
<p className="text-sm">{task.wo}</p>
</div>
<Separator />
{/* Frist */}
<div className="space-y-2">
<Label className="flex items-center gap-2">
<Calendar className="h-4 w-4" />
Frist
</Label>
<p className={`text-sm ${isOverdue ? "text-destructive font-semibold" : ""}`}>
{format(new Date(task.bisWann), "dd.MM.yyyy", { locale: de })}
{isOverdue && " (überfällig)"}
</p>
</div>
<Separator />
{/* Zuständig */}
{task.assignee && (
<>
<div className="space-y-2">
<Label className="flex items-center gap-2">
<User className="h-4 w-4" />
Zuständig
</Label>
<div className="flex items-center gap-2">
<Avatar>
<AvatarFallback>
{getInitials(task.assignee.name)}
</AvatarFallback>
</Avatar>
<div>
<p className="text-sm font-medium">{task.assignee.name}</p>
<p className="text-xs text-muted-foreground">
{task.assignee.email}
</p>
</div>
</div>
</div>
<Separator />
</>
)}
{/* Erstellt von */}
<div className="space-y-2">
<Label>Erstellt von</Label>
<div className="flex items-center gap-2">
<Avatar>
<AvatarFallback>
{getInitials(task.creator.name)}
</AvatarFallback>
</Avatar>
<div>
<p className="text-sm font-medium">{task.creator.name}</p>
<p className="text-xs text-muted-foreground">
{task.creator.email}
</p>
</div>
</div>
</div>
<Separator />
{/* Erstellt am */}
<div className="space-y-2">
<Label>Erstellt am</Label>
<p className="text-sm">
{format(new Date(task.createdAt), "dd.MM.yyyy HH:mm", { locale: de })}
</p>
</div>
{/* Optional: Beauftragt am */}
{task.beauftragtAm && (
<>
<Separator />
<div className="space-y-2">
<Label>Beauftragt am</Label>
<p className="text-sm">
{format(new Date(task.beauftragtAm), "dd.MM.yyyy", { locale: de })}
</p>
</div>
</>
)}
{/* Optional: Firma beauftragt */}
{task.firmaBeauftragt && (
<>
<Separator />
<div className="space-y-2">
<Label>Firma beauftragt</Label>
<p className="text-sm">{task.firmaBeauftragt}</p>
</div>
</>
)}
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,57 @@
"use client";
import { motion, AnimatePresence } from "framer-motion";
import { TaskCard } from "./task-card";
import { TaskWithRelations } from "@/types";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { AlertCircle } from "lucide-react";
/**
* Props für TaskList Component
*/
interface TaskListProps {
tasks: TaskWithRelations[];
}
/**
* Task List Component
* Zeigt eine Liste von Tasks an
* - Grid Layout mit CSS Grid
* - Framer Motion AnimatePresence mit Stagger
* - Empty State mit Alert Komponente
*/
export function TaskList({ tasks }: TaskListProps) {
// Empty State wenn keine Tasks vorhanden
if (tasks.length === 0) {
return (
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertTitle>Keine Aufgaben gefunden</AlertTitle>
<AlertDescription>
Es wurden keine Aufgaben gefunden, die Ihren Filterkriterien entsprechen.
</AlertDescription>
</Alert>
);
}
return (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
<AnimatePresence mode="popLayout">
{tasks.map((task, index) => (
<motion.div
key={task.id}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.9 }}
transition={{
duration: 0.3,
delay: index * 0.05,
}}
>
<TaskCard task={task} />
</motion.div>
))}
</AnimatePresence>
</div>
);
}

View File

@ -0,0 +1,338 @@
"use client";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { format } from "date-fns";
import { de } from "date-fns/locale";
import { Calendar as CalendarIcon } from "lucide-react";
import { useSession } from "next-auth/react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Calendar } from "@/components/ui/calendar";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { createTaskSchema, updateTaskSchema } from "@/lib/validations";
import { useCreateTask, useUpdateTask, useTask } from "@/hooks/use-tasks";
import { useUsers } from "@/hooks/use-users";
import { canEditTask, canChangeStatus } from "@/lib/permissions";
import { TaskStatus } from "@prisma/client";
import { cn } from "@/lib/utils";
/**
* Props für TaskModal Component
*/
interface TaskModalProps {
taskId?: string;
trigger: React.ReactNode;
mode?: "create" | "edit";
}
/**
* Task Modal Component
* Formular zum Erstellen/Bearbeiten von Tasks
* - Nur shadcn/ui Komponenten
* - React Hook Form mit Zod Validierung
* - Berechtigungsprüfung
*/
export function TaskModal({ taskId, trigger, mode = "create" }: TaskModalProps) {
const [open, setOpen] = useState(false);
const { data: session } = useSession();
// Hooks
const { data: task, isLoading: taskLoading } = useTask(taskId || "");
const { data: users, isLoading: usersLoading } = useUsers();
const createTask = useCreateTask();
const updateTask = useUpdateTask();
// Form Setup
const form = useForm({
resolver: zodResolver(mode === "create" ? createTaskSchema : updateTaskSchema),
defaultValues: {
wo: "",
was: "",
bisWann: new Date(),
assigneeId: "",
status: TaskStatus.UNERLEDIGT,
beauftragtAm: undefined as Date | undefined,
firmaBeauftragt: "",
},
});
// Lade Task-Daten bei Edit-Mode
useEffect(() => {
if (task && mode === "edit") {
form.reset({
wo: task.wo,
was: task.was,
bisWann: new Date(task.bisWann),
assigneeId: task.assigneeId || "",
status: task.status,
beauftragtAm: task.beauftragtAm ? new Date(task.beauftragtAm) : undefined,
firmaBeauftragt: task.firmaBeauftragt || "",
});
}
}, [task, mode, form]);
/**
* Berechtigungsprüfung
*/
const canEdit =
mode === "create" ||
(task && session?.user
? canEditTask(session.user.role, task, session.user.id)
: false);
const canOnlyChangeStatus =
mode === "edit" &&
task &&
session?.user &&
!canEditTask(session.user.role, task, session.user.id) &&
canChangeStatus(session.user.role, task, session.user.id);
/**
* Form Submit Handler
*/
const onSubmit = async (data: any) => {
try {
if (mode === "create") {
await createTask.mutateAsync(data);
toast.success("Aufgabe erstellt", {
description: "Die Aufgabe wurde erfolgreich erstellt.",
});
} else if (taskId) {
// Bearbeiter können nur Status ändern
const updateData = canOnlyChangeStatus
? { status: data.status }
: data;
await updateTask.mutateAsync({ id: taskId, data: updateData });
toast.success("Aufgabe aktualisiert", {
description: "Die Aufgabe wurde erfolgreich aktualisiert.",
});
}
setOpen(false);
form.reset();
} catch (error) {
toast.error("Fehler", {
description: "Es ist ein Fehler aufgetreten.",
});
}
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>{trigger}</DialogTrigger>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
{mode === "create" ? "Neue Aufgabe" : "Aufgabe bearbeiten"}
</DialogTitle>
<DialogDescription>
{mode === "create"
? "Erstellen Sie eine neue Aufgabe"
: "Bearbeiten Sie die Aufgabe"}
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
{/* Wo */}
<FormField
control={form.control}
name="wo"
render={({ field }) => (
<FormItem>
<FormLabel>Wo</FormLabel>
<FormControl>
<Input
placeholder="Ort der Aufgabe"
{...field}
disabled={!canEdit || canOnlyChangeStatus}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Was */}
<FormField
control={form.control}
name="was"
render={({ field }) => (
<FormItem>
<FormLabel>Was</FormLabel>
<FormControl>
<Textarea
placeholder="Beschreibung der Aufgabe"
{...field}
disabled={!canEdit || canOnlyChangeStatus}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Bis wann */}
<FormField
control={form.control}
name="bisWann"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel>Bis wann</FormLabel>
<Popover>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
className={cn(
"w-full pl-3 text-left font-normal",
!field.value && "text-muted-foreground"
)}
disabled={!canEdit || canOnlyChangeStatus}
>
{field.value ? (
format(field.value, "dd.MM.yyyy", { locale: de })
) : (
<span>Datum wählen</span>
)}
<CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={field.value}
onSelect={field.onChange}
disabled={(date) =>
date < new Date(new Date().setHours(0, 0, 0, 0))
}
initialFocus
/>
</PopoverContent>
</Popover>
<FormMessage />
</FormItem>
)}
/>
{/* Zuständigkeit */}
<FormField
control={form.control}
name="assigneeId"
render={({ field }) => (
<FormItem>
<FormLabel>Zuständigkeit</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
disabled={!canEdit || canOnlyChangeStatus || usersLoading}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Benutzer wählen" />
</SelectTrigger>
</FormControl>
<SelectContent>
{users?.filter(user => user.role === "BEARBEITER").map((user) => (
<SelectItem key={user.id} value={user.id}>
{user.name} ({user.role})
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
{/* Status - Nur bei Edit Mode */}
{mode === "edit" && (
<FormField
control={form.control}
name="status"
render={({ field }) => (
<FormItem>
<FormLabel>Status</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Status wählen" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value={TaskStatus.UNERLEDIGT}>
Unerledigt
</SelectItem>
<SelectItem value={TaskStatus.IN_BEARBEITUNG}>
In Bearbeitung
</SelectItem>
<SelectItem value={TaskStatus.ERLEDIGT}>
Erledigt
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
)}
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setOpen(false)}
>
Abbrechen
</Button>
<Button
type="submit"
disabled={form.formState.isSubmitting || (!canEdit && !canOnlyChangeStatus)}
>
{form.formState.isSubmitting ? "Wird gespeichert..." : "Speichern"}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,141 @@
"use client"
import * as React from "react"
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
const AlertDialog = AlertDialogPrimitive.Root
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
const AlertDialogPortal = AlertDialogPrimitive.Portal
const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
ref={ref}
/>
))
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
/>
</AlertDialogPortal>
))
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
const AlertDialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className
)}
{...props}
/>
)
AlertDialogHeader.displayName = "AlertDialogHeader"
const AlertDialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
AlertDialogFooter.displayName = "AlertDialogFooter"
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold", className)}
{...props}
/>
))
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
AlertDialogDescription.displayName =
AlertDialogPrimitive.Description.displayName
const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action
ref={ref}
className={cn(buttonVariants(), className)}
{...props}
/>
))
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(
buttonVariants({ variant: "outline" }),
"mt-2 sm:mt-0",
className
)}
{...props}
/>
))
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
}

View File

@ -0,0 +1,59 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7",
{
variants: {
variant: {
default: "bg-background text-foreground",
destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
))
Alert.displayName = "Alert"
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props}
/>
))
AlertTitle.displayName = "AlertTitle"
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm [&_p]:leading-relaxed", className)}
{...props}
/>
))
AlertDescription.displayName = "AlertDescription"
export { Alert, AlertTitle, AlertDescription }

View File

@ -0,0 +1,50 @@
"use client"
import * as React from "react"
import * as AvatarPrimitive from "@radix-ui/react-avatar"
import { cn } from "@/lib/utils"
const Avatar = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={cn(
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
className
)}
{...props}
/>
))
Avatar.displayName = AvatarPrimitive.Root.displayName
const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image
ref={ref}
className={cn("aspect-square h-full w-full", className)}
{...props}
/>
))
AvatarImage.displayName = AvatarPrimitive.Image.displayName
const AvatarFallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn(
"flex h-full w-full items-center justify-center rounded-full bg-muted",
className
)}
{...props}
/>
))
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
export { Avatar, AvatarImage, AvatarFallback }

View File

@ -0,0 +1,36 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
)
}
export { Badge, badgeVariants }

View File

@ -0,0 +1,114 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cn } from "@/lib/utils"
import { ChevronRightIcon, DotsHorizontalIcon } from "@radix-ui/react-icons"
const Breadcrumb = React.forwardRef<
HTMLElement,
React.ComponentPropsWithoutRef<"nav"> & {
separator?: React.ReactNode
}
>(({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />)
Breadcrumb.displayName = "Breadcrumb"
const BreadcrumbList = React.forwardRef<
HTMLOListElement,
React.ComponentPropsWithoutRef<"ol">
>(({ className, ...props }, ref) => (
<ol
ref={ref}
className={cn(
"flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5",
className
)}
{...props}
/>
))
BreadcrumbList.displayName = "BreadcrumbList"
const BreadcrumbItem = React.forwardRef<
HTMLLIElement,
React.ComponentPropsWithoutRef<"li">
>(({ className, ...props }, ref) => (
<li
ref={ref}
className={cn("inline-flex items-center gap-1.5", className)}
{...props}
/>
))
BreadcrumbItem.displayName = "BreadcrumbItem"
const BreadcrumbLink = React.forwardRef<
HTMLAnchorElement,
React.ComponentPropsWithoutRef<"a"> & {
asChild?: boolean
}
>(({ asChild, className, ...props }, ref) => {
const Comp = asChild ? Slot : "a"
return (
<Comp
ref={ref}
className={cn("transition-colors hover:text-foreground", className)}
{...props}
/>
)
})
BreadcrumbLink.displayName = "BreadcrumbLink"
const BreadcrumbPage = React.forwardRef<
HTMLSpanElement,
React.ComponentPropsWithoutRef<"span">
>(({ className, ...props }, ref) => (
<span
ref={ref}
role="link"
aria-disabled="true"
aria-current="page"
className={cn("font-normal text-foreground", className)}
{...props}
/>
))
BreadcrumbPage.displayName = "BreadcrumbPage"
const BreadcrumbSeparator = ({
children,
className,
...props
}: React.ComponentProps<"li">) => (
<li
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:w-3.5 [&>svg]:h-3.5", className)}
{...props}
>
{children ?? <ChevronRightIcon />}
</li>
)
BreadcrumbSeparator.displayName = "BreadcrumbSeparator"
const BreadcrumbEllipsis = ({
className,
...props
}: React.ComponentProps<"span">) => (
<span
role="presentation"
aria-hidden="true"
className={cn("flex h-9 w-9 items-center justify-center", className)}
{...props}
>
<DotsHorizontalIcon className="h-4 w-4" />
<span className="sr-only">More</span>
</span>
)
BreadcrumbEllipsis.displayName = "BreadcrumbElipssis"
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
}

View File

@ -0,0 +1,57 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline:
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
export { Button, buttonVariants }

View File

@ -0,0 +1,213 @@
"use client"
import * as React from "react"
import {
ChevronDownIcon,
ChevronLeftIcon,
ChevronRightIcon,
} from "lucide-react"
import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker"
import { cn } from "@/lib/utils"
import { Button, buttonVariants } from "@/components/ui/button"
function Calendar({
className,
classNames,
showOutsideDays = true,
captionLayout = "label",
buttonVariant = "ghost",
formatters,
components,
...props
}: React.ComponentProps<typeof DayPicker> & {
buttonVariant?: React.ComponentProps<typeof Button>["variant"]
}) {
const defaultClassNames = getDefaultClassNames()
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn(
"bg-background group/calendar p-3 [--cell-size:2rem] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
className
)}
captionLayout={captionLayout}
formatters={{
formatMonthDropdown: (date) =>
date.toLocaleString("default", { month: "short" }),
...formatters,
}}
classNames={{
root: cn("w-fit", defaultClassNames.root),
months: cn(
"relative flex flex-col gap-4 md:flex-row",
defaultClassNames.months
),
month: cn("flex w-full flex-col gap-4", defaultClassNames.month),
nav: cn(
"absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1",
defaultClassNames.nav
),
button_previous: cn(
buttonVariants({ variant: buttonVariant }),
"h-[--cell-size] w-[--cell-size] select-none p-0 aria-disabled:opacity-50",
defaultClassNames.button_previous
),
button_next: cn(
buttonVariants({ variant: buttonVariant }),
"h-[--cell-size] w-[--cell-size] select-none p-0 aria-disabled:opacity-50",
defaultClassNames.button_next
),
month_caption: cn(
"flex h-[--cell-size] w-full items-center justify-center px-[--cell-size]",
defaultClassNames.month_caption
),
dropdowns: cn(
"flex h-[--cell-size] w-full items-center justify-center gap-1.5 text-sm font-medium",
defaultClassNames.dropdowns
),
dropdown_root: cn(
"has-focus:border-ring border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] relative rounded-md border",
defaultClassNames.dropdown_root
),
dropdown: cn(
"bg-popover absolute inset-0 opacity-0",
defaultClassNames.dropdown
),
caption_label: cn(
"select-none font-medium",
captionLayout === "label"
? "text-sm"
: "[&>svg]:text-muted-foreground flex h-8 items-center gap-1 rounded-md pl-2 pr-1 text-sm [&>svg]:size-3.5",
defaultClassNames.caption_label
),
table: "w-full border-collapse",
weekdays: cn("flex", defaultClassNames.weekdays),
weekday: cn(
"text-muted-foreground flex-1 select-none rounded-md text-[0.8rem] font-normal",
defaultClassNames.weekday
),
week: cn("mt-2 flex w-full", defaultClassNames.week),
week_number_header: cn(
"w-[--cell-size] select-none",
defaultClassNames.week_number_header
),
week_number: cn(
"text-muted-foreground select-none text-[0.8rem]",
defaultClassNames.week_number
),
day: cn(
"group/day relative aspect-square h-full w-full select-none p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md",
defaultClassNames.day
),
range_start: cn(
"bg-accent rounded-l-md",
defaultClassNames.range_start
),
range_middle: cn("rounded-none", defaultClassNames.range_middle),
range_end: cn("bg-accent rounded-r-md", defaultClassNames.range_end),
today: cn(
"bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
defaultClassNames.today
),
outside: cn(
"text-muted-foreground aria-selected:text-muted-foreground",
defaultClassNames.outside
),
disabled: cn(
"text-muted-foreground opacity-50",
defaultClassNames.disabled
),
hidden: cn("invisible", defaultClassNames.hidden),
...classNames,
}}
components={{
Root: ({ className, rootRef, ...props }) => {
return (
<div
data-slot="calendar"
ref={rootRef}
className={cn(className)}
{...props}
/>
)
},
Chevron: ({ className, orientation, ...props }) => {
if (orientation === "left") {
return (
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
)
}
if (orientation === "right") {
return (
<ChevronRightIcon
className={cn("size-4", className)}
{...props}
/>
)
}
return (
<ChevronDownIcon className={cn("size-4", className)} {...props} />
)
},
DayButton: CalendarDayButton,
WeekNumber: ({ children, ...props }) => {
return (
<td {...props}>
<div className="flex size-[--cell-size] items-center justify-center text-center">
{children}
</div>
</td>
)
},
...components,
}}
{...props}
/>
)
}
function CalendarDayButton({
className,
day,
modifiers,
...props
}: React.ComponentProps<typeof DayButton>) {
const defaultClassNames = getDefaultClassNames()
const ref = React.useRef<HTMLButtonElement>(null)
React.useEffect(() => {
if (modifiers.focused) ref.current?.focus()
}, [modifiers.focused])
return (
<Button
ref={ref}
variant="ghost"
size="icon"
data-day={day.date.toLocaleDateString()}
data-selected-single={
modifiers.selected &&
!modifiers.range_start &&
!modifiers.range_end &&
!modifiers.range_middle
}
data-range-start={modifiers.range_start}
data-range-end={modifiers.range_end}
data-range-middle={modifiers.range_middle}
className={cn(
"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 flex aspect-square h-auto w-full min-w-[--cell-size] flex-col gap-1 font-normal leading-none data-[range-end=true]:rounded-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] [&>span]:text-xs [&>span]:opacity-70",
defaultClassNames.day,
className
)}
{...props}
/>
)
}
export { Calendar, CalendarDayButton }

View File

@ -0,0 +1,76 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-xl border bg-card text-card-foreground shadow",
className
)}
{...props}
/>
))
Card.displayName = "Card"
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("font-semibold leading-none tracking-tight", className)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

View File

@ -0,0 +1,121 @@
"use client"
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { cn } from "@/lib/utils"
import { Cross2Icon } from "@radix-ui/react-icons"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<Cross2Icon className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
{...props}
/>
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogTrigger,
DialogClose,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}

View File

@ -0,0 +1,200 @@
"use client"
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { cn } from "@/lib/utils"
import { CheckIcon, ChevronRightIcon, DotFilledIcon } from "@radix-ui/react-icons"
const DropdownMenu = DropdownMenuPrimitive.Root
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuGroup = DropdownMenuPrimitive.Group
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
const DropdownMenuSub = DropdownMenuPrimitive.Sub
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto" />
</DropdownMenuPrimitive.SubTrigger>
))
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",
className
)}
{...props}
/>
))
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
))
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<DotFilledIcon className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
))
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props}
/>
)
}
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
}

178
src/components/ui/form.tsx Normal file
View File

@ -0,0 +1,178 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { Slot } from "@radix-ui/react-slot"
import {
Controller,
FormProvider,
useFormContext,
type ControllerProps,
type FieldPath,
type FieldValues,
} from "react-hook-form"
import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label"
const Form = FormProvider
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
> = {
name: TName
}
const FormFieldContext = React.createContext<FormFieldContextValue | null>(null)
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
)
}
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext)
const itemContext = React.useContext(FormItemContext)
const { getFieldState, formState } = useFormContext()
if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>")
}
if (!itemContext) {
throw new Error("useFormField should be used within <FormItem>")
}
const fieldState = getFieldState(fieldContext.name, formState)
const { id } = itemContext
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
}
}
type FormItemContextValue = {
id: string
}
const FormItemContext = React.createContext<FormItemContextValue | null>(null)
const FormItem = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const id = React.useId()
return (
<FormItemContext.Provider value={{ id }}>
<div ref={ref} className={cn("space-y-2", className)} {...props} />
</FormItemContext.Provider>
)
})
FormItem.displayName = "FormItem"
const FormLabel = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => {
const { error, formItemId } = useFormField()
return (
<Label
ref={ref}
className={cn(error && "text-destructive", className)}
htmlFor={formItemId}
{...props}
/>
)
})
FormLabel.displayName = "FormLabel"
const FormControl = React.forwardRef<
React.ElementRef<typeof Slot>,
React.ComponentPropsWithoutRef<typeof Slot>
>(({ ...props }, ref) => {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
return (
<Slot
ref={ref}
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
)
})
FormControl.displayName = "FormControl"
const FormDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => {
const { formDescriptionId } = useFormField()
return (
<p
ref={ref}
id={formDescriptionId}
className={cn("text-[0.8rem] text-muted-foreground", className)}
{...props}
/>
)
})
FormDescription.displayName = "FormDescription"
const FormMessage = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, children, ...props }, ref) => {
const { error, formMessageId } = useFormField()
const body = error ? String(error?.message ?? "") : children
if (!body) {
return null
}
return (
<p
ref={ref}
id={formMessageId}
className={cn("text-[0.8rem] font-medium text-destructive", className)}
{...props}
>
{body}
</p>
)
})
FormMessage.displayName = "FormMessage"
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
}

View File

@ -0,0 +1,22 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }

View File

@ -0,0 +1,26 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
)
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }

View File

@ -0,0 +1,33 @@
"use client"
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "@/lib/utils"
const Popover = PopoverPrimitive.Root
const PopoverTrigger = PopoverPrimitive.Trigger
const PopoverAnchor = PopoverPrimitive.Anchor
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-popover-content-transform-origin]",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
))
PopoverContent.displayName = PopoverPrimitive.Content.displayName
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }

View File

@ -0,0 +1,48 @@
"use client"
import * as React from "react"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import { cn } from "@/lib/utils"
const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn("relative overflow-hidden", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
))
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = "vertical", ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
"flex touch-none select-none transition-colors",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent p-[1px]",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
))
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
export { ScrollArea, ScrollBar }

View File

@ -0,0 +1,158 @@
"use client"
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { cn } from "@/lib/utils"
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "@radix-ui/react-icons"
const Select = SelectPrimitive.Root
const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUpIcon className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDownIcon className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
))
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-[--radix-select-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-select-content-transform-origin]",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
{...props}
/>
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
}

View File

@ -0,0 +1,31 @@
"use client"
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "@/lib/utils"
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(
(
{ className, orientation = "horizontal", decorative = true, ...props },
ref
) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className
)}
{...props}
/>
)
)
Separator.displayName = SeparatorPrimitive.Root.displayName
export { Separator }

139
src/components/ui/sheet.tsx Normal file
View File

@ -0,0 +1,139 @@
"use client"
import * as React from "react"
import * as SheetPrimitive from "@radix-ui/react-dialog"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Cross2Icon } from "@radix-ui/react-icons"
const Sheet = SheetPrimitive.Root
const SheetTrigger = SheetPrimitive.Trigger
const SheetClose = SheetPrimitive.Close
const SheetPortal = SheetPrimitive.Portal
const SheetOverlay = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
ref={ref}
/>
))
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
const sheetVariants = cva(
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out",
{
variants: {
side: {
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
bottom:
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
right:
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
},
},
defaultVariants: {
side: "right",
},
}
)
interface SheetContentProps
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
VariantProps<typeof sheetVariants> {}
const SheetContent = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Content>,
SheetContentProps
>(({ side = "right", className, children, ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
ref={ref}
className={cn(sheetVariants({ side }), className)}
{...props}
>
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
<Cross2Icon className="h-4 w-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
{children}
</SheetPrimitive.Content>
</SheetPortal>
))
SheetContent.displayName = SheetPrimitive.Content.displayName
const SheetHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className
)}
{...props}
/>
)
SheetHeader.displayName = "SheetHeader"
const SheetFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
SheetFooter.displayName = "SheetFooter"
const SheetTitle = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold text-foreground", className)}
{...props}
/>
))
SheetTitle.displayName = SheetPrimitive.Title.displayName
const SheetDescription = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
SheetDescription.displayName = SheetPrimitive.Description.displayName
export {
Sheet,
SheetPortal,
SheetOverlay,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}

View File

@ -0,0 +1,772 @@
"use client"
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { useIsMobile } from "@/hooks/use-mobile"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet"
import { Skeleton } from "@/components/ui/skeleton"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { ViewVerticalIcon } from "@radix-ui/react-icons"
const SIDEBAR_COOKIE_NAME = "sidebar_state"
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
const SIDEBAR_WIDTH = "16rem"
const SIDEBAR_WIDTH_MOBILE = "18rem"
const SIDEBAR_WIDTH_ICON = "3rem"
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
type SidebarContextProps = {
state: "expanded" | "collapsed"
open: boolean
setOpen: (open: boolean) => void
openMobile: boolean
setOpenMobile: (open: boolean) => void
isMobile: boolean
toggleSidebar: () => void
}
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
function useSidebar() {
const context = React.useContext(SidebarContext)
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.")
}
return context
}
const SidebarProvider = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
defaultOpen?: boolean
open?: boolean
onOpenChange?: (open: boolean) => void
}
>(
(
{
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
className,
style,
children,
...props
},
ref
) => {
const isMobile = useIsMobile()
const [openMobile, setOpenMobile] = React.useState(false)
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen)
const open = openProp ?? _open
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === "function" ? value(open) : value
if (setOpenProp) {
setOpenProp(openState)
} else {
_setOpen(openState)
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
},
[setOpenProp, open]
)
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile
? setOpenMobile((open) => !open)
: setOpen((open) => !open)
}, [isMobile, setOpen, setOpenMobile])
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault()
toggleSidebar()
}
}
window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown)
}, [toggleSidebar])
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed"
const contextValue = React.useMemo<SidebarContextProps>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
)
return (
<SidebarContext.Provider value={contextValue}>
<TooltipProvider delayDuration={0}>
<div
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn(
"group/sidebar-wrapper flex min-h-svh w-full has-[[data-variant=inset]]:bg-sidebar",
className
)}
ref={ref}
{...props}
>
{children}
</div>
</TooltipProvider>
</SidebarContext.Provider>
)
}
)
SidebarProvider.displayName = "SidebarProvider"
const Sidebar = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
side?: "left" | "right"
variant?: "sidebar" | "floating" | "inset"
collapsible?: "offcanvas" | "icon" | "none"
}
>(
(
{
side = "left",
variant = "sidebar",
collapsible = "offcanvas",
className,
children,
...props
},
ref
) => {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
if (collapsible === "none") {
return (
<div
className={cn(
"flex h-full w-[--sidebar-width] flex-col bg-sidebar text-sidebar-foreground",
className
)}
ref={ref}
{...props}
>
{children}
</div>
)
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
data-sidebar="sidebar"
data-mobile="true"
className="w-[--sidebar-width] bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
side={side}
>
<SheetHeader className="sr-only">
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
</SheetHeader>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
)
}
return (
<div
ref={ref}
className="group peer hidden text-sidebar-foreground md:block"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
data-side={side}
>
{/* This is what handles the sidebar gap on desktop */}
<div
className={cn(
"relative w-[--sidebar-width] bg-transparent transition-[width] duration-200 ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4))]"
: "group-data-[collapsible=icon]:w-[--sidebar-width-icon]"
)}
/>
<div
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-[--sidebar-width] transition-[left,right,width] duration-200 ease-linear md:flex",
side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4)_+2px)]"
: "group-data-[collapsible=icon]:w-[--sidebar-width-icon] group-data-[side=left]:border-r group-data-[side=right]:border-l",
className
)}
{...props}
>
<div
data-sidebar="sidebar"
className="flex h-full w-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow"
>
{children}
</div>
</div>
</div>
)
}
)
Sidebar.displayName = "Sidebar"
const SidebarTrigger = React.forwardRef<
React.ElementRef<typeof Button>,
React.ComponentProps<typeof Button>
>(({ className, onClick, ...props }, ref) => {
const { toggleSidebar } = useSidebar()
return (
<Button
ref={ref}
data-sidebar="trigger"
variant="ghost"
size="icon"
className={cn("h-7 w-7", className)}
onClick={(event) => {
onClick?.(event)
toggleSidebar()
}}
{...props}
>
<ViewVerticalIcon />
<span className="sr-only">Toggle Sidebar</span>
</Button>
)
})
SidebarTrigger.displayName = "SidebarTrigger"
const SidebarRail = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button">
>(({ className, ...props }, ref) => {
const { toggleSidebar } = useSidebar()
return (
<button
ref={ref}
data-sidebar="rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
"absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border group-data-[side=left]:-right-4 group-data-[side=right]:left-0 sm:flex",
"[[data-side=left]_&]:cursor-w-resize [[data-side=right]_&]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full group-data-[collapsible=offcanvas]:hover:bg-sidebar",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className
)}
{...props}
/>
)
})
SidebarRail.displayName = "SidebarRail"
const SidebarInset = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"main">
>(({ className, ...props }, ref) => {
return (
<main
ref={ref}
className={cn(
"relative flex w-full flex-1 flex-col bg-background",
"md:peer-data-[variant=inset]:m-2 md:peer-data-[state=collapsed]:peer-data-[variant=inset]:ml-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow",
className
)}
{...props}
/>
)
})
SidebarInset.displayName = "SidebarInset"
const SidebarInput = React.forwardRef<
React.ElementRef<typeof Input>,
React.ComponentProps<typeof Input>
>(({ className, ...props }, ref) => {
return (
<Input
ref={ref}
data-sidebar="input"
className={cn(
"h-8 w-full bg-background shadow-none focus-visible:ring-2 focus-visible:ring-sidebar-ring",
className
)}
{...props}
/>
)
})
SidebarInput.displayName = "SidebarInput"
const SidebarHeader = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="header"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
})
SidebarHeader.displayName = "SidebarHeader"
const SidebarFooter = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="footer"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
})
SidebarFooter.displayName = "SidebarFooter"
const SidebarSeparator = React.forwardRef<
React.ElementRef<typeof Separator>,
React.ComponentProps<typeof Separator>
>(({ className, ...props }, ref) => {
return (
<Separator
ref={ref}
data-sidebar="separator"
className={cn("mx-2 w-auto bg-sidebar-border", className)}
{...props}
/>
)
})
SidebarSeparator.displayName = "SidebarSeparator"
const SidebarContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="content"
className={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className
)}
{...props}
/>
)
})
SidebarContent.displayName = "SidebarContent"
const SidebarGroup = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="group"
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...props}
/>
)
})
SidebarGroup.displayName = "SidebarGroup"
const SidebarGroupLabel = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & { asChild?: boolean }
>(({ className, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "div"
return (
<Comp
ref={ref}
data-sidebar="group-label"
className={cn(
"flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 outline-none ring-sidebar-ring transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
className
)}
{...props}
/>
)
})
SidebarGroupLabel.displayName = "SidebarGroupLabel"
const SidebarGroupAction = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> & { asChild?: boolean }
>(({ className, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
ref={ref}
data-sidebar="group-action"
className={cn(
"absolute right-3 top-3.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 after:md:hidden",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
})
SidebarGroupAction.displayName = "SidebarGroupAction"
const SidebarGroupContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => (
<div
ref={ref}
data-sidebar="group-content"
className={cn("w-full text-sm", className)}
{...props}
/>
))
SidebarGroupContent.displayName = "SidebarGroupContent"
const SidebarMenu = React.forwardRef<
HTMLUListElement,
React.ComponentProps<"ul">
>(({ className, ...props }, ref) => (
<ul
ref={ref}
data-sidebar="menu"
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
{...props}
/>
))
SidebarMenu.displayName = "SidebarMenu"
const SidebarMenuItem = React.forwardRef<
HTMLLIElement,
React.ComponentProps<"li">
>(({ className, ...props }, ref) => (
<li
ref={ref}
data-sidebar="menu-item"
className={cn("group/menu-item relative", className)}
{...props}
/>
))
SidebarMenuItem.displayName = "SidebarMenuItem"
const sidebarMenuButtonVariants = cva(
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-none ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
{
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline:
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
},
size: {
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "h-12 text-sm group-data-[collapsible=icon]:!p-0",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
const SidebarMenuButton = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> & {
asChild?: boolean
isActive?: boolean
tooltip?: string | React.ComponentProps<typeof TooltipContent>
} & VariantProps<typeof sidebarMenuButtonVariants>
>(
(
{
asChild = false,
isActive = false,
variant = "default",
size = "default",
tooltip,
className,
...props
},
ref
) => {
const Comp = asChild ? Slot : "button"
const { isMobile, state } = useSidebar()
const button = (
<Comp
ref={ref}
data-sidebar="menu-button"
data-size={size}
data-active={isActive}
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props}
/>
)
if (!tooltip) {
return button
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
}
}
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent
side="right"
align="center"
hidden={state !== "collapsed" || isMobile}
{...tooltip}
/>
</Tooltip>
)
}
)
SidebarMenuButton.displayName = "SidebarMenuButton"
const SidebarMenuAction = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> & {
asChild?: boolean
showOnHover?: boolean
}
>(({ className, asChild = false, showOnHover = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
ref={ref}
data-sidebar="menu-action"
className={cn(
"absolute right-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 peer-hover/menu-button:text-sidebar-accent-foreground [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 after:md:hidden",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
showOnHover &&
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground md:opacity-0",
className
)}
{...props}
/>
)
})
SidebarMenuAction.displayName = "SidebarMenuAction"
const SidebarMenuBadge = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => (
<div
ref={ref}
data-sidebar="menu-badge"
className={cn(
"pointer-events-none absolute right-1 flex h-5 min-w-5 select-none items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums text-sidebar-foreground",
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
))
SidebarMenuBadge.displayName = "SidebarMenuBadge"
const SidebarMenuSkeleton = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
showIcon?: boolean
}
>(({ className, showIcon = false, ...props }, ref) => {
// Random width between 50 to 90%.
const width = React.useMemo(() => {
return `${Math.floor(Math.random() * 40) + 50}%`
}, [])
return (
<div
ref={ref}
data-sidebar="menu-skeleton"
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
{...props}
>
{showIcon && (
<Skeleton
className="size-4 rounded-md"
data-sidebar="menu-skeleton-icon"
/>
)}
<Skeleton
className="h-4 max-w-[--skeleton-width] flex-1"
data-sidebar="menu-skeleton-text"
style={
{
"--skeleton-width": width,
} as React.CSSProperties
}
/>
</div>
)
})
SidebarMenuSkeleton.displayName = "SidebarMenuSkeleton"
const SidebarMenuSub = React.forwardRef<
HTMLUListElement,
React.ComponentProps<"ul">
>(({ className, ...props }, ref) => (
<ul
ref={ref}
data-sidebar="menu-sub"
className={cn(
"mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
))
SidebarMenuSub.displayName = "SidebarMenuSub"
const SidebarMenuSubItem = React.forwardRef<
HTMLLIElement,
React.ComponentProps<"li">
>(({ ...props }, ref) => <li ref={ref} {...props} />)
SidebarMenuSubItem.displayName = "SidebarMenuSubItem"
const SidebarMenuSubButton = React.forwardRef<
HTMLAnchorElement,
React.ComponentProps<"a"> & {
asChild?: boolean
size?: "sm" | "md"
isActive?: boolean
}
>(({ asChild = false, size = "md", isActive, className, ...props }, ref) => {
const Comp = asChild ? Slot : "a"
return (
<Comp
ref={ref}
data-sidebar="menu-sub-button"
data-size={size}
data-active={isActive}
className={cn(
"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground outline-none ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
size === "sm" && "text-xs",
size === "md" && "text-sm",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
})
SidebarMenuSubButton.displayName = "SidebarMenuSubButton"
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
useSidebar,
}

View File

@ -0,0 +1,15 @@
import { cn } from "@/lib/utils"
function Skeleton({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("animate-pulse rounded-md bg-primary/10", className)}
{...props}
/>
)
}
export { Skeleton }

View File

@ -0,0 +1,31 @@
"use client"
import { useTheme } from "next-themes"
import { Toaster as Sonner } from "sonner"
type ToasterProps = React.ComponentProps<typeof Sonner>
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
toastOptions={{
classNames: {
toast:
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
description: "group-[.toast]:text-muted-foreground",
actionButton:
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
cancelButton:
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
},
}}
{...props}
/>
)
}
export { Toaster }

120
src/components/ui/table.tsx Normal file
View File

@ -0,0 +1,120 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Table = React.forwardRef<
HTMLTableElement,
React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
))
Table.displayName = "Table"
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
))
TableHeader.displayName = "TableHeader"
const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
))
TableBody.displayName = "TableBody"
const TableFooter = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
))
TableFooter.displayName = "TableFooter"
const TableRow = React.forwardRef<
HTMLTableRowElement,
React.HTMLAttributes<HTMLTableRowElement>
>(({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
))
TableRow.displayName = "TableRow"
const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
"h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
))
TableHead.displayName = "TableHead"
const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn(
"p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
))
TableCell.displayName = "TableCell"
const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
React.HTMLAttributes<HTMLTableCaptionElement>
>(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
))
TableCaption.displayName = "TableCaption"
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}

View File

@ -0,0 +1,55 @@
"use client"
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
const Tabs = TabsPrimitive.Root
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
className
)}
{...props}
/>
))
TabsList.displayName = TabsPrimitive.List.displayName
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
className
)}
{...props}
/>
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className
)}
{...props}
/>
))
TabsContent.displayName = TabsPrimitive.Content.displayName
export { Tabs, TabsList, TabsTrigger, TabsContent }

View File

@ -0,0 +1,22 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Textarea = React.forwardRef<
HTMLTextAreaElement,
React.ComponentProps<"textarea">
>(({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
{...props}
/>
)
})
Textarea.displayName = "Textarea"
export { Textarea }

View File

@ -0,0 +1,32 @@
"use client"
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "@/lib/utils"
const TooltipProvider = TooltipPrimitive.Provider
const Tooltip = TooltipPrimitive.Root
const TooltipTrigger = TooltipPrimitive.Trigger
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-tooltip-content-transform-origin]",
className
)}
{...props}
/>
</TooltipPrimitive.Portal>
))
TooltipContent.displayName = TooltipPrimitive.Content.displayName
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

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