feat: industrial precision dark theme redesign

- Dark server-room slate with amber/copper status-light accents
- DM Sans body + JetBrains Mono for data/mono content
- Glass sidebar with noise grain texture
- Staggered fade-in animations across all pages
- Amber glow on interactions, refined border system
- New favicon (amber clock on dark bg)
This commit is contained in:
2026-05-08 22:58:12 +02:00
parent b799e0e5d3
commit 470b0d45dd
17 changed files with 566 additions and 174 deletions
+4 -1
View File
@@ -1,10 +1,13 @@
<!doctype html>
<html lang="en">
<html lang="de">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>UpdatePlaner</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:opsz,wght@9..40,300;9..40,400;9..40,500;9..40,600;9..40,700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet" />
</head>
<body>
<div id="root"></div>
+5 -1
View File
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 383 B

+29 -11
View File
@@ -2,18 +2,36 @@ interface BadgeProps {
status: 'pending' | 'confirmed' | 'cancelled';
}
const colors = {
pending: 'bg-yellow-100 text-yellow-800',
confirmed: 'bg-green-100 text-green-800',
cancelled: 'bg-red-100 text-red-800',
};
const labels = {
pending: 'Ausstehend',
confirmed: 'Bestätigt',
cancelled: 'Storniert',
const config = {
pending: {
bg: 'bg-amber-500/10',
text: 'text-amber-400',
dot: 'bg-amber-400',
label: 'Ausstehend',
animate: true,
},
confirmed: {
bg: 'bg-green-500/10',
text: 'text-green-400',
dot: 'bg-green-400',
label: 'Bestätigt',
animate: false,
},
cancelled: {
bg: 'bg-red-500/10',
text: 'text-red-400',
dot: 'bg-red-400',
label: 'Storniert',
animate: false,
},
};
export function Badge({ status }: BadgeProps) {
return <span className={`px-2 py-0.5 rounded-full text-xs font-medium ${colors[status]}`}>{labels[status]}</span>;
const c = config[status];
return (
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium ${c.bg} ${c.text}`}>
<span className={`w-1.5 h-1.5 rounded-full ${c.dot} ${c.animate ? 'animate-pulse' : ''}`} />
{c.label}
</span>
);
}
+22 -8
View File
@@ -2,15 +2,29 @@ import { ButtonHTMLAttributes } from 'react';
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'danger' | 'ghost';
size?: 'sm' | 'md';
}
export function Button({ variant = 'primary', className = '', ...props }: ButtonProps) {
const base = 'px-4 py-2 rounded-lg font-medium transition-colors disabled:opacity-50 cursor-pointer';
const variants = {
primary: 'bg-blue-600 text-white hover:bg-blue-700',
secondary: 'bg-gray-200 text-gray-800 hover:bg-gray-300',
danger: 'bg-red-600 text-white hover:bg-red-700',
ghost: 'text-gray-600 hover:bg-gray-100',
export function Button({ variant = 'primary', size = 'md', className = '', ...props }: ButtonProps) {
const base = 'inline-flex items-center justify-center gap-1.5 font-medium transition-all duration-200 disabled:opacity-40 disabled:cursor-not-allowed rounded-lg';
const sizes = {
sm: 'px-3 py-1.5 text-xs',
md: 'px-4 py-2 text-sm',
};
return <button className={`${base} ${variants[variant]} ${className}`} {...props} />;
const variants = {
primary:
'bg-amber-500/10 text-amber-400 border border-amber-500/20 hover:bg-amber-500/20 hover:border-amber-500/30 active:bg-amber-500/25',
secondary:
'bg-white/5 text-white/70 border border-white/10 hover:bg-white/10 hover:text-white/90 active:bg-white/[0.12]',
danger:
'bg-red-500/10 text-red-400 border border-red-500/20 hover:bg-red-500/20 hover:border-red-500/30 active:bg-red-500/25',
ghost:
'text-white/50 hover:text-white/80 hover:bg-white/5 active:bg-white/[0.08]',
};
return (
<button
className={`${base} ${sizes[size]} ${variants[variant]} ${className}`}
{...props}
/>
);
}
+8 -1
View File
@@ -5,5 +5,12 @@ interface CardProps extends HTMLAttributes<HTMLDivElement> {
}
export function Card({ children, className = '', ...props }: CardProps) {
return <div className={`bg-white rounded-xl shadow-sm border border-gray-200 p-6 ${className}`} {...props}>{children}</div>;
return (
<div
className={`bg-white/[0.03] border border-white/[0.06] rounded-xl backdrop-blur-sm ${className}`}
{...props}
>
{children}
</div>
);
}
+18 -4
View File
@@ -7,14 +7,28 @@ interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
export const Input = forwardRef<HTMLInputElement, InputProps>(
({ label, error, className = '', ...props }, ref) => (
<div className="flex flex-col gap-1">
{label && <label className="text-sm font-medium text-gray-700">{label}</label>}
<div className="flex flex-col gap-1.5">
{label && (
<label className="text-xs font-medium text-white/50 uppercase tracking-wider">
{label}
</label>
)}
<input
ref={ref}
className={`px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 ${error ? 'border-red-500' : 'border-gray-300'} ${className}`}
className={`
w-full px-3 py-2.5 text-sm
bg-white/5 border rounded-lg
text-white/90 placeholder:text-white/25
transition-all duration-200
focus:outline-none focus:border-amber-500/40 focus:bg-white/[0.07] focus:ring-1 focus:ring-amber-500/20
${error ? 'border-red-500/40' : 'border-white/10 hover:border-white/20'}
${className}
`}
{...props}
/>
{error && <span className="text-xs text-red-600">{error}</span>}
{error && (
<span className="text-xs text-red-400/80">{error}</span>
)}
</div>
)
);
+17 -5
View File
@@ -17,11 +17,23 @@ export function Modal({ open, onClose, title, children }: ModalProps) {
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onClose}>
<div className="bg-white rounded-xl shadow-xl p-6 w-full max-w-lg mx-4" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold">{title}</h2>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-xl leading-none cursor-pointer">&times;</button>
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4"
onClick={onClose}
>
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" />
<div
className="relative bg-[#181b28] border border-white/[0.08] rounded-2xl shadow-2xl p-6 w-full max-w-lg animate-fade-in"
onClick={e => e.stopPropagation()}
>
<div className="flex items-center justify-between mb-5">
<h2 className="text-base font-semibold text-white/90">{title}</h2>
<button
onClick={onClose}
className="text-white/30 hover:text-white/60 transition-colors text-lg leading-none cursor-pointer"
>
</button>
</div>
{children}
</div>
+42 -12
View File
@@ -3,7 +3,6 @@ import { authClient } from '@/lib/auth-client';
import { useNavigate } from 'react-router-dom';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { Card } from '@/components/ui/Card';
export function LoginPage() {
const [email, setEmail] = useState('');
@@ -28,17 +27,48 @@ export function LoginPage() {
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 p-4">
<Card className="w-full max-w-sm">
<h1 className="text-2xl font-bold mb-1">UpdatePlaner</h1>
<p className="text-gray-500 mb-6 text-sm">IT/AM Login</p>
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<Input label="Email" type="email" value={email} onChange={e => setEmail(e.target.value)} required />
<Input label="Passwort" type="password" value={password} onChange={e => setPassword(e.target.value)} required />
{error && <p className="text-sm text-red-600">{error}</p>}
<Button type="submit" disabled={loading}>{loading ? 'Lädt...' : 'Anmelden'}</Button>
</form>
</Card>
<div className="min-h-screen flex items-center justify-center bg-noise" style={{ background: 'var(--bg-primary)' }}>
<div className="absolute inset-0 bg-noise pointer-events-none" />
<div className="relative w-full max-w-sm mx-auto p-6">
<div className="text-center mb-8 animate-fade-in">
<div className="inline-flex items-center justify-center w-12 h-12 rounded-xl bg-amber-500/10 border border-amber-500/20 mb-4">
<svg className="w-6 h-6 text-amber-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
</svg>
</div>
<h1 className="text-2xl font-semibold text-white/90">UpdatePlaner</h1>
<p className="text-sm text-white/40 mt-1">IT/AM Login</p>
</div>
<div className="bg-white/[0.03] border border-white/[0.06] rounded-2xl p-6 animate-fade-in stagger-1">
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<Input
label="Email"
type="email"
value={email}
onChange={e => setEmail(e.target.value)}
required
placeholder="admin@example.de"
/>
<Input
label="Passwort"
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
required
placeholder="••••••••"
/>
{error && (
<p className="text-sm text-red-400/90 bg-red-500/10 border border-red-500/20 rounded-lg px-3 py-2">
{error}
</p>
)}
<Button type="submit" disabled={loading} className="w-full mt-1">
{loading ? 'Lädt...' : 'Anmelden'}
</Button>
</form>
</div>
</div>
</div>
);
}
+10 -1
View File
@@ -13,7 +13,16 @@ export function ProtectedRoute() {
});
}, []);
if (loading) return <div className="p-8 text-center text-gray-500">Lädt...</div>;
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center" style={{ background: 'var(--bg-primary)' }}>
<div className="flex flex-col items-center gap-3">
<div className="w-5 h-5 border-2 border-amber-500/30 border-t-amber-400 rounded-full animate-spin" />
<p className="text-sm text-white/40">Lädt...</p>
</div>
</div>
);
}
if (!session) return <Navigate to="/login" replace />;
return <Outlet />;
}
+78 -38
View File
@@ -2,12 +2,17 @@ import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from '@/lib/api';
import { Button } from '@/components/ui/Button';
import { Card } from '@/components/ui/Card';
import { Badge } from '@/components/ui/Badge';
import type { Booking } from '@/types';
const filters = [
{ value: '', label: 'Alle' },
{ value: 'pending', label: 'Ausstehend' },
{ value: 'confirmed', label: 'Bestätigt' },
{ value: 'cancelled', label: 'Storniert' },
];
export function BookingsPage() {
const [filter, setFilter] = useState<string>('');
const [filter, setFilter] = useState('');
const queryClient = useQueryClient();
const { data: bookings, isLoading } = useQuery({
@@ -25,58 +30,93 @@ export function BookingsPage() {
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['bookings'] }),
});
const filters = [
{ value: '', label: 'Alle' },
{ value: 'pending', label: 'Ausstehend' },
{ value: 'confirmed', label: 'Bestätigt' },
{ value: 'cancelled', label: 'Storniert' },
];
return (
<div>
<h2 className="text-2xl font-bold mb-6">Buchungen</h2>
<div className="animate-fade-in">
<div className="mb-8">
<h1 className="text-xl font-semibold text-white/90">Buchungen</h1>
<p className="text-sm text-white/40 mt-0.5">Alle Kundenbuchungen verwalten</p>
</div>
<div className="flex gap-2 mb-6">
<div className="flex gap-1.5 mb-6 p-1 bg-white/[0.03] rounded-lg border border-white/[0.06] w-fit">
{filters.map(f => (
<Button
<button
key={f.value}
variant={filter === f.value ? 'primary' : 'ghost'}
onClick={() => setFilter(f.value)}
className={`px-3 py-1.5 rounded-md text-xs font-medium transition-all duration-200 cursor-pointer ${
filter === f.value
? 'bg-amber-500/10 text-amber-400 border border-amber-500/10'
: 'text-white/40 hover:text-white/70 border border-transparent'
}`}
>
{f.label}
</Button>
</button>
))}
</div>
{isLoading ? (
<p className="text-gray-500">Lädt...</p>
<div className="flex items-center justify-center py-20">
<div className="w-5 h-5 border-2 border-amber-500/30 border-t-amber-400 rounded-full animate-spin" />
</div>
) : (
<div className="grid gap-4">
{bookings?.map((b: any) => (
<Card key={b.id}>
<div className="flex items-start justify-between">
<div>
<h3 className="font-semibold">{b.customer_name}</h3>
<p className="text-sm text-gray-500">{b.customer_email}</p>
{b.customer_company && <p className="text-sm text-gray-500">{b.customer_company}{b.customer_location ? ` ${b.customer_location}` : ''}</p>}
<p className="text-sm mt-2">
<strong>{b.slot_title}</strong> {b.slot_date} ({b.start_time} - {b.end_time})
</p>
<p className="text-xs text-gray-400 mt-1">Gebucht: {new Date(b.booked_at).toLocaleString('de-DE')}</p>
</div>
<div className="flex items-center gap-2">
<Badge status={b.status} />
<div className="grid gap-3">
{bookings?.map((b: any, i: number) => (
<div
key={b.id}
className="animate-fade-in"
style={{ animationDelay: `${i * 0.04}s` }}
>
<div className="bg-white/[0.03] border border-white/[0.06] rounded-xl p-5">
<div className="flex items-start justify-between">
<div className="flex items-start gap-3">
<div className="w-9 h-9 rounded-lg bg-white/[0.04] border border-white/[0.06] flex items-center justify-center flex-shrink-0 mt-0.5">
<svg className="w-4 h-4 text-white/40" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z" />
</svg>
</div>
<div>
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-white/90">{b.customer_name}</span>
<Badge status={b.status} />
</div>
<p className="text-xs text-white/40 mt-0.5">{b.customer_email}</p>
{(b.customer_company || b.customer_location) && (
<p className="text-xs text-white/30 mt-0.5">
{[b.customer_company, b.customer_location].filter(Boolean).join(' ')}
</p>
)}
<div className="flex items-center gap-2 mt-2">
<span className="text-xs font-mono text-white/50 bg-white/[0.04] px-2 py-0.5 rounded border border-white/[0.06]">
{b.slot_title}
</span>
<span className="text-xs text-white/30">·</span>
<span className="text-xs font-mono text-white/40">{b.slot_date}</span>
<span className="text-xs text-white/30">·</span>
<span className="text-xs font-mono text-white/40">{b.start_time}{b.end_time}</span>
</div>
<p className="text-[11px] text-white/25 mt-1.5">
Gebucht: {new Date(b.booked_at).toLocaleString('de-DE')}
</p>
</div>
</div>
{b.status === 'pending' && (
<>
<Button onClick={() => confirmMutation.mutate(b.id)}>Bestätigen</Button>
<Button variant="danger" onClick={() => cancelMutation.mutate(b.id)}>Stornieren</Button>
</>
<div className="flex items-center gap-2 flex-shrink-0">
<Button size="sm" onClick={() => confirmMutation.mutate(b.id)}>
Bestätigen
</Button>
<Button variant="danger" size="sm" onClick={() => cancelMutation.mutate(b.id)}>
Stornieren
</Button>
</div>
)}
</div>
</div>
</Card>
</div>
))}
{bookings?.length === 0 && <p className="text-gray-400 text-center py-8">Keine Buchungen</p>}
{(!bookings || bookings.length === 0) && (
<div className="text-center py-20">
<p className="text-sm text-white/30">Keine Buchungen</p>
</div>
)}
</div>
)}
</div>
+31 -12
View File
@@ -1,7 +1,6 @@
import { useParams } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { api } from '@/lib/api';
import { Card } from '@/components/ui/Card';
import { Badge } from '@/components/ui/Badge';
export function BookingConfirmation() {
@@ -14,24 +13,44 @@ export function BookingConfirmation() {
refetchInterval: 30000,
});
if (isLoading) return <div className="min-h-screen flex items-center justify-center bg-gray-50 p-4"><p className="text-gray-500">Lädt...</p></div>;
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center" style={{ background: 'var(--bg-primary)' }}>
<div className="w-5 h-5 border-2 border-amber-500/30 border-t-amber-400 rounded-full animate-spin" />
</div>
);
}
return (
<div className="min-h-screen bg-gray-50 p-4">
<div className="max-w-md mx-auto pt-12">
<Card>
<h2 className="text-xl font-bold mb-1">Buchungsstatus</h2>
<div className="min-h-screen" style={{ background: 'var(--bg-primary)' }}>
<div className="max-w-md mx-auto pt-20 p-4 animate-fade-in">
<div className="bg-white/[0.03] border border-white/[0.06] rounded-2xl p-6">
<h2 className="text-base font-semibold text-white/90 mb-5">Buchungsstatus</h2>
{data && (
<div className="mt-4 space-y-2">
<p><strong>{data.slot_title}</strong></p>
<p className="text-sm text-gray-500">{data.slot_date} | {data.start_time} - {data.end_time}</p>
<p className="text-sm text-gray-500">{data.customer_name} {data.customer_email}</p>
<div className="mt-4">
<div className="space-y-3">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-lg bg-amber-500/5 border border-amber-500/10 flex items-center justify-center flex-shrink-0">
<svg className="w-4 h-4 text-amber-400/60" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 0 1 2.25-2.25h13.5A2.25 2.25 0 0 1 21 7.5v11.25m-18 0A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75m-18 0v-7.5A2.25 2.25 0 0 1 5.25 9h13.5A2.25 2.25 0 0 1 21 11.25v7.5" />
</svg>
</div>
<div>
<p className="text-sm font-medium text-white/90">{data.slot_title}</p>
<p className="text-xs font-mono text-white/40 mt-0.5">
{data.slot_date} · {data.start_time} {data.end_time}
</p>
</div>
</div>
<div className="border-t border-white/[0.06] pt-3 space-y-1.5">
<p className="text-xs text-white/40">{data.customer_name} {data.customer_email}</p>
</div>
<div className="border-t border-white/[0.06] pt-3 flex items-center gap-2">
<span className="text-xs text-white/40">Status:</span>
<Badge status={data.status} />
</div>
</div>
)}
</Card>
</div>
</div>
</div>
);
+66 -32
View File
@@ -7,7 +7,6 @@ import { z } from 'zod';
import { api } from '@/lib/api';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { Card } from '@/components/ui/Card';
import type { PublicSlot } from '@/types';
const bookingSchema = z.object({
@@ -42,62 +41,97 @@ export function BookingPage() {
if (successBooking) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 p-4">
<Card className="max-w-md text-center">
<h2 className="text-xl font-bold mb-2">Buchung eingegangen</h2>
<p className="text-gray-600 mb-4">
Ihre Buchung für <strong>{successBooking.slot_title}</strong> am {successBooking.slot_date} ({successBooking.start_time} - {successBooking.end_time}) wurde registriert.
<div className="min-h-screen flex items-center justify-center p-4" style={{ background: 'var(--bg-primary)' }}>
<div className="bg-white/[0.03] border border-white/[0.06] rounded-2xl p-8 max-w-md text-center animate-fade-in">
<div className="w-14 h-14 rounded-full bg-green-500/10 border border-green-500/20 flex items-center justify-center mx-auto mb-4">
<svg className="w-6 h-6 text-green-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="m4.5 12.75 6 6 9-13.5" />
</svg>
</div>
<h2 className="text-lg font-semibold text-white/90 mb-2">Buchung eingegangen</h2>
<p className="text-sm text-white/50 mb-4">
Ihre Buchung für <strong className="text-white/70">{successBooking.slot_title}</strong> am{' '}
{successBooking.slot_date} ({successBooking.start_time} {successBooking.end_time}) wurde registriert.
</p>
<p className="text-sm text-gray-500">Status: <strong>Ausstehend</strong> Sie erhalten eine Email sobald bestätigt.</p>
</Card>
<p className="text-xs text-white/30">Status: <span className="text-amber-400 font-medium">Ausstehend</span></p>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50 p-4">
<div className="max-w-2xl mx-auto">
<h1 className="text-2xl font-bold mb-1">Update-Termin buchen</h1>
<p className="text-gray-500 mb-6">Wählen Sie einen freien Slot</p>
<div className="min-h-screen p-4" style={{ background: 'var(--bg-primary)' }}>
<div className="max-w-2xl mx-auto pt-12 animate-fade-in">
<div className="mb-8">
<h1 className="text-xl font-semibold text-white/90">Update-Termin buchen</h1>
<p className="text-sm text-white/40 mt-1">Wählen Sie einen verfügbaren Slot</p>
</div>
{isLoading ? (
<p className="text-gray-500">Lädt...</p>
<div className="flex items-center justify-center py-20">
<div className="w-5 h-5 border-2 border-amber-500/30 border-t-amber-400 rounded-full animate-spin" />
</div>
) : (
<div className="grid gap-3 mb-8">
{data?.slots?.map((slot: PublicSlot) => (
<Card
{data?.slots?.map((slot: PublicSlot, i: number) => (
<div
key={slot.id}
className={`cursor-pointer transition-colors ${selectedSlot === slot.id ? 'ring-2 ring-blue-500' : ''} ${!slot.available ? 'opacity-50' : ''}`}
onClick={() => slot.available && setSelectedSlot(slot.id)}
className="animate-fade-in"
style={{ animationDelay: `${i * 0.05}s` }}
>
<div className="flex items-center justify-between">
<div>
<h3 className="font-semibold">{slot.title}</h3>
<p className="text-sm text-gray-500">{slot.date} | {slot.start_time} - {slot.end_time}</p>
<div
className={`
bg-white/[0.03] border rounded-xl p-4 transition-all duration-200 cursor-pointer
${selectedSlot === slot.id
? 'border-amber-500/30 bg-amber-500/5'
: slot.available
? 'border-white/[0.06] hover:border-white/[0.12]'
: 'border-white/[0.04] opacity-40 cursor-not-allowed'
}
`}
onClick={() => slot.available && setSelectedSlot(slot.id)}
>
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-medium text-white/90">{slot.title}</h3>
<p className="text-xs font-mono text-white/40 mt-1">
{slot.date} · {slot.start_time} {slot.end_time}
</p>
</div>
<span className={`text-xs font-mono px-2.5 py-1 rounded-md border ${
slot.available
? 'text-green-400 border-green-500/20 bg-green-500/5'
: 'text-red-400 border-red-500/20 bg-red-500/5'
}`}>
{slot.available ? `${slot.max_bookings - slot.total_booked} frei` : 'Ausgebucht'}
</span>
</div>
<span className={`text-sm font-medium ${slot.available ? 'text-green-600' : 'text-red-600'}`}>
{slot.available ? `${slot.max_bookings - slot.total_booked} frei` : 'Ausgebucht'}
</span>
</div>
</Card>
</div>
))}
</div>
)}
{selectedSlot && (
<Card>
<h3 className="font-semibold mb-4">Ihre Angaben</h3>
<div className="bg-white/[0.03] border border-white/[0.06] rounded-xl p-6 animate-fade-in">
<h3 className="text-sm font-medium text-white/90 mb-4">Ihre Angaben</h3>
<form onSubmit={handleSubmit(data => bookingMutation.mutate(data))} className="flex flex-col gap-4">
<Input label="Name *" {...register('customer_name')} error={errors.customer_name?.message} />
<Input label="Email *" type="email" {...register('customer_email')} error={errors.customer_email?.message} />
<div className="grid grid-cols-2 gap-3">
<Input label="Name *" {...register('customer_name')} error={errors.customer_name?.message} />
<Input label="Email *" type="email" {...register('customer_email')} error={errors.customer_email?.message} />
</div>
<Input label="Unternehmen / Krankenhaus" {...register('customer_company')} />
<Input label="Standort" {...register('customer_location')} />
<Button type="submit" disabled={bookingMutation.isPending}>
{bookingMutation.error && (
<p className="text-sm text-red-400/90 bg-red-500/10 border border-red-500/20 rounded-lg px-3 py-2">
{(bookingMutation.error as Error).message}
</p>
)}
<Button type="submit" disabled={bookingMutation.isPending} className="w-full mt-1">
{bookingMutation.isPending ? 'Wird gebucht...' : 'Termin buchen'}
</Button>
{bookingMutation.error && <p className="text-sm text-red-600">{(bookingMutation.error as Error).message}</p>}
</form>
</Card>
</div>
)}
</div>
</div>
+31 -11
View File
@@ -2,9 +2,9 @@ import { NavLink, Outlet, useNavigate } from 'react-router-dom';
import { authClient } from '@/lib/auth-client';
const navItems = [
{ to: '/dashboard', label: '📅 Slots', end: true },
{ to: '/dashboard/bookings', label: '📋 Buchungen', end: false },
{ to: '/dashboard/settings', label: 'Einstellungen', end: false },
{ to: '/dashboard', label: 'Slots', end: true },
{ to: '/dashboard/bookings', label: 'Buchungen', end: false },
{ to: '/dashboard/settings', label: 'Einstellungen', end: false },
];
export function DashboardLayout() {
@@ -16,29 +16,49 @@ export function DashboardLayout() {
}
return (
<div className="min-h-screen bg-gray-50 flex">
<aside className="w-64 bg-white border-r border-gray-200 p-4 flex flex-col">
<h1 className="text-xl font-bold mb-6">UpdatePlaner</h1>
<div className="min-h-screen flex" style={{ background: 'var(--bg-primary)' }}>
<aside className="w-64 flex-shrink-0 border-r border-white/[0.06] backdrop-blur-xl p-5 flex flex-col" style={{ background: 'rgba(15, 17, 23, 0.85)' }}>
<div className="flex items-center gap-2.5 mb-8 px-1">
<div className="w-8 h-8 rounded-lg bg-amber-500/10 border border-amber-500/20 flex items-center justify-center">
<svg className="w-4 h-4 text-amber-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
</svg>
</div>
<span className="text-sm font-semibold text-white/90">UpdatePlaner</span>
</div>
<nav className="flex flex-col gap-1 flex-1">
{navItems.map(item => (
{navItems.map((item, i) => (
<NavLink
key={item.to}
to={item.to}
end={item.end}
className={({ isActive }) =>
`px-3 py-2 rounded-lg text-sm transition-colors ${isActive ? 'bg-blue-50 text-blue-700 font-medium' : 'text-gray-600 hover:bg-gray-100'}`
`px-3 py-2 rounded-lg text-sm transition-all duration-200 animate-slide-in ${
isActive
? 'bg-amber-500/10 text-amber-400 font-medium border border-amber-500/10'
: 'text-white/50 hover:text-white/80 hover:bg-white/[0.04] border border-transparent'
}`
}
style={{ animationDelay: `${i * 0.06}s` }}
>
{item.label}
</NavLink>
))}
</nav>
<button onClick={handleLogout} className="text-sm text-gray-500 hover:text-red-600 text-left px-3 py-2 cursor-pointer">
<button
onClick={handleLogout}
className="mt-auto px-3 py-2 rounded-lg text-sm text-white/40 hover:text-red-400 transition-colors text-left hover:bg-red-500/5 border border-transparent cursor-pointer"
>
Abmelden
</button>
</aside>
<main className="flex-1 p-8 overflow-auto">
<Outlet />
<main className="flex-1 overflow-auto">
<div className="max-w-5xl mx-auto p-8">
<Outlet />
</div>
</main>
</div>
);
+25 -10
View File
@@ -2,7 +2,6 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useForm } from 'react-hook-form';
import { api } from '@/lib/api';
import { Button } from '@/components/ui/Button';
import { Card } from '@/components/ui/Card';
import { Input } from '@/components/ui/Input';
import { useEffect } from 'react';
@@ -24,19 +23,35 @@ export function SettingsPage() {
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['settings'] }),
});
if (isLoading) return <p className="text-gray-500">Lädt...</p>;
if (isLoading) {
return (
<div className="flex items-center justify-center py-20">
<div className="w-5 h-5 border-2 border-amber-500/30 border-t-amber-400 rounded-full animate-spin" />
</div>
);
}
return (
<div>
<h2 className="text-2xl font-bold mb-6">Einstellungen</h2>
<Card>
<form onSubmit={handleSubmit(data => mutation.mutate(data))} className="flex flex-col gap-4 max-w-md">
<Input label="Standard max. Buchungen pro Slot" type="number" {...register('default_max_bookings')} />
<div className="flex justify-end">
<Button type="submit" disabled={mutation.isPending}>{mutation.isPending ? 'Speichert...' : 'Speichern'}</Button>
<div className="animate-fade-in">
<div className="mb-8">
<h1 className="text-xl font-semibold text-white/90">Einstellungen</h1>
<p className="text-sm text-white/40 mt-0.5">Globale Konfiguration</p>
</div>
<div className="bg-white/[0.03] border border-white/[0.06] rounded-xl p-6 max-w-md">
<form onSubmit={handleSubmit(data => mutation.mutate(data))} className="flex flex-col gap-4">
<Input
label="Standard max. Buchungen pro Slot"
type="number"
{...register('default_max_bookings')}
/>
<div className="flex justify-end pt-2 border-t border-white/[0.06]">
<Button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? 'Speichert...' : 'Speichern'}
</Button>
</div>
</form>
</Card>
</div>
</div>
);
}
+6 -4
View File
@@ -44,16 +44,18 @@ export function SlotForm({ slot, onClose }: SlotFormProps) {
return (
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-4">
<Input label="Titel" {...register('title')} error={errors.title?.message} />
<Input label="Titel" {...register('title')} error={errors.title?.message} placeholder="z.B. Windows 11 Update" />
<Input label="Datum" type="date" {...register('date')} error={errors.date?.message} />
<div className="grid grid-cols-2 gap-4">
<div className="grid grid-cols-2 gap-3">
<Input label="Startzeit" type="time" {...register('start_time')} error={errors.start_time?.message} />
<Input label="Endzeit" type="time" {...register('end_time')} error={errors.end_time?.message} />
</div>
<Input label="Max. gleichzeitige Buchungen" type="number" {...register('max_bookings')} error={errors.max_bookings?.message} />
<div className="flex justify-end gap-2 mt-2">
<div className="flex justify-end gap-2 mt-2 pt-2 border-t border-white/[0.06]">
<Button type="button" variant="secondary" onClick={onClose}>Abbrechen</Button>
<Button type="submit" disabled={isSubmitting}>{isSubmitting ? 'Speichert...' : slot ? 'Speichern' : 'Erstellen'}</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Speichert...' : slot ? 'Speichern' : 'Erstellen'}
</Button>
</div>
</form>
);
+57 -23
View File
@@ -2,7 +2,6 @@ import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from '@/lib/api';
import { Button } from '@/components/ui/Button';
import { Card } from '@/components/ui/Card';
import { Modal } from '@/components/ui/Modal';
import { SlotForm } from './SlotForm';
import type { Slot } from '@/types';
@@ -33,34 +32,69 @@ export function SlotsPage() {
}
return (
<div>
<div className="flex items-center justify-between mb-6">
<h2 className="text-2xl font-bold">Slots</h2>
<Button onClick={openCreate}>Slot erstellen</Button>
<div className="animate-fade-in">
<div className="flex items-center justify-between mb-8">
<div>
<h1 className="text-xl font-semibold text-white/90">Slots</h1>
<p className="text-sm text-white/40 mt-0.5">Update-Termin Slots verwalten</p>
</div>
<Button onClick={openCreate}>+ Slot erstellen</Button>
</div>
{isLoading ? (
<p className="text-gray-500">Lädt...</p>
<div className="flex items-center justify-center py-20">
<div className="w-5 h-5 border-2 border-amber-500/30 border-t-amber-400 rounded-full animate-spin" />
</div>
) : (
<div className="grid gap-4">
{slots?.map((slot: any) => (
<Card key={slot.id} className="flex items-center justify-between">
<div>
<h3 className="font-semibold">{slot.title}</h3>
<p className="text-sm text-gray-500">
{slot.date} | {slot.start_time} - {slot.end_time}
</p>
<p className="text-sm text-gray-500">
{slot.total_booked}/{slot.max_bookings} belegt
</p>
<div className="grid gap-3">
{slots?.map((slot: any, i: number) => (
<div
key={slot.id}
className="animate-fade-in"
style={{ animationDelay: `${i * 0.04}s` }}
>
<div className="bg-white/[0.03] border border-white/[0.06] rounded-xl p-5 hover:border-white/[0.1] transition-colors">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="w-10 h-10 rounded-lg bg-amber-500/5 border border-amber-500/10 flex items-center justify-center flex-shrink-0">
<svg className="w-5 h-5 text-amber-400/60" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 0 1 2.25-2.25h13.5A2.25 2.25 0 0 1 21 7.5v11.25m-18 0A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75m-18 0v-7.5A2.25 2.25 0 0 1 5.25 9h13.5A2.25 2.25 0 0 1 21 11.25v7.5" />
</svg>
</div>
<div>
<h3 className="text-sm font-medium text-white/90">{slot.title}</h3>
<div className="flex items-center gap-3 mt-1">
<span className="text-xs font-mono text-white/40">
{slot.date}
</span>
<span className="text-white/20">·</span>
<span className="text-xs font-mono text-white/40">
{slot.start_time} {slot.end_time}
</span>
<span className="text-white/20">·</span>
<span className={`text-xs font-mono ${slot.total_booked >= slot.max_bookings ? 'text-red-400' : 'text-white/50'}`}>
{slot.total_booked}/{slot.max_bookings} belegt
</span>
</div>
</div>
</div>
<div className="flex items-center gap-2">
<Button variant="secondary" size="sm" onClick={() => openEdit(slot)}>
Bearbeiten
</Button>
<Button variant="danger" size="sm" onClick={() => deleteMutation.mutate(slot.id)}>
Löschen
</Button>
</div>
</div>
</div>
<div className="flex gap-2">
<Button variant="secondary" onClick={() => openEdit(slot)}>Bearbeiten</Button>
<Button variant="danger" onClick={() => deleteMutation.mutate(slot.id)}>Löschen</Button>
</div>
</Card>
</div>
))}
{slots?.length === 0 && <p className="text-gray-400 text-center py-8">Keine Slots vorhanden</p>}
{(!slots || slots.length === 0) && (
<div className="text-center py-20">
<p className="text-sm text-white/30">Keine Slots vorhanden</p>
</div>
)}
</div>
)}
+117
View File
@@ -1 +1,118 @@
@import "tailwindcss";
@theme {
--font-sans: 'DM Sans', sans-serif;
--font-mono: 'JetBrains Mono', monospace;
--color-amber-50: #fffbeb;
--color-amber-100: #fef3c7;
--color-amber-200: #fde68a;
--color-amber-300: #fcd34d;
--color-amber-400: #fbbf24;
--color-amber-500: #f59e0b;
--color-amber-600: #d97706;
--color-amber-700: #b45309;
--color-slate-800: #1e293b;
--color-slate-900: #0f172a;
--color-slate-950: #020617;
}
:root {
--bg-primary: #0f1117;
--bg-surface: #1a1d27;
--bg-surface-hover: #222639;
--bg-elevated: #232740;
--border-subtle: rgba(255, 255, 255, 0.06);
--border-medium: rgba(255, 255, 255, 0.1);
--text-primary: rgba(255, 255, 255, 0.92);
--text-secondary: rgba(255, 255, 255, 0.55);
--text-tertiary: rgba(255, 255, 255, 0.35);
--amber: #f59e0b;
--amber-dim: rgba(245, 158, 11, 0.15);
--amber-glow: 0 0 20px rgba(245, 158, 11, 0.15);
--green: #22c55e;
--green-dim: rgba(34, 197, 94, 0.15);
--red: #ef4444;
--red-dim: rgba(239, 68, 68, 0.15);
--radius: 12px;
--radius-sm: 8px;
}
* {
scrollbar-width: thin;
scrollbar-color: rgba(255,255,255,0.1) transparent;
}
body {
font-family: 'DM Sans', sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
[class*="font-mono"] {
font-family: 'JetBrains Mono', monospace;
}
@keyframes fade-in {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes slide-in {
from { opacity: 0; transform: translateX(-12px); }
to { opacity: 1; transform: translateX(0); }
}
@keyframes pulse-glow {
0%, 100% { box-shadow: 0 0 8px rgba(245, 158, 11, 0.1); }
50% { box-shadow: 0 0 20px rgba(245, 158, 11, 0.25); }
}
@keyframes status-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.animate-fade-in {
animation: fade-in 0.4s ease-out forwards;
}
.animate-slide-in {
animation: slide-in 0.3s ease-out forwards;
}
.animate-pulse-glow {
animation: pulse-glow 3s ease-in-out infinite;
}
.stagger-1 { animation-delay: 0.05s; }
.stagger-2 { animation-delay: 0.1s; }
.stagger-3 { animation-delay: 0.15s; }
.stagger-4 { animation-delay: 0.2s; }
.stagger-5 { animation-delay: 0.25s; }
.stagger-6 { animation-delay: 0.3s; }
.stagger-7 { animation-delay: 0.35s; }
.stagger-8 { animation-delay: 0.4s; }
.bg-noise {
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.03'/%3E%3C/svg%3E");
background-repeat: repeat;
background-size: 256px 256px;
}
input[type="date"]::-webkit-calendar-picker-indicator,
input[type="time"]::-webkit-calendar-picker-indicator {
filter: invert(0.7);
cursor: pointer;
}
input:-webkit-autofill,
input:-webkit-autofill:hover,
input:-webkit-autofill:focus {
-webkit-text-fill-color: var(--text-primary);
-webkit-box-shadow: 0 0 0px 1000px var(--bg-surface) inset;
transition: background-color 5000s ease-in-out 0s;
}