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