Init commit
This commit is contained in:
commit
c221830705
32
.claude/settings.local.json
Normal file
32
.claude/settings.local.json
Normal 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
22
.dockerignore
Normal 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
48
.gitignore
vendored
Normal 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
212
API_DOCUMENTATION.md
Normal 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
54
Dockerfile
Normal 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
176
README.Docker.md
Normal 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
448
README.md
Normal 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
20
components.json
Normal 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
51
docker-compose.yml
Normal 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
12
next.config.ts
Normal 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
9288
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
73
package.json
Normal file
73
package.json
Normal 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
9
postcss.config.mjs
Normal 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
23
prisma.config.ts
Normal 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',
|
||||||
|
},
|
||||||
|
});
|
||||||
98
prisma/migrations/20251111164012_init/migration.sql
Normal file
98
prisma/migrations/20251111164012_init/migration.sql
Normal 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;
|
||||||
3
prisma/migrations/migration_lock.toml
Normal file
3
prisma/migrations/migration_lock.toml
Normal 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
113
prisma/schema.prisma
Normal 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
42
prisma/seed.ts
Normal 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
2
public/uploads/.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
*
|
||||||
|
!.gitignore
|
||||||
120
src/app/(auth)/login/page.tsx
Normal file
120
src/app/(auth)/login/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
13
src/app/api/auth/[...nextauth]/route.ts
Normal file
13
src/app/api/auth/[...nextauth]/route.ts
Normal 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 };
|
||||||
156
src/app/api/tasks/[id]/comments/route.ts
Normal file
156
src/app/api/tasks/[id]/comments/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
87
src/app/api/tasks/[id]/files/[fileId]/route.ts
Normal file
87
src/app/api/tasks/[id]/files/[fileId]/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
195
src/app/api/tasks/[id]/files/route.ts
Normal file
195
src/app/api/tasks/[id]/files/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
265
src/app/api/tasks/[id]/route.ts
Normal file
265
src/app/api/tasks/[id]/route.ts
Normal 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
172
src/app/api/tasks/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
170
src/app/api/users/[id]/route.ts
Normal file
170
src/app/api/users/[id]/route.ts
Normal 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
131
src/app/api/users/route.ts
Normal 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
45
src/app/calendar.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
25
src/app/dashboard/calendar/loading.tsx
Normal file
25
src/app/dashboard/calendar/loading.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
270
src/app/dashboard/calendar/page.tsx
Normal file
270
src/app/dashboard/calendar/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
33
src/app/dashboard/layout.tsx
Normal file
33
src/app/dashboard/layout.tsx
Normal 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
236
src/app/dashboard/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
13
src/app/dashboard/tasks/[id]/loading.tsx
Normal file
13
src/app/dashboard/tasks/[id]/loading.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
136
src/app/dashboard/tasks/[id]/page.tsx
Normal file
136
src/app/dashboard/tasks/[id]/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
88
src/app/dashboard/tasks/completed/page.tsx
Normal file
88
src/app/dashboard/tasks/completed/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
src/app/dashboard/tasks/loading.tsx
Normal file
15
src/app/dashboard/tasks/loading.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
110
src/app/dashboard/tasks/page.tsx
Normal file
110
src/app/dashboard/tasks/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
62
src/app/dashboard/users/page.tsx
Normal file
62
src/app/dashboard/users/page.tsx
Normal 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
58
src/app/error.tsx
Normal 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
83
src/app/globals.css
Normal 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
50
src/app/layout.tsx
Normal 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
43
src/app/loading.tsx
Normal 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
39
src/app/not-found.tsx
Normal 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
28
src/app/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
src/components/calendar/calendar-legend.tsx
Normal file
25
src/components/calendar/calendar-legend.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
182
src/components/calendar/calendar-view.tsx
Normal file
182
src/components/calendar/calendar-view.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
9
src/components/calendar/index.ts
Normal file
9
src/components/calendar/index.ts
Normal 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";
|
||||||
168
src/components/calendar/task-detail-dialog.tsx
Normal file
168
src/components/calendar/task-detail-dialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
51
src/components/calendar/task-event.tsx
Normal file
51
src/components/calendar/task-event.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
187
src/components/layout/app-sidebar.tsx
Normal file
187
src/components/layout/app-sidebar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
95
src/components/layout/header.tsx
Normal file
95
src/components/layout/header.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
9
src/components/layout/index.ts
Normal file
9
src/components/layout/index.ts
Normal 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";
|
||||||
37
src/components/layout/mobile-sidebar.tsx
Normal file
37
src/components/layout/mobile-sidebar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
102
src/components/layout/navbar.tsx
Normal file
102
src/components/layout/navbar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
92
src/components/layout/sidebar.tsx
Normal file
92
src/components/layout/sidebar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
45
src/components/layout/theme-toggle.tsx
Normal file
45
src/components/layout/theme-toggle.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
35
src/components/providers/query-provider.tsx
Normal file
35
src/components/providers/query-provider.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
src/components/providers/session-provider.tsx
Normal file
20
src/components/providers/session-provider.tsx
Normal 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>;
|
||||||
|
}
|
||||||
14
src/components/providers/theme-provider.tsx
Normal file
14
src/components/providers/theme-provider.tsx
Normal 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>;
|
||||||
|
}
|
||||||
66
src/components/shared/calendar-skeleton.tsx
Normal file
66
src/components/shared/calendar-skeleton.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
53
src/components/shared/comment.tsx
Normal file
53
src/components/shared/comment.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
9
src/components/shared/index.ts
Normal file
9
src/components/shared/index.ts
Normal 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";
|
||||||
92
src/components/shared/task-detail-skeleton.tsx
Normal file
92
src/components/shared/task-detail-skeleton.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
38
src/components/shared/task-list-skeleton.tsx
Normal file
38
src/components/shared/task-list-skeleton.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
95
src/components/tasks/comment-section.tsx
Normal file
95
src/components/tasks/comment-section.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
118
src/components/tasks/file-section.tsx
Normal file
118
src/components/tasks/file-section.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
142
src/components/tasks/file-upload-dialog.tsx
Normal file
142
src/components/tasks/file-upload-dialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
9
src/components/tasks/index.ts
Normal file
9
src/components/tasks/index.ts
Normal 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";
|
||||||
205
src/components/tasks/task-actions.tsx
Normal file
205
src/components/tasks/task-actions.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
142
src/components/tasks/task-card.tsx
Normal file
142
src/components/tasks/task-card.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
65
src/components/tasks/task-filters.tsx
Normal file
65
src/components/tasks/task-filters.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
208
src/components/tasks/task-info-card.tsx
Normal file
208
src/components/tasks/task-info-card.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
57
src/components/tasks/task-list.tsx
Normal file
57
src/components/tasks/task-list.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
338
src/components/tasks/task-modal.tsx
Normal file
338
src/components/tasks/task-modal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
141
src/components/ui/alert-dialog.tsx
Normal file
141
src/components/ui/alert-dialog.tsx
Normal 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,
|
||||||
|
}
|
||||||
59
src/components/ui/alert.tsx
Normal file
59
src/components/ui/alert.tsx
Normal 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 }
|
||||||
50
src/components/ui/avatar.tsx
Normal file
50
src/components/ui/avatar.tsx
Normal 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 }
|
||||||
36
src/components/ui/badge.tsx
Normal file
36
src/components/ui/badge.tsx
Normal 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 }
|
||||||
114
src/components/ui/breadcrumb.tsx
Normal file
114
src/components/ui/breadcrumb.tsx
Normal 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,
|
||||||
|
}
|
||||||
57
src/components/ui/button.tsx
Normal file
57
src/components/ui/button.tsx
Normal 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 }
|
||||||
213
src/components/ui/calendar.tsx
Normal file
213
src/components/ui/calendar.tsx
Normal 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 }
|
||||||
76
src/components/ui/card.tsx
Normal file
76
src/components/ui/card.tsx
Normal 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 }
|
||||||
121
src/components/ui/dialog.tsx
Normal file
121
src/components/ui/dialog.tsx
Normal 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,
|
||||||
|
}
|
||||||
200
src/components/ui/dropdown-menu.tsx
Normal file
200
src/components/ui/dropdown-menu.tsx
Normal 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
178
src/components/ui/form.tsx
Normal 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,
|
||||||
|
}
|
||||||
22
src/components/ui/input.tsx
Normal file
22
src/components/ui/input.tsx
Normal 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 }
|
||||||
26
src/components/ui/label.tsx
Normal file
26
src/components/ui/label.tsx
Normal 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 }
|
||||||
33
src/components/ui/popover.tsx
Normal file
33
src/components/ui/popover.tsx
Normal 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 }
|
||||||
48
src/components/ui/scroll-area.tsx
Normal file
48
src/components/ui/scroll-area.tsx
Normal 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 }
|
||||||
158
src/components/ui/select.tsx
Normal file
158
src/components/ui/select.tsx
Normal 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,
|
||||||
|
}
|
||||||
31
src/components/ui/separator.tsx
Normal file
31
src/components/ui/separator.tsx
Normal 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
139
src/components/ui/sheet.tsx
Normal 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,
|
||||||
|
}
|
||||||
772
src/components/ui/sidebar.tsx
Normal file
772
src/components/ui/sidebar.tsx
Normal 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,
|
||||||
|
}
|
||||||
15
src/components/ui/skeleton.tsx
Normal file
15
src/components/ui/skeleton.tsx
Normal 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 }
|
||||||
31
src/components/ui/sonner.tsx
Normal file
31
src/components/ui/sonner.tsx
Normal 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
120
src/components/ui/table.tsx
Normal 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,
|
||||||
|
}
|
||||||
55
src/components/ui/tabs.tsx
Normal file
55
src/components/ui/tabs.tsx
Normal 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 }
|
||||||
22
src/components/ui/textarea.tsx
Normal file
22
src/components/ui/textarea.tsx
Normal 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 }
|
||||||
32
src/components/ui/tooltip.tsx
Normal file
32
src/components/ui/tooltip.tsx
Normal 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
Loading…
Reference in New Issue
Block a user