commit ef676a95f43cc40558b153a18c5e70e5b9dbb035 Author: schakal00783 Date: Thu Sep 17 22:52:20 2026 +0200 feat: perinco Pulse - Zeiterfassung fuer perinco Erste Fassung, live unter https://pulse.perinco.de. Frontend React 19 + TypeScript + Tailwind 4 (Vite), Backend Express mit mysql2 auf MariaDB, Exporte als PDF (pdfkit), Excel (exceljs) und CSV. Aufbau, Designsystem und die sieben Themes kommen aus dem perinco CRM. Fachlich: - Buchung mit Wochen-, Monats- und Jahresansicht, Zeiten als 1:23 oder 1,5 - Projekte mit n Budgettoepfen: Laufzeit, Volumen, Sperre bei Ablauf oder aufgebrauchtem Budget, Buchung nur auf den passenden Topf - Kunden, Team mit Hierarchie, Reports im Harvest-Stil, Monatsexporte - Rollen user/lead/management/admin, doppelt geprueft (Menue und Backend) - SSO gegen Microsoft Entra ID, Uebernahme der AD-Benutzer per Drag & Drop Co-Authored-By: Claude Opus 5 (1M context) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4a38385 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +node_modules/ +dist/ +.env +server/.env +server/node_modules/ +*.log +.DS_Store +# Aufgabenstellung enthaelt Server-Passwoerter im Klartext +Anforderungen.txt diff --git a/README.md b/README.md new file mode 100644 index 0000000..5e32390 --- /dev/null +++ b/README.md @@ -0,0 +1,129 @@ +# perinco Pulse + +Zeiterfassung für perinco – live unter **https://pulse.perinco.de**. +Stil, Themes und Aufbau folgen dem perinco CRM. + +| Teil | Technik | Wo | +| --- | --- | --- | +| Frontend | React 19, TypeScript, Tailwind 4, Vite | `/var/www/pulse/dist` | +| Backend | Express 4, mysql2, exceljs, pdfkit | `/var/www/pulse/server`, `127.0.0.1:5010`, Dienst `perinco-pulse-backend` (User `pulse`) | +| Datenbank | MariaDB 11.8 | `perinco_pulse` (User `pulse`) | +| Webserver | Apache Vhost `pulse.perinco.de`, Let's Encrypt | `/etc/apache2/sites-available/pulse.perinco.de*.conf` | + +Erster Login: Benutzer `admin`, Passwort aus `ADMIN_PASSWORD` in `server/.env` (wird beim ersten Start einmalig angelegt) – danach unter *Mein Konto* (unten links auf den Namen klicken) ändern. + +## Rollen und Menü + +| Rolle | Startseite | Menü | +| --- | --- | --- | +| Mitarbeiter (`user`) | Buchung | Buchung, Projekte (nur zugewiesene, ohne Beträge) | +| Vorgesetzter (`lead`) | Buchung | Dashboard (nur eigenes Team, rekursiv), Buchung, Projekte, Team, Kunden, Reports | +| Management | Dashboard | wie Vorgesetzter, sieht alles; Einstellungen nur „SSO-Verwaltung“ | +| Administrator | Einstellungen | Dashboard, Projekte, Team, Kunden, Reports, Einstellungen – keine Buchung, taucht in Auswertungen nicht auf | + +Die Startseite gilt nach jeder Anmeldung (Passwort und SSO), auch wenn noch eine alte Seite in der Adresse steht; Neuladen bleibt auf der aktuellen Seite. Das Logo oben links führt ebenfalls zur Startseite. + +Rechte werden im Menü **und** im Backend (403) geprüft. Jeder bucht nur auf Projekte, denen er zugewiesen ist. + +## Fachregeln + +- **Zeiteingabe**: `1:23` = 1 h 23 min, `1,5` / `1.5` = 1 h 30 min, außerdem `2`, `:45`, `1h 30m`. +- **Projektcode** automatisch nach Muster (Standard `{KUERZEL}-{JAHR}-{NR}` → `KUKA-2026-001`), **Anzeigename** nach Muster (Standard `{KUNDE} - {PROJEKT}` → `KUKA - PMO`). Beides in *Einstellungen → Allgemein*. +- **Projekttypen**: Billable (Preis pro Stunde oder Tag), Non-billable (intern), Festpreis (virtueller Stundensatz = Festpreis ÷ gebuchte Stunden, nur intern). +- **Budgettöpfe** (mind. einer je Projekt, beliebig viele): Bezeichnung, Laufzeit von/bis, Budget € + Satz (→ Volumen in Stunden/Tagen) oder direktes Kontingent. + - Gebucht wird nur auf einen Topf, dessen **Laufzeit das Leistungsdatum enthält**; überlappen Töpfe, zuerst der früher endende. + - Volumen erreicht **oder** Ablaufdatum überschritten → Warnung **und nicht mehr buchbar**. + - Bei Billable-Projekten verbrauchen nur abrechenbare Stunden das Volumen (Reisezeit o. ä. nicht); ist ein Topf leer, ist er für alles gesperrt. + - Warnung ab 80 % Verbrauch bzw. 14 Tage vor Ablauf (einstellbar) – im Buchungsdialog, in der Buchungsansicht und an der Projektkarte. + - Wird ein Topf geändert, ordnet Pulse vorhandene Buchungen neu zu. +- **Umsatz erbracht/offen** je Topf: Billable = abrechenbare Stunden × Satz; Festpreis = anteilig nach Kontingent (sonst nach Laufzeit), abgelaufen = voll; offen = Rest in nicht abgelaufenen Töpfen. +- **Soll/Auslastung**: Wochenstunden (Standard 40, je Benutzer überschreibbar) ÷ 5 × Arbeitstage bis heute. +- **Löschen**: Benutzer, Projekte, Kunden mit Buchungen werden nicht gelöscht, sondern deaktiviert/archiviert – Reports bleiben stimmig. SSO-Benutzer werden nur deaktiviert. + +## Exporte + +Serverseitig erzeugt, alle mit Kopf/Fuß aus *Einstellungen → Export* (Logo, Akzentfarbe, Titel, 3 Fußspalten, Unterschriftenfelder): + +- **Stundennachweis je Monat** (PDF, Excel mit Summenformeln, CSV mit UTF-8-BOM/Semikolon): eigener Nachweis, eines Teammitglieds oder aller; filterbar nach Kunde/Projekt; optional mit Sätzen und Beträgen für die Rechnung. +- **Zeitreport** nach Kunden/Projekten/Tasks/Mitarbeitern oder als Detailliste. + +## SSO / Active Directory + +**Einrichtung** (nur Administrator, *Einstellungen → SSO / Entra ID*): Microsoft Entra ID per OpenID Connect (Authorization Code, Client Secret AES-verschlüsselt in der DB). App-Registrierung mit Redirect-URI `https://pulse.perinco.de/api/auth/sso/callback`, delegiert `openid profile email`, Anwendung `User.Read.All` mit Administratorzustimmung. + +**SSO-Verwaltung** (Management und Administrator, *Einstellungen → SSO-Verwaltung*): + +- Links alle zulässigen AD-Konten, rechts die Rollen Mitarbeiter / Vorgesetzter / Management. Konto auf eine Rolle **ziehen** = nach Pulse übernehmen bzw. Rolle ändern. Alternativ Mehrfachauswahl + Knopf (Handy, Tastatur). Optional gleich einen Vorgesetzten setzen. +- Zulässig sind nur Konten mit Mail **`vorname.nachname@perinco.de`** oder **`vorname.nachname@icon-experts.de`** (Bindestriche im Namen erlaubt). Ignoriert werden Adressen mit weiteren Teilen (`adm.sascha.wolf@…`), Funktionsadressen (`info@…`), deaktivierte Konten und Konten ohne Lizenz (freigegebene Postfächer) sowie Gäste. Regel: `server/src/lib/sso.js` → `isEligible`, Tests: `cd server && npm test`. +- Existiert schon ein lokales Konto mit derselben Mail, wird es verknüpft statt doppelt angelegt. +- **Anmelden per Microsoft kann sich nur, wer übernommen wurde.** Rollen kommen ausschließlich aus Pulse, AD-Gruppen spielen keine Rolle. Administratoren können hier nicht geändert und nicht vergeben werden. +- **Abgleich**: aktualisiert Name, Mail und Jobtitel der übernommenen Konten; wer im AD gelöscht, deaktiviert oder nicht mehr zulässig ist, wird in Pulse deaktiviert. Legt nie selbst Konten an. Die AD-Liste wird 5 Minuten zwischengespeichert („Neu laden“ holt sofort). +- Externe Benutzer bleiben lokale Konten. + +## Entwicklung + +```bash +npm install && (cd server && npm install) +cp server/.env.example server/.env # Werte setzen +# DB z. B. per Tunnel: plink -L 3307:127.0.0.1:3306 root@31.70.144.60 (DB_PORT=3307, DB_NAME=perinco_pulse_dev) +(cd server && npm run dev) # Backend :5010 +npm run dev # Frontend :3110, proxyt /api +``` + +`perinco_pulse_dev` enthält Demodaten (Kunden, Projekte, ~1000 Buchungen, Testbenutzer mit Passwort `Test12345!`) und wird von der Produktion nicht benutzt. + +## Deployment + +```powershell +$env:PULSE_SSH_PASSWORD = '…' +powershell -File deploy/deploy.ps1 # baut, lädt hoch, tauscht dist atomar, startet Dienst neu +powershell -File deploy/deploy.ps1 -Deps # zusätzlich npm ci im Backend +``` + +Die Produktions-`.env` liegt nur auf dem Server (`/var/www/pulse/server/.env`, 600). `SETTINGS_KEY` nicht ändern, sonst ist das gespeicherte SSO-Secret unlesbar. + +## Anforderungs-Checkliste + +Aus Auftrag und Call-Mitschrift. + +| Anforderung | Status | Wo | +| --- | --- | --- | +| Dashboard Mgmt/Admin: Billable/Non-billable-Grafik, umschaltbar Tag/Woche/Monat/Quartal/Jahr, animiert | ✅ | Dashboard | +| Letzte Buchungen | ✅ | Dashboard | +| Mitarbeiter-Übersicht auf-/absteigend, letzte Tage/Woche/Monat/Quartal/Jahr, Filter billable/non-billable | ✅ animierte Umsortierung | Dashboard | +| Vorgesetzte sehen nur ihr Team | ✅ rekursiv, auch im Backend | Dashboard, Reports, Buchung (Teamansicht, nur lesen) | +| Buchung: Wochenansicht Standard, umschaltbar Monat/Jahr | ✅ | Buchung | +| Grünes Plus → Projekt, Task, optionaler Freitext, Zeit rechts daneben, `0:00`, `1:23` und `1,5` | ✅ | Buchungsdialog | +| Nur zugewiesene Projekte, Task optional | ✅ | Buchungsdialog | +| Zeiten bearbeiten und löschen | ✅ | Klick auf Buchung | +| Monatsexport Excel/PDF/CSV als Kundenvorlage mit Logo und Footer | ✅ (Footer-Inhalt siehe Hinweis) | Buchung → Export | +| Menü je Rolle (Dashboard/Buchung, Projekte, Team, Kunden, Reports, Einstellungen) | ✅ | Seitenleiste / mobile Leiste | +| Projekte: User nur zugewiesene; andere alle inkl. Töpfe, bearbeiten/anlegen/entfernen | ✅ | Projekte | +| Projekt wählen → Tasks darunter | ✅ | Projekte | +| Projektanlage: Name, Code automatisch, Start/Ende, Budget, Tags, Notizen, Typ billable/non-billable/Festpreis, Stunde/Tag | ✅ | Neues Projekt | +| Budgettopf: Laufzeit, Budget €, Stundensatz, Bezeichnung; n Töpfe; Balken Stunden/Tage/Budget | ✅ | Projekte | +| Hinweis beim Buchen, wann Budget endet und wieviel offen ist | ✅ | Buchungsdialog, Buchung | +| Topf voll oder abgelaufen → nicht buchbar | ✅ serverseitig erzwungen | | +| Nur auf Topf buchen, dessen Laufzeit die Leistung enthält | ✅ | | +| Töpfe bearbeiten ab Vorgesetzter | ✅ | Projekte | +| Festpreis: virtueller Stundensatz intern | ✅ | Projekt, Projektreport | +| Report: Gesamt aller Töpfe und einzelne Töpfe | ✅ | Projektreport → Budgettöpfe | +| Projekte Usern zuweisen | ✅ | Projekt → Zuweisen / Formular | +| Kunden über Projekten | ✅ | Kunden, Projektformular (+ Kunde) | +| Team: Hierarchie Vorgesetzte/Mitarbeiter, lokale User anlegen/bearbeiten/löschen, Externe | ✅ Organigramm + Liste | Team | +| Kunden anlegen/bearbeiten, wer hat welche Projekte mit welchen Mitarbeitern, Umsatz erbracht/offen, animiert | ✅ | Kunden | +| Reports wie Harvest (Report.png), interaktiv, Export Excel/PDF/CSV | ✅ | Reports → Zeitreport | +| Report für einzelne Kunden / Projekt wie Report-Kunde.png | ✅ | Reports → Kunden-/Projektreport | +| Einstellungen (nur Admin): SSO-Credentials, Userverwaltung, Kopf/Fuß der Exporte | ✅ inkl. PDF-/Excel-Vorschau | Einstellungen | +| Arbeitsstunden pro Woche (40) | ✅ | Einstellungen → Allgemein | +| Projektbezeichnungen nach Muster | ✅ | Einstellungen → Allgemein | +| Themes wie CRM (perinco, Pixel Art, Outdoor, Cyberpunk, Rainy, Snow, Aura – jeweils hell/dunkel, am Benutzer gespeichert) | ✅ | Kopfleiste | +| Lokaler Admin mit vorgegebenem Passwort, später änderbar | ✅ | Mein Konto | +| Mobil: eigene Ansicht bei Handy | ✅ untere Navigation, Tagesstreifen, Bottom-Sheets, schwebender Plus-Knopf | | +| SSO, anfangs normaler Login; AD-User auslesen, Rollen intern | ✅ vorbereitet, noch nicht gegen echten Tenant getestet | Einstellungen → SSO | + +### Offene Punkte + +- **Wordvorlage** lag nicht im Ordner. Kopf nutzt das perinco-Logo; die Fußzeilen (Adresse, Registergericht, Bank) bitte in *Einstellungen → Export* eintragen. +- **SSO** ist implementiert, braucht aber eine App-Registrierung in Entra ID zum Test. +- Nicht umgesetzt, weil nicht gefordert: Rechnungsmodul („Uninvoiced amount“ aus Harvest), gespeicherte Reports. diff --git a/Report-Kunde.png b/Report-Kunde.png new file mode 100644 index 0000000..7cdeb59 Binary files /dev/null and b/Report-Kunde.png differ diff --git a/Report.png b/Report.png new file mode 100644 index 0000000..f42acb3 Binary files /dev/null and b/Report.png differ diff --git a/deploy/deploy.ps1 b/deploy/deploy.ps1 new file mode 100644 index 0000000..12af9da --- /dev/null +++ b/deploy/deploy.ps1 @@ -0,0 +1,57 @@ +# perinco Pulse - Deployment auf 31.70.144.60 +# +# Baut lokal und laedt dist/ plus Backend-Quellen hoch. +# +# Aufruf aus dem Projektverzeichnis: +# $env:PULSE_SSH_PASSWORD = '...' +# powershell -File deploy/deploy.ps1 +# powershell -File deploy/deploy.ps1 -SkipBuild +# powershell -File deploy/deploy.ps1 -Deps # bei neuen Backend-Paketen + +param( + [string]$ServerHost = '31.70.144.60', + [int]$Port = 22, + [string]$User = 'root', + [string]$Password = $env:PULSE_SSH_PASSWORD, + [string]$RemotePath = '/var/www/pulse', + [switch]$SkipBuild, + [switch]$Deps +) + +$ErrorActionPreference = 'Stop' +if (-not $Password) { throw 'Kein Passwort. Setze $env:PULSE_SSH_PASSWORD oder uebergib -Password.' } + +$plink = 'C:\Program Files\PuTTY\plink.exe' +$pscp = 'C:\Program Files\PuTTY\pscp.exe' +foreach ($tool in @($plink, $pscp)) { if (-not (Test-Path $tool)) { throw "Nicht gefunden: $tool" } } + +$projectRoot = Split-Path -Parent $PSScriptRoot +Set-Location $projectRoot + +if (-not $SkipBuild) { + Write-Host '==> Frontend bauen' -ForegroundColor Green + npm run build + if ($LASTEXITCODE -ne 0) { throw 'Build fehlgeschlagen - es wird nichts hochgeladen.' } +} + +$target = "$User@$ServerHost" + +Write-Host '==> dist/ hochladen' -ForegroundColor Green +& $plink -ssh -batch -P $Port -pw $Password $target "rm -rf $RemotePath/dist.new && mkdir -p $RemotePath/dist.new" +& $pscp -r -batch -P $Port -pw $Password 'dist\*' "${target}:$RemotePath/dist.new/" + +Write-Host '==> Backend hochladen' -ForegroundColor Green +& $pscp -r -batch -P $Port -pw $Password 'server\src' "${target}:$RemotePath/server/" +& $pscp -r -batch -P $Port -pw $Password 'server\assets' "${target}:$RemotePath/server/" +& $pscp -batch -P $Port -pw $Password 'server\package.json' 'server\package-lock.json' "${target}:$RemotePath/server/" + +# dist atomar tauschen, damit niemand eine halb hochgeladene Version sieht. +$remote = "cd $RemotePath && rm -rf dist.old && (mv dist dist.old 2>/dev/null; true) && mv dist.new dist && rm -rf dist.old" +if ($Deps) { $remote = "$remote && cd $RemotePath/server && npm ci --omit=dev --no-audit --no-fund" } +$remote = "$remote && chown -R pulse:pulse $RemotePath/server && systemctl restart perinco-pulse-backend && sleep 3 && systemctl is-active perinco-pulse-backend && curl -s http://127.0.0.1:5010/api/health" + +Write-Host '==> Tauschen, neu starten, pruefen' -ForegroundColor Green +& $plink -ssh -batch -P $Port -pw $Password $target $remote + +Write-Host '' +Write-Host 'Fertig: https://pulse.perinco.de' -ForegroundColor Green diff --git a/deploy/perinco-pulse-backend.service b/deploy/perinco-pulse-backend.service new file mode 100644 index 0000000..bacc897 --- /dev/null +++ b/deploy/perinco-pulse-backend.service @@ -0,0 +1,26 @@ +[Unit] +Description=perinco Pulse Backend (Express + MariaDB) +After=network.target mariadb.service +Wants=mariadb.service + +[Service] +Type=simple +# Eigener Systembenutzer ohne Login - das Backend braucht keine Root-Rechte. +User=pulse +Group=pulse +WorkingDirectory=/var/www/pulse/server +EnvironmentFile=/var/www/pulse/server/.env +# Server laeuft auf UTC; "heute", Monatsgrenzen und Ablaufdaten gelten in Berlin. +Environment=TZ=Europe/Berlin +Environment=NODE_ENV=production +ExecStart=/usr/bin/node src/server.js +Restart=on-failure +RestartSec=5 +# Sicherheitsnetz: ein Leck soll Apache, MariaDB und das ICON CRM nicht mitreissen. +MemoryMax=768M +NoNewPrivileges=true +ProtectSystem=full +PrivateTmp=true + +[Install] +WantedBy=multi-user.target diff --git a/deploy/pulse.perinco.de.conf b/deploy/pulse.perinco.de.conf new file mode 100644 index 0000000..fd3447a --- /dev/null +++ b/deploy/pulse.perinco.de.conf @@ -0,0 +1,44 @@ +# perinco Pulse - Apache Vhost +# Ablage: /etc/apache2/sites-available/pulse.perinco.de.conf +# Aktivieren: a2ensite pulse.perinco.de && systemctl reload apache2 +# +# Reihenfolge beim Aufsetzen: +# 1. DNS A-Record pulse.perinco.de -> 31.70.144.60 +# 2. Diese Datei aktivieren (HTTP-Teil), Apache neu laden +# 3. certbot --apache -d pulse.perinco.de (legt den :443-Block selbst an) + + + ServerName pulse.perinco.de + DocumentRoot /var/www/pulse/dist + + + Options -Indexes +FollowSymLinks + AllowOverride None + Require all granted + FallbackResource /index.html + + + # Gehashte Build-Dateien duerfen dauerhaft im Cache liegen, index.html nie. + + ExpiresActive On + + ExpiresDefault "access plus 1 year" + + + + Header set Cache-Control "no-cache, must-revalidate" + + + Header always set X-Content-Type-Options "nosniff" + Header always set Referrer-Policy "strict-origin-when-cross-origin" + Header always set X-Frame-Options "SAMEORIGIN" + + # API lokal auf 127.0.0.1:5010, von aussen nur hierueber erreichbar. + ProxyPreserveHost On + ProxyPass /api http://127.0.0.1:5010/api + ProxyPassReverse /api http://127.0.0.1:5010/api + RequestHeader set X-Forwarded-Proto "https" + + ErrorLog ${APACHE_LOG_DIR}/pulse_error.log + CustomLog ${APACHE_LOG_DIR}/pulse_access.log combined + diff --git a/index.html b/index.html new file mode 100644 index 0000000..2241fbd --- /dev/null +++ b/index.html @@ -0,0 +1,57 @@ + + + + + + + + + + + Pulse · perinco + + + + + + + +
+ + + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..90a995e --- /dev/null +++ b/package-lock.json @@ -0,0 +1,2370 @@ +{ + "name": "perinco-pulse", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "perinco-pulse", + "version": "1.0.0", + "dependencies": { + "@tailwindcss/vite": "^4.2.0", + "react": "^19.2.4", + "react-dom": "^19.2.4", + "tailwindcss": "^4.2.0" + }, + "devDependencies": { + "@types/node": "^22.14.0", + "@types/react": "^19.2.0", + "@types/react-dom": "^19.2.0", + "@vitejs/plugin-react": "^5.0.0", + "typescript": "~5.8.2", + "vite": "^6.2.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", + "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.3.tgz", + "integrity": "sha512-w3Jnvi1ocaVm/c7yVPpfB98XeSRBMyzp6njL5MVVbGyXjpmUkN+s6Hp4t0PqhGCCaI1ZHMKXt/w0lA1RCaLVcw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.3.tgz", + "integrity": "sha512-uI/ESiaIbbRYAEhzy8PCUWDp1hB0bjAqM06mW9flOoNO4Q8DQpeoREhBR5Hegfl+wpXiguyJv6XSPzEN7OxyHQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.3.tgz", + "integrity": "sha512-oxhrd1jmXLwWZ83eQYDXxuqRdkqkzrjR3JobKeuUyfdNZo11FuQIvqEOZhyIT7OBHxXoGslDDjN0cQcM6T0TqQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.3.tgz", + "integrity": "sha512-7/YiIMghVE8DrxKvNdorAaJVdriOFgOIpdStnPx8ppx5zfTwC3jBCSEAIzB7JD5404m65THl6H93UTTVUvypmg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.3.tgz", + "integrity": "sha512-GXFZRRoMAytaI5z6N3Zhfw0WL18Q0M8r95D5hlC4GqE/lGk8pbSJNUBoOWDfbm6dTciqHj2nU87tI5f6XhQiOg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.3.tgz", + "integrity": "sha512-77W+8X3ddYgPxUpB8nZFQs2Mq+wc4HVlcSRtApXLjYBcnPMkttrSnU8VwKQjeWYhMsITHFs5cWBQ8vz1Q+5RHQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.3.tgz", + "integrity": "sha512-FVkwK+iUC+mq+GipVK46rRVticfAPtvPUNlqlGXUDxdVk/UGjQiiiUVPUrEXdSpU2ufU0XxLGyTqDtBidDOVmg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.3.tgz", + "integrity": "sha512-+aGU1t3398yQOVj1Bz8o3e+KtswxAPvO+mtxtNdfXYMkXIHu7XhhkCD7/DEH9q8tF8uhDnMWvfpUKI8y1sZJsg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.3.tgz", + "integrity": "sha512-cR0kjpRXR2KJ2oQK8E2KTPtphs+b9hZ8IhTZubNryt/RsqgdOZBQ2Zq0q5UedtiIi0rs3jVhJh55RE1ZHUVGUA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.3.tgz", + "integrity": "sha512-y1RYi4Q3/9ByVWSSt9kX2ustE0B7kFYbJ6zZdVZVyqopZs3yhCTwRfrjIX4vezUJInma/Gs6BOFDJg7yZmJ0IQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.3.tgz", + "integrity": "sha512-DNhEA5viIj3Z5bZLE4z4oV8N5ozWqDwyt7T6KG7VdLDJ0nW+rNOYlphBl4/3HQkK75qipPLsVOfStHHOwN9WSg==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.3.tgz", + "integrity": "sha512-17gQCqrIpXBX2Cmi9/TygnVOqGbzsba/iaqcYSL8FY7lNugg+7AiYNs5c5nKWD+NRQha36Sa0CqkJqH4XVHwnQ==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.3.tgz", + "integrity": "sha512-6LwVnZRIyINpdku/yOcI8Tm9YqLmhHK5emmlOOnW9tO0SYEm1FmKPcsSAGp0NBlqR2P04xaND4jvN6sTHqhq8A==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.3.tgz", + "integrity": "sha512-xMUqkTXlEUtI/p5AAukMwBRr1enU3efsTeF+bskeFfk8t1C9rcC8sLREcZXmTfAXEbvRdJVSonVJez3TMlbR3w==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.3.tgz", + "integrity": "sha512-S3E94co9F9WRRqEaUoQZ38K1gCz6KiM+nL7/3ijq7fDGF3OznjS5TasgYITlvl27GQKtu4lOAOsr5MFwkijvOA==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.3.tgz", + "integrity": "sha512-1QtRDwG42x5BJI3s9mxu5rEjDnfbSnk20HQ9/ylTAYnSwYwxMVb+Vgu34wzzTQ7ogqBybebgQNUDAvZVQ38DbA==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.3.tgz", + "integrity": "sha512-BQhejF6ZXOpxbngiNTP12GCGQeaDVL2QXGeBVViKIYzFHM5RKxTxwUMB1fr1BeNFphFMpnRqC5QSXFSa4z6UQw==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.3.tgz", + "integrity": "sha512-SXagRwnI2Wlwlitllu59UK/nGVbD1CKPcNqDplHwIC4BqJcpXFjD32d1R/RbuISa95HdQrZM3/7v4bKiowFaLA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.3.tgz", + "integrity": "sha512-2IPozoEALRCziGqE8O9KMK60PMu5TS1huv4fwoeCexj+WjmcwFtX9CTOVbfXCUqcELAubEwRFPYlzb/WvwY2HQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.3.tgz", + "integrity": "sha512-AoxqosUHT9IX54hFn2TiN6A7d6ZKTtE6pd2bqWtqkkNJ6HJGaU6FRouGX8L1O7R/ZwsnCnpQrHzb4pDEx+UHRQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.3.tgz", + "integrity": "sha512-d+CaftKgmkFBzCwezMqqy1d0QNNYugqLCMcYVQWBy5SS2YfeMP8Q8ripkgx9O8IyBXXLHrJ+aaCV4U96usv6Yg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.3.tgz", + "integrity": "sha512-xXlDF6nR1eOuXbdDy5Hl5fmtY7teUDevF/k0O7IPoZe4Tpmdv+lgdE5JRsnhQtt37ql9P0VF2kAN9a0OCZdo+Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.3.tgz", + "integrity": "sha512-YtXAgLN+JP7Ay6qG3eWhc7IHMQPzLc8r3uvhAvlJIoCz/4Q32+Bl9Fmnywidh8v1GOIMmymjovfqY9ETAtysvA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.3.tgz", + "integrity": "sha512-WuWtSJRNo549vzcfZyEgfqb6zeSgn1F+UE5kQ+BCjzz0W4MGCjntUHkZVc1VRuAM7+ULaSyhiPxD1spyewFvkQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.3.tgz", + "integrity": "sha512-+lIKX7O0+IGe7WuhATaAMMeT7B76vfhXH/l9wLQL+nvyhbw2ohYCKIdWL56JfDu75CWt5oKRP4QFH/jkMtBquA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", + "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.24.1", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz", + "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-x64": "4.3.3", + "@tailwindcss/oxide-freebsd-x64": "4.3.3", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-x64-musl": "4.3.3", + "@tailwindcss/oxide-wasm32-wasi": "4.3.3", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz", + "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz", + "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz", + "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz", + "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz", + "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz", + "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz", + "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz", + "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz", + "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz", + "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", + "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", + "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.3.tgz", + "integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==", + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.3", + "@tailwindcss/oxide": "4.3.3", + "tailwindcss": "4.3.3" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.20.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.3.tgz", + "integrity": "sha512-DZmzkmwHzXrLPAXPyKNDzlIwMMUZCVacoD25ywdy5YTKGbOx/2ld+Q38Im2zJ0vBuZP5Prd3VZutKZyXwkOS8A==", + "devOptional": true, + "license": "MIT", + "peer": true, + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", + "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.3.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz", + "integrity": "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.29.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-rc.3", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.18.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.24", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.24.tgz", + "integrity": "sha512-hYrgxie335U08WqICoGqKRzV1HFXv6zdxwJE4ekCb80CM9a0SVVsN4QPwT67RraRo+9h8IATk6uxHJw7QSkdOg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.29.0", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.0.tgz", + "integrity": "sha512-3GSvyjvDI4Dur1Meg2BekJquu5uF+9R9a1+5M1Mde192eZoXbeXjzgOsgqPS2V8D5wrrip0gR5Hf/GhWQ9ZzaA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "baseline-browser-mapping": "^2.11.23", + "caniuse-lite": "^1.0.30001810", + "electron-to-chromium": "^1.5.427", + "node-releases": "^2.0.55", + "update-browserslist-db": "^1.3.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001810", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", + "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.430", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.430.tgz", + "integrity": "sha512-e1QEj72Y4zd8RlNZVmoTg+iCOSVwpk05IOiiQwdrkwCSVlZfPthevErhE+nckGd2YbsXfp1SkisznhGVIXP2NQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/enhanced-resolve": { + "version": "5.25.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.25.1.tgz", + "integrity": "sha512-nGXts5znJzmWPu+mIE9izCOzdg63oJca2mDzGWWTth7sr4aCToKcoyFVBQwN75Ij5Pf6p510EwkTqViTRzDV+w==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.56", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.56.tgz", + "integrity": "sha512-x0InOIyzgdk+eyaWaRJFH5snEtiImgBgblZ2CyPrLmqqcuMQkEvcDPHbzqbD8eDsSeJbVOjn+crzyzHaM4D+/A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz", + "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.28.0" + }, + "peerDependencies": { + "react": "^19.3.0" + } + }, + "node_modules/react-refresh": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.3.tgz", + "integrity": "sha512-1i2XreiAoMMXuPGD6Msj2xWrMMkHojNRKivInxGQcg7/1KuPuYlfUutLyh4drnOxUTHX9cHI4wFoat8D/NKaBw==", + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.63.3", + "@rollup/rollup-android-arm64": "4.63.3", + "@rollup/rollup-darwin-arm64": "4.63.3", + "@rollup/rollup-darwin-x64": "4.63.3", + "@rollup/rollup-freebsd-arm64": "4.63.3", + "@rollup/rollup-freebsd-x64": "4.63.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.63.3", + "@rollup/rollup-linux-arm-musleabihf": "4.63.3", + "@rollup/rollup-linux-arm64-gnu": "4.63.3", + "@rollup/rollup-linux-arm64-musl": "4.63.3", + "@rollup/rollup-linux-loong64-gnu": "4.63.3", + "@rollup/rollup-linux-loong64-musl": "4.63.3", + "@rollup/rollup-linux-ppc64-gnu": "4.63.3", + "@rollup/rollup-linux-ppc64-musl": "4.63.3", + "@rollup/rollup-linux-riscv64-gnu": "4.63.3", + "@rollup/rollup-linux-riscv64-musl": "4.63.3", + "@rollup/rollup-linux-s390x-gnu": "4.63.3", + "@rollup/rollup-linux-x64-gnu": "4.63.3", + "@rollup/rollup-linux-x64-musl": "4.63.3", + "@rollup/rollup-openbsd-x64": "4.63.3", + "@rollup/rollup-openharmony-arm64": "4.63.3", + "@rollup/rollup-win32-arm64-msvc": "4.63.3", + "@rollup/rollup-win32-ia32-msvc": "4.63.3", + "@rollup/rollup-win32-x64-gnu": "4.63.3", + "@rollup/rollup-win32-x64-msvc": "4.63.3", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", + "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", + "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.8.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", + "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.3.tgz", + "integrity": "sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "license": "MIT", + "peer": true, + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..a05d07a --- /dev/null +++ b/package.json @@ -0,0 +1,26 @@ +{ + "name": "perinco-pulse", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc --noEmit && vite build", + "preview": "vite preview", + "lint": "tsc --noEmit" + }, + "dependencies": { + "@tailwindcss/vite": "^4.2.0", + "react": "^19.2.4", + "react-dom": "^19.2.4", + "tailwindcss": "^4.2.0" + }, + "devDependencies": { + "@types/node": "^22.14.0", + "@types/react": "^19.2.0", + "@types/react-dom": "^19.2.0", + "@vitejs/plugin-react": "^5.0.0", + "typescript": "~5.8.2", + "vite": "^6.2.0" + } +} diff --git a/public/favicon.png b/public/favicon.png new file mode 100644 index 0000000..a36208c Binary files /dev/null and b/public/favicon.png differ diff --git a/public/logo.png b/public/logo.png new file mode 100644 index 0000000..a947497 Binary files /dev/null and b/public/logo.png differ diff --git a/public/themes/aura-clouds-far.svg b/public/themes/aura-clouds-far.svg new file mode 100644 index 0000000..6cfd4fe --- /dev/null +++ b/public/themes/aura-clouds-far.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + diff --git a/public/themes/aura-clouds-near.svg b/public/themes/aura-clouds-near.svg new file mode 100644 index 0000000..8ca9cb3 --- /dev/null +++ b/public/themes/aura-clouds-near.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + diff --git a/public/themes/aura-moon.svg b/public/themes/aura-moon.svg new file mode 100644 index 0000000..30c1c21 --- /dev/null +++ b/public/themes/aura-moon.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/themes/aura-sidebar.svg b/public/themes/aura-sidebar.svg new file mode 100644 index 0000000..163766f --- /dev/null +++ b/public/themes/aura-sidebar.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + diff --git a/public/themes/aura-stars.svg b/public/themes/aura-stars.svg new file mode 100644 index 0000000..f7868e0 --- /dev/null +++ b/public/themes/aura-stars.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/public/themes/aura-sun.svg b/public/themes/aura-sun.svg new file mode 100644 index 0000000..ad2b2d7 --- /dev/null +++ b/public/themes/aura-sun.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + diff --git a/public/themes/cyberpunk-header.svg b/public/themes/cyberpunk-header.svg new file mode 100644 index 0000000..9824ab0 --- /dev/null +++ b/public/themes/cyberpunk-header.svg @@ -0,0 +1,93 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/themes/cyberpunk-sidebar.svg b/public/themes/cyberpunk-sidebar.svg new file mode 100644 index 0000000..da91abd --- /dev/null +++ b/public/themes/cyberpunk-sidebar.svg @@ -0,0 +1,106 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/themes/outdoor-header.svg b/public/themes/outdoor-header.svg new file mode 100644 index 0000000..ec2001a --- /dev/null +++ b/public/themes/outdoor-header.svg @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/themes/outdoor-sidebar.svg b/public/themes/outdoor-sidebar.svg new file mode 100644 index 0000000..b4cb976 --- /dev/null +++ b/public/themes/outdoor-sidebar.svg @@ -0,0 +1,70 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/themes/pixel-header.svg b/public/themes/pixel-header.svg new file mode 100644 index 0000000..7432585 --- /dev/null +++ b/public/themes/pixel-header.svg @@ -0,0 +1,68 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/themes/pixel-sidebar.svg b/public/themes/pixel-sidebar.svg new file mode 100644 index 0000000..b569b2b --- /dev/null +++ b/public/themes/pixel-sidebar.svg @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/themes/rainy-header.svg b/public/themes/rainy-header.svg new file mode 100644 index 0000000..3d03595 --- /dev/null +++ b/public/themes/rainy-header.svg @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/themes/rainy-sidebar.svg b/public/themes/rainy-sidebar.svg new file mode 100644 index 0000000..41ebdd3 --- /dev/null +++ b/public/themes/rainy-sidebar.svg @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/themes/snow-header.svg b/public/themes/snow-header.svg new file mode 100644 index 0000000..da0f43b --- /dev/null +++ b/public/themes/snow-header.svg @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/themes/snow-sidebar.svg b/public/themes/snow-sidebar.svg new file mode 100644 index 0000000..7f6eea9 --- /dev/null +++ b/public/themes/snow-sidebar.svg @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/server/.env.example b/server/.env.example new file mode 100644 index 0000000..991fd97 --- /dev/null +++ b/server/.env.example @@ -0,0 +1,30 @@ +# perinco Pulse Backend +# Als .env kopieren und Werte setzen. .env niemals einchecken. + +PORT=5010 +# Nur lokal binden - von aussen ausschliesslich ueber den Apache-Reverse-Proxy. +HOST=127.0.0.1 + +DB_HOST=localhost +DB_PORT=3306 +DB_USER=pulse +DB_PASSWORD=changeme +DB_NAME=perinco_pulse + +# Signiert die Sitzungen. Langer Zufallswert: +# node -e "console.log(require('crypto').randomBytes(48).toString('hex'))" +JWT_SECRET=changeme-langer-zufallswert +JWT_EXPIRES_IN=12h + +# Verschluesselt das SSO Client Secret in der Datenbank. Nie aendern, sonst ist +# das gespeicherte Secret nicht mehr lesbar (dann in den Einstellungen neu setzen). +SETTINGS_KEY=changeme-zweiter-zufallswert + +# Wird beim ersten Start angelegt, falls noch kein Benutzer existiert. +ADMIN_USERNAME=admin +ADMIN_NAME=Administrator +ADMIN_EMAIL=admin@perinco.de +ADMIN_PASSWORD=changeme + +# Nur fuer den lokalen Vite-Dev-Server noetig. +# CORS_ORIGINS=http://localhost:3110 diff --git a/server/assets/logo.png b/server/assets/logo.png new file mode 100644 index 0000000..a947497 Binary files /dev/null and b/server/assets/logo.png differ diff --git a/server/package-lock.json b/server/package-lock.json new file mode 100644 index 0000000..520b9f1 --- /dev/null +++ b/server/package-lock.json @@ -0,0 +1,2902 @@ +{ + "name": "perinco-pulse-backend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "perinco-pulse-backend", + "version": "1.0.0", + "dependencies": { + "bcryptjs": "^2.4.3", + "cors": "^2.8.5", + "dotenv": "^16.4.5", + "exceljs": "^4.4.0", + "express": "^4.19.2", + "jsonwebtoken": "^9.0.2", + "mysql2": "^3.11.5", + "pdfkit": "^0.15.0" + } + }, + "node_modules/@fast-csv/format": { + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/@fast-csv/format/-/format-4.3.5.tgz", + "integrity": "sha512-8iRn6QF3I8Ak78lNAa+Gdl5MJJBM5vRHivFtMRUWINdevNo00K7OXxS2PshawLKTejVwieIlPmK5YlLu6w4u8A==", + "license": "MIT", + "dependencies": { + "@types/node": "^14.0.1", + "lodash.escaperegexp": "^4.1.2", + "lodash.isboolean": "^3.0.3", + "lodash.isequal": "^4.5.0", + "lodash.isfunction": "^3.0.9", + "lodash.isnil": "^4.0.0" + } + }, + "node_modules/@fast-csv/format/node_modules/@types/node": { + "version": "14.18.63", + "resolved": "https://registry.npmjs.org/@types/node/-/node-14.18.63.tgz", + "integrity": "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==", + "license": "MIT" + }, + "node_modules/@fast-csv/parse": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/@fast-csv/parse/-/parse-4.3.6.tgz", + "integrity": "sha512-uRsLYksqpbDmWaSmzvJcuApSEe38+6NQZBUsuAyMZKqHxH0g1wcJgsKUvN3WC8tewaqFjBMMGrkHmC+T7k8LvA==", + "license": "MIT", + "dependencies": { + "@types/node": "^14.0.1", + "lodash.escaperegexp": "^4.1.2", + "lodash.groupby": "^4.6.0", + "lodash.isfunction": "^3.0.9", + "lodash.isnil": "^4.0.0", + "lodash.isundefined": "^3.0.1", + "lodash.uniq": "^4.5.0" + } + }, + "node_modules/@fast-csv/parse/node_modules/@types/node": { + "version": "14.18.63", + "resolved": "https://registry.npmjs.org/@types/node/-/node-14.18.63.tgz", + "integrity": "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==", + "license": "MIT" + }, + "node_modules/@swc/helpers": { + "version": "0.3.17", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.3.17.tgz", + "integrity": "sha512-tb7Iu+oZ+zWJZ3HJqwx8oNwSDIU440hmVMDPhpACWQWnrZHK99Bxs70gT1L2dnr5Hg50ZRWEFkQCAnOVVV0z1Q==", + "license": "MIT", + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/node": { + "version": "22.20.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.3.tgz", + "integrity": "sha512-DZmzkmwHzXrLPAXPyKNDzlIwMMUZCVacoD25ywdy5YTKGbOx/2ld+Q38Im2zJ0vBuZP5Prd3VZutKZyXwkOS8A==", + "license": "MIT", + "peer": true, + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/archiver": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/archiver/-/archiver-5.3.2.tgz", + "integrity": "sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==", + "license": "MIT", + "dependencies": { + "archiver-utils": "^2.1.0", + "async": "^3.2.4", + "buffer-crc32": "^0.2.1", + "readable-stream": "^3.6.0", + "readdir-glob": "^1.1.2", + "tar-stream": "^2.2.0", + "zip-stream": "^4.1.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/archiver-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-2.1.0.tgz", + "integrity": "sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==", + "license": "MIT", + "dependencies": { + "glob": "^7.1.4", + "graceful-fs": "^4.2.0", + "lazystream": "^1.0.0", + "lodash.defaults": "^4.2.0", + "lodash.difference": "^4.5.0", + "lodash.flatten": "^4.4.0", + "lodash.isplainobject": "^4.0.6", + "lodash.union": "^4.6.0", + "normalize-path": "^3.0.0", + "readable-stream": "^2.0.0" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/archiver-utils/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/archiver-utils/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/archiver-utils/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/archiver-utils/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "license": "MIT" + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/aws-ssl-profiles": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", + "integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bcryptjs": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz", + "integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==", + "license": "MIT" + }, + "node_modules/big-integer": { + "version": "1.6.52", + "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz", + "integrity": "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==", + "license": "Unlicense", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/binary": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/binary/-/binary-0.3.0.tgz", + "integrity": "sha512-D4H1y5KYwpJgK8wk1Cue5LLPgmwHKYSChkbspQg5JtVuR5ulGckxfR62H3AE9UDkdMC8yyXlqYihuz3Aqg2XZg==", + "license": "MIT", + "dependencies": { + "buffers": "~0.1.1", + "chainsaw": "~0.1.0" + }, + "engines": { + "node": "*" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/bluebird": { + "version": "3.4.7", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.4.7.tgz", + "integrity": "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==", + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "1.20.8", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.8.tgz", + "integrity": "sha512-JNcyFQ64OiijEkPzUBTCe+hyPXUD/3LEldGQ6iF5LR1w00mx9o7xtDWHXBY2iItjdCFGoilOLNQbH943ut7pHA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.16.0", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/brotli": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz", + "integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==", + "license": "MIT", + "dependencies": { + "base64-js": "^1.1.2" + } + }, + "node_modules/browserify-zlib": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/browserify-zlib/-/browserify-zlib-0.2.0.tgz", + "integrity": "sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==", + "license": "MIT", + "dependencies": { + "pako": "~1.0.5" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/buffer-indexof-polyfill": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/buffer-indexof-polyfill/-/buffer-indexof-polyfill-1.0.2.tgz", + "integrity": "sha512-I7wzHwA3t1/lwXQh+A5PbNvJxgfo5r3xulgpYDB5zckTu/Z9oUK9biouBKQUjEqzaz3HnAT6TYoovmE+GqSf7A==", + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/buffers": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/buffers/-/buffers-0.1.1.tgz", + "integrity": "sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ==", + "engines": { + "node": ">=0.2.0" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chainsaw": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/chainsaw/-/chainsaw-0.1.0.tgz", + "integrity": "sha512-75kWfWt6MEKNC8xYXIdRpDehRYY/tNSgwKaJq+dbbDcxORuVrrQ+SEHoWsniVn9XPYfP4gmdWIeDk/4YNp1rNQ==", + "license": "MIT/X11", + "dependencies": { + "traverse": ">=0.3.0 <0.4" + }, + "engines": { + "node": "*" + } + }, + "node_modules/clone": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz", + "integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==", + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/compress-commons": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-4.1.2.tgz", + "integrity": "sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==", + "license": "MIT", + "dependencies": { + "buffer-crc32": "^0.2.13", + "crc32-stream": "^4.0.2", + "normalize-path": "^3.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "license": "MIT" + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/crc32-stream": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-4.0.3.tgz", + "integrity": "sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==", + "license": "MIT", + "dependencies": { + "crc-32": "^1.2.0", + "readable-stream": "^3.4.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/crypto-js": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz", + "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==", + "deprecated": "Active development of CryptoJS has been discontinued. This library is no longer maintained.", + "license": "MIT" + }, + "node_modules/dayjs": { + "version": "1.11.23", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.23.tgz", + "integrity": "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/deep-equal": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-2.2.3.tgz", + "integrity": "sha512-ZIwpnevOurS8bpT4192sqAowWM76JDKSHYzMLty3BZGSswgq6pBaH3DhCSW5xVAZICZyKdOBPjwww5wfgT/6PA==", + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.0", + "call-bind": "^1.0.5", + "es-get-iterator": "^1.1.3", + "get-intrinsic": "^1.2.2", + "is-arguments": "^1.1.1", + "is-array-buffer": "^3.0.2", + "is-date-object": "^1.0.5", + "is-regex": "^1.1.4", + "is-shared-array-buffer": "^1.0.2", + "isarray": "^2.0.5", + "object-is": "^1.1.5", + "object-keys": "^1.1.1", + "object.assign": "^4.1.4", + "regexp.prototype.flags": "^1.5.1", + "side-channel": "^1.0.4", + "which-boxed-primitive": "^1.0.2", + "which-collection": "^1.0.1", + "which-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dfa": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz", + "integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==", + "license": "MIT" + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/duplexer2": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz", + "integrity": "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==", + "license": "BSD-3-Clause", + "dependencies": { + "readable-stream": "^2.0.2" + } + }, + "node_modules/duplexer2/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/duplexer2/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/duplexer2/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/duplexer2/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-get-iterator": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/es-get-iterator/-/es-get-iterator-1.1.3.tgz", + "integrity": "sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.1.3", + "has-symbols": "^1.0.3", + "is-arguments": "^1.1.1", + "is-map": "^2.0.2", + "is-set": "^2.0.2", + "is-string": "^1.0.7", + "isarray": "^2.0.5", + "stop-iteration-iterator": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/exceljs": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/exceljs/-/exceljs-4.4.0.tgz", + "integrity": "sha512-XctvKaEMaj1Ii9oDOqbW/6e1gXknSY4g/aLCDicOXqBE4M0nRWkUu0PTp++UPNzoFY12BNHMfs/VadKIS6llvg==", + "license": "MIT", + "dependencies": { + "archiver": "^5.0.0", + "dayjs": "^1.8.34", + "fast-csv": "^4.3.1", + "jszip": "^3.10.1", + "readable-stream": "^3.6.0", + "saxes": "^5.0.1", + "tmp": "^0.2.0", + "unzipper": "^0.10.11", + "uuid": "^8.3.0" + }, + "engines": { + "node": ">=8.3.0" + } + }, + "node_modules/express": { + "version": "4.22.3", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.3.tgz", + "integrity": "sha512-Bdcs4+3qlpVlx2NRn6fgX2Ue2/gGRaPeawebgclM0ERSCqDpA+owF1fdPwjJUTAJWMTuAaxjDf+hzb0/4eKvvw==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.5", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.13", + "proxy-addr": "~2.0.7", + "qs": "~6.16.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/fast-csv": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/fast-csv/-/fast-csv-4.3.6.tgz", + "integrity": "sha512-2RNSpuwwsJGP0frGsOmTb9oUF+VkFSM4SyLTDgwf2ciHWTarN0lQTC+F2f/t5J9QjW+c65VFIAAu85GsvMIusw==", + "license": "MIT", + "dependencies": { + "@fast-csv/format": "4.3.5", + "@fast-csv/parse": "4.3.6" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fontkit": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/fontkit/-/fontkit-1.9.0.tgz", + "integrity": "sha512-HkW/8Lrk8jl18kzQHvAw9aTHe1cqsyx5sDnxncx652+CIfhawokEPkeM3BoIC+z/Xv7a0yMr0f3pRRwhGH455g==", + "license": "MIT", + "dependencies": { + "@swc/helpers": "^0.3.13", + "brotli": "^1.3.2", + "clone": "^2.1.2", + "deep-equal": "^2.0.5", + "dfa": "^1.2.0", + "restructure": "^2.0.1", + "tiny-inflate": "^1.0.3", + "unicode-properties": "^1.3.1", + "unicode-trie": "^2.0.0" + } + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "license": "ISC" + }, + "node_modules/fstream": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/fstream/-/fstream-1.0.12.tgz", + "integrity": "sha512-WvJ193OHa0GHPEL+AycEJgxvBEwyfRkN1vhjca23OaPVMCaLCXTd5qAu82AjTcgP1UJmytkOKb63Ypde7raDIg==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "dependencies": { + "graceful-fs": "^4.1.2", + "inherits": "~2.0.0", + "mkdirp": ">=0.5 0", + "rimraf": "2" + }, + "engines": { + "node": ">=0.6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generate-function": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", + "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==", + "license": "MIT", + "dependencies": { + "is-property": "^1.0.2" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-arguments": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz", + "integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-property": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", + "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==", + "license": "MIT" + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "license": "MIT" + }, + "node_modules/jpeg-exif": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/jpeg-exif/-/jpeg-exif-1.1.4.tgz", + "integrity": "sha512-a+bKEcCjtuW5WTdgeXFzswSrdqi0jk4XlEtZlx5A94wCoBpFjfFTbo/Tra5SpNCl/YFZPvcV1dJc+TAYeg6ROQ==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "license": "MIT" + }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jsonwebtoken/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/jszip": { + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.2.tgz", + "integrity": "sha512-3l+rb15IOWtUhU0H5MFqES/T6Kh7abYwjosBey/vD6hDt8zoEffkSC5Ws5SGtgVw3gBx2NEbhTeSW1+kWkpyTQ==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/jszip/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/jszip/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/jszip/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/jszip/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/lazystream": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", + "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", + "license": "MIT", + "dependencies": { + "readable-stream": "^2.0.5" + }, + "engines": { + "node": ">= 0.6.3" + } + }, + "node_modules/lazystream/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/lazystream/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/lazystream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/lazystream/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, + "node_modules/linebreak": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz", + "integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==", + "license": "MIT", + "dependencies": { + "base64-js": "0.0.8", + "unicode-trie": "^2.0.0" + } + }, + "node_modules/linebreak/node_modules/base64-js": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz", + "integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/listenercount": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/listenercount/-/listenercount-1.0.1.tgz", + "integrity": "sha512-3mk/Zag0+IJxeDrxSgaDPy4zZ3w05PRZeJNnlWhzFz5OkX49J4krc+A8X2d2M69vGMBEX0uyl8M+W+8gH+kBqQ==", + "license": "ISC" + }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", + "license": "MIT" + }, + "node_modules/lodash.difference": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.difference/-/lodash.difference-4.5.0.tgz", + "integrity": "sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==", + "license": "MIT" + }, + "node_modules/lodash.escaperegexp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz", + "integrity": "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==", + "license": "MIT" + }, + "node_modules/lodash.flatten": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz", + "integrity": "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==", + "license": "MIT" + }, + "node_modules/lodash.groupby": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.groupby/-/lodash.groupby-4.6.0.tgz", + "integrity": "sha512-5dcWxm23+VAoz+awKmBaiBvzox8+RqMgFhi7UvX9DHZr2HdxHXM/Wrf8cfKpsW37RNrvtPn6hSwNqurSILbmJw==", + "license": "MIT" + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", + "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", + "license": "MIT" + }, + "node_modules/lodash.isfunction": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/lodash.isfunction/-/lodash.isfunction-3.0.9.tgz", + "integrity": "sha512-AirXNj15uRIMMPihnkInB4i3NHeb4iBtNg9WRWuK2o31S+ePwwNmDPaTL3o7dTJ+VXNZim7rFs4rxN4YU1oUJw==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnil": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/lodash.isnil/-/lodash.isnil-4.0.0.tgz", + "integrity": "sha512-up2Mzq3545mwVnMhTDMdfoG1OurpA/s5t88JmQX809eH3C8491iu2sfKhTfhQtKY78oPNhiaHJUpT/dUDAAtng==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.isundefined": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/lodash.isundefined/-/lodash.isundefined-3.0.1.tgz", + "integrity": "sha512-MXB1is3s899/cD8jheYYE2V9qTHwKvt+npCwpD+1Sxm3Q3cECXCiYHjeHWXNwr6Q0SOBPrYUDxendrO6goVTEA==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, + "node_modules/lodash.union": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.union/-/lodash.union-4.6.0.tgz", + "integrity": "sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==", + "license": "MIT" + }, + "node_modules/lodash.uniq": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz", + "integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==", + "license": "MIT" + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/lru.min": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.5.tgz", + "integrity": "sha512-5J9ysMYUpYIg9RF2vJpy9SinEmSviFSe0GyPpCQ4L5QSkLAgeLXlTAOu2ZwWUU5m+0SBl6gUU1R1ZQB3aKypfA==", + "license": "MIT", + "engines": { + "bun": ">=1.0.0", + "deno": ">=1.30.0", + "node": ">=8.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wellwelwel" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/mysql2": { + "version": "3.24.4", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.24.4.tgz", + "integrity": "sha512-A2olluVlj0mvgyIRRISMEzXc51m+21mRtcMVjJyIpt2GG98+XrC9m9HzsqcMsX2LcnfccJvY5NB22g8fENBnOA==", + "license": "MIT", + "dependencies": { + "aws-ssl-profiles": "^1.1.2", + "generate-function": "^2.3.1", + "iconv-lite": "^0.7.3", + "long": "^5.3.2", + "lru.min": "^1.1.4", + "named-placeholders": "^1.1.6", + "sql-escaper": "^1.5.1" + }, + "engines": { + "node": ">= 8.0" + }, + "peerDependencies": { + "@types/node": ">= 8" + } + }, + "node_modules/mysql2/node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/named-placeholders": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.6.tgz", + "integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==", + "license": "MIT", + "dependencies": { + "lru.min": "^1.1.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-is": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz", + "integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/pdfkit": { + "version": "0.15.2", + "resolved": "https://registry.npmjs.org/pdfkit/-/pdfkit-0.15.2.tgz", + "integrity": "sha512-s3GjpdBFSCaeDSX/v73MI5UsPqH1kjKut2AXCgxQ5OH10lPVOu5q5vLAG0OCpz/EYqKsTSw1WHpENqMvp43RKg==", + "license": "MIT", + "dependencies": { + "crypto-js": "^4.2.0", + "fontkit": "^1.8.1", + "jpeg-exif": "^1.1.4", + "linebreak": "^1.0.2", + "png-js": "^1.0.0" + } + }, + "node_modules/png-js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/png-js/-/png-js-1.1.0.tgz", + "integrity": "sha512-PM/uYGzGdNSzqeOgly68+6wKQDL1SY0a/N+OEa/+br6LnHWOAJB0Npiamnodfq3jd2LS/i2fMeOKSAILjA+m5Q==", + "dependencies": { + "browserify-zlib": "^0.2.0" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, + "node_modules/proxy-addr": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", + "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/qs": { + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readdir-glob": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.3.tgz", + "integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==", + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.1.0" + } + }, + "node_modules/readdir-glob/node_modules/brace-expansion": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz", + "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/readdir-glob/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/restructure": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/restructure/-/restructure-2.0.1.tgz", + "integrity": "sha512-e0dOpjm5DseomnXx2M5lpdZ5zoHqF1+bqdMJUohoYVVQa7cBdnk7fdmeI6byNWP/kiME72EeTiSypTCVnpLiDg==", + "license": "MIT" + }, + "node_modules/rimraf": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", + "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/saxes": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-5.0.1.tgz", + "integrity": "sha512-5LBh1Tls8c9xgGjw3QrMwETmTMVk0oFgvrFSvWx62llR2hcEInrKNZ2GZCCuuy2lvWrdl5jhbpeqc5hRYKFOcw==", + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/sql-escaper": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/sql-escaper/-/sql-escaper-1.5.2.tgz", + "integrity": "sha512-6CKD38c31SENivxOADeMNLdukOnUxUcflKtzVWzace7Riv1v7cAEym5Cx9Q7gYZ3ezIJI7ZpqecQq8cqNeSSFg==", + "license": "MIT", + "engines": { + "bun": ">=1.0.0", + "deno": ">=2.0.0", + "node": ">=12.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/mysqljs/sql-escaper?sponsor=1" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tiny-inflate": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz", + "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==", + "license": "MIT" + }, + "node_modules/tmp": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", + "integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==", + "license": "MIT", + "engines": { + "node": ">=14.14" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/traverse": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/traverse/-/traverse-0.3.9.tgz", + "integrity": "sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ==", + "license": "MIT/X11", + "engines": { + "node": "*" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, + "node_modules/unicode-properties": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz", + "integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==", + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.0", + "unicode-trie": "^2.0.0" + } + }, + "node_modules/unicode-trie": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz", + "integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==", + "license": "MIT", + "dependencies": { + "pako": "^0.2.5", + "tiny-inflate": "^1.0.0" + } + }, + "node_modules/unicode-trie/node_modules/pako": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz", + "integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==", + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/unzipper": { + "version": "0.10.14", + "resolved": "https://registry.npmjs.org/unzipper/-/unzipper-0.10.14.tgz", + "integrity": "sha512-ti4wZj+0bQTiX2KmKWuwj7lhV+2n//uXEotUmGuQqrbVZSEGFMbI68+c6JCQ8aAmUWYvtHEz2A8K6wXvueR/6g==", + "license": "MIT", + "dependencies": { + "big-integer": "^1.6.17", + "binary": "~0.3.0", + "bluebird": "~3.4.1", + "buffer-indexof-polyfill": "~1.0.0", + "duplexer2": "~0.1.4", + "fstream": "^1.0.12", + "graceful-fs": "^4.2.2", + "listenercount": "~1.0.1", + "readable-stream": "~2.3.6", + "setimmediate": "~1.0.4" + } + }, + "node_modules/unzipper/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/unzipper/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/unzipper/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/unzipper/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.23", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.23.tgz", + "integrity": "sha512-JMh8aK+1B/0bk/YNupICmH5MgCq6yNLKYQUfsZtSDLLCCmxUkHjUY+oOlIa90lO7lHN1woAfpenLdlKpXy9o+A==", + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "license": "MIT" + }, + "node_modules/zip-stream": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-4.1.1.tgz", + "integrity": "sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==", + "license": "MIT", + "dependencies": { + "archiver-utils": "^3.0.4", + "compress-commons": "^4.1.2", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/zip-stream/node_modules/archiver-utils": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-3.0.4.tgz", + "integrity": "sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==", + "license": "MIT", + "dependencies": { + "glob": "^7.2.3", + "graceful-fs": "^4.2.0", + "lazystream": "^1.0.0", + "lodash.defaults": "^4.2.0", + "lodash.difference": "^4.5.0", + "lodash.flatten": "^4.4.0", + "lodash.isplainobject": "^4.0.6", + "lodash.union": "^4.6.0", + "normalize-path": "^3.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + } + } +} diff --git a/server/package.json b/server/package.json new file mode 100644 index 0000000..22f5dbc --- /dev/null +++ b/server/package.json @@ -0,0 +1,23 @@ +{ + "name": "perinco-pulse-backend", + "version": "1.0.0", + "private": true, + "description": "REST-Backend fuer perinco Pulse (Zeiterfassung, MariaDB perinco_pulse)", + "main": "src/server.js", + "type": "module", + "scripts": { + "start": "node src/server.js", + "dev": "node --watch src/server.js", + "test": "node --test test/sso-filter.test.mjs" + }, + "dependencies": { + "bcryptjs": "^2.4.3", + "cors": "^2.8.5", + "dotenv": "^16.4.5", + "exceljs": "^4.4.0", + "express": "^4.19.2", + "jsonwebtoken": "^9.0.2", + "mysql2": "^3.11.5", + "pdfkit": "^0.15.0" + } +} diff --git a/server/src/db.js b/server/src/db.js new file mode 100644 index 0000000..c312b16 --- /dev/null +++ b/server/src/db.js @@ -0,0 +1,94 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import mysql from 'mysql2/promise'; +import bcrypt from 'bcryptjs'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +export const pool = mysql.createPool({ + host: process.env.DB_HOST || 'localhost', + port: Number(process.env.DB_PORT || 3306), + user: process.env.DB_USER || 'pulse', + password: process.env.DB_PASSWORD || '', + database: process.env.DB_NAME || 'perinco_pulse', + waitForConnections: true, + // Klein gehalten: dieselbe MariaDB bedient weitere Anwendungen. + connectionLimit: 8, + charset: 'utf8mb4', + dateStrings: true, + decimalNumbers: true, +}); + +/** Fuehrt schema.sql aus und legt bei leerer Benutzertabelle den Admin an. */ +export async function initDatabase() { + const sql = await fs.readFile(path.join(__dirname, 'schema.sql'), 'utf8'); + // Split an Semikolon am Zeilenende - so braucht es kein multipleStatements. + const statements = sql + .split('\n') + .filter((line) => !line.trim().startsWith('--')) + .join('\n') + .split(/;\s*$/m) + .map((s) => s.trim()) + .filter(Boolean); + + const conn = await pool.getConnection(); + try { + for (const statement of statements) await conn.query(statement); + } finally { + conn.release(); + } + + await ensureAdminUser(); + await ensureInternalCustomer(); +} + +async function ensureAdminUser() { + const [rows] = await pool.query('SELECT COUNT(*) AS n FROM pulse_users'); + if (rows[0].n > 0) return; + + const password = process.env.ADMIN_PASSWORD; + if (!password) throw new Error('ADMIN_PASSWORD fehlt - erster Administrator kann nicht angelegt werden.'); + const hash = await bcrypt.hash(password, 12); + await pool.query( + `INSERT INTO pulse_users (username, email, name, password_hash, role) + VALUES (?, ?, ?, ?, 'admin')`, + [ + process.env.ADMIN_USERNAME || 'admin', + (process.env.ADMIN_EMAIL || 'admin@perinco.de').toLowerCase(), + process.env.ADMIN_NAME || 'Administrator', + hash, + ] + ); + console.log('[Pulse] Erster Administrator angelegt.'); +} + +/* + * perinco selbst ist der Kunde fuer alle internen Taetigkeiten ("perinco + * intern"). Wird einmalig angelegt, damit direkt nach der Installation ein + * Projekt fuer nicht abrechenbare Zeiten angelegt werden kann. + */ +async function ensureInternalCustomer() { + const [rows] = await pool.query('SELECT COUNT(*) AS n FROM pulse_customers'); + if (rows[0].n > 0) return; + await pool.query( + `INSERT INTO pulse_customers (name, short_name, notes, color) VALUES (?, ?, ?, ?)`, + ['perinco GmbH', 'PERINCO', 'Interne Tätigkeiten (nicht abrechenbar)', '#6ba04a'] + ); +} + +/** Kleiner Helfer fuer Transaktionen. */ +export async function withTransaction(fn) { + const conn = await pool.getConnection(); + try { + await conn.beginTransaction(); + const result = await fn(conn); + await conn.commit(); + return result; + } catch (err) { + await conn.rollback(); + throw err; + } finally { + conn.release(); + } +} diff --git a/server/src/export/render.js b/server/src/export/render.js new file mode 100644 index 0000000..b5c3f4e --- /dev/null +++ b/server/src/export/render.js @@ -0,0 +1,450 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import PDFDocument from 'pdfkit'; +import ExcelJS from 'exceljs'; + +/* + * Gemeinsamer Unterbau fuer alle Exporte. Ein Export wird als neutrales + * Dokument beschrieben (Titel, Infoblock, Kennzahlen, Tabellen) und von hier + * aus als PDF, Excel oder CSV geschrieben - so sehen Stundenzettel und Reports + * in allen drei Formaten gleich aufgebaut aus und tragen denselben Kopf und Fuss + * aus den Einstellungen. + * + * doc = { + * title, subtitle, filename, + * info: [[Label, Wert], ...], + * kpis: [[Label, Wert], ...], + * tables: [{ title?, columns: [{ key, label, width?, align?, type? }], rows: [...], totals?: {...} }], + * signature: boolean, + * landscape: boolean, + * } + * type: 'hours' (Minuten -> 1,50), 'duration' (Minuten -> 1:30), 'money', 'date', 'text', 'percent' + */ + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const DEFAULT_LOGO = path.join(__dirname, '..', '..', 'assets', 'logo.png'); + +export function hoursDe(minutes) { + return (minutes / 60).toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); +} +export function durationDe(minutes) { + const m = Math.round(minutes); + return `${Math.floor(m / 60)}:${String(m % 60).padStart(2, '0')}`; +} +export function moneyDe(value) { + return `${Number(value || 0).toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} €`; +} +export function dateDe(value) { + const [y, m, d] = String(value).slice(0, 10).split('-'); + return `${d}.${m}.${y}`; +} + +function formatCell(value, type) { + if (value == null || value === '') return ''; + switch (type) { + case 'hours': + return hoursDe(value); + case 'duration': + return durationDe(value); + case 'money': + return moneyDe(value); + case 'date': + return dateDe(value); + case 'percent': + return `${value} %`; + default: + return String(value); + } +} + +function logoBuffer(exportSettings) { + const logo = exportSettings.logo; + if (typeof logo === 'string' && logo.startsWith('data:image/')) { + const match = logo.match(/^data:image\/(png|jpe?g);base64,(.+)$/); + if (match) return Buffer.from(match[2], 'base64'); + } + return fs.readFileSync(DEFAULT_LOGO); +} + +/* ------------------------------------------------------------------ CSV -- */ + +export function renderCsv(doc) { + const escape = (v) => { + const s = String(v ?? ''); + return /[;"\n\r]/.test(s) ? `"${s.replaceAll('"', '""')}"` : s; + }; + const lines = []; + doc.tables.forEach((table, index) => { + if (index > 0) lines.push(''); + if (doc.tables.length > 1 && table.title) lines.push(escape(table.title)); + lines.push(table.columns.map((c) => escape(c.label)).join(';')); + for (const row of table.rows) { + lines.push(table.columns.map((c) => escape(formatCell(row[c.key], c.type))).join(';')); + } + if (table.totals) lines.push(table.columns.map((c) => escape(formatCell(table.totals[c.key], c.type))).join(';')); + }); + // BOM, damit Excel die Umlaute als UTF-8 liest. + return Buffer.from('' + lines.join('\r\n'), 'utf8'); +} + +/* ---------------------------------------------------------------- Excel -- */ + +export async function renderXlsx(doc, settings) { + const ex = settings.export; + const accent = (ex.accent || '#6BA04A').replace('#', '').toUpperCase(); + const workbook = new ExcelJS.Workbook(); + workbook.creator = settings.company_name || 'perinco'; + workbook.created = new Date(); + + const sheet = workbook.addWorksheet(doc.sheetName || 'Stundennachweis', { + pageSetup: { + paperSize: 9, + orientation: doc.landscape ? 'landscape' : 'portrait', + fitToPage: true, + fitToWidth: 1, + fitToHeight: 0, + margins: { left: 0.5, right: 0.5, top: 0.6, bottom: 0.8, header: 0.3, footer: 0.3 }, + }, + headerFooter: { + oddFooter: `&L&8${xlEsc(ex.footer_left)}&C&8${xlEsc(ex.footer_center)}&R&8${xlEsc(ex.footer_right)} · Seite &P von &N`, + }, + views: [{ showGridLines: false }], + }); + + const widest = doc.tables.reduce((m, t) => Math.max(m, t.columns.length), 2); + // Spaltenbreiten aus der ersten (Haupt-)Tabelle. + sheet.columns = doc.tables[0].columns.map((c) => ({ width: c.xlsWidth || Math.max(10, (c.width || 80) / 5.5) })); + + const logo = workbook.addImage({ buffer: logoBuffer(ex), extension: /^data:image\/jpe?g/.test(ex.logo || '') ? 'jpeg' : 'png' }); + sheet.addImage(logo, { tl: { col: 0, row: 0 }, ext: { width: 170, height: 77 } }); + sheet.getRow(1).height = 22; + sheet.getRow(2).height = 22; + sheet.getRow(3).height = 22; + const headerRight = sheet.getCell(1, widest); + headerRight.value = ex.header_right || ''; + headerRight.alignment = { horizontal: 'right' }; + headerRight.font = { size: 9, color: { argb: 'FF6E6E6E' } }; + + let row = 5; + const title = sheet.getCell(row, 1); + title.value = doc.title; + title.font = { size: 18, bold: true, color: { argb: `FF${accent}` } }; + row++; + if (doc.subtitle) { + sheet.getCell(row, 1).value = doc.subtitle; + sheet.getCell(row, 1).font = { size: 11, color: { argb: 'FF6E6E6E' } }; + row++; + } + row++; + + for (const [label, value] of doc.info || []) { + sheet.getCell(row, 1).value = label; + sheet.getCell(row, 1).font = { bold: true, size: 10, color: { argb: 'FF6E6E6E' } }; + sheet.getCell(row, 2).value = value; + sheet.getCell(row, 2).font = { size: 10 }; + row++; + } + if (doc.kpis?.length) { + row++; + doc.kpis.forEach(([label, value], i) => { + const col = 1 + i * 2; + if (col > Math.max(widest, 8)) return; + sheet.getCell(row, col).value = label; + sheet.getCell(row, col).font = { size: 9, color: { argb: 'FF6E6E6E' } }; + sheet.getCell(row + 1, col).value = value; + sheet.getCell(row + 1, col).font = { size: 14, bold: true }; + }); + row += 2; + } + + for (const table of doc.tables) { + row++; + if (table.title) { + sheet.getCell(row, 1).value = table.title; + sheet.getCell(row, 1).font = { bold: true, size: 12, color: { argb: `FF${accent}` } }; + row++; + } + const headRow = sheet.getRow(row); + table.columns.forEach((c, i) => { + const cell = headRow.getCell(i + 1); + cell.value = c.label; + cell.font = { bold: true, color: { argb: 'FFFFFFFF' }, size: 10 }; + cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: `FF${accent}` } }; + cell.alignment = { horizontal: c.align === 'right' ? 'right' : 'left', vertical: 'middle' }; + }); + headRow.height = 20; + row++; + const first = row; + table.rows.forEach((data, index) => { + const r = sheet.getRow(row); + table.columns.forEach((c, i) => writeXlsCell(r.getCell(i + 1), data[c.key], c, index % 2 === 1)); + row++; + }); + if (table.totals) { + const r = sheet.getRow(row); + table.columns.forEach((c, i) => { + const cell = r.getCell(i + 1); + if ((c.type === 'hours' || c.type === 'money') && table.rows.length && table.totals[c.key] != null) { + const letter = sheet.getColumn(i + 1).letter; + const value = c.type === 'hours' ? table.totals[c.key] / 60 : table.totals[c.key]; + cell.value = { formula: `SUM(${letter}${first}:${letter}${row - 1})`, result: value }; + cell.numFmt = c.type === 'money' ? '#,##0.00 "€"' : '#,##0.00'; + } else { + cell.value = formatCell(table.totals[c.key], c.type); + } + cell.font = { bold: true }; + cell.alignment = { horizontal: c.align === 'right' ? 'right' : 'left' }; + cell.border = { top: { style: 'medium', color: { argb: `FF${accent}` } } }; + }); + row++; + } + } + + if (doc.signature && ex.show_signature) { + row += 3; + const half = Math.max(2, Math.floor(widest / 2)); + for (const [col, label] of [ + [1, ex.signature_left], + [half + 1, ex.signature_right], + ]) { + const cell = sheet.getCell(row, col); + cell.border = { top: { style: 'thin', color: { argb: 'FF9A9A9A' } } }; + sheet.getCell(row, col + 1).border = { top: { style: 'thin', color: { argb: 'FF9A9A9A' } } }; + cell.value = label; + cell.font = { size: 9, color: { argb: 'FF6E6E6E' } }; + } + } + + return Buffer.from(await workbook.xlsx.writeBuffer()); +} + +function xlEsc(value) { + return String(value || '').replaceAll('&', '&&'); +} + +function writeXlsCell(cell, value, column, zebra) { + switch (column.type) { + case 'hours': + cell.value = value == null ? null : Math.round((value / 60) * 100) / 100; + cell.numFmt = '#,##0.00'; + break; + case 'duration': + cell.value = value == null ? null : durationDe(value); + break; + case 'money': + cell.value = value == null ? null : Number(value); + cell.numFmt = '#,##0.00 "€"'; + break; + case 'date': + cell.value = value ? new Date(`${String(value).slice(0, 10)}T00:00:00Z`) : null; + cell.numFmt = 'dd.mm.yyyy'; + break; + case 'percent': + cell.value = value == null ? null : value / 100; + cell.numFmt = '0%'; + break; + default: + cell.value = value ?? ''; + } + cell.font = { size: 10 }; + cell.alignment = { horizontal: column.align === 'right' ? 'right' : 'left', vertical: 'top', wrapText: column.type === 'text' || !column.type }; + if (zebra) cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFF4F7F1' } }; + cell.border = { bottom: { style: 'hair', color: { argb: 'FFDCDCDC' } } }; +} + +/* ------------------------------------------------------------------ PDF -- */ + +export function renderPdf(doc, settings) { + const ex = settings.export; + const accent = ex.accent || '#6BA04A'; + const pdf = new PDFDocument({ + size: 'A4', + layout: doc.landscape ? 'landscape' : 'portrait', + margins: { top: 40, bottom: 70, left: 42, right: 42 }, + bufferPages: true, + info: { Title: `${doc.title} ${doc.subtitle || ''}`.trim(), Author: settings.company_name || 'perinco' }, + }); + const chunks = []; + pdf.on('data', (c) => chunks.push(c)); + const done = new Promise((resolve) => pdf.on('end', () => resolve(Buffer.concat(chunks)))); + + const left = pdf.page.margins.left; + const width = pdf.page.width - left - pdf.page.margins.right; + const logo = logoBuffer(ex); + + const drawHeader = () => { + pdf.image(logo, left, 30, { fit: [120, 46] }); + pdf.fontSize(8).fillColor('#6E6E6E').font('Helvetica'); + if (ex.subtitle) pdf.text(ex.subtitle, left, 34, { width, align: 'right' }); + if (ex.header_right) pdf.text(ex.header_right, left, 46, { width, align: 'right' }); + pdf.moveTo(left, 84).lineTo(left + width, 84).lineWidth(1.5).strokeColor(accent).stroke(); + pdf.x = left; + pdf.y = 100; + }; + + drawHeader(); + pdf.on('pageAdded', drawHeader); + + // Titel + pdf.font('Helvetica-Bold').fontSize(20).fillColor('#1F1F1F').text(doc.title, left, pdf.y); + if (doc.subtitle) pdf.font('Helvetica').fontSize(11).fillColor(accent).text(doc.subtitle); + pdf.moveDown(0.8); + + // Infoblock zweispaltig + if (doc.info?.length) { + const colW = width / 2; + const startY = pdf.y; + let maxY = startY; + doc.info.forEach(([label, value], i) => { + const x = left + (i % 2) * colW; + const y = startY + Math.floor(i / 2) * 30; + pdf.font('Helvetica').fontSize(7.5).fillColor('#9A9A9A').text(label.toUpperCase(), x, y, { width: colW - 10, characterSpacing: 0.6 }); + pdf.font('Helvetica-Bold').fontSize(10).fillColor('#1F1F1F').text(String(value ?? '–'), x, y + 10, { width: colW - 10, height: 14, ellipsis: true }); + maxY = Math.max(maxY, y + 26); + }); + pdf.y = maxY + 8; + } + + // Kennzahlen als Kacheln + if (doc.kpis?.length) { + const gap = 8; + const boxW = (width - gap * (doc.kpis.length - 1)) / doc.kpis.length; + const y = pdf.y; + doc.kpis.forEach(([label, value], i) => { + const x = left + i * (boxW + gap); + pdf.roundedRect(x, y, boxW, 46, 6).fillColor('#F4F7F1').fill(); + pdf.rect(x, y, 3, 46).fillColor(accent).fill(); + pdf.font('Helvetica').fontSize(7.5).fillColor('#6E6E6E').text(label, x + 10, y + 8, { width: boxW - 16 }); + pdf.font('Helvetica-Bold').fontSize(14).fillColor('#1F1F1F').text(String(value), x + 10, y + 21, { width: boxW - 16 }); + }); + pdf.y = y + 60; + } + + for (const table of doc.tables) drawTable(pdf, table, { left, width, accent }); + + if (doc.signature && ex.show_signature) { + if (pdf.y + 80 > pdf.page.height - pdf.page.margins.bottom) pdf.addPage(); + const y = pdf.y + 45; + const w = (width - 40) / 2; + for (const [x, label] of [ + [left, ex.signature_left], + [left + w + 40, ex.signature_right], + ]) { + pdf.moveTo(x, y).lineTo(x + w, y).lineWidth(0.6).strokeColor('#9A9A9A').stroke(); + pdf.font('Helvetica').fontSize(8).fillColor('#6E6E6E').text(label || '', x, y + 4, { width: w }); + } + } + + // Fuss auf jeder Seite, inkl. Seitenzahl. + const range = pdf.bufferedPageRange(); + for (let i = 0; i < range.count; i++) { + pdf.switchToPage(range.start + i); + const bottom = pdf.page.height - 48; + const margin = pdf.page.margins.bottom; + pdf.page.margins.bottom = 0; // sonst bricht text() unten auf eine neue Seite um + pdf.moveTo(left, bottom - 8).lineTo(left + width, bottom - 8).lineWidth(0.5).strokeColor('#DCDCDC').stroke(); + pdf.font('Helvetica').fontSize(7.5).fillColor('#6E6E6E'); + const third = width / 3; + pdf.text(ex.footer_left || '', left, bottom, { width: third, align: 'left', lineGap: 1 }); + pdf.text(ex.footer_center || '', left + third, bottom, { width: third, align: 'center', lineGap: 1 }); + pdf.text(`${ex.footer_right || ''}${ex.footer_right ? '\n' : ''}Seite ${i + 1} von ${range.count}`, left + 2 * third, bottom, { + width: third, + align: 'right', + lineGap: 1, + }); + pdf.page.margins.bottom = margin; + } + + pdf.end(); + return done; +} + +function drawTable(pdf, table, { left, width, accent }) { + const fixed = table.columns.reduce((s, c) => s + (c.width || 0), 0); + const flex = table.columns.filter((c) => !c.width).length; + const widths = table.columns.map((c) => c.width || Math.max(40, (width - fixed) / Math.max(1, flex))); + const scale = width / widths.reduce((a, b) => a + b, 0); + const cols = widths.map((w) => w * scale); + const bottomLimit = () => pdf.page.height - pdf.page.margins.bottom; + + const header = () => { + const y = pdf.y; + pdf.rect(left, y, width, 18).fillColor(accent).fill(); + let x = left; + table.columns.forEach((c, i) => { + pdf.font('Helvetica-Bold').fontSize(8).fillColor('#FFFFFF').text(c.label, x + 5, y + 5, { + width: cols[i] - 10, + align: c.align || 'left', + lineBreak: false, + ellipsis: true, + }); + x += cols[i]; + }); + pdf.y = y + 18; + }; + + if (pdf.y + 60 > bottomLimit()) pdf.addPage(); + if (table.title) { + pdf.moveDown(0.6); + pdf.font('Helvetica-Bold').fontSize(11).fillColor('#1F1F1F').text(table.title, left, pdf.y); + pdf.moveDown(0.3); + } + header(); + + const drawRow = (data, { zebra = false, bold = false } = {}) => { + const texts = table.columns.map((c) => formatCell(data[c.key], c.type)); + pdf.font(bold ? 'Helvetica-Bold' : 'Helvetica').fontSize(8.5); + const height = Math.max(16, ...texts.map((t, i) => pdf.heightOfString(t || ' ', { width: cols[i] - 10 }) + 8)); + if (pdf.y + height > bottomLimit()) { + pdf.addPage(); + header(); + } + const y = pdf.y; + if (zebra) pdf.rect(left, y, width, height).fillColor('#F4F7F1').fill(); + if (bold) { + pdf.moveTo(left, y).lineTo(left + width, y).lineWidth(1.2).strokeColor(accent).stroke(); + } + let x = left; + texts.forEach((text, i) => { + pdf.font(bold ? 'Helvetica-Bold' : 'Helvetica').fontSize(8.5).fillColor('#1F1F1F').text(text, x + 5, y + 4, { + width: cols[i] - 10, + align: table.columns[i].align || 'left', + }); + x += cols[i]; + }); + if (!bold) pdf.moveTo(left, y + height).lineTo(left + width, y + height).lineWidth(0.3).strokeColor('#E4E4E4').stroke(); + pdf.y = y + height; + }; + + if (table.rows.length === 0) { + pdf.font('Helvetica-Oblique').fontSize(9).fillColor('#9A9A9A').text('Keine Buchungen im Zeitraum.', left + 5, pdf.y + 6); + pdf.y += 10; + } + table.rows.forEach((row, index) => drawRow(row, { zebra: index % 2 === 1 })); + if (table.totals) drawRow(table.totals, { bold: true }); + pdf.x = left; + pdf.moveDown(0.8); +} + +export async function sendDocument(res, doc, format, settings) { + const safe = doc.filename.replace(/[^\w.\-äöüÄÖÜß ]+/g, '_'); + let buffer; + let type; + if (format === 'pdf') { + buffer = await renderPdf(doc, settings); + type = 'application/pdf'; + } else if (format === 'xlsx') { + buffer = await renderXlsx(doc, settings); + type = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'; + } else { + buffer = renderCsv(doc); + type = 'text/csv; charset=utf-8'; + } + res.setHeader('Content-Type', type); + res.setHeader( + 'Content-Disposition', + `attachment; filename="${safe.replace(/[^\x20-\x7e]/g, '_')}.${format}"; filename*=UTF-8''${encodeURIComponent(safe)}.${format}` + ); + res.send(buffer); +} diff --git a/server/src/lib/access.js b/server/src/lib/access.js new file mode 100644 index 0000000..b9ca079 --- /dev/null +++ b/server/src/lib/access.js @@ -0,0 +1,45 @@ +import { pool } from '../db.js'; +import { hasRole } from '../middleware/auth.js'; + +/** + * Wessen Zeiten darf der Benutzer sehen? + * management/admin: alle (null) + * lead: sich selbst und alle, die direkt oder indirekt unter ihm haengen + * user: nur sich selbst + */ +export async function visibleUserIds(user) { + if (hasRole(user, 'management')) return null; + if (user.role !== 'lead') return [user.id]; + + const [rows] = await pool.query('SELECT id, manager_id FROM pulse_users'); + const children = new Map(); + for (const row of rows) { + if (row.manager_id == null) continue; + if (!children.has(row.manager_id)) children.set(row.manager_id, []); + children.get(row.manager_id).push(row.id); + } + const seen = new Set([user.id]); + const queue = [user.id]; + while (queue.length) { + const current = queue.shift(); + for (const child of children.get(current) || []) { + if (seen.has(child)) continue; // Schutz gegen Zyklen + seen.add(child); + queue.push(child); + } + } + return [...seen]; +} + +/** Alle Unterstellten (rekursiv) eines Vorgesetzten, ohne ihn selbst. */ +export async function teamOf(leadId) { + const ids = await visibleUserIds({ id: leadId, role: 'lead' }); + return ids.filter((id) => id !== leadId); +} + +/** SQL-Fragment fuer "user_id IN (...)" - bei null ohne Einschraenkung. */ +export function userFilter(ids, column = 'e.user_id') { + if (ids === null) return { sql: '1=1', params: [] }; + if (ids.length === 0) return { sql: '1=0', params: [] }; + return { sql: `${column} IN (${ids.map(() => '?').join(',')})`, params: ids }; +} diff --git a/server/src/lib/dates.js b/server/src/lib/dates.js new file mode 100644 index 0000000..ae7def4 --- /dev/null +++ b/server/src/lib/dates.js @@ -0,0 +1,116 @@ +/* + * Datumsrechnung ohne Zeitzonenfallen: alles laeuft ueber 'YYYY-MM-DD' und + * UTC-Mitternacht, damit ein Tag nie durch die Sommerzeit verrutscht. + */ +export function parseDate(value) { + if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return null; + const date = new Date(`${value}T00:00:00Z`); + return Number.isNaN(date.getTime()) || iso(date) !== value ? null : date; +} + +export function iso(date) { + return date.toISOString().slice(0, 10); +} + +export function today() { + const now = new Date(); + // Lokales Datum des Servers (Europe/Berlin), nicht UTC. + const local = new Date(now.getTime() - now.getTimezoneOffset() * 60000); + return local.toISOString().slice(0, 10); +} + +export function addDays(value, days) { + const date = parseDate(value); + date.setUTCDate(date.getUTCDate() + days); + return iso(date); +} + +export function daysBetween(from, to) { + return Math.round((parseDate(to) - parseDate(from)) / 86400000); +} + +/** Arbeitstage Mo-Fr im geschlossenen Intervall. */ +export function workdays(from, to) { + let count = 0; + const end = parseDate(to); + for (const d = parseDate(from); d <= end; d.setUTCDate(d.getUTCDate() + 1)) { + const day = d.getUTCDay(); + if (day !== 0 && day !== 6) count++; + } + return count; +} + +export function monthRange(month) { + if (!/^\d{4}-\d{2}$/.test(month || '')) return null; + const [y, m] = month.split('-').map(Number); + if (m < 1 || m > 12) return null; + const last = new Date(Date.UTC(y, m, 0)).getUTCDate(); + return { from: `${month}-01`, to: `${month}-${String(last).padStart(2, '0')}` }; +} + +export function isoWeek(value) { + const date = parseDate(value); + const day = date.getUTCDay() || 7; + date.setUTCDate(date.getUTCDate() + 4 - day); + const yearStart = new Date(Date.UTC(date.getUTCFullYear(), 0, 1)); + return { year: date.getUTCFullYear(), week: Math.ceil(((date - yearStart) / 86400000 + 1) / 7) }; +} + +export function mondayOf(value) { + const date = parseDate(value); + const day = date.getUTCDay() || 7; + date.setUTCDate(date.getUTCDate() - day + 1); + return iso(date); +} + +export const MONTHS_DE = ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember']; + +export function formatDateDe(value) { + const [y, m, d] = String(value).slice(0, 10).split('-'); + return `${d}.${m}.${y}`; +} + +/** Zeitraum, in dem `value` liegt: day | week | month | quarter | year. */ +export function periodRange(granularity, value) { + const [y, m] = value.split('-').map(Number); + switch (granularity) { + case 'day': + return { from: value, to: value }; + case 'week': { + const from = mondayOf(value); + return { from, to: addDays(from, 6) }; + } + case 'month': + return monthRange(value.slice(0, 7)); + case 'quarter': { + const q = Math.floor((m - 1) / 3); + const startMonth = String(q * 3 + 1).padStart(2, '0'); + const end = monthRange(`${y}-${String(q * 3 + 3).padStart(2, '0')}`); + return { from: `${y}-${startMonth}-01`, to: end.to }; + } + case 'year': + return { from: `${y}-01-01`, to: `${y}-12-31` }; + default: + return null; + } +} + +/** Beginn des vorherigen Zeitraums (fuer Reihen und Vergleich). */ +export function previousStart(granularity, from) { + const d = parseDate(from); + if (granularity === 'day') d.setUTCDate(d.getUTCDate() - 1); + if (granularity === 'week') d.setUTCDate(d.getUTCDate() - 7); + if (granularity === 'month') d.setUTCMonth(d.getUTCMonth() - 1); + if (granularity === 'quarter') d.setUTCMonth(d.getUTCMonth() - 3); + if (granularity === 'year') d.setUTCFullYear(d.getUTCFullYear() - 1); + return iso(d); +} + +export function periodLabel(granularity, from) { + const [y, m, d] = from.split('-'); + if (granularity === 'day') return `${d}.${m}.`; + if (granularity === 'week') return `KW ${isoWeek(from).week}`; + if (granularity === 'month') return `${MONTHS_DE[Number(m) - 1].slice(0, 3)} ${y.slice(2)}`; + if (granularity === 'quarter') return `Q${Math.floor((Number(m) - 1) / 3) + 1} ${y.slice(2)}`; + return y; +} diff --git a/server/src/lib/entries.js b/server/src/lib/entries.js new file mode 100644 index 0000000..8bd64f0 --- /dev/null +++ b/server/src/lib/entries.js @@ -0,0 +1,95 @@ +import { pool } from '../db.js'; +import { displayName, hourlyRate } from './projects.js'; +import { userFilter } from './access.js'; + +/* + * Buchungen fuer Auswertungen laden - mit allem, was Dashboard, Reports, + * Kundenansicht und Exporte brauchen, und mit dem Betrag je Buchung: + * + * amount abrechenbarer Betrag (Stunden x Stundensatz des Topfs); + * nur fuer Projekte vom Typ "billable" + * fixed_amount Festpreisprojekte: Stunden x virtueller Stundensatz + * (Topfbudget / alle auf dem Topf gebuchten Stunden). Nur + * intern, in Reports per Schalter zuschaltbar. + */ +export async function loadEntries(settings, { from, to, userIds = null, customerId, projectId, taskId } = {}) { + // Administratoren sind keine Mitarbeiter - ihre Zeiten zaehlen nirgends mit. + const where = ["u.role <> 'admin'"]; + const params = []; + if (from) { + where.push('e.entry_date >= ?'); + params.push(from); + } + if (to) { + where.push('e.entry_date <= ?'); + params.push(to); + } + const users = userFilter(userIds); + where.push(users.sql); + params.push(...users.params); + if (customerId) { + where.push('p.customer_id = ?'); + params.push(Number(customerId)); + } + if (projectId) { + where.push('e.project_id = ?'); + params.push(Number(projectId)); + } + if (taskId) { + where.push('e.task_id = ?'); + params.push(Number(taskId)); + } + + const [rows] = await pool.query( + `SELECT e.id, e.user_id, e.project_id, e.task_id, e.pot_id, e.entry_date, e.minutes, e.note, e.billable, + e.created_at, e.updated_at, + u.name AS user_name, u.color AS user_color, u.is_external, u.company AS user_company, u.weekly_hours, + u.manager_id, + p.name AS project_name, p.code AS project_code, p.type AS project_type, p.billing_unit, + c.id AS customer_id, c.name AS customer_name, c.short_name AS customer_short, c.color AS customer_color, + t.name AS task_name, + b.label AS pot_label, b.rate AS pot_rate, b.budget_amount AS pot_amount + FROM pulse_time_entries e + JOIN pulse_users u ON u.id = e.user_id + JOIN pulse_projects p ON p.id = e.project_id + JOIN pulse_customers c ON c.id = p.customer_id + LEFT JOIN pulse_tasks t ON t.id = e.task_id + LEFT JOIN pulse_budget_pots b ON b.id = e.pot_id + WHERE ${where.join(' AND ')} + ORDER BY e.entry_date, e.id`, + params + ); + + // Virtueller Stundensatz braucht die Stunden des ganzen Topfs, nicht nur des Zeitraums. + const fixedPots = [...new Set(rows.filter((r) => r.project_type === 'fixed' && r.pot_id).map((r) => r.pot_id))]; + const potMinutes = new Map(); + if (fixedPots.length) { + const [usage] = await pool.query( + `SELECT pot_id, SUM(minutes) AS minutes FROM pulse_time_entries WHERE pot_id IN (${fixedPots.map(() => '?').join(',')}) GROUP BY pot_id`, + fixedPots + ); + for (const u of usage) potMinutes.set(u.pot_id, Number(u.minutes)); + } + + return rows.map((r) => { + const project = { name: r.project_name, code: r.project_code, billing_unit: r.billing_unit }; + const hours = r.minutes / 60; + let amount = 0; + let fixedAmount = 0; + if (r.billable && r.project_type === 'billable') { + const rate = hourlyRate({ rate: r.pot_rate }, project, settings); + amount = rate ? hours * rate : 0; + } + if (r.billable && r.project_type === 'fixed' && r.pot_amount && potMinutes.get(r.pot_id)) { + fixedAmount = (Number(r.pot_amount) / (potMinutes.get(r.pot_id) / 60)) * hours; + } + return { + ...r, + billable: Boolean(r.billable), + is_external: Boolean(r.is_external), + project_display: displayName(settings, project, { name: r.customer_name, short_name: r.customer_short }), + amount: Math.round(amount * 100) / 100, + fixed_amount: Math.round(fixedAmount * 100) / 100, + }; + }); +} diff --git a/server/src/lib/projects.js b/server/src/lib/projects.js new file mode 100644 index 0000000..7f6b789 --- /dev/null +++ b/server/src/lib/projects.js @@ -0,0 +1,375 @@ +import { pool } from '../db.js'; +import { daysBetween, parseDate, today } from './dates.js'; + +/* + * Fachlogik rund um Projekte und Budgettoepfe - an einer Stelle, weil Buchung, + * Projektansicht, Kunden und Reports dieselben Zahlen zeigen muessen. + */ + +const round2 = (n) => Math.round(n * 100) / 100; + +export function parseTags(value) { + if (!value) return []; + try { + const parsed = JSON.parse(value); + return Array.isArray(parsed) ? parsed.map(String) : []; + } catch { + return []; + } +} + +/** Anzeigename nach dem Muster aus den Einstellungen, z. B. "KUKA - PMO". */ +export function displayName(settings, project, customer) { + const pattern = settings.project_name_pattern || '{KUNDE} - {PROJEKT}'; + const kunde = customer?.short_name && customer.short_name !== customer.name ? prettyShort(customer) : customer?.name || ''; + return pattern + .replaceAll('{KUNDE}', kunde) + .replaceAll('{KUNDENNAME}', customer?.name || '') + .replaceAll('{PROJEKT}', project.name) + .replaceAll('{CODE}', project.code || '') + .trim(); +} + +/** Kuerzel in Grossbuchstaben wirken in Namen laut - "KUKA" bleibt, "PERINCO" wird "perinco". */ +function prettyShort(customer) { + if (customer.short_name.toUpperCase() === 'PERINCO') return 'perinco'; + return customer.short_name; +} + +/** Stundensatz je Stunde, auch wenn das Projekt nach Tagen abrechnet. */ +export function hourlyRate(pot, project, settings) { + if (pot?.rate == null) return null; + const rate = Number(pot.rate); + return project.billing_unit === 'day' ? rate / Number(settings.hours_per_day || 8) : rate; +} + +/** Buchbares Volumen eines Topfs in Stunden (null = unbegrenzt). */ +export function deriveBudgetHours(input, project, settings) { + const amount = input.budget_amount == null || input.budget_amount === '' ? null : Number(input.budget_amount); + const rate = input.rate == null || input.rate === '' ? null : Number(input.rate); + if (project.type === 'billable' && amount != null && rate) { + const perUnit = project.billing_unit === 'day' ? Number(settings.hours_per_day || 8) : 1; + return round2((amount / rate) * perUnit); + } + if (input.budget_hours == null || input.budget_hours === '') return null; + return round2(Number(input.budget_hours)); +} + +/** + * Kennzahlen eines Topfs. `usedMinutes` sind alle Buchungen auf diesem Topf. + * status: upcoming | active | expired | exhausted + * earned: bereits erbrachter Umsatz, open: noch offener Umsatz + */ +export function potStats(pot, project, usedMinutes, settings) { + const now = today(); + const usedHours = usedMinutes / 60; + const budgetHours = pot.budget_hours == null ? null : Number(pot.budget_hours); + const amount = pot.budget_amount == null ? null : Number(pot.budget_amount); + const rateHour = hourlyRate(pot, project, settings); + + let status = 'active'; + if (now < pot.valid_from) status = 'upcoming'; + else if (now > pot.valid_to) status = 'expired'; + else if (budgetHours != null && usedHours >= budgetHours - 1e-9) status = 'exhausted'; + + let earned = 0; + if (project.type === 'billable') { + earned = rateHour ? usedHours * rateHour : 0; + } else if (project.type === 'fixed' && amount) { + if (budgetHours) { + earned = amount * Math.min(1, usedHours / budgetHours); + } else { + // Ohne Stundenkontingent: Festpreis anteilig ueber die Laufzeit. + const total = Math.max(1, daysBetween(pot.valid_from, pot.valid_to) + 1); + const elapsed = now < pot.valid_from ? 0 : Math.min(total, daysBetween(pot.valid_from, now) + 1); + earned = amount * (elapsed / total); + } + if (status === 'expired') earned = amount; + } + + const budgetValue = project.type === 'billable' ? (amount ?? (budgetHours && rateHour ? budgetHours * rateHour : null)) : amount; + const open = budgetValue == null || status === 'expired' ? 0 : Math.max(0, budgetValue - earned); + const daysLeft = daysBetween(now, pot.valid_to); + + return { + used_minutes: usedMinutes, + used_hours: round2(usedHours), + remaining_hours: budgetHours == null ? null : round2(Math.max(0, budgetHours - usedHours)), + percent_used: budgetHours ? Math.round((usedHours / budgetHours) * 1000) / 10 : null, + hourly_rate: rateHour == null ? null : round2(rateHour), + // Festpreis: Budget geteilt durch gebuchte Stunden - nur intern. + virtual_rate: project.type === 'fixed' && amount && usedHours > 0 ? round2(amount / usedHours) : null, + budget_value: budgetValue == null ? null : round2(budgetValue), + earned: round2(earned), + open: round2(open), + days_left: daysLeft, + status, + }; +} + +/** Ist der Topf zu leer oder laeuft er bald ab? */ +export function potWarning(pot, stats, settings) { + if (stats.status === 'expired') return `Budgettopf „${pot.label}“ ist abgelaufen.`; + if (stats.status === 'exhausted') return `Budgettopf „${pot.label}“ ist aufgebraucht.`; + const parts = []; + if (stats.percent_used != null && stats.percent_used >= Number(settings.budget_warn_percent || 80)) { + parts.push(`${Math.round(stats.percent_used)} % verbraucht`); + } + if (stats.status === 'active' && stats.days_left <= Number(settings.budget_warn_days || 14)) { + parts.push(stats.days_left === 0 ? 'läuft heute ab' : `läuft in ${stats.days_left} Tagen ab`); + } + return parts.length ? `Budgettopf „${pot.label}“: ${parts.join(', ')}.` : null; +} + +/** Laufende Nummer je Kunde und Jahr, eingesetzt in das Codemuster. */ +export async function generateCode(conn, settings, customer, startDate) { + const year = String(startDate).slice(0, 4); + const pattern = settings.project_code_pattern || '{KUERZEL}-{JAHR}-{NR}'; + const [rows] = await conn.query( + 'SELECT COALESCE(MAX(seq), 0) AS n FROM pulse_projects WHERE customer_id = ? AND YEAR(start_date) = ?', + [customer.id, Number(year)] + ); + let seq = Number(rows[0].n) + 1; + // Gegen Kollisionen, falls das Muster geaendert wurde. + for (let attempt = 0; attempt < 500; attempt++, seq++) { + const code = pattern + .replaceAll('{KUERZEL}', customer.short_name.toUpperCase()) + .replaceAll('{JAHR}', year) + .replaceAll('{JJ}', year.slice(2)) + .replaceAll('{NR}', String(seq).padStart(3, '0')) + .slice(0, 40); + const [exists] = await conn.query('SELECT id FROM pulse_projects WHERE code = ? LIMIT 1', [code]); + if (exists.length === 0) return { code, seq }; + } + throw new Error('Projektcode konnte nicht erzeugt werden.'); +} + +/** + * Was verbraucht Budget? Bei Billable-Projekten ist das Volumen aus Betrag und + * Satz abgeleitet - also verkaufte Stunden. Nicht abrechenbare Task-Stunden + * (z. B. Reisezeit) verbrauchen es nicht. Bei Festpreis und intern zaehlt alles. + */ +export function countsAgainstBudget(project) { + return project.type === 'billable' ? 'billable' : 'all'; +} + +/** Abrechenbar ist eine Buchung, wenn Projekt UND Task es sind. */ +export function isBillable(project, task) { + if (project.type === 'non_billable') return false; + return task ? Boolean(task.billable) : true; +} + +/** + * Laedt Projekte samt Kunde, Toepfen (mit Kennzahlen), Tasks und Mitgliedern. + * `where` schraenkt pulse_projects (Alias p) ein. + */ +export async function loadProjects(settings, { where = '1=1', params = [] } = {}) { + const [projects] = await pool.query( + `SELECT p.*, c.name AS customer_name, c.short_name AS customer_short, c.color AS customer_color, + u.name AS owner_name + FROM pulse_projects p + JOIN pulse_customers c ON c.id = p.customer_id + LEFT JOIN pulse_users u ON u.id = p.owner_id + WHERE ${where} + ORDER BY c.name, p.name`, + params + ); + if (projects.length === 0) return []; + const ids = projects.map((p) => p.id); + const inList = ids.map(() => '?').join(','); + + const [[pots], [tasks], [members], [potUsage], [projectUsage], [taskUsage]] = await Promise.all([ + pool.query(`SELECT * FROM pulse_budget_pots WHERE project_id IN (${inList}) ORDER BY valid_from, id`, ids), + pool.query(`SELECT * FROM pulse_tasks WHERE project_id IN (${inList}) ORDER BY name`, ids), + pool.query( + `SELECT m.project_id, u.id, u.name, u.color, u.is_external + FROM pulse_project_members m JOIN pulse_users u ON u.id = m.user_id + WHERE m.project_id IN (${inList}) AND u.role <> 'admin' ORDER BY u.name`, + ids + ), + pool.query( + `SELECT pot_id, SUM(minutes) AS minutes, SUM(CASE WHEN billable = 1 THEN minutes ELSE 0 END) AS billable_minutes + FROM pulse_time_entries + WHERE project_id IN (${inList}) AND pot_id IS NOT NULL GROUP BY pot_id`, + ids + ), + pool.query( + `SELECT project_id, SUM(minutes) AS minutes, SUM(CASE WHEN billable = 1 THEN minutes ELSE 0 END) AS billable_minutes, + MAX(entry_date) AS last_entry + FROM pulse_time_entries WHERE project_id IN (${inList}) GROUP BY project_id`, + ids + ), + pool.query( + `SELECT task_id, SUM(minutes) AS minutes FROM pulse_time_entries + WHERE project_id IN (${inList}) AND task_id IS NOT NULL GROUP BY task_id`, + ids + ), + ]); + + const potMinutes = new Map(potUsage.map((r) => [r.pot_id, r])); + const projectMinutes = new Map(projectUsage.map((r) => [r.project_id, r])); + const taskMinutes = new Map(taskUsage.map((r) => [r.task_id, Number(r.minutes)])); + + return projects.map((p) => { + const project = { ...p, tags: parseTags(p.tags), is_active: Boolean(p.is_active) }; + const customer = { id: p.customer_id, name: p.customer_name, short_name: p.customer_short }; + const projectPots = pots + .filter((pot) => pot.project_id === p.id) + .map((pot) => { + const usage = potMinutes.get(pot.id); + const booked = Number(usage?.minutes || 0); + const counted = countsAgainstBudget(project) === 'billable' ? Number(usage?.billable_minutes || 0) : booked; + const stats = potStats(pot, project, counted, settings); + return { ...pot, ...stats, booked_minutes: booked, warning: potWarning(pot, stats, settings) }; + }); + const usage = projectMinutes.get(p.id); + const totalBudgetHours = projectPots.every((pot) => pot.budget_hours != null) && projectPots.length + ? round2(projectPots.reduce((s, pot) => s + Number(pot.budget_hours), 0)) + : null; + const usedMinutes = Number(usage?.minutes || 0); + + return { + ...project, + display_name: displayName(settings, project, customer), + customer: { id: p.customer_id, name: p.customer_name, short_name: p.customer_short, color: p.customer_color }, + pots: projectPots, + tasks: tasks + .filter((t) => t.project_id === p.id) + .map((t) => ({ ...t, billable: Boolean(t.billable), is_active: Boolean(t.is_active), used_minutes: taskMinutes.get(t.id) || 0 })), + members: members + .filter((m) => m.project_id === p.id) + .map((m) => ({ id: m.id, name: m.name, color: m.color, is_external: Boolean(m.is_external) })), + used_minutes: usedMinutes, + billable_minutes: Number(usage?.billable_minutes || 0), + // Was gegen das Budget zaehlt (bei Billable nur abrechenbare Stunden). + budget_used_minutes: projectPots.reduce((s, pot) => s + pot.used_minutes, 0), + last_entry: usage?.last_entry || null, + budget_hours: totalBudgetHours, + remaining_hours: + totalBudgetHours == null ? null : round2(Math.max(0, totalBudgetHours - projectPots.reduce((s, pot) => s + pot.used_minutes, 0) / 60)), + budget_value: round2(projectPots.reduce((s, pot) => s + (pot.budget_value || 0), 0)), + earned: round2(projectPots.reduce((s, pot) => s + pot.earned, 0)), + open: round2(projectPots.reduce((s, pot) => s + pot.open, 0)), + }; + }); +} + +/** Betraege weg fuer die Rolle "user" - Mitarbeiter sehen Stunden, keine Preise. */ +export function stripAmounts(project) { + const clean = { ...project, budget_value: null, earned: null, open: null }; + clean.pots = project.pots.map((pot) => ({ + ...pot, + budget_amount: null, + rate: null, + hourly_rate: null, + virtual_rate: null, + budget_value: null, + earned: null, + open: null, + })); + return clean; +} + +export class BookingError extends Error { + constructor(message) { + super(message); + this.status = 422; + } +} + +/** + * Findet den Topf fuer eine Buchung. + * + * Regeln (aus dem Anforderungscall): + * - gebucht wird nur auf einen Topf, dessen Laufzeit das Leistungsdatum enthaelt + * - ist das Volumen erreicht oder das Ablaufdatum ueberschritten: nicht buchbar + * - ueberlappen mehrere Toepfe, zuerst der, der am fruehesten endet + * - Projekte ohne Topf (z. B. intern, unbegrenzt) sind frei buchbar + */ +export async function resolvePot(conn, project, date, minutes, excludeEntryId = null, billable = true) { + const onlyBillable = countsAgainstBudget(project) === 'billable'; + // Nicht abrechenbare Stunden in einem Billable-Projekt verbrauchen kein Volumen. + const needed = onlyBillable && !billable ? 0 : minutes; + if (date < project.start_date) { + throw new BookingError(`Das Projekt beginnt erst am ${de(project.start_date)}.`); + } + if (project.end_date && date > project.end_date) { + throw new BookingError(`Das Projekt ist am ${de(project.end_date)} beendet.`); + } + + const [pots] = await conn.query( + 'SELECT * FROM pulse_budget_pots WHERE project_id = ? ORDER BY valid_to, id FOR UPDATE', + [project.id] + ); + if (pots.length === 0) return null; + + const covering = pots.filter((pot) => pot.valid_from <= date && pot.valid_to >= date); + if (covering.length === 0) { + throw new BookingError( + `Kein Budgettopf deckt den ${de(date)} ab. Buchbar ist nur innerhalb der Topf-Laufzeiten ` + + `(${pots.map((p) => `${p.label}: ${de(p.valid_from)}–${de(p.valid_to)}`).join('; ')}).` + ); + } + let lastShortage = null; + for (const pot of covering) { + if (pot.valid_to < today()) { + lastShortage = `Budgettopf „${pot.label}“ ist seit ${de(pot.valid_to)} abgelaufen.`; + continue; + } + if (pot.budget_hours == null) return pot; + const [rows] = await conn.query( + `SELECT COALESCE(SUM(minutes), 0) AS used FROM pulse_time_entries + WHERE pot_id = ? AND id <> ? ${onlyBillable ? 'AND billable = 1' : ''}`, + [pot.id, excludeEntryId || 0] + ); + const free = Number(pot.budget_hours) * 60 - Number(rows[0].used); + // Ein aufgebrauchter Topf ist fuer alles gesperrt, auch fuer Reisezeit. + if (free > 1e-9 && needed <= free + 1e-9) return pot; + lastShortage = + free <= 0 + ? `Budgettopf „${pot.label}“ ist aufgebraucht.` + : `Budgettopf „${pot.label}“ hat nur noch ${fmtMinutes(free)} h frei.`; + } + throw new BookingError(`${lastShortage} Buchung nicht möglich – bitte Projektverantwortlichen informieren.`); +} + +/** + * Nach Aenderung an Toepfen: bestehende Buchungen neu zuordnen. Historische + * Buchungen werden nicht abgewiesen - sie landen im passenden Topf, auch wenn + * dieser dadurch ueberbucht ist (das zeigt die Projektansicht dann rot). + */ +export async function reassignEntries(conn, projectId) { + const [pots] = await conn.query('SELECT * FROM pulse_budget_pots WHERE project_id = ? ORDER BY valid_to, id', [projectId]); + const [[project]] = await conn.query('SELECT type FROM pulse_projects WHERE id = ?', [projectId]); + const onlyBillable = project && countsAgainstBudget(project) === 'billable'; + const [entries] = await conn.query( + 'SELECT id, entry_date, minutes, pot_id, billable FROM pulse_time_entries WHERE project_id = ? ORDER BY entry_date, id', + [projectId] + ); + const used = new Map(pots.map((p) => [p.id, 0])); + for (const entry of entries) { + const needed = onlyBillable && !entry.billable ? 0 : entry.minutes; + const covering = pots.filter((p) => p.valid_from <= entry.entry_date && p.valid_to >= entry.entry_date); + const target = + covering.find((p) => p.budget_hours == null || used.get(p.id) + needed <= Number(p.budget_hours) * 60) || + covering[0] || + null; + if (target) used.set(target.id, used.get(target.id) + needed); + const targetId = target ? target.id : null; + if (targetId !== entry.pot_id) { + await conn.query('UPDATE pulse_time_entries SET pot_id = ? WHERE id = ?', [targetId, entry.id]); + } + } +} + +function de(value) { + const [y, m, d] = String(value).slice(0, 10).split('-'); + return `${d}.${m}.${y}`; +} + +export function fmtMinutes(minutes) { + const total = Math.max(0, Math.round(minutes)); + return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, '0')}`; +} + +export { parseDate }; diff --git a/server/src/lib/settings.js b/server/src/lib/settings.js new file mode 100644 index 0000000..94ed9ef --- /dev/null +++ b/server/src/lib/settings.js @@ -0,0 +1,101 @@ +import crypto from 'node:crypto'; +import { pool } from '../db.js'; + +/* + * Einstellungen liegen als JSON je Schluessel in pulse_settings. Fehlt ein + * Schluessel, gilt der Standard von hier - so muss beim Hinzufuegen neuer + * Einstellungen nichts migriert werden. + */ +export const DEFAULTS = { + weekly_hours: 40, + hours_per_day: 8, + // Platzhalter: {KUERZEL} Kundenkuerzel, {JAHR} vierstellig, {NR} laufende + // Nummer je Kunde und Jahr (dreistellig). + project_code_pattern: '{KUERZEL}-{JAHR}-{NR}', + // Anzeigename der Projekte: {KUNDE} Kundenkuerzel oder -name, {PROJEKT} Name, {CODE}. + project_name_pattern: '{KUNDE} - {PROJEKT}', + budget_warn_percent: 80, + budget_warn_days: 14, + company_name: 'perinco GmbH', + export: { + title: 'Stundennachweis', + subtitle: 'Performance Intelligence Consulting', + accent: '#6BA04A', + logo: null, // Data-URL; null = perinco Logo + header_right: 'www.perinco.de', + footer_left: 'perinco GmbH', + footer_center: '', + footer_right: 'www.perinco.de', + show_signature: true, + signature_left: 'Datum, Unterschrift Berater', + signature_right: 'Datum, Unterschrift Kunde', + }, + sso: { + enabled: false, + button_label: 'Mit Microsoft anmelden', + tenant_id: '', + client_id: '', + client_secret: '', + redirect_uri: 'https://pulse.perinco.de/api/auth/sso/callback', + // Uebernommen wird nur von Hand in der SSO-Verwaltung; Rollen vergibt Pulse. + local_login: true, + }, +}; + +export async function getSettings() { + const [rows] = await pool.query('SELECT k, v FROM pulse_settings'); + const stored = {}; + for (const row of rows) { + try { + stored[row.k] = JSON.parse(row.v); + } catch { + /* kaputter Wert - Standard gilt */ + } + } + const result = {}; + for (const [key, def] of Object.entries(DEFAULTS)) { + const value = stored[key]; + if (def && typeof def === 'object' && !Array.isArray(def)) { + result[key] = { ...def, ...(value && typeof value === 'object' ? value : {}) }; + } else { + result[key] = value ?? def; + } + } + if (result.sso.client_secret) result.sso.client_secret = decrypt(result.sso.client_secret); + return result; +} + +export async function saveSetting(key, value) { + if (!(key in DEFAULTS)) throw new Error(`Unbekannte Einstellung: ${key}`); + let stored = value; + if (key === 'sso' && value.client_secret) stored = { ...value, client_secret: encrypt(value.client_secret) }; + await pool.query( + 'INSERT INTO pulse_settings (k, v) VALUES (?, ?) ON DUPLICATE KEY UPDATE v = VALUES(v)', + [key, JSON.stringify(stored)] + ); +} + +/* Client Secret verschluesselt ablegen (AES-256-GCM, Schluessel aus SETTINGS_KEY). */ +function key() { + const base = process.env.SETTINGS_KEY || process.env.JWT_SECRET || ''; + return crypto.createHash('sha256').update(base).digest(); +} + +function encrypt(plain) { + const iv = crypto.randomBytes(12); + const cipher = crypto.createCipheriv('aes-256-gcm', key(), iv); + const data = Buffer.concat([cipher.update(String(plain), 'utf8'), cipher.final()]); + return `enc:${iv.toString('base64')}:${cipher.getAuthTag().toString('base64')}:${data.toString('base64')}`; +} + +function decrypt(value) { + if (typeof value !== 'string' || !value.startsWith('enc:')) return value; + try { + const [, iv, tag, data] = value.split(':'); + const decipher = crypto.createDecipheriv('aes-256-gcm', key(), Buffer.from(iv, 'base64')); + decipher.setAuthTag(Buffer.from(tag, 'base64')); + return Buffer.concat([decipher.update(Buffer.from(data, 'base64')), decipher.final()]).toString('utf8'); + } catch { + return ''; + } +} diff --git a/server/src/lib/sso.js b/server/src/lib/sso.js new file mode 100644 index 0000000..eb4647e --- /dev/null +++ b/server/src/lib/sso.js @@ -0,0 +1,274 @@ +import crypto from 'node:crypto'; +import { pool } from '../db.js'; + +/* + * Microsoft Entra ID (M365). + * + * Anmeldung: OpenID Connect, Authorization Code Flow als vertrauliche App + * (Client Secret). Das id_token kommt direkt vom Token-Endpunkt ueber TLS - + * nach OIDC Core 3.1.3.7 reicht dann die Pruefung von aud/tid/exp. + * + * Verzeichnis: Client Credentials gegen Microsoft Graph (Anwendungsberechtigung + * User.Read.All). Angezeigt werden nur echte Mitarbeiterkonten - siehe + * isEligible(). Uebernommen wird von Hand in der SSO-Verwaltung; anmelden kann + * sich nur, wer uebernommen wurde. Rollen vergibt ausschliesslich Pulse, AD- + * Gruppen spielen keine Rolle. + */ + +const LOGIN = 'https://login.microsoftonline.com'; + +/** Erlaubte Maildomains fuer Mitarbeiterkonten. */ +export const ALLOWED_DOMAINS = ['perinco.de', 'icon-experts.de']; + +/* + * Genau zwei Namensteile: vorname.nachname@domain. Bindestriche innerhalb eines + * Teils sind erlaubt (anna-lena.mueller), weitere Punkte nicht - damit fallen + * Admin-Konten wie adm.sascha.wolf@ und Funktionsadressen wie info@ heraus. + */ +const MAIL_PATTERN = new RegExp( + `^[a-z]+(?:-[a-z]+)*\\.[a-z]+(?:-[a-z]+)*@(?:${ALLOWED_DOMAINS.map((d) => d.replace('.', '\\.')).join('|')})$` +); + +export function ssoConfigured(sso) { + return Boolean(sso.tenant_id && sso.client_id && sso.client_secret && sso.redirect_uri); +} + +/** Primaere Mailadresse eines AD-Benutzers (mail, sonst UPN), klein geschrieben. */ +export function adEmail(ad) { + return String(ad.mail || ad.userPrincipalName || '').trim().toLowerCase(); +} + +/** Technische Praefixe, die nie eine Person sind (adm.sascha.wolf, sa.hrworks, srv.pa). */ +const TECHNICAL_PREFIXES = ['adm', 'admin', 'sa', 'srv', 'svc', 'service', 'test']; + +/** "Mülln" -> "muelln", "René" -> "rene", "Grote-Aksu" -> "groteaksu". */ +export function normalizeName(value) { + return String(value || '') + .toLowerCase() + .replace(/ä/g, 'ae') + .replace(/ö/g, 'oe') + .replace(/ü/g, 'ue') + .replace(/ß/g, 'ss') + .normalize('NFD') + .replace(/[̀-ͯ]/g, '') + .replace(/[^a-z]/g, ''); +} + +/** + * Ist das ein echtes Mitarbeiterkonto? + * - Mail im Format vorname.nachname@perinco.de bzw. @icon-experts.de + * - kein technisches Praefix (sa., srv., adm. ...) + * - Nachname im AD passt zum Teil hinter dem Punkt - Dienstkonten wie + * sa.hrworks ("Service Account") oder srv.pa ("SRV Flow") fallen so heraus. + * Der Vorname wird bewusst nicht verglichen: Tippfehler im AD kommen vor. + * - kein Gast + * - Konto aktiv (freigegebene Postfaecher sind in Entra deaktiviert) + * - mindestens eine Lizenz (freigegebene Postfaecher und Dienstkonten haben keine) + */ +export function isEligible(ad) { + if (ad.userType === 'Guest') return false; + if (ad.accountEnabled === false) return false; + if (Array.isArray(ad.assignedLicenses) && ad.assignedLicenses.length === 0) return false; + const email = adEmail(ad); + if (!MAIL_PATTERN.test(email)) return false; + const [first, last] = email.split('@')[0].split('.'); + if (TECHNICAL_PREFIXES.includes(first)) return false; + const surname = normalizeName(ad.surname); + return surname.length > 0 && normalizeName(last) === surname; +} + +export function authorizeUrl(sso, state) { + const params = new URLSearchParams({ + client_id: sso.client_id, + response_type: 'code', + redirect_uri: sso.redirect_uri, + response_mode: 'query', + scope: 'openid profile email', + state, + prompt: 'select_account', + }); + return `${LOGIN}/${encodeURIComponent(sso.tenant_id)}/oauth2/v2.0/authorize?${params}`; +} + +async function tokenRequest(sso, body) { + const response = await fetch(`${LOGIN}/${encodeURIComponent(sso.tenant_id)}/oauth2/v2.0/token`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ client_id: sso.client_id, client_secret: sso.client_secret, ...body }), + }); + const payload = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error(String(payload.error_description || payload.error || `HTTP ${response.status}`).split('\n')[0]); + } + return payload; +} + +export async function exchangeCode(sso, code) { + const tokens = await tokenRequest(sso, { + grant_type: 'authorization_code', + code, + redirect_uri: sso.redirect_uri, + scope: 'openid profile email', + }); + const [, body] = String(tokens.id_token || '').split('.'); + if (!body) throw new Error('Kein id_token erhalten.'); + const claims = JSON.parse(Buffer.from(body, 'base64url').toString('utf8')); + if (claims.aud !== sso.client_id) throw new Error('id_token gehört zu einer anderen Anwendung.'); + if (claims.exp * 1000 < Date.now()) throw new Error('id_token ist abgelaufen.'); + if (/^[0-9a-f-]{36}$/i.test(sso.tenant_id) && claims.tid !== sso.tenant_id) throw new Error('Falscher Mandant.'); + return { + oid: claims.oid, + email: String(claims.email || claims.preferred_username || '').toLowerCase(), + name: claims.name || claims.preferred_username, + }; +} + +async function graphToken(sso) { + const tokens = await tokenRequest(sso, { + grant_type: 'client_credentials', + scope: 'https://graph.microsoft.com/.default', + }); + return tokens.access_token; +} + +const SELECT = 'id,displayName,givenName,surname,mail,userPrincipalName,jobTitle,department,accountEnabled,userType,assignedLicenses'; + +async function graphGetAll(token, url, maxPages = 100) { + const items = []; + let next = url; + let pages = 0; + while (next && pages++ < maxPages) { + const response = await fetch(next, { headers: { Authorization: `Bearer ${token}` } }); + const payload = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(payload.error?.message || `Graph HTTP ${response.status}`); + items.push(...(payload.value || [])); + next = payload['@odata.nextLink']; + } + return items; +} + +export async function testConnection(sso) { + const token = await graphToken(sso); + const users = await graphGetAll(token, `https://graph.microsoft.com/v1.0/users?$select=${SELECT}&$top=50`, 1); + const eligible = users.filter(isEligible); + return { sample: eligible.slice(0, 5).map((u) => u.displayName), checked: users.length, eligible: eligible.length }; +} + +/* + * Verzeichnis-Zwischenspeicher. Die SSO-Verwaltung filtert und sucht im + * Browser; Graph wird hoechstens alle 5 Minuten (oder auf Knopfdruck) gefragt. + * Der Schluessel haengt an Tenant und Client, damit ein Wechsel der + * Zugangsdaten nie alte Daten zeigt. + */ +const CACHE_MS = 5 * 60 * 1000; +let cache = { key: null, at: 0, users: null, raw: null }; + +function mapUser(ad) { + return { + id: ad.id, + name: ad.displayName || adEmail(ad), + given_name: ad.givenName || null, + surname: ad.surname || null, + email: adEmail(ad), + job_title: ad.jobTitle || null, + department: ad.department || null, + }; +} + +/** Alle AD-Konten (roh) - fuer den Abgleich, der auch Deaktivierungen sehen muss. */ +async function fetchAll(sso, refresh) { + const key = `${sso.tenant_id}|${sso.client_id}`; + if (!refresh && cache.key === key && cache.raw && Date.now() - cache.at < CACHE_MS) return cache.raw; + const token = await graphToken(sso); + const raw = await graphGetAll(token, `https://graph.microsoft.com/v1.0/users?$select=${SELECT}&$top=999`); + cache = { key, at: Date.now(), raw, users: raw.filter(isEligible).map(mapUser).sort((a, b) => a.name.localeCompare(b.name, 'de')) }; + return raw; +} + +/** Gefilterte Mitarbeiterkonten aus dem AD. */ +export async function listDirectory(sso, { refresh = false } = {}) { + await fetchAll(sso, refresh); + return { users: cache.users, fetched_at: new Date(cache.at).toISOString() }; +} + +/** + * Uebernimmt AD-Konten nach Pulse. Existiert schon ein Benutzer mit derselben + * Objekt-ID oder Mailadresse (z. B. ein vorher lokal angelegtes Konto), wird er + * verknuepft statt doppelt angelegt. Die Rolle wird in beiden Faellen gesetzt. + */ +export async function importUsers(sso, adIds, { role, managerId }) { + const { users } = await listDirectory(sso); + const byId = new Map(users.map((u) => [u.id, u])); + const result = { created: [], linked: [], skipped: [] }; + + for (const adId of adIds) { + const ad = byId.get(adId); + if (!ad) { + result.skipped.push(adId); + continue; + } + const [rows] = await pool.query('SELECT id FROM pulse_users WHERE external_id = ? OR email = ? LIMIT 1', [ad.id, ad.email]); + if (rows[0]) { + await pool.query( + `UPDATE pulse_users + SET external_id = ?, source = 'sso', name = ?, email = ?, job_title = COALESCE(?, job_title), + role = ?, manager_id = COALESCE(?, manager_id), is_active = 1, is_external = 0 + WHERE id = ?`, + [ad.id, ad.name, ad.email, ad.job_title, role, managerId ?? null, rows[0].id] + ); + result.linked.push(rows[0].id); + } else { + const [insert] = await pool.query( + `INSERT INTO pulse_users (username, email, name, role, manager_id, source, external_id, job_title) + VALUES (?, ?, ?, ?, ?, 'sso', ?, ?)`, + [await freeUsername(ad.email), ad.email, ad.name, role, managerId ?? null, ad.id, ad.job_title] + ); + result.created.push(insert.insertId); + } + } + return result; +} + +/** + * Abgleich der uebernommenen Benutzer: Name, Mail, Jobtitel aus dem AD. + * Wer im AD geloescht, deaktiviert oder nicht mehr zulaessig ist, wird in Pulse + * deaktiviert. Neue Konten legt der Abgleich nie an - das geschieht nur in der + * SSO-Verwaltung. + */ +export async function syncUsers(sso) { + const raw = await fetchAll(sso, true); + const byId = new Map(raw.map((u) => [u.id, u])); + const [linked] = await pool.query("SELECT id, external_id, is_active FROM pulse_users WHERE source = 'sso' AND external_id IS NOT NULL"); + + let updated = 0; + let deactivated = 0; + for (const user of linked) { + const ad = byId.get(user.external_id); + if (!ad || !isEligible(ad)) { + if (user.is_active) { + await pool.query('UPDATE pulse_users SET is_active = 0 WHERE id = ?', [user.id]); + deactivated++; + } + continue; + } + await pool.query('UPDATE pulse_users SET name = ?, email = ?, job_title = COALESCE(?, job_title) WHERE id = ?', [ + ad.displayName || adEmail(ad), + adEmail(ad), + ad.jobTitle || null, + user.id, + ]); + updated++; + } + return { total: raw.length, eligible: cache.users.length, linked: linked.length, updated, deactivated }; +} + +export async function freeUsername(email) { + const base = email.split('@')[0].replace(/[^a-z0-9._-]/gi, '').toLowerCase().slice(0, 60) || 'user'; + let candidate = base; + for (let i = 2; i < 1000; i++) { + const [rows] = await pool.query('SELECT id FROM pulse_users WHERE username = ? LIMIT 1', [candidate]); + if (rows.length === 0) return candidate; + candidate = `${base}${i}`; + } + return `${base}-${crypto.randomBytes(3).toString('hex')}`; +} diff --git a/server/src/middleware/auth.js b/server/src/middleware/auth.js new file mode 100644 index 0000000..dccdfa2 --- /dev/null +++ b/server/src/middleware/auth.js @@ -0,0 +1,93 @@ +import jwt from 'jsonwebtoken'; +import { pool } from '../db.js'; + +export const ROLES = ['user', 'lead', 'management', 'admin']; +const RANK = { user: 0, lead: 1, management: 2, admin: 3 }; + +function secret() { + const value = process.env.JWT_SECRET; + if (!value || value.length < 16) throw new Error('JWT_SECRET fehlt oder ist zu kurz (min. 16 Zeichen).'); + return value; +} + +export function signToken(user) { + return jwt.sign({ sub: user.id, role: user.role }, secret(), { + expiresIn: process.env.JWT_EXPIRES_IN || '12h', + }); +} + +export function signShort(payload, expiresIn = '10m') { + return jwt.sign(payload, secret(), { expiresIn }); +} + +export function verifyShort(token) { + return jwt.verify(token, secret()); +} + +export const USER_COLUMNS = `id, username, email, name, role, manager_id, job_title, is_external, company, + source, external_id, weekly_hours, color, theme, theme_mode, is_active, last_login_at, created_at, updated_at`; + +/** + * Prueft das Bearer-Token und laedt den Benutzer bei jeder Anfrage frisch aus + * der Datenbank - ein deaktiviertes Konto ist sofort draussen und eine + * geaenderte Rolle gilt sofort. + */ +export async function requireAuth(req, res, next) { + const header = req.headers.authorization || ''; + const token = header.startsWith('Bearer ') ? header.slice(7) : null; + if (!token) return res.status(401).json({ error: 'Nicht angemeldet.' }); + + let payload; + try { + payload = jwt.verify(token, secret()); + } catch { + return res.status(401).json({ error: 'Sitzung abgelaufen. Bitte neu anmelden.' }); + } + + try { + const [rows] = await pool.query(`SELECT ${USER_COLUMNS} FROM pulse_users WHERE id = ? LIMIT 1`, [payload.sub]); + const user = rows[0]; + if (!user) return res.status(401).json({ error: 'Benutzer existiert nicht mehr.' }); + if (!user.is_active) return res.status(401).json({ error: 'Benutzerkonto ist deaktiviert.' }); + req.user = user; + next(); + } catch (err) { + next(err); + } +} + +export function hasRole(user, minimum) { + return RANK[user?.role] >= RANK[minimum]; +} + +/** Mindestrolle: lead < management < admin. */ +export function requireRole(minimum) { + return (req, res, next) => { + if (!hasRole(req.user, minimum)) { + return res.status(403).json({ error: 'Für diese Aktion fehlen die Berechtigungen.' }); + } + next(); + }; +} + +export function publicUser(row) { + return { + id: row.id, + username: row.username, + email: row.email, + name: row.name, + role: row.role, + manager_id: row.manager_id ?? null, + job_title: row.job_title ?? null, + is_external: Boolean(row.is_external), + company: row.company ?? null, + source: row.source, + weekly_hours: row.weekly_hours == null ? null : Number(row.weekly_hours), + color: row.color ?? null, + theme: row.theme ?? null, + theme_mode: row.theme_mode ?? null, + is_active: Boolean(row.is_active), + last_login_at: row.last_login_at ?? null, + created_at: row.created_at, + }; +} diff --git a/server/src/routes/auth.js b/server/src/routes/auth.js new file mode 100644 index 0000000..c1fd806 --- /dev/null +++ b/server/src/routes/auth.js @@ -0,0 +1,170 @@ +import express from 'express'; +import bcrypt from 'bcryptjs'; +import crypto from 'node:crypto'; +import { pool } from '../db.js'; +import { publicUser, requireAuth, signShort, signToken, verifyShort } from '../middleware/auth.js'; +import { getSettings } from '../lib/settings.js'; +import { authorizeUrl, exchangeCode, ssoConfigured } from '../lib/sso.js'; + +const router = express.Router(); + +/* + * Bremse gegen Passwortraten: 10 Fehlversuche je IP und Kennung in 15 Minuten. + * Im Speicher reicht - es gibt genau eine Backend-Instanz. + */ +const attempts = new Map(); +const WINDOW = 15 * 60000; +function isLimited(key) { + const now = Date.now(); + const recent = (attempts.get(key) || []).filter((t) => now - t < WINDOW); + if (recent.length) attempts.set(key, recent); + else attempts.delete(key); + return recent.length >= 10; +} +function recordFailure(key) { + attempts.set(key, [...(attempts.get(key) || []), Date.now()]); +} + +// Oeffentlich: was zeigt der Anmeldeschirm? +router.get('/config', async (req, res, next) => { + try { + const { sso } = await getSettings(); + const ssoOn = Boolean(sso.enabled && ssoConfigured(sso)); + res.json({ sso_enabled: ssoOn, sso_label: sso.button_label, local_login: ssoOn ? sso.local_login !== false : true }); + } catch (err) { + next(err); + } +}); + +router.post('/login', async (req, res, next) => { + const identifier = String(req.body?.identifier || '').trim(); + const password = String(req.body?.password || ''); + if (!identifier || !password) { + return res.status(400).json({ error: 'Benutzername und Passwort sind erforderlich.' }); + } + const key = `${req.ip}|${identifier.toLowerCase()}`; + if (isLimited(key)) { + return res.status(429).json({ error: 'Zu viele Fehlversuche. Bitte in 15 Minuten erneut versuchen.' }); + } + + try { + const [rows] = await pool.query('SELECT * FROM pulse_users WHERE username = ? OR email = ? LIMIT 1', [ + identifier, + identifier.toLowerCase(), + ]); + const user = rows[0]; + // Identische Meldung fuer "gibt es nicht" und "falsches Passwort". + const ok = user?.password_hash ? await bcrypt.compare(password, user.password_hash) : false; + if (!ok) { + recordFailure(key); + return res.status(401).json({ error: 'Benutzername oder Passwort ist falsch.' }); + } + if (!user.is_active) return res.status(403).json({ error: 'Dieses Benutzerkonto ist deaktiviert.' }); + + await pool.query('UPDATE pulse_users SET last_login_at = NOW() WHERE id = ?', [user.id]); + res.json({ token: signToken(user), user: publicUser(user) }); + } catch (err) { + next(err); + } +}); + +router.get('/me', requireAuth, (req, res) => res.json({ user: publicUser(req.user) })); + +// Eigene Darstellung: Design, Modus, Farbe. +router.patch('/me', requireAuth, async (req, res, next) => { + const updates = {}; + if (req.body?.theme !== undefined) { + const theme = req.body.theme; + if (theme !== null && !['perinco', 'pixel', 'outdoor', 'cyberpunk', 'rainy', 'snow', 'aura'].includes(theme)) { + return res.status(400).json({ error: 'Unbekanntes Design.' }); + } + updates.theme = theme === 'perinco' ? null : theme; + } + if (req.body?.theme_mode !== undefined) { + const mode = req.body.theme_mode; + if (mode !== null && mode !== 'light' && mode !== 'dark') return res.status(400).json({ error: 'Unbekannter Modus.' }); + updates.theme_mode = mode; + } + if (req.body?.color !== undefined) { + const color = req.body.color; + if (color !== null && !/^#[0-9a-f]{6}$/i.test(color)) return res.status(400).json({ error: 'Ungültige Farbe.' }); + updates.color = color; + } + const keys = Object.keys(updates); + if (keys.length === 0) return res.status(400).json({ error: 'Keine Änderungen übergeben.' }); + try { + await pool.query(`UPDATE pulse_users SET ${keys.map((k) => `${k} = ?`).join(', ')} WHERE id = ?`, [ + ...keys.map((k) => updates[k]), + req.user.id, + ]); + res.json({ user: publicUser({ ...req.user, ...updates }) }); + } catch (err) { + next(err); + } +}); + +router.post('/change-password', requireAuth, async (req, res, next) => { + const current = String(req.body?.current_password || ''); + const wanted = String(req.body?.new_password || ''); + if (wanted.length < 8) return res.status(400).json({ error: 'Das neue Passwort muss mindestens 8 Zeichen haben.' }); + try { + const [rows] = await pool.query('SELECT password_hash FROM pulse_users WHERE id = ?', [req.user.id]); + const hash = rows[0]?.password_hash; + if (!hash) return res.status(400).json({ error: 'Dieses Konto meldet sich über SSO an und hat kein Passwort.' }); + if (!(await bcrypt.compare(current, hash))) return res.status(400).json({ error: 'Das aktuelle Passwort ist falsch.' }); + await pool.query('UPDATE pulse_users SET password_hash = ? WHERE id = ?', [await bcrypt.hash(wanted, 12), req.user.id]); + res.json({ ok: true }); + } catch (err) { + next(err); + } +}); + +/* ---------------------------------------------------------------- SSO -- */ + +router.get('/sso/start', async (req, res, next) => { + try { + const { sso } = await getSettings(); + if (!sso.enabled || !ssoConfigured(sso)) { + return res.redirect('/?sso_error=' + encodeURIComponent('SSO ist nicht eingerichtet.')); + } + const state = signShort({ n: crypto.randomBytes(12).toString('hex'), t: 'sso' }); + res.redirect(authorizeUrl(sso, state)); + } catch (err) { + next(err); + } +}); + +router.get('/sso/callback', async (req, res) => { + const back = (message) => res.redirect('/?sso_error=' + encodeURIComponent(message)); + try { + if (req.query.error) return back(String(req.query.error_description || req.query.error).split('\n')[0]); + try { + const state = verifyShort(String(req.query.state || '')); + if (state.t !== 'sso') throw new Error('state'); + } catch { + return back('Anmeldung abgelaufen. Bitte erneut versuchen.'); + } + + const { sso } = await getSettings(); + if (!sso.enabled || !ssoConfigured(sso)) return back('SSO ist nicht eingerichtet.'); + const identity = await exchangeCode(sso, String(req.query.code || '')); + + // Nur wer in der SSO-Verwaltung uebernommen wurde, kommt herein - verknuepft + // wird ueber die Objekt-ID, nicht ueber die Mailadresse. + const [rows] = await pool.query("SELECT * FROM pulse_users WHERE external_id = ? AND source = 'sso' LIMIT 1", [identity.oid]); + const user = rows[0]; + if (!user) { + return back(`Für ${identity.email || 'dieses Konto'} ist Pulse noch nicht freigeschaltet. Bitte an einen Manager oder Administrator wenden.`); + } + if (!user.is_active) return back('Dieses Benutzerkonto ist deaktiviert.'); + + await pool.query('UPDATE pulse_users SET last_login_at = NOW() WHERE id = ?', [user.id]); + // Token im Fragment: landet weder im Server-Log noch im Referer. + res.redirect(`/#sso_token=${encodeURIComponent(signToken(user))}`); + } catch (err) { + console.error('[Pulse] SSO-Anmeldung fehlgeschlagen:', err.message); + back('Anmeldung über Microsoft fehlgeschlagen: ' + err.message); + } +}); + +export default router; diff --git a/server/src/routes/customers.js b/server/src/routes/customers.js new file mode 100644 index 0000000..a0bfc5c --- /dev/null +++ b/server/src/routes/customers.js @@ -0,0 +1,161 @@ +import express from 'express'; +import { pool } from '../db.js'; +import { getSettings } from '../lib/settings.js'; +import { loadProjects } from '../lib/projects.js'; +import { loadEntries } from '../lib/entries.js'; +import { addDays, today } from '../lib/dates.js'; + +const router = express.Router(); +const str = (v, max) => (v == null ? null : String(v).trim().slice(0, max) || null); + +function normalize(body, creating) { + const errors = []; + const out = {}; + if (creating || body.name !== undefined) { + out.name = str(body.name, 190); + if (!out.name) errors.push('Kundenname fehlt.'); + } + if (creating || body.short_name !== undefined) { + out.short_name = str(body.short_name, 16)?.toUpperCase().replace(/[^A-Z0-9ÄÖÜ_-]/g, '') || null; + if (!out.short_name) errors.push('Kürzel fehlt (z. B. KUKA) - es steht im Projektcode.'); + } + for (const [key, max] of [ + ['contact_person', 160], + ['email', 190], + ['phone', 60], + ['street', 190], + ['zip', 20], + ['city', 120], + ['country', 80], + ['notes', 5000], + ]) { + if (body[key] !== undefined) out[key] = str(body[key], max); + } + if (body.color !== undefined) out.color = /^#[0-9a-f]{6}$/i.test(body.color || '') ? body.color : null; + if (body.is_active !== undefined) out.is_active = body.is_active ? 1 : 0; + return { out, errors }; +} + +router.get('/', async (req, res, next) => { + try { + const [rows] = await pool.query( + `SELECT c.*, (SELECT COUNT(*) FROM pulse_projects p WHERE p.customer_id = c.id) AS project_count + FROM pulse_customers c ORDER BY c.name` + ); + res.json(rows.map((r) => ({ ...r, is_active: Boolean(r.is_active), project_count: Number(r.project_count) }))); + } catch (err) { + next(err); + } +}); + +/** + * Uebersicht: welche Kunden haben welche Projekte mit welchen Mitarbeitern, + * wie viel Umsatz ist erbracht und wie viel noch offen - plus die letzten 12 + * Monate Umsatz fuer die Verlaufslinie. + */ +router.get('/overview', async (req, res, next) => { + try { + const settings = await getSettings(); + const [customers] = await pool.query('SELECT * FROM pulse_customers ORDER BY name'); + const projects = await loadProjects(settings); + const from = `${addDays(today(), -365).slice(0, 7)}-01`; + const entries = await loadEntries(settings, { from }); + + const months = []; + const cursor = new Date(`${from}T00:00:00Z`); + for (let i = 0; i < 13; i++) { + months.push(cursor.toISOString().slice(0, 7)); + cursor.setUTCMonth(cursor.getUTCMonth() + 1); + } + + const result = customers.map((customer) => { + const own = projects.filter((p) => p.customer_id === customer.id); + const memberMap = new Map(); + for (const project of own) for (const m of project.members) memberMap.set(m.id, m); + const monthly = months.map((month) => + entries + .filter((e) => e.customer_id === customer.id && e.entry_date.startsWith(month)) + .reduce((sum, e) => sum + e.amount + e.fixed_amount, 0) + ); + return { + ...customer, + is_active: Boolean(customer.is_active), + projects: own.map((p) => ({ + id: p.id, + code: p.code, + name: p.name, + display_name: p.display_name, + type: p.type, + is_active: p.is_active, + used_minutes: p.used_minutes, + budget_hours: p.budget_hours, + earned: p.earned, + open: p.open, + budget_value: p.budget_value, + members: p.members, + end_date: p.end_date, + })), + members: [...memberMap.values()], + used_minutes: own.reduce((s, p) => s + p.used_minutes, 0), + earned: Math.round(own.reduce((s, p) => s + p.earned, 0) * 100) / 100, + open: Math.round(own.reduce((s, p) => s + p.open, 0) * 100) / 100, + monthly: monthly.map((v) => Math.round(v * 100) / 100), + }; + }); + res.json({ months, customers: result }); + } catch (err) { + next(err); + } +}); + +router.post('/', async (req, res, next) => { + const { out, errors } = normalize(req.body || {}, true); + if (errors.length) return res.status(400).json({ error: errors.join(' ') }); + try { + const keys = Object.keys(out); + const [result] = await pool.query( + `INSERT INTO pulse_customers (${keys.join(', ')}) VALUES (${keys.map(() => '?').join(', ')})`, + keys.map((k) => out[k]) + ); + const [[row]] = await pool.query('SELECT * FROM pulse_customers WHERE id = ?', [result.insertId]); + res.status(201).json(row); + } catch (err) { + if (err.code === 'ER_DUP_ENTRY') return res.status(409).json({ error: 'Dieses Kürzel ist schon vergeben.' }); + next(err); + } +}); + +router.put('/:id', async (req, res, next) => { + const { out, errors } = normalize(req.body || {}, false); + if (errors.length) return res.status(400).json({ error: errors.join(' ') }); + try { + const keys = Object.keys(out); + if (keys.length) { + const [result] = await pool.query(`UPDATE pulse_customers SET ${keys.map((k) => `${k} = ?`).join(', ')} WHERE id = ?`, [ + ...keys.map((k) => out[k]), + Number(req.params.id), + ]); + if (!result.affectedRows) return res.status(404).json({ error: 'Kunde nicht gefunden.' }); + } + const [[row]] = await pool.query('SELECT * FROM pulse_customers WHERE id = ?', [Number(req.params.id)]); + res.json(row); + } catch (err) { + if (err.code === 'ER_DUP_ENTRY') return res.status(409).json({ error: 'Dieses Kürzel ist schon vergeben.' }); + next(err); + } +}); + +router.delete('/:id', async (req, res, next) => { + try { + const [[{ n }]] = await pool.query('SELECT COUNT(*) AS n FROM pulse_projects WHERE customer_id = ?', [Number(req.params.id)]); + if (Number(n) > 0) { + return res.status(409).json({ error: `Dem Kunden sind ${n} Projekte zugeordnet. Bitte deaktivieren statt löschen.` }); + } + await pool.query('DELETE FROM pulse_customers WHERE id = ?', [Number(req.params.id)]); + res.json({ ok: true }); + } catch (err) { + next(err); + } +}); + +export default router; diff --git a/server/src/routes/dashboard.js b/server/src/routes/dashboard.js new file mode 100644 index 0000000..48b2116 --- /dev/null +++ b/server/src/routes/dashboard.js @@ -0,0 +1,158 @@ +import express from 'express'; +import { pool } from '../db.js'; +import { visibleUserIds, userFilter } from '../lib/access.js'; +import { getSettings } from '../lib/settings.js'; +import { loadEntries } from '../lib/entries.js'; +import { addDays, periodLabel, periodRange, previousStart, today, workdays } from '../lib/dates.js'; + +const router = express.Router(); + +const SERIES_LENGTH = { day: 14, week: 12, month: 12, quarter: 8, year: 5 }; + +export function capacityMinutes(user, settings, from, to) { + const weekly = user.weekly_hours == null ? Number(settings.weekly_hours) : Number(user.weekly_hours); + return Math.round((weekly / 5) * workdays(from, to) * 60); +} + +function sum(entries) { + let minutes = 0; + let billable = 0; + let amount = 0; + for (const e of entries) { + minutes += e.minutes; + if (e.billable) billable += e.minutes; + amount += e.amount; + } + return { minutes, billable_minutes: billable, non_billable_minutes: minutes - billable, amount: Math.round(amount * 100) / 100 }; +} + +/** + * Dashboard fuer Fuehrungsrollen. Vorgesetzte sehen ihr Team (rekursiv), + * Management und Administratoren alle. + * + * ?granularity day|week|month|quarter|year - Diagramm und Kennzahlen + * ?rank days|week|month|quarter|year - Zeitraum der Mitarbeiterliste + */ +router.get('/', async (req, res, next) => { + try { + const granularity = SERIES_LENGTH[req.query.granularity] ? req.query.granularity : 'week'; + const rank = ['days', 'week', 'month', 'quarter', 'year'].includes(req.query.rank) ? req.query.rank : 'week'; + const settings = await getSettings(); + const userIds = await visibleUserIds(req.user); + const now = today(); + + // Diagramm: die letzten N Zeitraeume bis einschliesslich des aktuellen. + const buckets = []; + let start = periodRange(granularity, now).from; + for (let i = 0; i < SERIES_LENGTH[granularity]; i++) { + buckets.unshift({ ...periodRange(granularity, start), label: periodLabel(granularity, start) }); + start = previousStart(granularity, start); + } + const rankRange = rank === 'days' ? { from: addDays(now, -6), to: now } : periodRange(rank, now); + const earliest = [buckets[0].from, rankRange.from].sort()[0]; + const entries = await loadEntries(settings, { from: earliest, to: buckets[buckets.length - 1].to, userIds }); + + const series = buckets.map((b) => ({ + from: b.from, + to: b.to, + label: b.label, + ...sum(entries.filter((e) => e.entry_date >= b.from && e.entry_date <= b.to)), + })); + + const filter = userFilter(userIds, 'id'); + const [users] = await pool.query( + `SELECT id, name, color, role, weekly_hours, is_external, company, job_title FROM pulse_users + WHERE is_active = 1 AND role <> 'admin' AND ${filter.sql} ORDER BY name`, + filter.params + ); + + const current = buckets[buckets.length - 1]; + const previous = buckets[buckets.length - 2]; + // Kapazitaet nur bis heute - sonst sieht die Auslastung am Montag schlecht aus. + const capTo = current.to > now ? now : current.to; + const capacity = users.reduce((s, u) => s + capacityMinutes(u, settings, current.from, capTo), 0); + const kpis = { + current: { ...series[series.length - 1], capacity_minutes: capacity }, + previous: series[series.length - 2] ?? null, + period_label: current.label, + previous_label: previous?.label ?? null, + }; + + const rankTo = rankRange.to > now ? now : rankRange.to; + const ranking = users.map((u) => { + const own = entries.filter((e) => e.user_id === u.id && e.entry_date >= rankRange.from && e.entry_date <= rankRange.to); + const totals = sum(own); + return { + user_id: u.id, + name: u.name, + color: u.color, + job_title: u.job_title, + is_external: Boolean(u.is_external), + ...totals, + entries: own.length, + capacity_minutes: capacityMinutes(u, settings, rankRange.from, rankTo), + }; + }); + + // Projekte im aktuellen Zeitraum - fuer die Verteilung. + const byProject = new Map(); + for (const e of entries.filter((e) => e.entry_date >= current.from && e.entry_date <= current.to)) { + const item = byProject.get(e.project_id) || { + project_id: e.project_id, + name: e.project_display, + customer_color: e.customer_color, + minutes: 0, + billable_minutes: 0, + }; + item.minutes += e.minutes; + if (e.billable) item.billable_minutes += e.minutes; + byProject.set(e.project_id, item); + } + + const recentFilter = userFilter(userIds, 'e.user_id'); + const [recentIds] = await pool.query( + `SELECT e.id, e.entry_date FROM pulse_time_entries e WHERE ${recentFilter.sql} ORDER BY e.created_at DESC, e.id DESC LIMIT 12`, + recentFilter.params + ); + let recent = []; + if (recentIds.length) { + const dates = recentIds.map((r) => r.entry_date).sort(); + const pool_ = await loadEntries(settings, { from: dates[0], to: dates[dates.length - 1], userIds }); + const byId = new Map(pool_.map((e) => [e.id, e])); + recent = recentIds + .map((r) => byId.get(r.id)) + .filter(Boolean) + .map((e) => ({ + id: e.id, + user_id: e.user_id, + user_name: e.user_name, + user_color: e.user_color, + project_display: e.project_display, + customer_color: e.customer_color, + task_name: e.task_name, + note: e.note, + entry_date: e.entry_date, + minutes: e.minutes, + billable: e.billable, + created_at: e.created_at, + })); + } + + res.json({ + granularity, + rank, + rank_range: rankRange, + scope: userIds === null ? 'all' : 'team', + team_size: users.length, + series, + kpis, + ranking, + projects: [...byProject.values()].sort((a, b) => b.minutes - a.minutes).slice(0, 8), + recent, + }); + } catch (err) { + next(err); + } +}); + +export default router; diff --git a/server/src/routes/directory.js b/server/src/routes/directory.js new file mode 100644 index 0000000..a2eb65f --- /dev/null +++ b/server/src/routes/directory.js @@ -0,0 +1,88 @@ +import express from 'express'; +import { pool } from '../db.js'; +import { getSettings } from '../lib/settings.js'; +import { ALLOWED_DOMAINS, importUsers, listDirectory, ssoConfigured, syncUsers } from '../lib/sso.js'; + +/* + * SSO-Verwaltung: AD-Konten ansehen und nach Pulse uebernehmen. + * Zugriff ab Rolle Management (in server.js). Die Zugangsdaten selbst bleiben + * Administratoren vorbehalten (routes/settings.js). + */ +const router = express.Router(); +const IMPORT_ROLES = ['user', 'lead', 'management']; + +router.get('/ad-users', async (req, res) => { + const { sso } = await getSettings(); + if (!ssoConfigured(sso)) { + return res.json({ configured: false, domains: ALLOWED_DOMAINS, users: [], fetched_at: null }); + } + try { + const { users, fetched_at } = await listDirectory(sso, { refresh: req.query.refresh === '1' }); + const [pulseUsers] = await pool.query('SELECT id, external_id, email, role, is_active, source FROM pulse_users'); + const byExternal = new Map(pulseUsers.filter((u) => u.external_id).map((u) => [u.external_id, u])); + const byEmail = new Map(pulseUsers.filter((u) => u.email).map((u) => [u.email.toLowerCase(), u])); + res.json({ + configured: true, + domains: ALLOWED_DOMAINS, + fetched_at, + users: users.map((ad) => { + const linked = byExternal.get(ad.id); + const sameMail = !linked ? byEmail.get(ad.email) : null; + return { + ...ad, + pulse_user_id: linked?.id ?? null, + pulse_role: linked?.role ?? null, + pulse_active: linked ? Boolean(linked.is_active) : null, + // Lokales Konto mit derselben Mail - wird beim Uebernehmen verknuepft. + local_match_id: sameMail?.id ?? null, + }; + }), + }); + } catch (err) { + res.status(502).json({ error: `Microsoft Graph nicht erreichbar: ${err.message}` }); + } +}); + +router.post('/import', async (req, res, next) => { + try { + const { sso } = await getSettings(); + if (!ssoConfigured(sso)) return res.status(400).json({ error: 'SSO ist noch nicht eingerichtet.' }); + const ids = Array.isArray(req.body?.ad_ids) ? [...new Set(req.body.ad_ids.map(String))].slice(0, 500) : []; + const role = req.body?.role; + if (!ids.length) return res.status(400).json({ error: 'Keine Benutzer ausgewählt.' }); + if (!IMPORT_ROLES.includes(role)) return res.status(400).json({ error: 'Ungültige Rolle.' }); + + let managerId = req.body?.manager_id ? Number(req.body.manager_id) : null; + if (managerId) { + const [[manager]] = await pool.query('SELECT id, role, is_active FROM pulse_users WHERE id = ?', [managerId]); + if (!manager || !manager.is_active || !['lead', 'management'].includes(manager.role)) { + return res.status(400).json({ error: 'Der Vorgesetzte muss ein aktiver Vorgesetzter oder Manager sein.' }); + } + } + // Ein Administrator in der Auswahl darf nicht herabgestuft werden. + const [admins] = await pool.query( + `SELECT external_id FROM pulse_users WHERE role = 'admin' AND external_id IN (${ids.map(() => '?').join(',')})`, + ids + ); + if (admins.length) return res.status(400).json({ error: 'Administratoren können hier nicht geändert werden.' }); + + const result = await importUsers(sso, ids, { role, managerId }); + res.json(result); + } catch (err) { + if (err.code === 'ER_DUP_ENTRY') return res.status(409).json({ error: 'Benutzername oder E-Mail ist schon vergeben.' }); + if (/Graph|AADSTS|HTTP/.test(err.message)) return res.status(502).json({ error: `Microsoft Graph: ${err.message}` }); + next(err); + } +}); + +router.post('/sync', async (req, res) => { + try { + const { sso } = await getSettings(); + if (!ssoConfigured(sso)) return res.status(400).json({ error: 'SSO ist noch nicht eingerichtet.' }); + res.json(await syncUsers(sso)); + } catch (err) { + res.status(502).json({ error: `Abgleich fehlgeschlagen: ${err.message}` }); + } +}); + +export default router; diff --git a/server/src/routes/entries.js b/server/src/routes/entries.js new file mode 100644 index 0000000..0882dcc --- /dev/null +++ b/server/src/routes/entries.js @@ -0,0 +1,174 @@ +import express from 'express'; +import { pool, withTransaction } from '../db.js'; +import { hasRole } from '../middleware/auth.js'; +import { visibleUserIds } from '../lib/access.js'; +import { getSettings } from '../lib/settings.js'; +import { loadEntries } from '../lib/entries.js'; +import { parseDate } from '../lib/dates.js'; +import { BookingError, isBillable, loadProjects, resolvePot } from '../lib/projects.js'; + +const router = express.Router(); + +function publicEntry(entry, showAmounts) { + return { + id: entry.id, + user_id: entry.user_id, + user_name: entry.user_name, + project_id: entry.project_id, + project_name: entry.project_name, + project_code: entry.project_code, + project_display: entry.project_display, + project_type: entry.project_type, + customer_id: entry.customer_id, + customer_name: entry.customer_name, + customer_color: entry.customer_color, + task_id: entry.task_id, + task_name: entry.task_name, + pot_id: entry.pot_id, + pot_label: entry.pot_label, + entry_date: entry.entry_date, + minutes: entry.minutes, + note: entry.note, + billable: entry.billable, + amount: showAmounts ? entry.amount : null, + created_at: entry.created_at, + updated_at: entry.updated_at, + }; +} + +router.get('/', async (req, res, next) => { + try { + const { from, to } = req.query; + if (!parseDate(from) || !parseDate(to)) return res.status(400).json({ error: 'Zeitraum von/bis fehlt.' }); + let userIds = [req.user.id]; + if (req.query.user_id && Number(req.query.user_id) !== req.user.id) { + const visible = await visibleUserIds(req.user); + const wanted = Number(req.query.user_id); + if (visible !== null && !visible.includes(wanted)) return res.status(403).json({ error: 'Keine Einsicht in diese Zeiten.' }); + userIds = [wanted]; + } + const settings = await getSettings(); + const entries = await loadEntries(settings, { from, to, userIds }); + res.json(entries.map((e) => publicEntry(e, hasRole(req.user, 'lead')))); + } catch (err) { + next(err); + } +}); + +async function validate(conn, user, body, existing) { + const projectId = Number(body.project_id ?? existing?.project_id); + const date = body.entry_date ?? existing?.entry_date; + const minutes = Number(body.minutes ?? existing?.minutes); + const taskId = body.task_id !== undefined ? (body.task_id ? Number(body.task_id) : null) : existing?.task_id ?? null; + const note = body.note !== undefined ? String(body.note || '').trim().slice(0, 1000) || null : existing?.note ?? null; + + if (user.role === 'admin' && !existing) throw new BookingError('Administratoren buchen keine Zeiten.'); + if (!parseDate(date)) throw new BookingError('Datum fehlt.'); + if (!Number.isInteger(minutes) || minutes < 1) throw new BookingError('Bitte eine Zeit größer 0:00 eingeben.'); + if (minutes > 24 * 60) throw new BookingError('Mehr als 24 Stunden an einem Tag gehen nicht.'); + + const [projects] = await conn.query('SELECT * FROM pulse_projects WHERE id = ? LIMIT 1', [projectId]); + const project = projects[0]; + if (!project) throw new BookingError('Projekt nicht gefunden.'); + if (!project.is_active) throw new BookingError('Das Projekt ist archiviert und nicht mehr buchbar.'); + + // Gebucht wird nur auf zugewiesene Projekte - fuer jede Rolle. + const [member] = await conn.query('SELECT 1 FROM pulse_project_members WHERE project_id = ? AND user_id = ?', [ + project.id, + existing?.user_id ?? user.id, + ]); + if (!member.length) throw new BookingError('Dieses Projekt ist dir nicht zugewiesen.'); + + let task = null; + if (taskId) { + const [tasks] = await conn.query('SELECT * FROM pulse_tasks WHERE id = ? AND project_id = ?', [taskId, project.id]); + task = tasks[0]; + if (!task) throw new BookingError('Der Task gehört nicht zu diesem Projekt.'); + if (!task.is_active && task.id !== existing?.task_id) throw new BookingError('Der Task ist nicht mehr aktiv.'); + } + + const [[day]] = await conn.query( + 'SELECT COALESCE(SUM(minutes), 0) AS total FROM pulse_time_entries WHERE user_id = ? AND entry_date = ? AND id <> ?', + [existing?.user_id ?? user.id, date, existing?.id || 0] + ); + if (Number(day.total) + minutes > 24 * 60) throw new BookingError('An diesem Tag wären es mehr als 24 Stunden.'); + + const billable = isBillable(project, task); + const pot = await resolvePot(conn, project, date, minutes, existing?.id, billable); + return { project, task, pot, date, minutes, note, billable: billable ? 1 : 0 }; +} + +async function respond(req, res, id, status) { + const settings = await getSettings(); + const [[row]] = await pool.query('SELECT entry_date, user_id, project_id, pot_id FROM pulse_time_entries WHERE id = ?', [id]); + const entries = await loadEntries(settings, { from: row.entry_date, to: row.entry_date, userIds: [row.user_id] }); + const entry = entries.find((e) => e.id === id); + // Hinweis zum Topf gleich mitliefern, damit der Dialog warnen kann. + let warning = null; + if (row.pot_id) { + const [project] = await loadProjects(settings, { where: 'p.id = ?', params: [row.project_id] }); + warning = project?.pots.find((p) => p.id === row.pot_id)?.warning ?? null; + } + res.status(status).json({ entry: publicEntry(entry, hasRole(req.user, 'lead')), warning }); +} + +router.post('/', async (req, res, next) => { + try { + const id = await withTransaction(async (conn) => { + const v = await validate(conn, req.user, req.body || {}, null); + const [result] = await conn.query( + `INSERT INTO pulse_time_entries (user_id, project_id, task_id, pot_id, entry_date, minutes, note, billable) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + [req.user.id, v.project.id, v.task?.id ?? null, v.pot?.id ?? null, v.date, v.minutes, v.note, v.billable] + ); + return result.insertId; + }); + await respond(req, res, id, 201); + } catch (err) { + if (err instanceof BookingError) return res.status(422).json({ error: err.message }); + next(err); + } +}); + +async function loadOwned(req) { + const [rows] = await pool.query('SELECT * FROM pulse_time_entries WHERE id = ? LIMIT 1', [Number(req.params.id)]); + const entry = rows[0]; + if (!entry) return { error: [404, 'Buchung nicht gefunden.'] }; + // Eigene Buchungen; Administratoren duerfen korrigieren. + if (entry.user_id !== req.user.id && req.user.role !== 'admin') { + return { error: [403, 'Nur eigene Buchungen können geändert werden.'] }; + } + return { entry }; +} + +router.put('/:id', async (req, res, next) => { + try { + const { entry, error } = await loadOwned(req); + if (error) return res.status(error[0]).json({ error: error[1] }); + await withTransaction(async (conn) => { + const v = await validate(conn, req.user, req.body || {}, entry); + await conn.query( + `UPDATE pulse_time_entries SET project_id = ?, task_id = ?, pot_id = ?, entry_date = ?, minutes = ?, note = ?, billable = ? + WHERE id = ?`, + [v.project.id, v.task?.id ?? null, v.pot?.id ?? null, v.date, v.minutes, v.note, v.billable, entry.id] + ); + }); + await respond(req, res, entry.id, 200); + } catch (err) { + if (err instanceof BookingError) return res.status(422).json({ error: err.message }); + next(err); + } +}); + +router.delete('/:id', async (req, res, next) => { + try { + const { entry, error } = await loadOwned(req); + if (error) return res.status(error[0]).json({ error: error[1] }); + await pool.query('DELETE FROM pulse_time_entries WHERE id = ?', [entry.id]); + res.json({ ok: true }); + } catch (err) { + next(err); + } +}); + +export default router; diff --git a/server/src/routes/exports.js b/server/src/routes/exports.js new file mode 100644 index 0000000..535730f --- /dev/null +++ b/server/src/routes/exports.js @@ -0,0 +1,259 @@ +import express from 'express'; +import { pool } from '../db.js'; +import { hasRole } from '../middleware/auth.js'; +import { visibleUserIds } from '../lib/access.js'; +import { getSettings } from '../lib/settings.js'; +import { loadEntries } from '../lib/entries.js'; +import { MONTHS_DE, formatDateDe, monthRange, parseDate } from '../lib/dates.js'; +import { dateDe, durationDe, hoursDe, moneyDe, sendDocument } from '../export/render.js'; +import { buildTimeReport } from './reports.js'; + +const router = express.Router(); +const FORMATS = ['pdf', 'xlsx', 'csv']; +const WEEKDAYS = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa']; + +/** + * Stundennachweis eines Monats - die Vorlage fuer Kunden und Rechnungsstellung. + * + * ?month=2026-09 &format=pdf|xlsx|csv + * ?user_id=|all eigener Nachweis (Standard), eines Teammitglieds oder aller sichtbaren + * ?customer_id / ?project_id auf einen Kunden bzw. ein Projekt einschraenken + * ?amounts=1 Satz und Betrag mit ausgeben (ab Rolle Vorgesetzter) + */ +router.get('/timesheet', async (req, res, next) => { + try { + const format = FORMATS.includes(req.query.format) ? req.query.format : 'pdf'; + const range = monthRange(String(req.query.month || '')); + if (!range) return res.status(400).json({ error: 'Monat fehlt (JJJJ-MM).' }); + + let userIds = [req.user.id]; + const wanted = req.query.user_id; + if (wanted && String(wanted) !== String(req.user.id)) { + const visible = await visibleUserIds(req.user); + if (wanted === 'all') { + if (!hasRole(req.user, 'lead')) return res.status(403).json({ error: 'Keine Einsicht in fremde Zeiten.' }); + userIds = visible; + } else { + if (visible !== null && !visible.includes(Number(wanted))) { + return res.status(403).json({ error: 'Keine Einsicht in diese Zeiten.' }); + } + userIds = [Number(wanted)]; + } + } + const showAmounts = req.query.amounts === '1' && hasRole(req.user, 'lead'); + const settings = await getSettings(); + const entries = await loadEntries(settings, { + from: range.from, + to: range.to, + userIds, + customerId: req.query.customer_id, + projectId: req.query.project_id, + }); + + const [y, m] = req.query.month.split('-'); + const monthName = `${MONTHS_DE[Number(m) - 1]} ${y}`; + const multiUser = userIds === null || userIds.length > 1; + + let personName = 'Alle Mitarbeiter'; + let company = ''; + if (!multiUser) { + const [[person]] = await pool.query('SELECT name, company, is_external FROM pulse_users WHERE id = ?', [userIds[0]]); + personName = person?.name || ''; + company = person?.is_external ? person.company || '' : settings.company_name; + } + let customerName = 'Alle Kunden'; + let projectName = [...new Set(entries.map((e) => e.project_display))].join(', ') || '–'; + if (req.query.customer_id) { + const [[c]] = await pool.query('SELECT name FROM pulse_customers WHERE id = ?', [Number(req.query.customer_id)]); + customerName = c?.name || '–'; + } else { + const names = [...new Set(entries.map((e) => e.customer_name))]; + if (names.length === 1) customerName = names[0]; + } + if (projectName.length > 120) projectName = `${new Set(entries.map((e) => e.project_id)).size} Projekte`; + + const total = entries.reduce((s, e) => s + e.minutes, 0); + const billable = entries.filter((e) => e.billable).reduce((s, e) => s + e.minutes, 0); + const days = new Set(entries.map((e) => e.entry_date)).size; + const amount = entries.reduce((s, e) => s + e.amount, 0); + + const columns = [ + { key: 'entry_date', label: 'Datum', type: 'date', width: 58, xlsWidth: 12 }, + { key: 'weekday', label: 'Tag', width: 26, xlsWidth: 6 }, + ...(multiUser ? [{ key: 'user_name', label: 'Mitarbeiter', width: 90, xlsWidth: 22 }] : []), + { key: 'project_display', label: 'Projekt', width: 110, xlsWidth: 28 }, + { key: 'task_name', label: 'Task', width: 80, xlsWidth: 20 }, + { key: 'note', label: 'Tätigkeit', type: 'text', xlsWidth: 44 }, + { key: 'duration', label: 'Dauer', align: 'right', width: 38, xlsWidth: 9 }, + { key: 'minutes', label: 'Stunden', type: 'hours', align: 'right', width: 46, xlsWidth: 10 }, + ...(showAmounts + ? [ + { key: 'rate', label: 'Satz/h', type: 'money', align: 'right', width: 56, xlsWidth: 12 }, + { key: 'amount', label: 'Betrag', type: 'money', align: 'right', width: 66, xlsWidth: 14 }, + ] + : []), + ]; + const rows = entries.map((e) => ({ + ...e, + weekday: WEEKDAYS[new Date(`${e.entry_date}T00:00:00Z`).getUTCDay()], + duration: durationDe(e.minutes), + rate: e.amount && e.minutes ? Math.round((e.amount / (e.minutes / 60)) * 100) / 100 : null, + })); + + // Zusammenfassung je Projekt und Task. + const summary = new Map(); + for (const e of entries) { + const key = `${e.project_id}|${e.task_name || ''}`; + const item = summary.get(key) || { project_display: e.project_display, task_name: e.task_name || '–', minutes: 0, amount: 0 }; + item.minutes += e.minutes; + item.amount += e.amount; + summary.set(key, item); + } + + const doc = { + title: settings.export.title || 'Stundennachweis', + subtitle: monthName, + filename: `Stundennachweis ${y}-${m} ${multiUser ? customerName : personName}`, + landscape: showAmounts || multiUser, + info: [ + [multiUser ? 'Mitarbeiter' : 'Berater', personName + (company && !multiUser ? ` (${company})` : '')], + ['Kunde', customerName], + ['Zeitraum', `${formatDateDe(range.from)} – ${formatDateDe(range.to)}`], + ['Projekt', projectName], + ], + kpis: [ + ['Gesamtstunden', hoursDe(total)], + ['Dauer (h:mm)', durationDe(total)], + ['Tage mit Leistung', String(days)], + ...(showAmounts ? [['Betrag', moneyDe(amount)]] : [['davon abrechenbar', hoursDe(billable)]]), + ], + tables: [ + { + columns, + rows, + totals: { entry_date: null, weekday: 'Summe', duration: durationDe(total), minutes: total, ...(showAmounts ? { amount } : {}) }, + }, + { + title: 'Zusammenfassung nach Projekt und Task', + columns: [ + { key: 'project_display', label: 'Projekt', xlsWidth: 28 }, + { key: 'task_name', label: 'Task', width: 150 }, + { key: 'duration', label: 'Dauer', align: 'right', width: 60 }, + { key: 'minutes', label: 'Stunden', type: 'hours', align: 'right', width: 60 }, + ...(showAmounts ? [{ key: 'amount', label: 'Betrag', type: 'money', align: 'right', width: 80 }] : []), + ], + rows: [...summary.values()].map((s) => ({ ...s, duration: durationDe(s.minutes) })), + totals: { project_display: 'Summe', duration: durationDe(total), minutes: total, ...(showAmounts ? { amount } : {}) }, + }, + ], + signature: true, + }; + // CSV: nur die Buchungen - die Zusammenfassung stoert beim Weiterverarbeiten. + if (format === 'csv') doc.tables = [doc.tables[0]]; + await sendDocument(res, doc, format, settings); + } catch (err) { + next(err); + } +}); + +const GROUPS = { + users: { title: 'Mitarbeiter', label: 'Mitarbeiter' }, + projects: { title: 'Projekte', label: 'Projekt' }, + clients: { title: 'Kunden', label: 'Kunde' }, + tasks: { title: 'Tasks', label: 'Task' }, +}; + +/** Export des Zeitreports in der gewaehlten Aufteilung oder als Detailliste. */ +router.get('/report', async (req, res, next) => { + try { + if (!hasRole(req.user, 'lead')) return res.status(403).json({ error: 'Keine Berechtigung.' }); + const format = FORMATS.includes(req.query.format) ? req.query.format : 'pdf'; + if (!parseDate(req.query.from) || !parseDate(req.query.to)) return res.status(400).json({ error: 'Zeitraum fehlt.' }); + const settings = await getSettings(); + const report = await buildTimeReport(req.user, req.query); + const s = report.summary; + const groupKey = GROUPS[req.query.group] ? req.query.group : req.query.group === 'detail' ? 'detail' : 'users'; + + let customerName = null; + if (req.query.customer_id) { + const [[c]] = await pool.query('SELECT name FROM pulse_customers WHERE id = ?', [Number(req.query.customer_id)]); + customerName = c?.name; + } + + let table; + if (groupKey === 'detail') { + table = { + columns: [ + { key: 'entry_date', label: 'Datum', type: 'date', width: 58, xlsWidth: 12 }, + { key: 'user_name', label: 'Mitarbeiter', width: 90, xlsWidth: 22 }, + { key: 'project_display', label: 'Projekt', width: 120, xlsWidth: 28 }, + { key: 'task_name', label: 'Task', width: 80, xlsWidth: 20 }, + { key: 'note', label: 'Tätigkeit', type: 'text', xlsWidth: 40 }, + { key: 'minutes', label: 'Stunden', type: 'hours', align: 'right', width: 48, xlsWidth: 10 }, + { key: 'billable_label', label: 'Abr.', width: 30, xlsWidth: 6 }, + { key: 'amount', label: 'Betrag', type: 'money', align: 'right', width: 66, xlsWidth: 14 }, + ], + rows: report.entries.map((e) => ({ + ...e, + billable_label: e.billable ? 'ja' : 'nein', + amount: e.amount + (report.include_fixed ? e.fixed_amount : 0), + })), + totals: { user_name: 'Summe', minutes: s.minutes, amount: s.amount }, + }; + } else { + const rows = report[groupKey].map((row) => ({ + ...row, + billable_percent: row.minutes ? Math.round((row.billable_minutes / row.minutes) * 100) : 0, + })); + table = { + columns: [ + { key: 'name', label: GROUPS[groupKey].label, xlsWidth: 36 }, + ...(groupKey === 'projects' ? [{ key: 'customer_name', label: 'Kunde', width: 110, xlsWidth: 24 }] : []), + { key: 'minutes', label: 'Stunden', type: 'hours', align: 'right', width: 60, xlsWidth: 12 }, + ...(groupKey === 'users' ? [{ key: 'utilization', label: 'Auslastung', type: 'percent', align: 'right', width: 60, xlsWidth: 12 }] : []), + { key: 'billable_minutes', label: 'Abrechenbar', type: 'hours', align: 'right', width: 66, xlsWidth: 13 }, + { key: 'billable_percent', label: 'Anteil', type: 'percent', align: 'right', width: 46, xlsWidth: 9 }, + { key: 'non_billable_minutes', label: 'Nicht abr.', type: 'hours', align: 'right', width: 60, xlsWidth: 12 }, + { key: 'amount', label: 'Betrag', type: 'money', align: 'right', width: 80, xlsWidth: 15 }, + ], + rows, + totals: { + name: 'Summe', + minutes: s.minutes, + billable_minutes: s.billable_minutes, + non_billable_minutes: s.non_billable_minutes, + amount: s.amount, + }, + }; + } + + const title = groupKey === 'detail' ? 'Zeitreport – Detail' : `Zeitreport – ${GROUPS[groupKey].title}`; + await sendDocument( + res, + { + title, + subtitle: `${dateDe(report.range.from)} – ${dateDe(report.range.to)}`, + filename: `Zeitreport ${report.range.from} bis ${report.range.to}${customerName ? ` ${customerName}` : ''}`, + landscape: true, + sheetName: 'Zeitreport', + info: [ + ['Kunde', customerName || 'Alle Kunden'], + ['Festpreisprojekte', report.include_fixed ? 'im Betrag enthalten' : 'nicht im Betrag enthalten'], + ], + kpis: [ + ['Gesamtstunden', hoursDe(s.minutes)], + ['Abrechenbar', `${hoursDe(s.billable_minutes)} (${s.minutes ? Math.round((s.billable_minutes / s.minutes) * 100) : 0} %)`], + ['Nicht abrechenbar', hoursDe(s.non_billable_minutes)], + ['Abrechenbarer Betrag', moneyDe(s.amount)], + ], + tables: [table], + }, + format, + settings + ); + } catch (err) { + next(err); + } +}); + +export default router; diff --git a/server/src/routes/projects.js b/server/src/routes/projects.js new file mode 100644 index 0000000..a053de5 --- /dev/null +++ b/server/src/routes/projects.js @@ -0,0 +1,385 @@ +import express from 'express'; +import { pool, withTransaction } from '../db.js'; +import { hasRole, requireRole } from '../middleware/auth.js'; +import { getSettings } from '../lib/settings.js'; +import { parseDate } from '../lib/dates.js'; +import { + deriveBudgetHours, + generateCode, + loadProjects, + reassignEntries, + stripAmounts, +} from '../lib/projects.js'; + +const router = express.Router(); +const TYPES = ['billable', 'non_billable', 'fixed']; + +const str = (v, max) => (v == null ? null : String(v).trim().slice(0, max) || null); +const num = (v) => (v === '' || v == null ? null : Number(v)); + +function validatePot(input, project, settings) { + const errors = []; + const pot = { + label: str(input.label, 160), + valid_from: input.valid_from, + valid_to: input.valid_to, + budget_amount: num(input.budget_amount), + rate: num(input.rate), + notes: str(input.notes, 500), + }; + if (!pot.label) errors.push('Budgettopf: Bezeichnung fehlt.'); + if (!parseDate(pot.valid_from) || !parseDate(pot.valid_to)) errors.push(`Budgettopf „${pot.label || ''}“: Laufzeit von/bis fehlt.`); + else if (pot.valid_from > pot.valid_to) errors.push(`Budgettopf „${pot.label}“: Laufzeit endet vor dem Beginn.`); + for (const key of ['budget_amount', 'rate']) { + if (pot[key] != null && !(Number.isFinite(pot[key]) && pot[key] >= 0)) errors.push(`Budgettopf „${pot.label}“: ungültiger Betrag.`); + } + if (project.type === 'billable') { + if (!pot.rate) errors.push(`Budgettopf „${pot.label}“: Stundensatz/Tagessatz fehlt.`); + if (pot.budget_amount == null && num(input.budget_hours) == null) { + errors.push(`Budgettopf „${pot.label}“: Budget in Euro oder Stunden angeben.`); + } + } + if (project.type === 'fixed' && !pot.budget_amount) errors.push(`Budgettopf „${pot.label}“: Festpreis fehlt.`); + if (project.type === 'non_billable') pot.rate = null; + pot.budget_hours = deriveBudgetHours({ ...input, budget_amount: pot.budget_amount, rate: pot.rate }, project, settings); + if (pot.budget_hours != null && !(pot.budget_hours > 0)) errors.push(`Budgettopf „${pot.label}“: Volumen muss größer 0 sein.`); + return { pot, errors }; +} + +function validateProject(body, { creating }) { + const errors = []; + const out = {}; + if (creating || body.customer_id !== undefined) { + out.customer_id = Number(body.customer_id) || null; + if (!out.customer_id) errors.push('Kunde fehlt.'); + } + if (creating || body.name !== undefined) { + out.name = str(body.name, 190); + if (!out.name) errors.push('Projektname fehlt.'); + } + if (creating || body.type !== undefined) { + out.type = body.type || 'billable'; + if (!TYPES.includes(out.type)) errors.push('Unbekannter Projekttyp.'); + } + if (creating || body.billing_unit !== undefined) out.billing_unit = body.billing_unit === 'day' ? 'day' : 'hour'; + if (creating || body.start_date !== undefined) { + out.start_date = body.start_date; + if (!parseDate(out.start_date)) errors.push('Startdatum fehlt.'); + } + if (body.end_date !== undefined) { + out.end_date = body.end_date || null; + if (out.end_date && !parseDate(out.end_date)) errors.push('Enddatum ist ungültig.'); + if (out.end_date && out.start_date && out.end_date < out.start_date) errors.push('Enddatum liegt vor dem Startdatum.'); + } + if (body.tags !== undefined) { + const tags = Array.isArray(body.tags) ? body.tags : String(body.tags || '').split(','); + out.tags = JSON.stringify([...new Set(tags.map((t) => String(t).trim()).filter(Boolean))].slice(0, 20)); + } + if (body.notes !== undefined) out.notes = str(body.notes, 5000); + if (body.owner_id !== undefined) out.owner_id = Number(body.owner_id) || null; + if (body.is_active !== undefined) out.is_active = body.is_active ? 1 : 0; + return { out, errors }; +} + +async function findProject(id) { + const [rows] = await pool.query('SELECT * FROM pulse_projects WHERE id = ? LIMIT 1', [id]); + return rows[0] || null; +} + +async function respondProject(req, res, id, status = 200) { + const settings = await getSettings(); + const [project] = await loadProjects(settings, { where: 'p.id = ?', params: [id] }); + res.status(status).json(hasRole(req.user, 'lead') ? project : stripAmounts(project)); +} + +async function setMembers(conn, projectId, userIds) { + let ids = [...new Set((userIds || []).map(Number).filter(Boolean))]; + // Administratoren buchen keine Zeiten und werden deshalb nie zugewiesen. + if (ids.length) { + const [admins] = await conn.query( + `SELECT id FROM pulse_users WHERE role = 'admin' AND id IN (${ids.map(() => '?').join(',')})`, + ids + ); + const blocked = new Set(admins.map((a) => a.id)); + ids = ids.filter((id) => !blocked.has(id)); + } + await conn.query('DELETE FROM pulse_project_members WHERE project_id = ?', [projectId]); + if (ids.length) { + await conn.query('INSERT INTO pulse_project_members (project_id, user_id) VALUES ?', [ids.map((id) => [projectId, id])]); + } +} + +/** Abrechenbar-Merker aller Buchungen nach Aenderung von Projekttyp oder Task. */ +async function refreshBillable(conn, projectId) { + await conn.query( + `UPDATE pulse_time_entries e + JOIN pulse_projects p ON p.id = e.project_id + LEFT JOIN pulse_tasks t ON t.id = e.task_id + SET e.billable = CASE WHEN p.type = 'non_billable' THEN 0 WHEN t.id IS NULL THEN 1 ELSE t.billable END + WHERE e.project_id = ?`, + [projectId] + ); +} + +// Liste. Mitarbeiter sehen nur zugewiesene Projekte, ohne Betraege. +// ?scope=mine liefert auch fuer Fuehrungsrollen nur die eigenen (Buchungsdialog). +router.get('/', async (req, res, next) => { + try { + const settings = await getSettings(); + const mineOnly = req.user.role === 'user' || req.query.scope === 'mine'; + const projects = await loadProjects( + settings, + mineOnly + ? { where: 'p.id IN (SELECT project_id FROM pulse_project_members WHERE user_id = ?)', params: [req.user.id] } + : {} + ); + res.json(hasRole(req.user, 'lead') ? projects : projects.map(stripAmounts)); + } catch (err) { + next(err); + } +}); + +router.get('/:id', async (req, res, next) => { + try { + const id = Number(req.params.id); + if (req.user.role === 'user') { + const [rows] = await pool.query('SELECT 1 FROM pulse_project_members WHERE project_id = ? AND user_id = ?', [id, req.user.id]); + if (!rows.length) return res.status(404).json({ error: 'Projekt nicht gefunden.' }); + } + if (!(await findProject(id))) return res.status(404).json({ error: 'Projekt nicht gefunden.' }); + await respondProject(req, res, id); + } catch (err) { + next(err); + } +}); + +router.post('/', requireRole('lead'), async (req, res, next) => { + try { + const settings = await getSettings(); + const body = req.body || {}; + const { out, errors } = validateProject(body, { creating: true }); + const pots = []; + const potInputs = Array.isArray(body.pots) ? body.pots : []; + if (potInputs.length === 0) errors.push('Mindestens ein Budgettopf ist erforderlich.'); + for (const input of potInputs) { + const result = validatePot(input, out, settings); + errors.push(...result.errors); + pots.push(result.pot); + } + const [customers] = await pool.query('SELECT * FROM pulse_customers WHERE id = ?', [out.customer_id || 0]); + if (out.customer_id && !customers[0]) errors.push('Kunde nicht gefunden.'); + if (errors.length) return res.status(400).json({ error: [...new Set(errors)].join(' ') }); + + const id = await withTransaction(async (conn) => { + const { code, seq } = await generateCode(conn, settings, customers[0], out.start_date); + const fields = { ...out, code, seq, created_by: req.user.id, owner_id: out.owner_id ?? (req.user.role === 'admin' ? null : req.user.id) }; + const keys = Object.keys(fields); + const [result] = await conn.query( + `INSERT INTO pulse_projects (${keys.join(', ')}) VALUES (${keys.map(() => '?').join(', ')})`, + keys.map((k) => fields[k]) + ); + const projectId = result.insertId; + for (const pot of pots) { + await conn.query( + `INSERT INTO pulse_budget_pots (project_id, label, valid_from, valid_to, budget_amount, rate, budget_hours, notes) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + [projectId, pot.label, pot.valid_from, pot.valid_to, pot.budget_amount, pot.rate, pot.budget_hours, pot.notes] + ); + } + const tasks = (Array.isArray(body.tasks) ? body.tasks : []).filter((t) => str(t?.name, 160)); + for (const task of tasks) { + await conn.query('INSERT INTO pulse_tasks (project_id, name, billable) VALUES (?, ?, ?)', [ + projectId, + str(task.name, 160), + out.type === 'non_billable' ? 0 : task.billable === false ? 0 : 1, + ]); + } + await setMembers(conn, projectId, body.member_ids); + return projectId; + }); + await respondProject(req, res, id, 201); + } catch (err) { + next(err); + } +}); + +router.put('/:id', requireRole('lead'), async (req, res, next) => { + try { + const project = await findProject(Number(req.params.id)); + if (!project) return res.status(404).json({ error: 'Projekt nicht gefunden.' }); + const body = req.body || {}; + const { out, errors } = validateProject( + { ...body, start_date: body.start_date ?? project.start_date }, + { creating: false } + ); + if (errors.length) return res.status(400).json({ error: errors.join(' ') }); + + await withTransaction(async (conn) => { + const keys = Object.keys(out); + if (keys.length) { + await conn.query(`UPDATE pulse_projects SET ${keys.map((k) => `${k} = ?`).join(', ')} WHERE id = ?`, [ + ...keys.map((k) => out[k]), + project.id, + ]); + } + if (Array.isArray(body.member_ids)) await setMembers(conn, project.id, body.member_ids); + if (out.type && out.type !== project.type) { + if (out.type === 'non_billable') await conn.query('UPDATE pulse_budget_pots SET rate = NULL WHERE project_id = ?', [project.id]); + await refreshBillable(conn, project.id); + await reassignEntries(conn, project.id); + } + }); + await respondProject(req, res, project.id); + } catch (err) { + next(err); + } +}); + +router.delete('/:id', requireRole('lead'), async (req, res, next) => { + try { + const project = await findProject(Number(req.params.id)); + if (!project) return res.status(404).json({ error: 'Projekt nicht gefunden.' }); + const [[{ n }]] = await pool.query('SELECT COUNT(*) AS n FROM pulse_time_entries WHERE project_id = ?', [project.id]); + if (Number(n) > 0) { + return res.status(409).json({ error: `Auf dem Projekt sind ${n} Buchungen. Bitte archivieren statt löschen.` }); + } + await pool.query('DELETE FROM pulse_projects WHERE id = ?', [project.id]); + res.json({ ok: true }); + } catch (err) { + next(err); + } +}); + +router.put('/:id/members', requireRole('lead'), async (req, res, next) => { + try { + const project = await findProject(Number(req.params.id)); + if (!project) return res.status(404).json({ error: 'Projekt nicht gefunden.' }); + await withTransaction((conn) => setMembers(conn, project.id, req.body?.user_ids)); + await respondProject(req, res, project.id); + } catch (err) { + next(err); + } +}); + +/* ------------------------------------------------------ Budgettoepfe -- */ + +router.post('/:id/pots', requireRole('lead'), async (req, res, next) => { + try { + const project = await findProject(Number(req.params.id)); + if (!project) return res.status(404).json({ error: 'Projekt nicht gefunden.' }); + const { pot, errors } = validatePot(req.body || {}, project, await getSettings()); + if (errors.length) return res.status(400).json({ error: errors.join(' ') }); + await withTransaction(async (conn) => { + await conn.query( + `INSERT INTO pulse_budget_pots (project_id, label, valid_from, valid_to, budget_amount, rate, budget_hours, notes) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + [project.id, pot.label, pot.valid_from, pot.valid_to, pot.budget_amount, pot.rate, pot.budget_hours, pot.notes] + ); + await reassignEntries(conn, project.id); + }); + await respondProject(req, res, project.id, 201); + } catch (err) { + next(err); + } +}); + +router.put('/:id/pots/:potId', requireRole('lead'), async (req, res, next) => { + try { + const project = await findProject(Number(req.params.id)); + if (!project) return res.status(404).json({ error: 'Projekt nicht gefunden.' }); + const [existing] = await pool.query('SELECT * FROM pulse_budget_pots WHERE id = ? AND project_id = ?', [ + Number(req.params.potId), + project.id, + ]); + if (!existing[0]) return res.status(404).json({ error: 'Budgettopf nicht gefunden.' }); + const { pot, errors } = validatePot({ ...existing[0], ...req.body }, project, await getSettings()); + if (errors.length) return res.status(400).json({ error: errors.join(' ') }); + await withTransaction(async (conn) => { + await conn.query( + `UPDATE pulse_budget_pots SET label = ?, valid_from = ?, valid_to = ?, budget_amount = ?, rate = ?, budget_hours = ?, notes = ? + WHERE id = ?`, + [pot.label, pot.valid_from, pot.valid_to, pot.budget_amount, pot.rate, pot.budget_hours, pot.notes, existing[0].id] + ); + await reassignEntries(conn, project.id); + }); + await respondProject(req, res, project.id); + } catch (err) { + next(err); + } +}); + +router.delete('/:id/pots/:potId', requireRole('lead'), async (req, res, next) => { + try { + const projectId = Number(req.params.id); + const potId = Number(req.params.potId); + const [[{ n }]] = await pool.query('SELECT COUNT(*) AS n FROM pulse_time_entries WHERE pot_id = ?', [potId]); + if (Number(n) > 0) { + return res.status(409).json({ error: `Auf dem Topf sind ${n} Buchungen - er kann nicht gelöscht werden.` }); + } + const [[{ pots }]] = await pool.query('SELECT COUNT(*) AS pots FROM pulse_budget_pots WHERE project_id = ?', [projectId]); + if (Number(pots) <= 1) return res.status(400).json({ error: 'Ein Projekt braucht mindestens einen Budgettopf.' }); + await pool.query('DELETE FROM pulse_budget_pots WHERE id = ? AND project_id = ?', [potId, projectId]); + await respondProject(req, res, projectId); + } catch (err) { + next(err); + } +}); + +/* ------------------------------------------------------------- Tasks -- */ + +router.post('/:id/tasks', requireRole('lead'), async (req, res, next) => { + try { + const project = await findProject(Number(req.params.id)); + if (!project) return res.status(404).json({ error: 'Projekt nicht gefunden.' }); + const name = str(req.body?.name, 160); + if (!name) return res.status(400).json({ error: 'Taskname fehlt.' }); + await pool.query('INSERT INTO pulse_tasks (project_id, name, billable) VALUES (?, ?, ?)', [ + project.id, + name, + project.type === 'non_billable' ? 0 : req.body?.billable === false ? 0 : 1, + ]); + await respondProject(req, res, project.id, 201); + } catch (err) { + next(err); + } +}); + +router.put('/:id/tasks/:taskId', requireRole('lead'), async (req, res, next) => { + try { + const projectId = Number(req.params.id); + const [rows] = await pool.query('SELECT * FROM pulse_tasks WHERE id = ? AND project_id = ?', [Number(req.params.taskId), projectId]); + if (!rows[0]) return res.status(404).json({ error: 'Task nicht gefunden.' }); + const name = req.body?.name !== undefined ? str(req.body.name, 160) : rows[0].name; + if (!name) return res.status(400).json({ error: 'Taskname fehlt.' }); + const billable = req.body?.billable !== undefined ? (req.body.billable ? 1 : 0) : rows[0].billable; + const active = req.body?.is_active !== undefined ? (req.body.is_active ? 1 : 0) : rows[0].is_active; + await withTransaction(async (conn) => { + await conn.query('UPDATE pulse_tasks SET name = ?, billable = ?, is_active = ? WHERE id = ?', [name, billable, active, rows[0].id]); + if (billable !== rows[0].billable) { + await refreshBillable(conn, projectId); + await reassignEntries(conn, projectId); + } + }); + await respondProject(req, res, projectId); + } catch (err) { + next(err); + } +}); + +router.delete('/:id/tasks/:taskId', requireRole('lead'), async (req, res, next) => { + try { + const projectId = Number(req.params.id); + const taskId = Number(req.params.taskId); + const [[{ n }]] = await pool.query('SELECT COUNT(*) AS n FROM pulse_time_entries WHERE task_id = ?', [taskId]); + if (Number(n) > 0) { + await pool.query('UPDATE pulse_tasks SET is_active = 0 WHERE id = ? AND project_id = ?', [taskId, projectId]); + } else { + await pool.query('DELETE FROM pulse_tasks WHERE id = ? AND project_id = ?', [taskId, projectId]); + } + await respondProject(req, res, projectId); + } catch (err) { + next(err); + } +}); + +export default router; diff --git a/server/src/routes/reports.js b/server/src/routes/reports.js new file mode 100644 index 0000000..05f7272 --- /dev/null +++ b/server/src/routes/reports.js @@ -0,0 +1,234 @@ +import express from 'express'; +import { pool } from '../db.js'; +import { teamOf, userFilter, visibleUserIds } from '../lib/access.js'; +import { getSettings } from '../lib/settings.js'; +import { loadEntries } from '../lib/entries.js'; +import { loadProjects } from '../lib/projects.js'; +import { addDays, mondayOf, parseDate, today, isoWeek } from '../lib/dates.js'; +import { capacityMinutes } from './dashboard.js'; + +const router = express.Router(); +const r2 = (n) => Math.round(n * 100) / 100; + +/** Welche Benutzer fliessen ein: Sicht des Anfragenden, optional auf ein Team verengt. */ +export async function reportUserIds(viewer, teamLeadId) { + const visible = await visibleUserIds(viewer); + if (!teamLeadId) return visible; + const team = [Number(teamLeadId), ...(await teamOf(Number(teamLeadId)))]; + return visible === null ? team : team.filter((id) => visible.includes(id)); +} + +function group(entries, keyFn, labelFn, includeFixed) { + const map = new Map(); + for (const e of entries) { + const key = keyFn(e); + const item = map.get(key) || { key, ...labelFn(e), minutes: 0, billable_minutes: 0, amount: 0 }; + item.minutes += e.minutes; + if (e.billable) item.billable_minutes += e.minutes; + item.amount += e.amount + (includeFixed ? e.fixed_amount : 0); + map.set(key, item); + } + return [...map.values()] + .map((i) => ({ ...i, amount: r2(i.amount), non_billable_minutes: i.minutes - i.billable_minutes })) + .sort((a, b) => b.minutes - a.minutes); +} + +/** Zeitreport wie in Harvest: Summen plus Aufteilung nach Kunde, Projekt, Task, Mitarbeiter. */ +export async function buildTimeReport(viewer, query) { + const settings = await getSettings(); + const from = query.from; + const to = query.to; + const includeFixed = query.include_fixed === '1' || query.include_fixed === 'true'; + const userIds = await reportUserIds(viewer, query.team); + const entries = await loadEntries(settings, { + from, + to, + userIds, + customerId: query.customer_id, + projectId: query.project_id, + }); + + const filter = userFilter(userIds, 'id'); + const [users] = await pool.query( + `SELECT id, name, color, weekly_hours, is_external, company, is_active FROM pulse_users WHERE role <> 'admin' AND ${filter.sql} ORDER BY name`, + filter.params + ); + const capTo = to > today() ? today() : to; + + const minutes = entries.reduce((s, e) => s + e.minutes, 0); + const billable = entries.filter((e) => e.billable).reduce((s, e) => s + e.minutes, 0); + const amount = entries.reduce((s, e) => s + e.amount, 0); + const fixedAmount = entries.reduce((s, e) => s + e.fixed_amount, 0); + + const userRows = group(entries, (e) => e.user_id, (e) => ({ user_id: e.user_id, name: e.user_name, color: e.user_color, is_external: e.is_external }), includeFixed); + // Mitarbeiter ohne Buchung trotzdem zeigen (nur aktive, nicht bei Kunden-/Projektfilter). + if (!query.customer_id && !query.project_id) { + for (const u of users) { + if (u.is_active && !userRows.some((row) => row.user_id === u.id)) { + userRows.push({ key: u.id, user_id: u.id, name: u.name, color: u.color, is_external: Boolean(u.is_external), minutes: 0, billable_minutes: 0, non_billable_minutes: 0, amount: 0 }); + } + } + } + const byId = new Map(users.map((u) => [u.id, u])); + for (const row of userRows) { + const u = byId.get(row.user_id); + row.capacity_minutes = u && from <= capTo ? capacityMinutes(u, settings, from, capTo) : 0; + row.utilization = row.capacity_minutes ? Math.round((row.minutes / row.capacity_minutes) * 100) : null; + } + + return { + range: { from, to }, + include_fixed: includeFixed, + summary: { + minutes, + billable_minutes: billable, + non_billable_minutes: minutes - billable, + amount: r2(amount + (includeFixed ? fixedAmount : 0)), + amount_without_fixed: r2(amount), + fixed_amount: r2(fixedAmount), + entries: entries.length, + }, + clients: group(entries, (e) => e.customer_id, (e) => ({ customer_id: e.customer_id, name: e.customer_name, color: e.customer_color }), includeFixed), + projects: group( + entries, + (e) => e.project_id, + (e) => ({ project_id: e.project_id, name: e.project_display, code: e.project_code, type: e.project_type, customer_name: e.customer_name, color: e.customer_color }), + includeFixed + ), + tasks: group(entries, (e) => e.task_name || '(ohne Task)', (e) => ({ name: e.task_name || '(ohne Task)' }), includeFixed), + users: userRows.sort((a, b) => a.name.localeCompare(b.name, 'de')), + entries, + }; +} + +router.get('/time', async (req, res, next) => { + try { + if (!parseDate(req.query.from) || !parseDate(req.query.to)) return res.status(400).json({ error: 'Zeitraum fehlt.' }); + const report = await buildTimeReport(req.user, req.query); + delete report.entries; + res.json(report); + } catch (err) { + next(err); + } +}); + +/** Projektreport (Harvest-Projektseite): Verlauf, Budget, Tasks, Team, Toepfe. */ +router.get('/project/:id', async (req, res, next) => { + try { + const settings = await getSettings(); + const [project] = await loadProjects(settings, { where: 'p.id = ?', params: [Number(req.params.id)] }); + if (!project) return res.status(404).json({ error: 'Projekt nicht gefunden.' }); + const userIds = await visibleUserIds(req.user); + const entries = await loadEntries(settings, { projectId: project.id, userIds, from: req.query.from, to: req.query.to }); + res.json(buildProjectDetail(project, entries)); + } catch (err) { + next(err); + } +}); + +function buildProjectDetail(project, entries) { + const includeFixed = project.type === 'fixed'; + // Wochenreihe vom Projektstart bis max(Ende, heute) - hoechstens 104 Wochen. + const end = [project.end_date || today(), today(), ...project.pots.map((p) => p.valid_to)].sort().pop(); + const lastMonday = mondayOf(end); + let start = [mondayOf(project.start_date), addDays(lastMonday, -103 * 7)].sort().pop(); + const weeks = []; + while (start <= lastMonday) { + weeks.push(start); + start = addDays(start, 7); + } + let cumulative = entries.filter((e) => e.entry_date < weeks[0]).reduce((s, e) => s + e.minutes, 0); + const weekly = weeks.map((monday) => { + const sunday = addDays(monday, 6); + const own = entries.filter((e) => e.entry_date >= monday && e.entry_date <= sunday); + const minutes = own.reduce((s, e) => s + e.minutes, 0); + const billable = own.filter((e) => e.billable).reduce((s, e) => s + e.minutes, 0); + cumulative += minutes; + return { + week_start: monday, + label: `KW ${isoWeek(monday).week}`, + minutes, + billable_minutes: billable, + cumulative_minutes: monday <= today() ? cumulative : null, + }; + }); + + const minutes = entries.reduce((s, e) => s + e.minutes, 0); + const billable = entries.filter((e) => e.billable).reduce((s, e) => s + e.minutes, 0); + return { + project, + totals: { + minutes, + billable_minutes: billable, + non_billable_minutes: minutes - billable, + amount: r2(entries.reduce((s, e) => s + e.amount + e.fixed_amount, 0)), + }, + weekly, + tasks: group(entries, (e) => e.task_name || '(ohne Task)', (e) => ({ name: e.task_name || '(ohne Task)' }), includeFixed), + team: group(entries, (e) => e.user_id, (e) => ({ user_id: e.user_id, name: e.user_name, color: e.user_color }), includeFixed), + pots: project.pots.map((pot) => { + const own = entries.filter((e) => e.pot_id === pot.id); + return { + ...pot, + tasks: group(own, (e) => e.task_name || '(ohne Task)', (e) => ({ name: e.task_name || '(ohne Task)' }), includeFixed), + team: group(own, (e) => e.user_id, (e) => ({ user_id: e.user_id, name: e.user_name, color: e.user_color }), includeFixed), + }; + }), + }; +} + +/** Kundenreport: alle Projekte eines Kunden mit Budget, Verlauf und Team. */ +router.get('/customer/:id', async (req, res, next) => { + try { + const settings = await getSettings(); + const [[customer]] = await pool.query('SELECT * FROM pulse_customers WHERE id = ?', [Number(req.params.id)]); + if (!customer) return res.status(404).json({ error: 'Kunde nicht gefunden.' }); + const projects = await loadProjects(settings, { where: 'p.customer_id = ?', params: [customer.id] }); + const userIds = await visibleUserIds(req.user); + const entries = await loadEntries(settings, { customerId: customer.id, userIds, from: req.query.from, to: req.query.to }); + + // Monatsreihe ueber die ganze Kundenbeziehung, hoechstens 24 Monate. + const months = [...new Set(entries.map((e) => e.entry_date.slice(0, 7)))].sort(); + const first = months[0] || today().slice(0, 7); + const series = []; + const cursor = new Date(`${first}-01T00:00:00Z`); + const last = today().slice(0, 7); + while (cursor.toISOString().slice(0, 7) <= last && series.length < 36) { + const month = cursor.toISOString().slice(0, 7); + const own = entries.filter((e) => e.entry_date.startsWith(month)); + series.push({ + month, + minutes: own.reduce((s, e) => s + e.minutes, 0), + billable_minutes: own.filter((e) => e.billable).reduce((s, e) => s + e.minutes, 0), + amount: r2(own.reduce((s, e) => s + e.amount + e.fixed_amount, 0)), + }); + cursor.setUTCMonth(cursor.getUTCMonth() + 1); + } + + const minutes = entries.reduce((s, e) => s + e.minutes, 0); + const billable = entries.filter((e) => e.billable).reduce((s, e) => s + e.minutes, 0); + res.json({ + customer, + totals: { + minutes, + billable_minutes: billable, + non_billable_minutes: minutes - billable, + amount: r2(entries.reduce((s, e) => s + e.amount + e.fixed_amount, 0)), + earned: r2(projects.reduce((s, p) => s + p.earned, 0)), + open: r2(projects.reduce((s, p) => s + p.open, 0)), + budget_value: r2(projects.reduce((s, p) => s + p.budget_value, 0)), + }, + series: series.slice(-24), + projects: projects.map((p) => { + const own = entries.filter((e) => e.project_id === p.id); + return { ...buildProjectDetail(p, own), weekly: undefined }; + }), + team: group(entries, (e) => e.user_id, (e) => ({ user_id: e.user_id, name: e.user_name, color: e.user_color }), true), + tasks: group(entries, (e) => e.task_name || '(ohne Task)', (e) => ({ name: e.task_name || '(ohne Task)' }), true), + }); + } catch (err) { + next(err); + } +}); + +export default router; diff --git a/server/src/routes/settings.js b/server/src/routes/settings.js new file mode 100644 index 0000000..239e2e4 --- /dev/null +++ b/server/src/routes/settings.js @@ -0,0 +1,182 @@ +import express from 'express'; +import { requireRole, ROLES } from '../middleware/auth.js'; +import { DEFAULTS, getSettings, saveSetting } from '../lib/settings.js'; +import { ssoConfigured, testConnection } from '../lib/sso.js'; +import { sendDocument } from '../export/render.js'; + +const router = express.Router(); + +// Fuer alle Angemeldeten: was die Oberflaeche zum Rechnen braucht. +router.get('/public', async (req, res, next) => { + try { + const s = await getSettings(); + res.json({ + weekly_hours: Number(s.weekly_hours), + hours_per_day: Number(s.hours_per_day), + budget_warn_percent: Number(s.budget_warn_percent), + budget_warn_days: Number(s.budget_warn_days), + company_name: s.company_name, + project_code_pattern: s.project_code_pattern, + project_name_pattern: s.project_name_pattern, + }); + } catch (err) { + next(err); + } +}); + +router.get('/', requireRole('admin'), async (req, res, next) => { + try { + const s = await getSettings(); + res.json({ ...s, sso: { ...s.sso, client_secret: '', client_secret_set: Boolean(s.sso.client_secret), configured: ssoConfigured(s.sso) } }); + } catch (err) { + next(err); + } +}); + +const text = (v, max) => String(v ?? '').slice(0, max); + +router.put('/', requireRole('admin'), async (req, res, next) => { + try { + const body = req.body || {}; + const current = await getSettings(); + const errors = []; + + const numbers = { + weekly_hours: [1, 80], + hours_per_day: [1, 24], + budget_warn_percent: [1, 100], + budget_warn_days: [0, 365], + }; + for (const [key, [min, max]] of Object.entries(numbers)) { + if (body[key] === undefined) continue; + const value = Number(body[key]); + if (!(value >= min && value <= max)) errors.push(`${key}: Wert zwischen ${min} und ${max}.`); + else await saveSetting(key, value); + } + if (body.company_name !== undefined) await saveSetting('company_name', text(body.company_name, 160)); + if (body.project_code_pattern !== undefined) { + const pattern = text(body.project_code_pattern, 60).trim(); + if (!pattern.includes('{NR}')) errors.push('Das Codemuster braucht {NR}, sonst sind Codes nicht eindeutig.'); + else await saveSetting('project_code_pattern', pattern); + } + if (body.project_name_pattern !== undefined) { + const pattern = text(body.project_name_pattern, 60).trim(); + if (!pattern.includes('{PROJEKT}')) errors.push('Das Namensmuster braucht {PROJEKT}.'); + else await saveSetting('project_name_pattern', pattern); + } + + if (body.export) { + const next_ = { ...current.export }; + for (const key of Object.keys(DEFAULTS.export)) { + if (body.export[key] === undefined) continue; + if (key === 'show_signature') next_[key] = Boolean(body.export[key]); + else if (key === 'logo') { + const logo = body.export.logo; + if (logo === null || logo === '') next_.logo = null; + else if (/^data:image\/(png|jpe?g);base64,/.test(logo) && logo.length < 2_500_000) next_.logo = logo; + else errors.push('Logo: PNG oder JPG bis ca. 1,8 MB.'); + } else if (key === 'accent') { + if (/^#[0-9a-f]{6}$/i.test(body.export.accent)) next_.accent = body.export.accent; + else errors.push('Akzentfarbe ist ungültig.'); + } else next_[key] = text(body.export[key], 300); + } + await saveSetting('export', next_); + } + + if (body.sso) { + const next_ = { ...current.sso }; + for (const key of ['tenant_id', 'client_id', 'redirect_uri', 'button_label']) { + if (body.sso[key] !== undefined) next_[key] = text(body.sso[key], 300).trim(); + } + for (const key of ['enabled', 'local_login']) { + if (body.sso[key] !== undefined) next_[key] = Boolean(body.sso[key]); + } + // Leer = unveraendert lassen; das Secret wird nie ausgeliefert. + if (body.sso.client_secret) next_.client_secret = text(body.sso.client_secret, 500).trim(); + if (body.sso.clear_secret) next_.client_secret = ''; + if (next_.enabled && !ssoConfigured(next_)) errors.push('SSO kann erst aktiviert werden, wenn Tenant, Client-ID, Secret und Redirect-URI gesetzt sind.'); + if (next_.enabled && !ssoConfigured(next_)) next_.enabled = false; + if (!next_.enabled) next_.local_login = true; // ohne SSO braucht es die normale Anmeldung + await saveSetting('sso', next_); + } + + if (errors.length) return res.status(400).json({ error: errors.join(' ') }); + res.json({ ok: true }); + } catch (err) { + next(err); + } +}); + +router.post('/sso/test', requireRole('admin'), async (req, res) => { + try { + const { sso } = await getSettings(); + if (!ssoConfigured(sso)) return res.status(400).json({ error: 'Tenant, Client-ID und Secret fehlen.' }); + const result = await testConnection(sso); + res.json({ ok: true, ...result }); + } catch (err) { + res.status(502).json({ error: `Verbindung fehlgeschlagen: ${err.message}` }); + } +}); + +/** Beispiel-Stundennachweis mit Platzhalterdaten - zeigt Kopf und Fuss. */ +router.get('/export-preview', requireRole('admin'), async (req, res, next) => { + try { + const settings = await getSettings(); + const format = ['pdf', 'xlsx', 'csv'].includes(req.query.format) ? req.query.format : 'pdf'; + const rows = [ + ['2026-09-01', 'Di', 'Projektleitung', 'Jour fixe und Statusbericht', 150], + ['2026-09-02', 'Mi', 'Konzeption', 'Workshop Anforderungen', 420], + ['2026-09-03', 'Do', 'Konzeption', 'Dokumentation Prozessmodell', 390], + ['2026-09-04', 'Fr', 'Projektleitung', '', 240], + ].map(([entry_date, weekday, task_name, note, minutes]) => ({ + entry_date, + weekday, + project_display: 'Musterkunde - Beispielprojekt', + task_name, + note, + minutes, + duration: `${Math.floor(minutes / 60)}:${String(minutes % 60).padStart(2, '0')}`, + })); + const total = rows.reduce((s, r) => s + r.minutes, 0); + await sendDocument( + res, + { + title: settings.export.title || 'Stundennachweis', + subtitle: 'Vorschau', + filename: 'Vorschau Stundennachweis', + info: [ + ['Berater', 'Max Mustermann'], + ['Kunde', 'Musterkunde GmbH'], + ['Zeitraum', '01.09.2026 – 30.09.2026'], + ['Projekt', 'Musterkunde - Beispielprojekt'], + ], + kpis: [ + ['Gesamtstunden', (total / 60).toLocaleString('de-DE', { minimumFractionDigits: 2 })], + ['Tage mit Leistung', '4'], + ], + tables: [ + { + columns: [ + { key: 'entry_date', label: 'Datum', type: 'date', width: 58 }, + { key: 'weekday', label: 'Tag', width: 26 }, + { key: 'project_display', label: 'Projekt', width: 120 }, + { key: 'task_name', label: 'Task', width: 80 }, + { key: 'note', label: 'Tätigkeit', type: 'text' }, + { key: 'duration', label: 'Dauer', align: 'right', width: 38 }, + { key: 'minutes', label: 'Stunden', type: 'hours', align: 'right', width: 46 }, + ], + rows, + totals: { weekday: 'Summe', minutes: total }, + }, + ], + signature: true, + }, + format, + settings + ); + } catch (err) { + next(err); + } +}); + +export default router; diff --git a/server/src/routes/users.js b/server/src/routes/users.js new file mode 100644 index 0000000..7f0e3fb --- /dev/null +++ b/server/src/routes/users.js @@ -0,0 +1,229 @@ +import express from 'express'; +import bcrypt from 'bcryptjs'; +import { pool } from '../db.js'; +import { ROLES, USER_COLUMNS, publicUser, requireRole } from '../middleware/auth.js'; +import { visibleUserIds } from '../lib/access.js'; + +const router = express.Router(); + +/* + * Wer darf wen verwalten? + * admin alle + * management alle ausser Administratoren, vergibt hoechstens "management" + * lead nur Mitarbeiter (Rolle user) im eigenen Team, vergibt nur "user" + */ +function assignableRoles(actor) { + if (actor.role === 'admin') return ROLES; + if (actor.role === 'management') return ['user', 'lead', 'management']; + return ['user']; +} + +async function canManage(actor, target) { + if (actor.role === 'admin') return true; + if (actor.role === 'management') return target.role !== 'admin'; + if (actor.role === 'lead') { + if (target.role !== 'user') return false; + const team = await visibleUserIds(actor); + return team.includes(target.id); + } + return false; +} + +async function loadUser(id) { + const [rows] = await pool.query(`SELECT ${USER_COLUMNS} FROM pulse_users WHERE id = ? LIMIT 1`, [id]); + return rows[0] || null; +} + +/** Verhindert, dass jemand (indirekt) sein eigener Vorgesetzter wird. */ +async function createsCycle(userId, managerId) { + if (!managerId || !userId) return false; + const [rows] = await pool.query('SELECT id, manager_id FROM pulse_users'); + const parent = new Map(rows.map((r) => [r.id, r.manager_id])); + let current = managerId; + for (let guard = 0; current && guard < 1000; guard++) { + if (current === userId) return true; + current = parent.get(current); + } + return false; +} + +async function activeAdmins(excludeId) { + const [rows] = await pool.query( + "SELECT COUNT(*) AS n FROM pulse_users WHERE role = 'admin' AND is_active = 1 AND id <> ?", + [excludeId] + ); + return Number(rows[0].n); +} + +function normalize(body, { creating }) { + const errors = []; + const out = {}; + const str = (v, max) => (v == null ? null : String(v).trim().slice(0, max) || null); + + if (creating || body.name !== undefined) { + out.name = str(body.name, 160); + if (!out.name) errors.push('Name fehlt.'); + } + if (creating || body.username !== undefined) { + out.username = str(body.username, 80); + if (!out.username || !/^[a-zA-Z0-9._@-]{2,80}$/.test(out.username)) errors.push('Benutzername: 2-80 Zeichen, keine Leerzeichen.'); + } + if (body.email !== undefined) { + out.email = str(body.email, 190)?.toLowerCase() ?? null; + if (out.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(out.email)) errors.push('E-Mail-Adresse ist ungültig.'); + } + if (body.role !== undefined || creating) { + out.role = body.role || 'user'; + if (!ROLES.includes(out.role)) errors.push('Unbekannte Rolle.'); + } + if (body.manager_id !== undefined) out.manager_id = body.manager_id ? Number(body.manager_id) : null; + if (body.job_title !== undefined) out.job_title = str(body.job_title, 120); + if (body.is_external !== undefined) out.is_external = body.is_external ? 1 : 0; + if (body.company !== undefined) out.company = str(body.company, 160); + if (body.color !== undefined) out.color = /^#[0-9a-f]{6}$/i.test(body.color || '') ? body.color : null; + if (body.is_active !== undefined) out.is_active = body.is_active ? 1 : 0; + if (body.weekly_hours !== undefined) { + out.weekly_hours = body.weekly_hours === '' || body.weekly_hours == null ? null : Number(body.weekly_hours); + if (out.weekly_hours != null && !(out.weekly_hours >= 0 && out.weekly_hours <= 80)) errors.push('Wochenstunden: 0-80.'); + } + return { out, errors }; +} + +// Verzeichnis fuer alle: Namen, Farben und Hierarchie (ohne Kontaktdaten). +router.get('/directory', async (req, res, next) => { + try { + const [rows] = await pool.query( + 'SELECT id, name, color, role, manager_id, is_external, company, job_title, is_active FROM pulse_users ORDER BY name' + ); + res.json(rows.map((r) => ({ ...r, is_external: Boolean(r.is_external), is_active: Boolean(r.is_active) }))); + } catch (err) { + next(err); + } +}); + +router.get('/', requireRole('lead'), async (req, res, next) => { + try { + const [rows] = await pool.query( + `SELECT ${USER_COLUMNS.split(',').map((c) => `u.${c.trim()}`).join(', ')}, + (SELECT COUNT(*) FROM pulse_time_entries e WHERE e.user_id = u.id) AS entry_count, + (SELECT COUNT(*) FROM pulse_project_members m WHERE m.user_id = u.id) AS project_count + FROM pulse_users u ORDER BY u.name` + ); + const team = await visibleUserIds(req.user); + const result = []; + for (const row of rows) { + result.push({ + ...publicUser(row), + entry_count: Number(row.entry_count), + project_count: Number(row.project_count), + can_manage: await canManage(req.user, row), + in_team: team === null || team.includes(row.id), + }); + } + res.json({ users: result, assignable_roles: assignableRoles(req.user) }); + } catch (err) { + next(err); + } +}); + +router.post('/', requireRole('lead'), async (req, res, next) => { + const { out, errors } = normalize(req.body || {}, { creating: true }); + const password = String(req.body?.password || ''); + if (password.length < 8) errors.push('Passwort: mindestens 8 Zeichen.'); + if (!assignableRoles(req.user).includes(out.role)) errors.push('Diese Rolle darfst du nicht vergeben.'); + if (req.user.role === 'lead') { + const team = await visibleUserIds(req.user); + if (!out.manager_id || !team.includes(out.manager_id)) out.manager_id = req.user.id; + } + if (errors.length) return res.status(400).json({ error: errors.join(' ') }); + + try { + const hash = await bcrypt.hash(password, 12); + const fields = { ...out, password_hash: hash, source: 'local' }; + const keys = Object.keys(fields); + const [result] = await pool.query( + `INSERT INTO pulse_users (${keys.join(', ')}) VALUES (${keys.map(() => '?').join(', ')})`, + keys.map((k) => fields[k]) + ); + res.status(201).json(publicUser(await loadUser(result.insertId))); + } catch (err) { + if (err.code === 'ER_DUP_ENTRY') return res.status(409).json({ error: 'Benutzername oder E-Mail ist schon vergeben.' }); + next(err); + } +}); + +router.put('/:id', requireRole('lead'), async (req, res, next) => { + try { + const target = await loadUser(Number(req.params.id)); + if (!target) return res.status(404).json({ error: 'Benutzer nicht gefunden.' }); + if (!(await canManage(req.user, target))) return res.status(403).json({ error: 'Diesen Benutzer darfst du nicht bearbeiten.' }); + + const { out, errors } = normalize(req.body || {}, { creating: false }); + if (out.role && out.role !== target.role && !assignableRoles(req.user).includes(out.role)) { + errors.push('Diese Rolle darfst du nicht vergeben.'); + } + // SSO-Benutzer: Stammdaten kommen aus dem AD. + if (target.source === 'sso') { + delete out.username; + delete out.email; + } + if (out.manager_id !== undefined && (await createsCycle(target.id, out.manager_id))) { + errors.push('Die Hierarchie darf keinen Kreis bilden.'); + } + if (req.user.role === 'lead' && out.manager_id !== undefined) { + const team = await visibleUserIds(req.user); + if (!out.manager_id || !team.includes(out.manager_id)) errors.push('Vorgesetzter muss aus deinem Team kommen.'); + } + if (target.id === req.user.id && (out.is_active === 0 || (out.role && out.role !== target.role))) { + errors.push('Die eigene Rolle und Aktivierung können nicht geändert werden.'); + } + if (target.role === 'admin' && ((out.role && out.role !== 'admin') || out.is_active === 0) && (await activeAdmins(target.id)) === 0) { + errors.push('Der letzte aktive Administrator kann nicht herabgestuft oder deaktiviert werden.'); + } + if (errors.length) return res.status(400).json({ error: errors.join(' ') }); + + if (req.body?.password) { + if (String(req.body.password).length < 8) return res.status(400).json({ error: 'Passwort: mindestens 8 Zeichen.' }); + out.password_hash = await bcrypt.hash(String(req.body.password), 12); + } + const keys = Object.keys(out); + if (keys.length) { + await pool.query(`UPDATE pulse_users SET ${keys.map((k) => `${k} = ?`).join(', ')} WHERE id = ?`, [ + ...keys.map((k) => out[k]), + target.id, + ]); + } + res.json(publicUser(await loadUser(target.id))); + } catch (err) { + if (err.code === 'ER_DUP_ENTRY') return res.status(409).json({ error: 'Benutzername oder E-Mail ist schon vergeben.' }); + next(err); + } +}); + +router.delete('/:id', requireRole('lead'), async (req, res, next) => { + try { + const target = await loadUser(Number(req.params.id)); + if (!target) return res.status(404).json({ error: 'Benutzer nicht gefunden.' }); + if (target.id === req.user.id) return res.status(400).json({ error: 'Das eigene Konto kann nicht gelöscht werden.' }); + if (!(await canManage(req.user, target))) return res.status(403).json({ error: 'Diesen Benutzer darfst du nicht löschen.' }); + if (target.source !== 'local') { + return res.status(400).json({ error: 'SSO-Benutzer werden über das AD verwaltet - hier nur deaktivieren.' }); + } + if (target.role === 'admin' && (await activeAdmins(target.id)) === 0) { + return res.status(400).json({ error: 'Der letzte Administrator kann nicht gelöscht werden.' }); + } + const [[{ n }]] = await pool.query('SELECT COUNT(*) AS n FROM pulse_time_entries WHERE user_id = ?', [target.id]); + if (Number(n) > 0) { + return res.status(409).json({ + error: `Der Benutzer hat ${n} Buchungen. Damit Reports stimmen, bitte deaktivieren statt löschen.`, + }); + } + await pool.query('UPDATE pulse_users SET manager_id = ? WHERE manager_id = ?', [target.manager_id, target.id]); + await pool.query('DELETE FROM pulse_users WHERE id = ?', [target.id]); + res.json({ ok: true }); + } catch (err) { + next(err); + } +}); + +export default router; diff --git a/server/src/schema.sql b/server/src/schema.sql new file mode 100644 index 0000000..de5d17a --- /dev/null +++ b/server/src/schema.sql @@ -0,0 +1,147 @@ +-- perinco Pulse - Datenbankschema (MariaDB) +-- Wird bei jedem Start ausgefuehrt; alle Statements sind idempotent. + +CREATE TABLE IF NOT EXISTS pulse_users ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT, + username VARCHAR(80) NOT NULL, + email VARCHAR(190) NULL, + name VARCHAR(160) NOT NULL, + password_hash VARCHAR(100) NULL, + role ENUM('user','lead','management','admin') NOT NULL DEFAULT 'user', + manager_id INT UNSIGNED NULL, + job_title VARCHAR(120) NULL, + is_external TINYINT(1) NOT NULL DEFAULT 0, + company VARCHAR(160) NULL, + source ENUM('local','sso') NOT NULL DEFAULT 'local', + external_id VARCHAR(64) NULL, + weekly_hours DECIMAL(5,2) NULL, + color VARCHAR(7) NULL, + theme VARCHAR(20) NULL, + theme_mode VARCHAR(10) NULL, + is_active TINYINT(1) NOT NULL DEFAULT 1, + last_login_at DATETIME NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uq_pulse_users_username (username), + UNIQUE KEY uq_pulse_users_email (email), + UNIQUE KEY uq_pulse_users_external (external_id), + KEY idx_pulse_users_manager (manager_id), + CONSTRAINT fk_pulse_users_manager FOREIGN KEY (manager_id) REFERENCES pulse_users (id) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS pulse_customers ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT, + name VARCHAR(190) NOT NULL, + short_name VARCHAR(16) NOT NULL, + contact_person VARCHAR(160) NULL, + email VARCHAR(190) NULL, + phone VARCHAR(60) NULL, + street VARCHAR(190) NULL, + zip VARCHAR(20) NULL, + city VARCHAR(120) NULL, + country VARCHAR(80) NULL, + notes TEXT NULL, + color VARCHAR(7) NULL, + is_active TINYINT(1) NOT NULL DEFAULT 1, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uq_pulse_customers_short (short_name) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS pulse_projects ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT, + customer_id INT UNSIGNED NOT NULL, + code VARCHAR(40) NOT NULL, + seq INT UNSIGNED NOT NULL DEFAULT 0, + name VARCHAR(190) NOT NULL, + type ENUM('billable','non_billable','fixed') NOT NULL DEFAULT 'billable', + billing_unit ENUM('hour','day') NOT NULL DEFAULT 'hour', + start_date DATE NOT NULL, + end_date DATE NULL, + tags TEXT NULL, + notes TEXT NULL, + owner_id INT UNSIGNED NULL, + is_active TINYINT(1) NOT NULL DEFAULT 1, + created_by INT UNSIGNED NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uq_pulse_projects_code (code), + KEY idx_pulse_projects_customer (customer_id), + CONSTRAINT fk_pulse_projects_customer FOREIGN KEY (customer_id) REFERENCES pulse_customers (id) ON DELETE RESTRICT, + CONSTRAINT fk_pulse_projects_owner FOREIGN KEY (owner_id) REFERENCES pulse_users (id) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Budgettopf. rate ist immer in der Abrechnungseinheit des Projekts (Stunde +-- oder Tag). budget_hours ist das buchbare Volumen: bei Betrag + Satz +-- abgeleitet, sonst direkt eingegeben. NULL = unbegrenzt. +CREATE TABLE IF NOT EXISTS pulse_budget_pots ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT, + project_id INT UNSIGNED NOT NULL, + label VARCHAR(160) NOT NULL, + valid_from DATE NOT NULL, + valid_to DATE NOT NULL, + budget_amount DECIMAL(12,2) NULL, + rate DECIMAL(10,2) NULL, + budget_hours DECIMAL(10,2) NULL, + notes VARCHAR(500) NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + KEY idx_pulse_pots_project (project_id, valid_from, valid_to), + CONSTRAINT fk_pulse_pots_project FOREIGN KEY (project_id) REFERENCES pulse_projects (id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS pulse_tasks ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT, + project_id INT UNSIGNED NOT NULL, + name VARCHAR(160) NOT NULL, + billable TINYINT(1) NOT NULL DEFAULT 1, + is_active TINYINT(1) NOT NULL DEFAULT 1, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + KEY idx_pulse_tasks_project (project_id), + CONSTRAINT fk_pulse_tasks_project FOREIGN KEY (project_id) REFERENCES pulse_projects (id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS pulse_project_members ( + project_id INT UNSIGNED NOT NULL, + user_id INT UNSIGNED NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (project_id, user_id), + KEY idx_pulse_members_user (user_id), + CONSTRAINT fk_pulse_members_project FOREIGN KEY (project_id) REFERENCES pulse_projects (id) ON DELETE CASCADE, + CONSTRAINT fk_pulse_members_user FOREIGN KEY (user_id) REFERENCES pulse_users (id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS pulse_time_entries ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT, + user_id INT UNSIGNED NOT NULL, + project_id INT UNSIGNED NOT NULL, + task_id INT UNSIGNED NULL, + pot_id INT UNSIGNED NULL, + entry_date DATE NOT NULL, + minutes INT UNSIGNED NOT NULL, + note VARCHAR(1000) NULL, + billable TINYINT(1) NOT NULL DEFAULT 0, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + KEY idx_pulse_entries_user_date (user_id, entry_date), + KEY idx_pulse_entries_project_date (project_id, entry_date), + KEY idx_pulse_entries_pot (pot_id), + KEY idx_pulse_entries_date (entry_date), + CONSTRAINT fk_pulse_entries_user FOREIGN KEY (user_id) REFERENCES pulse_users (id) ON DELETE RESTRICT, + CONSTRAINT fk_pulse_entries_project FOREIGN KEY (project_id) REFERENCES pulse_projects (id) ON DELETE RESTRICT, + CONSTRAINT fk_pulse_entries_task FOREIGN KEY (task_id) REFERENCES pulse_tasks (id) ON DELETE SET NULL, + CONSTRAINT fk_pulse_entries_pot FOREIGN KEY (pot_id) REFERENCES pulse_budget_pots (id) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS pulse_settings ( + k VARCHAR(80) NOT NULL, + v MEDIUMTEXT NULL, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (k) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/server/src/server.js b/server/src/server.js new file mode 100644 index 0000000..e85d872 --- /dev/null +++ b/server/src/server.js @@ -0,0 +1,72 @@ +import 'dotenv/config'; +import express from 'express'; +import cors from 'cors'; +import { initDatabase, pool } from './db.js'; +import { requireAuth, requireRole } from './middleware/auth.js'; +import authRoutes from './routes/auth.js'; +import userRoutes from './routes/users.js'; +import customerRoutes from './routes/customers.js'; +import projectRoutes from './routes/projects.js'; +import entryRoutes from './routes/entries.js'; +import dashboardRoutes from './routes/dashboard.js'; +import reportRoutes from './routes/reports.js'; +import exportRoutes from './routes/exports.js'; +import settingsRoutes from './routes/settings.js'; +import directoryRoutes from './routes/directory.js'; + +const app = express(); +const PORT = Number(process.env.PORT || 5010); +const HOST = process.env.HOST || '127.0.0.1'; + +// Apache steht davor - fuer req.ip (Login-Bremse) die echte Client-IP nehmen. +app.set('trust proxy', 'loopback'); +app.disable('x-powered-by'); +// Logos kommen als Data-URL. +app.use(express.json({ limit: '4mb' })); + +app.use( + cors({ + origin: (process.env.CORS_ORIGINS || 'http://localhost:3110,http://127.0.0.1:3110').split(','), + }) +); + +app.get('/api/health', async (req, res) => { + try { + await pool.query('SELECT 1'); + res.json({ ok: true, db: 'up' }); + } catch { + res.status(503).json({ ok: false, db: 'down' }); + } +}); + +app.use('/api/auth', authRoutes); +app.use('/api/users', requireAuth, userRoutes); +app.use('/api/customers', requireAuth, requireRole('lead'), customerRoutes); +app.use('/api/projects', requireAuth, projectRoutes); +app.use('/api/entries', requireAuth, entryRoutes); +app.use('/api/dashboard', requireAuth, requireRole('lead'), dashboardRoutes); +app.use('/api/reports', requireAuth, requireRole('lead'), reportRoutes); +app.use('/api/exports', requireAuth, exportRoutes); +app.use('/api/settings', requireAuth, settingsRoutes); +app.use('/api/directory', requireAuth, requireRole('management'), directoryRoutes); + +app.use('/api', (req, res) => res.status(404).json({ error: 'Endpunkt nicht gefunden.' })); + +app.use((err, req, res, next) => { + if (res.headersSent) return next(err); + if (err?.type === 'entity.parse.failed') return res.status(400).json({ error: 'Ungültige Anfrage.' }); + if (err?.type === 'entity.too.large') return res.status(413).json({ error: 'Die Anfrage ist zu groß.' }); + if (err?.code === 'ER_ROW_IS_REFERENCED_2') { + return res.status(409).json({ error: 'Der Datensatz wird noch verwendet und kann nicht gelöscht werden.' }); + } + console.error('[Pulse] Unbehandelter Fehler:', err); + res.status(500).json({ error: 'Interner Serverfehler.' }); +}); + +try { + await initDatabase(); + app.listen(PORT, HOST, () => console.log(`[Pulse] Backend läuft auf http://${HOST}:${PORT}`)); +} catch (err) { + console.error('[Pulse] Start fehlgeschlagen:', err); + process.exit(1); +} diff --git a/server/test/sso-filter.test.mjs b/server/test/sso-filter.test.mjs new file mode 100644 index 0000000..7b7c034 --- /dev/null +++ b/server/test/sso-filter.test.mjs @@ -0,0 +1,49 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { isEligible } from '../src/lib/sso.js'; + +const user = (mail, surname, extra = {}) => ({ + mail, + surname, + userType: 'Member', + accountEnabled: true, + assignedLicenses: [{ skuId: 'x' }], + ...extra, +}); + +test('Mitarbeiterkonten beider Domains werden angezeigt', () => { + assert.equal(isEligible(user('sascha.wolf@perinco.de', 'Wolf')), true); + assert.equal(isEligible(user('Sascha.Wolf@PERINCO.de', 'Wolf')), true); + assert.equal(isEligible(user('kai.schwickert@icon-experts.de', 'Schwickert')), true); + assert.equal(isEligible(user('timo.grote-aksu@perinco.de', 'Grote-Aksu')), true); + assert.equal(isEligible(user('niklas.muelln@perinco.de', 'Mülln')), true); + assert.equal(isEligible(user('rene.kleinau@perinco.de', 'Kleinau', { givenName: 'René' })), true); + // Tippfehler im Vornamen (AD: "Chrisitan") schadet nicht. + assert.equal(isEligible(user('christian.brix@perinco.de', 'Brix', { givenName: 'Chrisitan' })), true); + assert.equal(isEligible(user('merle.schwegler@perinco.de', 'Schwegler', { givenName: 'Merle Sophie' })), true); +}); + +test('Admin-, Dienst- und Funktionskonten werden ignoriert', () => { + assert.equal(isEligible(user('adm.sascha.wolf@perinco.de', 'Wolf')), false); + assert.equal(isEligible(user('sa.hrworks@perinco.de', 'Account', { givenName: 'Service' })), false); + assert.equal(isEligible(user('srv.pa@perinco.de', 'Flow', { givenName: 'SRV' })), false); + assert.equal(isEligible(user('srv.flow@perinco.de', 'Flow')), false); + assert.equal(isEligible(user('info@perinco.de', 'Info')), false); + assert.equal(isEligible(user('max.muster@perinco.de', undefined)), false); +}); + +test('Fremde Domains und falsche Formate werden ignoriert', () => { + for (const mail of ['sascha.wolf@perinco.com', 'sascha.wolf@sub.perinco.de', 'sascha.wolf2@perinco.de', 'sascha..wolf@perinco.de', '']) { + assert.equal(isEligible(user(mail, 'Wolf')), false, mail); + } +}); + +test('Freigegebene Postfaecher, Gaeste und Konten ohne Lizenz werden ignoriert', () => { + assert.equal(isEligible(user('philip.daum@perinco.de', 'Daum', { accountEnabled: false })), false); + assert.equal(isEligible(user('lisa.gast@perinco.de', 'Gast', { userType: 'Guest' })), false); + assert.equal(isEligible(user('sven.krueger@perinco.de', 'Krüger', { assignedLicenses: [] })), false); +}); + +test('Ohne mail zaehlt der UPN', () => { + assert.equal(isEligible({ userPrincipalName: 'jonas.korn@perinco.de', surname: 'Korn', accountEnabled: true, assignedLicenses: [{}] }), true); +}); diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..f9ade93 --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,84 @@ +import React, { useEffect, useState } from 'react'; +import { Sidebar } from './components/layout/Sidebar'; +import { Header } from './components/layout/Header'; +import { MobileNav } from './components/layout/MobileNav'; +import { LoginScreen } from './components/layout/LoginScreen'; +import { PulseMark } from './components/layout/PulseMark'; +import { AccountDialog } from './components/settings/AccountDialog'; +import { Dashboard } from './components/dashboard/Dashboard'; +import { Booking } from './components/booking/Booking'; +import { Projects } from './components/projects/Projects'; +import { Team } from './components/team/Team'; +import { Customers } from './components/customers/Customers'; +import { Reports } from './components/reports/Reports'; +import { Settings } from './components/settings/Settings'; +import { useAuth } from './lib/auth'; +import { useIsMobile, usePersistentState } from './lib/hooks'; +import { RouterContext, useHashRoute } from './lib/router'; +import { homeFor, VIEW_LABELS, viewsFor, type ViewKey } from './types'; + +const SUBTITLES: Record = { + dashboard: 'Gebuchte Zeiten, Auslastung und letzte Buchungen', + buchung: 'Zeiten erfassen, bearbeiten und exportieren', + projekte: 'Projekte, Budgettöpfe und Tasks', + team: 'Hierarchie, Benutzer und externe Mitarbeiter', + kunden: 'Kundenstamm, Projekte und Umsatz', + reports: 'Auswertungen nach Kunde, Projekt, Task und Mitarbeiter', + einstellungen: 'Arbeitszeit, Export, SSO und Benutzerverwaltung', +}; + +const Splash: React.FC = () => ( +
+ perinco + +

Lade Pulse…

+
+); + +const App: React.FC = () => { + const { user, isLoading } = useAuth(); + const { route, navigate } = useHashRoute(); + const isMobile = useIsMobile(); + const [collapsed, setCollapsed] = usePersistentState('pulse_sidebar_collapsed', false); + const [accountOpen, setAccountOpen] = useState(false); + + const allowed = user ? viewsFor(user.role) : []; + const view: ViewKey | null = user ? (route.view && allowed.includes(route.view) ? route.view : homeFor(user.role)) : null; + + // Unbekannte oder nicht erlaubte Adresse: still auf die Startseite. + useEffect(() => { + if (user && view && route.view !== view) navigate(view, true); + }, [user, view, route.view, navigate]); + + useEffect(() => { + if (view) document.title = `${VIEW_LABELS[view]} · Pulse`; + }, [view]); + + if (isLoading) return ; + if (!user) return ; + if (!view) return ; + + return ( + +
+ navigate(v)} collapsed={collapsed} onAccount={() => setAccountOpen(true)} /> +
+
setCollapsed((c) => !c)} isMobile={isMobile} /> +
+ {view === 'dashboard' && } + {view === 'buchung' && } + {view === 'projekte' && } + {view === 'team' && } + {view === 'kunden' && } + {view === 'reports' && } + {view === 'einstellungen' && } +
+
+ navigate(v)} onAccount={() => setAccountOpen(true)} /> + {accountOpen && setAccountOpen(false)} />} +
+
+ ); +}; + +export default App; diff --git a/src/components/Toast.tsx b/src/components/Toast.tsx new file mode 100644 index 0000000..33353f7 --- /dev/null +++ b/src/components/Toast.tsx @@ -0,0 +1,78 @@ +import React, { createContext, useCallback, useContext, useMemo, useState } from 'react'; + +type ToastKind = 'success' | 'error' | 'info'; + +interface Toast { + id: number; + message: string; + kind: ToastKind; +} + +interface ToastContextValue { + addToast: (message: string, kind?: ToastKind) => void; +} + +const ToastContext = createContext(null); + +let nextId = 1; + +const STYLES: Record = { + success: 'border-brand-200 bg-brand-50 text-brand-800', + error: 'border-red-200 bg-red-50 text-red-700', + info: 'border-ink-200 bg-surface text-ink-800', +}; + +const ICONS: Record = { + success: 'M5 13l4 4L19 7', + error: 'M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z', + info: 'M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z', +}; + +export const ToastProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const [toasts, setToasts] = useState([]); + + const addToast = useCallback((message: string, kind: ToastKind = 'info') => { + const id = nextId++; + setToasts((prev) => [...prev, { id, message, kind }]); + window.setTimeout(() => { + setToasts((prev) => prev.filter((t) => t.id !== id)); + }, 4500); + }, []); + + const value = useMemo(() => ({ addToast }), [addToast]); + + return ( + + {children} +
+ {toasts.map((toast) => ( +
+ + + +

{toast.message}

+ +
+ ))} +
+
+ ); +}; + +export function useToast(): ToastContextValue { + const ctx = useContext(ToastContext); + if (!ctx) throw new Error('useToast muss innerhalb von verwendet werden.'); + return ctx; +} diff --git a/src/components/booking/Booking.tsx b/src/components/booking/Booking.tsx new file mode 100644 index 0000000..4809254 --- /dev/null +++ b/src/components/booking/Booking.tsx @@ -0,0 +1,305 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { Avatar, Button, Card, ErrorBox, Segmented, Spinner, inputClass } from '../ui/controls'; +import { Icon } from '../ui/Icon'; +import { Donut } from '../ui/charts'; +import { EntryModal } from './EntryModal'; +import { ExportDialog } from './ExportDialog'; +import { WeekView } from './WeekView'; +import { MonthView } from './MonthView'; +import { YearView } from './YearView'; +import { useAuth } from '../../lib/auth'; +import { entriesApi, projectsApi, usersApi } from '../../lib/api'; +import { useIsMobile, useLoader, usePersistentState, useCountUp } from '../../lib/hooks'; +import { + MONTHS, + addDays, + addMonths, + formatDate, + formatDuration, + isoWeek, + mondayOf, + monthEnd, + monthStart, + todayIso, + weekdayIndex, +} from '../../lib/format'; +import { visibleTeam } from '../team/hierarchy'; +import { atLeast, type DirectoryUser, type Project, type TimeEntry } from '../../types'; + +export type BookingMode = 'week' | 'month' | 'year'; + +function rangeFor(mode: BookingMode, anchor: string) { + if (mode === 'week') { + const from = mondayOf(anchor); + return { from, to: addDays(from, 6) }; + } + if (mode === 'month') return { from: monthStart(anchor), to: monthEnd(anchor) }; + return { from: `${anchor.slice(0, 4)}-01-01`, to: `${anchor.slice(0, 4)}-12-31` }; +} + +/** Arbeitstage Mo-Fr im Intervall, fuer das Soll. */ +function workdays(from: string, to: string) { + let count = 0; + for (let d = from; d <= to; d = addDays(d, 1)) if (weekdayIndex(d) < 5) count++; + return count; +} + +export const Booking: React.FC = () => { + const { user, settings } = useAuth(); + const isMobile = useIsMobile(); + const [mode, setMode] = usePersistentState('pulse_booking_mode', 'week'); + const [anchor, setAnchor] = useState(todayIso()); + const [modal, setModal] = useState<{ date: string; entry?: TimeEntry | null } | null>(null); + const [exportOpen, setExportOpen] = useState(false); + const [viewUserId, setViewUserId] = useState(user!.id); + const isLead = atLeast(user, 'lead'); + const readOnly = viewUserId !== user!.id; + + const range = rangeFor(mode, anchor); + const entries = useLoader(() => entriesApi.list(range.from, range.to, viewUserId), [range.from, range.to, viewUserId]); + const projects = useLoader(() => projectsApi.list('mine'), []); + const [team, setTeam] = useState([]); + const [allProjects, setAllProjects] = useState(null); + + useEffect(() => { + if (!isLead) return; + usersApi + .directory() + .then((list) => setTeam(visibleTeam(list, user!))) + .catch(() => undefined); + }, [isLead, user]); + + const list = entries.data ?? []; + const total = list.reduce((s, e) => s + e.minutes, 0); + const billable = list.filter((e) => e.billable).reduce((s, e) => s + e.minutes, 0); + const viewedUser = team.find((t) => t.id === viewUserId); + const weekly = (readOnly ? null : user!.weekly_hours) ?? settings.weekly_hours; + const today = todayIso(); + // Soll bis heute (bzw. ganzer Zeitraum, wenn er vorbei ist). + const targetTo = range.to < today ? range.to : today < range.from ? range.from : today; + const target = today < range.from ? 0 : (weekly / 5) * workdays(range.from, targetTo) * 60; + + const shift = (step: number) => { + if (mode === 'week') setAnchor(addDays(anchor, 7 * step)); + else if (mode === 'month') setAnchor(addMonths(anchor, step)); + else setAnchor(`${Number(anchor.slice(0, 4)) + step}-${anchor.slice(5)}`); + }; + + const label = + mode === 'week' + ? `KW ${isoWeek(range.from)} · ${formatDate(range.from).slice(0, 6)} – ${formatDate(range.to)}` + : mode === 'month' + ? `${MONTHS[Number(anchor.slice(5, 7)) - 1]} ${anchor.slice(0, 4)}` + : anchor.slice(0, 4); + + const openNew = (date?: string) => { + if (readOnly) return; + setModal({ date: date ?? (today >= range.from && today <= range.to ? today : range.from) }); + }; + + const openExport = () => { + if (isLead && !allProjects) projectsApi.list().then(setAllProjects).catch(() => undefined); + setExportOpen(true); + }; + + // Tastenkuerzel: N = neue Buchung, Pfeile = blaettern. + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (modal || exportOpen) return; + const target = e.target as HTMLElement; + if (['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName)) return; + if (e.key === 'n' || e.key === '+') { + e.preventDefault(); + openNew(); + } + if (e.key === 'ArrowLeft') shift(-1); + if (e.key === 'ArrowRight') shift(1); + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }); + + const warnings = useMemo( + () => (projects.data ?? []).flatMap((p) => p.pots.filter((pot) => pot.warning && pot.status !== 'expired' && pot.valid_to >= today).map((pot) => ({ project: p, pot }))), + [projects.data, today] + ); + + return ( +
+ {/* Werkzeugleiste */} +
+
+ + + +
+

{label}

+ +
+ + {isLead && team.length > 1 && ( + + )} + + {!isMobile && !readOnly && ( + + )} +
+
+ + {readOnly && viewedUser && ( +
+ + Du siehst die Zeiten von {viewedUser.name} (nur lesen). + +
+ )} + + {!readOnly && warnings.length > 0 && ( +
+ {warnings.slice(0, 3).map(({ project, pot }) => ( +
+ + + {project.display_name} – {pot.warning} + {pot.remaining_hours != null && ` Noch ${pot.remaining_hours.toLocaleString('de-DE')} h frei.`} + +
+ ))} +
+ )} + + + + {entries.error && } + {entries.loading && !entries.data ? ( + + ) : ( +
+ {mode === 'week' && ( + !readOnly && setModal({ date: entry.entry_date, entry })} /> + )} + {mode === 'month' && ( + { + setAnchor(date); + setMode('week'); + }} + onAdd={openNew} + readOnly={readOnly} + /> + )} + {mode === 'year' && ( + { + setAnchor(`${month}-01`); + setMode('month'); + }} + /> + )} +
+ )} + + {isMobile && !readOnly && ( + + )} + + {modal && projects.data && ( + setModal(null)} onSaved={() => { entries.reload(); projects.reload(); }} /> + )} + {exportOpen && ( + setExportOpen(false)} + /> + )} +
+ ); +}; + +const SummaryStrip: React.FC<{ total: number; billable: number; target: number; weekly: number; mode: BookingMode; count: number }> = ({ + total, + billable, + target, + weekly, + mode, + count, +}) => { + const shown = useCountUp(total); + const percent = target > 0 ? Math.round((total / target) * 100) : 0; + const diff = total - target; + return ( + + +
+

+ Gebucht {mode === 'week' ? 'diese Woche' : mode === 'month' ? 'im Monat' : 'im Jahr'} +

+

+ {formatDuration(shown)} + h +

+
+
+ + = 0 ? '+' : '−'}${formatDuration(Math.abs(diff))} h`} tone={diff >= 0 ? 'good' : 'bad'} /> + +
+
+ ); +}; + +const Mini: React.FC<{ label: string; value: string; hint?: string; tone?: 'good' | 'bad' }> = ({ label, value, hint, tone }) => ( +
+

{label}

+

{value}

+ {hint &&

{hint}

} +
+); diff --git a/src/components/booking/EntryModal.tsx b/src/components/booking/EntryModal.tsx new file mode 100644 index 0000000..c68d357 --- /dev/null +++ b/src/components/booking/EntryModal.tsx @@ -0,0 +1,319 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { Modal, ConfirmDialog } from '../ui/Modal'; +import { BudgetBar, Button, Field, inputClass } from '../ui/controls'; +import { Icon } from '../ui/Icon'; +import { useAuth } from '../../lib/auth'; +import { entriesApi } from '../../lib/api'; +import { formatDate, formatDuration, parseDuration } from '../../lib/format'; +import { useToast } from '../Toast'; +import { checkPot } from './budget'; +import { PROJECT_TYPE_LABELS, type Project, type TimeEntry } from '../../types'; + +interface Props { + projects: Project[]; + date: string; + entry?: TimeEntry | null; + onClose: () => void; + onSaved: () => void; +} + +/** Reihenfolge wie in der Auswahlliste: nach Kunde gruppiert, innerhalb in API-Reihenfolge. */ +function inListOrder(projects: Project[]) { + const map = new Map(); + for (const p of projects) map.set(p.customer.name, [...(map.get(p.customer.name) || []), p]); + return [...map.values()].flat(); +} + +/** + * Vorauswahl fuer neue Buchungen: das erste billable Projekt der Liste - + * bevorzugt eines, auf das am Datum auch gebucht werden kann (Topf-Laufzeit, + * Budget). Gibt es kein billable Projekt, bleibt nur ein einziges Projekt + * vorausgewaehlt. + */ +function defaultProject(projects: Project[], date: string, settings: { hours_per_day: number; budget_warn_percent: number; budget_warn_days: number }): number | '' { + const ordered = inListOrder(projects); + const billable = ordered.filter((p) => p.type === 'billable'); + const bookableNow = billable.find( + (p) => checkPot(p, date, 0, settings.hours_per_day, settings.budget_warn_percent, settings.budget_warn_days).ok + ); + if (bookableNow) return bookableNow.id; + if (billable.length) return billable[0].id; + return ordered.length === 1 ? ordered[0].id : ''; +} + +/** Erster aktiver Task in Listenreihenfolge; ohne Tasks bleibt "Ohne Task". */ +function firstTask(project: Project | undefined): number | '' { + return project?.tasks.find((t) => t.is_active)?.id ?? ''; +} + +/** + * Buchungsdialog: oben Projekt, darunter Task, darunter Freitext mit der Zeit + * rechts daneben. Die Zeit versteht "1:23" und "1,5". + */ +export const EntryModal: React.FC = ({ projects, date: initialDate, entry, onClose, onSaved }) => { + const { settings } = useAuth(); + const { addToast } = useToast(); + const bookable = useMemo(() => projects.filter((p) => p.is_active || p.id === entry?.project_id), [projects, entry]); + + const [projectId, setProjectId] = useState(() => entry?.project_id ?? defaultProject(bookable, initialDate, settings)); + // Neue Buchung: erster aktiver Task des vorausgewaehlten Projekts. + const [taskId, setTaskId] = useState(() => + entry ? entry.task_id ?? '' : firstTask(bookable.find((p) => p.id === projectId)) + ); + const [note, setNote] = useState(entry?.note ?? ''); + const [date, setDate] = useState(entry?.entry_date ?? initialDate); + const [timeText, setTimeText] = useState(entry ? formatDuration(entry.minutes) : '0:00'); + const [busy, setBusy] = useState(false); + const [confirmDelete, setConfirmDelete] = useState(false); + const timeRef = useRef(null); + const projectRef = useRef(null); + + /* + * Schnell buchen: ist ein Projekt vorausgewaehlt, liegt der Fokus sofort auf + * der Zeit (Inhalt markiert, also direkt ueberschreiben). Sonst zuerst das + * Projekt waehlen. Kurz verzoegert, damit die Dialog-Animation nicht stoert. + */ + useEffect(() => { + const timer = setTimeout(() => { + if (projectId) { + timeRef.current?.focus(); + timeRef.current?.select(); + } else { + projectRef.current?.focus(); + } + }, 60); + return () => clearTimeout(timer); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const project = bookable.find((p) => p.id === projectId) ?? null; + const tasks = project?.tasks.filter((t) => t.is_active || t.id === entry?.task_id) ?? []; + const minutes = parseDuration(timeText); + const validTime = minutes != null && minutes > 0 && minutes <= 24 * 60; + + // Projekt gewechselt: passt der Task nicht mehr, den ersten Task des neuen Projekts nehmen. + useEffect(() => { + if (taskId && tasks.some((t) => t.id === taskId)) return; + if (!taskId && entry && entry.project_id === projectId) return; // bewusst ohne Task gebucht + setTaskId(firstTask(project ?? undefined)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [projectId]); + + const task = tasks.find((t) => t.id === taskId); + const billable = project ? project.type !== 'non_billable' && (task ? task.billable : true) : true; + const check = project + ? checkPot( + project, + date, + validTime ? minutes! : 0, + settings.hours_per_day, + settings.budget_warn_percent, + settings.budget_warn_days, + entry ? { potId: entry.pot_id, minutes: project.type === 'billable' && !entry.billable ? 0 : entry.minutes } : null, + billable + ) + : null; + + // Gruppiert nach Kunde fuer die Auswahl. + const groups = useMemo(() => { + const map = new Map(); + for (const p of bookable) { + const key = p.customer.name; + map.set(key, [...(map.get(key) || []), p]); + } + return [...map.entries()]; + }, [bookable]); + + const save = async () => { + if (!project) return addToast('Bitte ein Projekt wählen.', 'error'); + if (!validTime) { + timeRef.current?.focus(); + return addToast('Bitte eine Zeit eingeben, z. B. 1:30 oder 1,5.', 'error'); + } + setBusy(true); + try { + const payload = { project_id: project.id, task_id: taskId || null, entry_date: date, minutes: minutes!, note }; + const result = entry ? await entriesApi.update(entry.id, payload) : await entriesApi.create(payload); + addToast(`${formatDuration(minutes!)} h auf ${project.display_name} ${entry ? 'aktualisiert' : 'gebucht'}.`, 'success'); + if (result.warning) addToast(result.warning, 'info'); + onSaved(); + onClose(); + } catch (err) { + addToast(err instanceof Error ? err.message : 'Speichern fehlgeschlagen.', 'error'); + } finally { + setBusy(false); + } + }; + + const remove = async () => { + if (!entry) return; + setBusy(true); + try { + await entriesApi.remove(entry.id); + addToast('Buchung gelöscht.', 'success'); + onSaved(); + onClose(); + } catch (err) { + addToast(err instanceof Error ? err.message : 'Löschen fehlgeschlagen.', 'error'); + setBusy(false); + } + }; + + const onKeyDown = (event: React.KeyboardEvent) => { + if (event.key === 'Enter' && (event.ctrlKey || event.metaKey)) { + event.preventDefault(); + save(); + } + }; + + const pot = check?.pot; + + return ( + <> + + {entry ? ( + + ) : ( + Enter im Zeitfeld bucht · sonst Strg + Enter + )} +
+ + +
+ + } + > + {bookable.length === 0 ? ( +
+ Dir ist noch kein Projekt zugewiesen. Bitte deinen Vorgesetzten, dich einem Projekt zuzuordnen. +
+ ) : ( +
+ + + + + + + + +
+ +