feat: Add admin calendar and improve availability management
- Add admin calendar component with booking overview and status management - Implement treatment-specific availability slots with automatic duration - Enhance availability management with better UI and error handling - Move admin credentials to .env configuration - Add .env.example with all required environment variables - Update README.md with comprehensive setup guide including PowerShell password hash generation - Improve slot deletion with proper error handling and user feedback - Add toast notifications for better UX
This commit is contained in:
17
.env.example
Normal file
17
.env.example
Normal file
@@ -0,0 +1,17 @@
|
||||
# Email Configuration
|
||||
RESEND_API_KEY=your_resend_api_key_here
|
||||
EMAIL_FROM=noreply@yourdomain.com
|
||||
ADMIN_EMAIL=admin@yourdomain.com
|
||||
|
||||
# Admin Account Configuration
|
||||
ADMIN_USERNAME=owner
|
||||
ADMIN_PASSWORD_HASH=YWRtaW4xMjM=
|
||||
|
||||
# OpenAI Configuration (optional)
|
||||
OPENAI_API_KEY=your_openai_api_key_here
|
||||
|
||||
# AWS Configuration (optional)
|
||||
AWS_SECRET_ACCESS_KEY=your_aws_secret_key_here
|
||||
|
||||
# Other API Keys (optional)
|
||||
BW_CLIENTSECRET=your_bw_client_secret_here
|
96
.gitignore
vendored
96
.gitignore
vendored
@@ -1,49 +1,49 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules/
|
||||
dist/
|
||||
dist-ssr/
|
||||
.vite/
|
||||
coverage/
|
||||
|
||||
# Editor directories and files
|
||||
.idea
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# Turbo
|
||||
.turbo
|
||||
|
||||
# Storage
|
||||
*.local
|
||||
.storage/
|
||||
|
||||
# Quests
|
||||
.quests/
|
||||
|
||||
# SQLite
|
||||
*.db-journal
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
etilqs_*
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
.env.local
|
||||
|
||||
# TypeScript cache
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules/
|
||||
dist/
|
||||
dist-ssr/
|
||||
.vite/
|
||||
coverage/
|
||||
|
||||
# Editor directories and files
|
||||
.idea
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# Turbo
|
||||
.turbo
|
||||
|
||||
# Storage
|
||||
*.local
|
||||
.storage/
|
||||
|
||||
# Quests
|
||||
.quests/
|
||||
|
||||
# SQLite
|
||||
*.db-journal
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
etilqs_*
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
.env.local
|
||||
|
||||
# TypeScript cache
|
||||
*.tsbuildinfo
|
90
README.md
90
README.md
@@ -1,4 +1,6 @@
|
||||
# Quests Base Template
|
||||
# Stargirlnails Kiel - Nail Salon Booking System
|
||||
|
||||
Ein vollständiges Buchungssystem für Nagelstudios mit Admin-Panel, Kalender und E-Mail-Benachrichtigungen.
|
||||
|
||||
## Dependencies
|
||||
|
||||
@@ -9,3 +11,89 @@
|
||||
- [oRPC](https://orpc.unnoq.com/)
|
||||
- [Hono](https://hono.dev/)
|
||||
- [Zod](https://zod.dev/)
|
||||
|
||||
## Setup
|
||||
|
||||
### 1. Umgebungsvariablen konfigurieren
|
||||
|
||||
Kopiere die `.env.example` Datei zu `.env` und konfiguriere deine Umgebungsvariablen:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
### 2. Admin-Passwort Hash generieren
|
||||
|
||||
Das Admin-Passwort wird als Base64-Hash in der `.env` Datei gespeichert. Hier sind verschiedene Methoden, um einen Hash zu generieren:
|
||||
|
||||
#### PowerShell (Windows)
|
||||
```powershell
|
||||
# Einfache Methode mit Base64-Encoding
|
||||
$password = "dein_sicheres_passwort"
|
||||
$bytes = [System.Text.Encoding]::UTF8.GetBytes($password)
|
||||
$hash = [System.Convert]::ToBase64String($bytes)
|
||||
Write-Host "Password Hash: $hash"
|
||||
|
||||
# Alternative mit PowerShell 7+ (kürzer)
|
||||
$password = "dein_sicheres_passwort"
|
||||
[Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($password))
|
||||
```
|
||||
|
||||
#### Node.js (falls verfügbar)
|
||||
```javascript
|
||||
// In der Node.js Konsole oder als separates Script
|
||||
const password = "dein_sicheres_passwort";
|
||||
const hash = Buffer.from(password).toString('base64');
|
||||
console.log("Password Hash:", hash);
|
||||
```
|
||||
|
||||
#### Online-Tools (nur für Entwicklung)
|
||||
- Verwende einen Base64-Encoder wie [base64encode.org](https://www.base64encode.org/)
|
||||
|
||||
### 3. .env Datei konfigurieren
|
||||
|
||||
Bearbeite deine `.env` Datei und setze die generierten Werte:
|
||||
|
||||
```env
|
||||
# Admin Account Configuration
|
||||
ADMIN_USERNAME=owner
|
||||
ADMIN_PASSWORD_HASH=ZGVpbl9zaWNoZXJlc19wYXNzd29ydA== # Dein generierter Hash
|
||||
|
||||
# Email Configuration
|
||||
RESEND_API_KEY=your_resend_api_key_here
|
||||
EMAIL_FROM=noreply@yourdomain.com
|
||||
ADMIN_EMAIL=admin@yourdomain.com
|
||||
```
|
||||
|
||||
### 4. Anwendung starten
|
||||
|
||||
```bash
|
||||
# Dependencies installieren
|
||||
pnpm install
|
||||
|
||||
# Entwicklungsserver starten
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- 📅 **Terminbuchung**: Kunden können online Termine buchen
|
||||
- 💅 **Behandlungsverwaltung**: Admin kann Behandlungen hinzufügen/bearbeiten
|
||||
- 📆 **Kalender-Ansicht**: Übersichtliche Darstellung aller Termine
|
||||
- ⏰ **Verfügbarkeits-Slots**: Flexible Slot-Verwaltung mit behandlungsspezifischen Dauern
|
||||
- 📧 **E-Mail-Benachrichtigungen**: Automatische Benachrichtigungen bei Buchungen
|
||||
- 🔐 **Admin-Panel**: Geschützter Bereich für Inhaber
|
||||
|
||||
## Admin-Zugang
|
||||
|
||||
Nach dem Setup kannst du dich mit den in der `.env` konfigurierten Admin-Credentials anmelden:
|
||||
- **Benutzername**: Wert aus `ADMIN_USERNAME`
|
||||
- **Passwort**: Das ursprüngliche Passwort (nicht der Hash)
|
||||
|
||||
## Sicherheit
|
||||
|
||||
⚠️ **Wichtige Hinweise:**
|
||||
- Ändere das Standard-Passwort vor dem Produktionseinsatz
|
||||
- Die aktuelle Passwort-Hashing-Methode (Base64) ist nur für Entwicklung geeignet
|
||||
- Für Produktion sollte bcrypt oder ein ähnliches sicheres Hashing-Verfahren verwendet werden
|
||||
- Speichere niemals echte Passwörter in der `.env` Datei, nur die Hashes
|
||||
|
@@ -1,20 +1,22 @@
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$ResendApiKey,
|
||||
|
||||
[Parameter(Mandatory = $false)]
|
||||
[string]$EmailFrom = "Stargirlnails <no-reply@stargirlnails.de>",
|
||||
|
||||
[Parameter(Mandatory = $false)]
|
||||
[string]$AdminEmail
|
||||
)
|
||||
|
||||
Write-Host "Setting environment variables for Resend..."
|
||||
$env:RESEND_API_KEY = $ResendApiKey
|
||||
$env:EMAIL_FROM = $EmailFrom
|
||||
if ($AdminEmail) { $env:ADMIN_EMAIL = $AdminEmail }
|
||||
# Lade .env Datei automatisch
|
||||
Write-Host "Loading .env file..."
|
||||
if (Test-Path ".env") {
|
||||
Get-Content ".env" | ForEach-Object {
|
||||
if ($_ -match '^([^#][^=]+)=(.*)$') {
|
||||
$name = $matches[1].Trim()
|
||||
$value = $matches[2].Trim()
|
||||
[Environment]::SetEnvironmentVariable($name, $value, 'Process')
|
||||
Write-Host "✓ Loaded from .env: $name" -ForegroundColor Green
|
||||
}
|
||||
}
|
||||
Write-Host "Environment variables loaded from .env file" -ForegroundColor Cyan
|
||||
} else {
|
||||
Write-Warning ".env file not found!"
|
||||
Write-Host "Please create a .env file with your environment variables." -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "Starting app with pnpm dev..."
|
||||
pnpm dev
|
||||
|
@@ -5,12 +5,13 @@ import { UserProfile } from "@/client/components/user-profile";
|
||||
import { BookingForm } from "@/client/components/booking-form";
|
||||
import { AdminTreatments } from "@/client/components/admin-treatments";
|
||||
import { AdminBookings } from "@/client/components/admin-bookings";
|
||||
import { AdminCalendar } from "@/client/components/admin-calendar";
|
||||
import { InitialDataLoader } from "@/client/components/initial-data-loader";
|
||||
import { AdminAvailability } from "@/client/components/admin-availability";
|
||||
|
||||
function App() {
|
||||
const { user, isLoading, isOwner } = useAuth();
|
||||
const [activeTab, setActiveTab] = useState<"booking" | "admin-treatments" | "admin-bookings" | "admin-availability" | "profile">("booking");
|
||||
const [activeTab, setActiveTab] = useState<"booking" | "admin-treatments" | "admin-bookings" | "admin-calendar" | "admin-availability" | "profile">("booking");
|
||||
|
||||
// Show loading spinner while checking authentication
|
||||
if (isLoading) {
|
||||
@@ -29,7 +30,7 @@ function App() {
|
||||
}
|
||||
|
||||
// Show login form if user is not authenticated and trying to access admin features
|
||||
const needsAuth = !user && (activeTab === "admin-treatments" || activeTab === "admin-bookings" || activeTab === "admin-availability" || activeTab === "profile");
|
||||
const needsAuth = !user && (activeTab === "admin-treatments" || activeTab === "admin-bookings" || activeTab === "admin-calendar" || activeTab === "admin-availability" || activeTab === "profile");
|
||||
if (needsAuth) {
|
||||
return <LoginForm />;
|
||||
}
|
||||
@@ -38,6 +39,7 @@ function App() {
|
||||
{ id: "booking", label: "Termin buchen", icon: "📅", requiresAuth: false },
|
||||
{ id: "admin-treatments", label: "Behandlungen verwalten", icon: "💅", requiresAuth: true },
|
||||
{ id: "admin-bookings", label: "Buchungen verwalten", icon: "📋", requiresAuth: true },
|
||||
{ id: "admin-calendar", label: "Kalender", icon: "📆", requiresAuth: true },
|
||||
{ id: "admin-availability", label: "Verfügbarkeiten", icon: "⏰", requiresAuth: true },
|
||||
...(user ? [{ id: "profile", label: "Profil", icon: "👤", requiresAuth: true }] : []),
|
||||
] as const;
|
||||
@@ -169,6 +171,20 @@ function App() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "admin-calendar" && isOwner && (
|
||||
<div>
|
||||
<div className="text-center mb-8">
|
||||
<h2 className="text-3xl font-bold text-gray-900 mb-4">
|
||||
Kalender - Bevorstehende Buchungen
|
||||
</h2>
|
||||
<p className="text-lg text-gray-600">
|
||||
Übersichtliche Darstellung aller bevorstehenden Termine im Kalenderformat.
|
||||
</p>
|
||||
</div>
|
||||
<AdminCalendar />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "admin-availability" && isOwner && (
|
||||
<div>
|
||||
<div className="text-center mb-8">
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { queryClient } from "@/client/rpc-client";
|
||||
|
||||
@@ -7,14 +7,35 @@ export function AdminAvailability() {
|
||||
const [selectedDate, setSelectedDate] = useState<string>(today);
|
||||
const [time, setTime] = useState<string>("09:00");
|
||||
const [duration, setDuration] = useState<number>(30);
|
||||
const [selectedTreatmentId, setSelectedTreatmentId] = useState<string>("");
|
||||
const [slotType, setSlotType] = useState<"treatment" | "manual">("treatment");
|
||||
|
||||
const { data: allSlots } = useQuery(
|
||||
queryClient.availability.live.list.experimental_liveOptions()
|
||||
);
|
||||
|
||||
const { data: treatments } = useQuery(
|
||||
queryClient.treatments.live.list.experimental_liveOptions()
|
||||
);
|
||||
|
||||
const [errorMsg, setErrorMsg] = useState<string>("");
|
||||
const [successMsg, setSuccessMsg] = useState<string>("");
|
||||
|
||||
// Auto-clear messages after 5 seconds
|
||||
useEffect(() => {
|
||||
if (errorMsg) {
|
||||
const timer = setTimeout(() => setErrorMsg(""), 5000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [errorMsg]);
|
||||
|
||||
useEffect(() => {
|
||||
if (successMsg) {
|
||||
const timer = setTimeout(() => setSuccessMsg(""), 5000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [successMsg]);
|
||||
|
||||
const { mutate: createSlot, isPending: isCreating } = useMutation(
|
||||
queryClient.availability.create.mutationOptions()
|
||||
);
|
||||
@@ -22,30 +43,60 @@ export function AdminAvailability() {
|
||||
queryClient.availability.remove.mutationOptions()
|
||||
);
|
||||
|
||||
// Auto-update duration when treatment is selected
|
||||
useEffect(() => {
|
||||
if (selectedTreatmentId && treatments) {
|
||||
const treatment = treatments.find(t => t.id === selectedTreatmentId);
|
||||
if (treatment) {
|
||||
setDuration(treatment.duration);
|
||||
}
|
||||
}
|
||||
}, [selectedTreatmentId, treatments]);
|
||||
|
||||
// Get selected treatment details
|
||||
const selectedTreatment = treatments?.find(t => t.id === selectedTreatmentId);
|
||||
|
||||
// Get treatment name for display
|
||||
const getTreatmentName = (treatmentId: string) => {
|
||||
return treatments?.find(t => t.id === treatmentId)?.name || "Unbekannte Behandlung";
|
||||
};
|
||||
|
||||
const addSlot = () => {
|
||||
setErrorMsg("");
|
||||
setSuccessMsg("");
|
||||
|
||||
// Validation based on slot type
|
||||
if (slotType === "treatment" && !selectedTreatmentId) {
|
||||
setErrorMsg("Bitte eine Behandlung auswählen.");
|
||||
return;
|
||||
}
|
||||
if (!selectedDate || !time || !duration) {
|
||||
setErrorMsg("Bitte Datum, Uhrzeit und Dauer angeben.");
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionId = localStorage.getItem("sessionId") || "";
|
||||
if (!sessionId) {
|
||||
setErrorMsg("Nicht eingeloggt. Bitte als Inhaber anmelden.");
|
||||
return;
|
||||
}
|
||||
|
||||
createSlot(
|
||||
{ sessionId, date: selectedDate, time, durationMinutes: duration },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setSuccessMsg("Slot angelegt.");
|
||||
// advance time to next 30-minute step
|
||||
const slotDescription = slotType === "treatment"
|
||||
? `${getTreatmentName(selectedTreatmentId)} (${duration} Min)`
|
||||
: `Manueller Slot (${duration} Min)`;
|
||||
setSuccessMsg(`${slotDescription} angelegt.`);
|
||||
|
||||
// advance time by the duration of the slot
|
||||
const [hStr, mStr] = time.split(":");
|
||||
let h = parseInt(hStr, 10);
|
||||
let m = parseInt(mStr, 10);
|
||||
m += 30;
|
||||
if (m >= 60) { h += 1; m -= 60; }
|
||||
if (h >= 24) { h = 0; }
|
||||
m += duration;
|
||||
if (m >= 60) { h += Math.floor(m / 60); m = m % 60; }
|
||||
if (h >= 24) { h = h % 24; }
|
||||
const next = `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`;
|
||||
setTime(next);
|
||||
},
|
||||
@@ -58,76 +109,274 @@ export function AdminAvailability() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto space-y-6">
|
||||
<h2 className="text-xl font-semibold">Verfügbarkeiten verwalten</h2>
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
<h2 className="text-2xl font-bold text-gray-900">Verfügbarkeiten verwalten</h2>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="date"
|
||||
value={selectedDate}
|
||||
onChange={(e) => setSelectedDate(e.target.value)}
|
||||
className="border rounded px-3 py-2"
|
||||
/>
|
||||
<input
|
||||
type="time"
|
||||
value={time}
|
||||
onChange={(e) => setTime(e.target.value)}
|
||||
className="border rounded px-3 py-2"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
min={5}
|
||||
step={5}
|
||||
value={duration}
|
||||
onChange={(e) => setDuration(Number(e.target.value))}
|
||||
className="border rounded px-3 py-2 w-28"
|
||||
/>
|
||||
<button
|
||||
onClick={addSlot}
|
||||
disabled={isCreating}
|
||||
className="bg-black text-white px-4 py-2 rounded"
|
||||
>
|
||||
{isCreating ? "Anlegen..." : "Slot hinzufügen"}
|
||||
</button>
|
||||
{/* Slot Type Selection */}
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<h3 className="text-lg font-semibold mb-4">Neuen Slot anlegen</h3>
|
||||
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
<button
|
||||
onClick={() => setSlotType("treatment")}
|
||||
className={`px-4 py-2 rounded-md font-medium transition-colors ${
|
||||
slotType === "treatment"
|
||||
? "bg-pink-600 text-white"
|
||||
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
|
||||
}`}
|
||||
>
|
||||
💅 Behandlungs-Slot
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSlotType("manual")}
|
||||
className={`px-4 py-2 rounded-md font-medium transition-colors ${
|
||||
slotType === "manual"
|
||||
? "bg-pink-600 text-white"
|
||||
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
|
||||
}`}
|
||||
>
|
||||
⚙️ Manueller Slot
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Datum
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={selectedDate}
|
||||
onChange={(e) => setSelectedDate(e.target.value)}
|
||||
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-pink-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Startzeit
|
||||
</label>
|
||||
<input
|
||||
type="time"
|
||||
value={time}
|
||||
onChange={(e) => setTime(e.target.value)}
|
||||
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-pink-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{slotType === "treatment" ? (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Behandlung
|
||||
</label>
|
||||
<select
|
||||
value={selectedTreatmentId}
|
||||
onChange={(e) => setSelectedTreatmentId(e.target.value)}
|
||||
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-pink-500"
|
||||
>
|
||||
<option value="">Behandlung wählen...</option>
|
||||
{treatments?.map((treatment) => (
|
||||
<option key={treatment.id} value={treatment.id}>
|
||||
{treatment.name} ({treatment.duration} Min)
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Dauer (Minuten)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min={5}
|
||||
step={5}
|
||||
value={duration}
|
||||
onChange={(e) => setDuration(Number(e.target.value))}
|
||||
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-pink-500"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-end">
|
||||
<button
|
||||
onClick={addSlot}
|
||||
disabled={isCreating || (slotType === "treatment" && !selectedTreatmentId)}
|
||||
className="w-full bg-pink-600 text-white px-4 py-2 rounded-md hover:bg-pink-700 disabled:bg-gray-400 disabled:cursor-not-allowed font-medium transition-colors"
|
||||
>
|
||||
{isCreating ? "Anlegen..." : "Slot hinzufügen"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Treatment Info Display */}
|
||||
{slotType === "treatment" && selectedTreatment && (
|
||||
<div className="mt-4 p-3 bg-pink-50 rounded-md border border-pink-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h4 className="font-medium text-pink-900">{selectedTreatment.name}</h4>
|
||||
<p className="text-sm text-pink-700">{selectedTreatment.description}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-lg font-semibold text-pink-900">
|
||||
{(selectedTreatment.price / 100).toFixed(2)} €
|
||||
</div>
|
||||
<div className="text-sm text-pink-700">
|
||||
{selectedTreatment.duration} Minuten
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(errorMsg || successMsg) && (
|
||||
<div className="text-sm">
|
||||
{errorMsg && <div className="text-red-600">{errorMsg}</div>}
|
||||
{successMsg && <div className="text-green-700">{successMsg}</div>}
|
||||
<div className="fixed top-4 right-4 z-50 max-w-md">
|
||||
{errorMsg && (
|
||||
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded-lg shadow-lg mb-2">
|
||||
<div className="flex items-center">
|
||||
<svg className="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<span className="font-medium">Fehler:</span>
|
||||
<span className="ml-1">{errorMsg}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{successMsg && (
|
||||
<div className="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded-lg shadow-lg">
|
||||
<div className="flex items-center">
|
||||
<svg className="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<span className="font-medium">Erfolg:</span>
|
||||
<span className="ml-1">{successMsg}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<h3 className="font-medium">Alle freien Slots</h3>
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
{/* Quick Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<div className="text-2xl font-bold text-green-600">
|
||||
{allSlots?.filter(s => s.status === "free").length || 0}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">Freie Slots</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<div className="text-2xl font-bold text-yellow-600">
|
||||
{allSlots?.filter(s => s.status === "reserved").length || 0}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">Reservierte Slots</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<div className="text-2xl font-bold text-blue-600">
|
||||
{allSlots?.length || 0}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">Slots gesamt</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* All Slots List */}
|
||||
<div className="bg-white rounded-lg shadow">
|
||||
<div className="p-4 border-b">
|
||||
<h3 className="text-lg font-semibold">Alle Slots</h3>
|
||||
</div>
|
||||
<div className="divide-y">
|
||||
{allSlots
|
||||
?.filter((s) => s.status === "free")
|
||||
.sort((a, b) => (a.date === b.date ? a.time.localeCompare(b.time) : a.date.localeCompare(b.date)))
|
||||
.map((slot) => (
|
||||
<div key={slot.id} className="flex items-center justify-between border rounded px-3 py-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-gray-600">{slot.date}</span>
|
||||
<span className="font-mono">{slot.time}</span>
|
||||
<span className="text-sm text-gray-600">{slot.durationMinutes} Min</span>
|
||||
<span className={`text-xs px-2 py-1 rounded ${slot.status === "free" ? "bg-green-100 text-green-800" : "bg-yellow-100 text-yellow-800"}`}>
|
||||
{slot.status === "free" ? "frei" : "reserviert"}
|
||||
</span>
|
||||
?.sort((a, b) => (a.date === b.date ? a.time.localeCompare(b.time) : a.date.localeCompare(b.date)))
|
||||
.map((slot) => {
|
||||
// Try to find matching treatment based on duration
|
||||
const matchingTreatments = treatments?.filter(t => t.duration === slot.durationMinutes) || [];
|
||||
|
||||
return (
|
||||
<div key={slot.id} className="p-4 hover:bg-gray-50 transition-colors">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-center">
|
||||
<div className="text-sm text-gray-500">Datum</div>
|
||||
<div className="font-medium">{new Date(slot.date).toLocaleDateString('de-DE')}</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-sm text-gray-500">Zeit</div>
|
||||
<div className="font-mono text-lg">{slot.time}</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-sm text-gray-500">Dauer</div>
|
||||
<div className="font-medium">{slot.durationMinutes} Min</div>
|
||||
</div>
|
||||
{matchingTreatments.length > 0 && (
|
||||
<div className="text-center">
|
||||
<div className="text-sm text-gray-500">Passende Behandlungen</div>
|
||||
<div className="text-sm">
|
||||
{matchingTreatments.length === 1
|
||||
? matchingTreatments[0].name
|
||||
: `${matchingTreatments.length} Behandlungen`
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<span className={`px-3 py-1 rounded-full text-sm font-medium ${
|
||||
slot.status === "free"
|
||||
? "bg-green-100 text-green-800"
|
||||
: "bg-yellow-100 text-yellow-800"
|
||||
}`}>
|
||||
{slot.status === "free" ? "Frei" : "Reserviert"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
const sessionId = localStorage.getItem("sessionId");
|
||||
if (!sessionId) {
|
||||
setErrorMsg("Nicht eingeloggt. Bitte als Inhaber anmelden.");
|
||||
return;
|
||||
}
|
||||
removeSlot(
|
||||
{ sessionId, id: slot.id },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setSuccessMsg("Slot erfolgreich gelöscht.");
|
||||
},
|
||||
onError: (err: any) => {
|
||||
const msg = (err && (err.message || (err as any).toString())) || "Fehler beim Löschen des Slots.";
|
||||
setErrorMsg(msg);
|
||||
}
|
||||
}
|
||||
);
|
||||
}}
|
||||
className="px-3 py-1 text-red-600 hover:bg-red-50 rounded-md transition-colors text-sm"
|
||||
disabled={slot.status === "reserved"}
|
||||
title={slot.status === "reserved" ? "Slot ist reserviert" : "Slot löschen"}
|
||||
>
|
||||
Löschen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Show matching treatments if multiple */}
|
||||
{matchingTreatments.length > 1 && (
|
||||
<div className="mt-2 ml-20">
|
||||
<div className="text-xs text-gray-500 mb-1">Passende Behandlungen:</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{matchingTreatments.map(treatment => (
|
||||
<span key={treatment.id} className="px-2 py-1 bg-pink-100 text-pink-700 rounded text-xs">
|
||||
{treatment.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => removeSlot({ sessionId: localStorage.getItem("sessionId") || "", id: slot.id })}
|
||||
className="text-red-600 hover:underline"
|
||||
disabled={slot.status === "reserved"}
|
||||
title={slot.status === "reserved" ? "Slot ist reserviert" : "Slot löschen"}
|
||||
>
|
||||
Löschen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{allSlots?.filter((s) => s.status === "free").length === 0 && (
|
||||
<div className="text-sm text-gray-600">Keine freien Slots vorhanden.</div>
|
||||
);
|
||||
})}
|
||||
{allSlots?.length === 0 && (
|
||||
<div className="p-8 text-center text-gray-500">
|
||||
<div className="text-lg font-medium mb-2">Keine Slots vorhanden</div>
|
||||
<div className="text-sm">Legen Sie den ersten Slot an, um zu beginnen.</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
322
src/client/components/admin-calendar.tsx
Normal file
322
src/client/components/admin-calendar.tsx
Normal file
@@ -0,0 +1,322 @@
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { queryClient } from "@/client/rpc-client";
|
||||
|
||||
export function AdminCalendar() {
|
||||
const [currentMonth, setCurrentMonth] = useState(new Date());
|
||||
const [selectedDate, setSelectedDate] = useState<string | null>(null);
|
||||
|
||||
const { data: bookings } = useQuery(
|
||||
queryClient.bookings.live.list.experimental_liveOptions()
|
||||
);
|
||||
|
||||
const { data: treatments } = useQuery(
|
||||
queryClient.treatments.live.list.experimental_liveOptions()
|
||||
);
|
||||
|
||||
const { mutate: updateBookingStatus } = useMutation(
|
||||
queryClient.bookings.updateStatus.mutationOptions()
|
||||
);
|
||||
|
||||
const getTreatmentName = (treatmentId: string) => {
|
||||
return treatments?.find(t => t.id === treatmentId)?.name || "Unbekannte Behandlung";
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case "pending": return "bg-yellow-100 text-yellow-800 border-yellow-200";
|
||||
case "confirmed": return "bg-green-100 text-green-800 border-green-200";
|
||||
case "cancelled": return "bg-red-100 text-red-800 border-red-200";
|
||||
case "completed": return "bg-blue-100 text-blue-800 border-blue-200";
|
||||
default: return "bg-gray-100 text-gray-800 border-gray-200";
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusText = (status: string) => {
|
||||
switch (status) {
|
||||
case "pending": return "Ausstehend";
|
||||
case "confirmed": return "Bestätigt";
|
||||
case "cancelled": return "Storniert";
|
||||
case "completed": return "Abgeschlossen";
|
||||
default: return status;
|
||||
}
|
||||
};
|
||||
|
||||
// Filter upcoming bookings (today and future, not cancelled)
|
||||
const upcomingBookings = bookings?.filter(booking => {
|
||||
const bookingDate = new Date(booking.appointmentDate);
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
return bookingDate >= today && booking.status !== "cancelled";
|
||||
}).sort((a, b) => {
|
||||
if (a.appointmentDate === b.appointmentDate) {
|
||||
return a.appointmentTime.localeCompare(b.appointmentTime);
|
||||
}
|
||||
return a.appointmentDate.localeCompare(b.appointmentDate);
|
||||
});
|
||||
|
||||
// Get bookings for a specific date
|
||||
const getBookingsForDate = (date: string) => {
|
||||
return upcomingBookings?.filter(booking => booking.appointmentDate === date) || [];
|
||||
};
|
||||
|
||||
// Calendar generation
|
||||
const year = currentMonth.getFullYear();
|
||||
const month = currentMonth.getMonth();
|
||||
|
||||
const firstDay = new Date(year, month, 1);
|
||||
const lastDay = new Date(year, month + 1, 0);
|
||||
const startDate = new Date(firstDay);
|
||||
startDate.setDate(startDate.getDate() - firstDay.getDay());
|
||||
|
||||
const calendarDays = [];
|
||||
const currentDate = new Date(startDate);
|
||||
|
||||
for (let i = 0; i < 42; i++) {
|
||||
calendarDays.push(new Date(currentDate));
|
||||
currentDate.setDate(currentDate.getDate() + 1);
|
||||
}
|
||||
|
||||
const monthNames = [
|
||||
"Januar", "Februar", "März", "April", "Mai", "Juni",
|
||||
"Juli", "August", "September", "Oktober", "November", "Dezember"
|
||||
];
|
||||
|
||||
const dayNames = ["So", "Mo", "Di", "Mi", "Do", "Fr", "Sa"];
|
||||
|
||||
const navigateMonth = (direction: 'prev' | 'next') => {
|
||||
const newMonth = new Date(currentMonth);
|
||||
if (direction === 'prev') {
|
||||
newMonth.setMonth(month - 1);
|
||||
} else {
|
||||
newMonth.setMonth(month + 1);
|
||||
}
|
||||
setCurrentMonth(newMonth);
|
||||
setSelectedDate(null);
|
||||
};
|
||||
|
||||
const handleStatusUpdate = (bookingId: string, newStatus: string) => {
|
||||
const sessionId = localStorage.getItem('sessionId');
|
||||
if (!sessionId) return;
|
||||
|
||||
updateBookingStatus({
|
||||
sessionId,
|
||||
id: bookingId,
|
||||
status: newStatus as "pending" | "confirmed" | "cancelled" | "completed"
|
||||
});
|
||||
};
|
||||
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-6">Kalender - Bevorstehende Buchungen</h2>
|
||||
|
||||
{/* Quick Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<div className="text-2xl font-bold text-yellow-600">
|
||||
{upcomingBookings?.filter(b => b.status === "pending").length || 0}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">Ausstehende Bestätigungen</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<div className="text-2xl font-bold text-green-600">
|
||||
{upcomingBookings?.filter(b => b.status === "confirmed").length || 0}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">Bestätigte Termine</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<div className="text-2xl font-bold text-blue-600">
|
||||
{upcomingBookings?.filter(b => b.appointmentDate === today).length || 0}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">Heute</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<div className="text-2xl font-bold text-purple-600">
|
||||
{upcomingBookings?.length || 0}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">Bevorstehende Termine</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Calendar */}
|
||||
<div className="bg-white rounded-lg shadow-lg overflow-hidden">
|
||||
{/* Calendar Header */}
|
||||
<div className="flex items-center justify-between p-4 bg-pink-50 border-b">
|
||||
<button
|
||||
onClick={() => navigateMonth('prev')}
|
||||
className="p-2 hover:bg-pink-100 rounded-md transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<h3 className="text-xl font-semibold text-gray-900">
|
||||
{monthNames[month]} {year}
|
||||
</h3>
|
||||
|
||||
<button
|
||||
onClick={() => navigateMonth('next')}
|
||||
className="p-2 hover:bg-pink-100 rounded-md transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Calendar Grid */}
|
||||
<div className="grid grid-cols-7">
|
||||
{/* Day headers */}
|
||||
{dayNames.map(day => (
|
||||
<div key={day} className="p-3 text-center font-semibold text-gray-600 bg-gray-50 border-b">
|
||||
{day}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Calendar days */}
|
||||
{calendarDays.map((date, index) => {
|
||||
const dateStr = date.toISOString().split('T')[0];
|
||||
const isCurrentMonth = date.getMonth() === month;
|
||||
const isToday = dateStr === today;
|
||||
const isSelected = selectedDate === dateStr;
|
||||
const dayBookings = getBookingsForDate(dateStr);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={`min-h-[120px] p-2 border-r border-b cursor-pointer hover:bg-gray-50 transition-colors ${
|
||||
!isCurrentMonth ? 'bg-gray-50 text-gray-400' : 'bg-white'
|
||||
} ${isToday ? 'bg-pink-50' : ''} ${isSelected ? 'bg-pink-100' : ''}`}
|
||||
onClick={() => setSelectedDate(isSelected ? null : dateStr)}
|
||||
>
|
||||
<div className={`text-sm font-medium mb-1 ${
|
||||
isToday ? 'text-pink-600' : isCurrentMonth ? 'text-gray-900' : 'text-gray-400'
|
||||
}`}>
|
||||
{date.getDate()}
|
||||
</div>
|
||||
|
||||
{/* Show bookings for this day */}
|
||||
<div className="space-y-1">
|
||||
{dayBookings.slice(0, 2).map(booking => (
|
||||
<div
|
||||
key={booking.id}
|
||||
className={`text-xs p-1 rounded border-l-2 ${getStatusColor(booking.status)} truncate`}
|
||||
title={`${booking.customerName} - ${getTreatmentName(booking.treatmentId)} (${booking.appointmentTime})`}
|
||||
>
|
||||
<div className="font-medium">{booking.appointmentTime}</div>
|
||||
<div className="truncate">{booking.customerName}</div>
|
||||
</div>
|
||||
))}
|
||||
{dayBookings.length > 2 && (
|
||||
<div className="text-xs text-gray-500 font-medium">
|
||||
+{dayBookings.length - 2} weitere
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Selected Date Details */}
|
||||
{selectedDate && (
|
||||
<div className="mt-6 bg-white rounded-lg shadow-lg p-6">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900">
|
||||
Termine für {new Date(selectedDate).toLocaleDateString('de-DE', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
})}
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => setSelectedDate(null)}
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{getBookingsForDate(selectedDate).length === 0 ? (
|
||||
<p className="text-gray-500 text-center py-8">Keine Termine für diesen Tag</p>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{getBookingsForDate(selectedDate).map(booking => (
|
||||
<div key={booking.id} className="border rounded-lg p-4 hover:shadow-md transition-shadow">
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center space-x-3 mb-2">
|
||||
<h4 className="font-semibold text-gray-900">{booking.customerName}</h4>
|
||||
<span className={`px-2 py-1 rounded-full text-xs font-medium border ${getStatusColor(booking.status)}`}>
|
||||
{getStatusText(booking.status)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm text-gray-600">
|
||||
<div>
|
||||
<strong>Behandlung:</strong> {getTreatmentName(booking.treatmentId)}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Uhrzeit:</strong> {booking.appointmentTime}
|
||||
</div>
|
||||
<div>
|
||||
<strong>E-Mail:</strong> {booking.customerEmail}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Telefon:</strong> {booking.customerPhone}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{booking.notes && (
|
||||
<div className="mt-3 text-sm">
|
||||
<strong>Notizen:</strong>
|
||||
<p className="text-gray-600 mt-1">{booking.notes}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Status Update Buttons */}
|
||||
<div className="flex flex-col space-y-2 ml-4">
|
||||
{booking.status === "pending" && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => handleStatusUpdate(booking.id, "confirmed")}
|
||||
className="px-3 py-1 bg-green-600 text-white text-sm rounded hover:bg-green-700 transition-colors"
|
||||
>
|
||||
Bestätigen
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleStatusUpdate(booking.id, "cancelled")}
|
||||
className="px-3 py-1 bg-red-600 text-white text-sm rounded hover:bg-red-700 transition-colors"
|
||||
>
|
||||
Stornieren
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{booking.status === "confirmed" && (
|
||||
<button
|
||||
onClick={() => handleStatusUpdate(booking.id, "completed")}
|
||||
className="px-3 py-1 bg-blue-600 text-white text-sm rounded hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Als erledigt markieren
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
@@ -34,20 +34,31 @@ const verifyPassword = (password: string, hash: string): boolean => {
|
||||
return hashPassword(password) === hash;
|
||||
};
|
||||
|
||||
// Export hashPassword for external use (e.g., generating hashes for .env)
|
||||
export const generatePasswordHash = hashPassword;
|
||||
|
||||
// Initialize default owner account
|
||||
const initializeOwner = async () => {
|
||||
const existingUsers = await usersKV.getAllItems();
|
||||
if (existingUsers.length === 0) {
|
||||
const ownerId = randomUUID();
|
||||
|
||||
// Get admin credentials from environment variables
|
||||
const adminUsername = process.env.ADMIN_USERNAME || "owner";
|
||||
const adminPasswordHash = process.env.ADMIN_PASSWORD_HASH || hashPassword("admin123");
|
||||
const adminEmail = process.env.ADMIN_EMAIL || "owner@stargirlnails.de";
|
||||
|
||||
const owner: User = {
|
||||
id: ownerId,
|
||||
username: "owner",
|
||||
email: "owner@stargirlnails.de",
|
||||
passwordHash: hashPassword("admin123"), // Default password
|
||||
username: adminUsername,
|
||||
email: adminEmail,
|
||||
passwordHash: adminPasswordHash,
|
||||
role: "owner",
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
await usersKV.setItem(ownerId, owner);
|
||||
|
||||
console.log(`✅ Admin account created: username="${adminUsername}", email="${adminEmail}"`);
|
||||
}
|
||||
};
|
||||
|
||||
|
Reference in New Issue
Block a user