Latest Version

This commit is contained in:
MarcWieland
2026-08-05 18:51:37 +02:00
parent de3bce8dcc
commit 3600036833
29 changed files with 3932 additions and 849 deletions
+59 -4
View File
@@ -1,5 +1,8 @@
import React, { useState } from 'react'; import React, { useState, useEffect } from 'react';
import { Menu, Wrench } from 'lucide-react'; import { Menu, Wrench, Loader2 } from 'lucide-react';
import { AuthProvider, useAuth } from './context/AuthContext';
import { ThemeProvider } from './context/ThemeContext';
import { AuthView } from './views/AuthView';
import { Sidebar } from './components/Sidebar'; import { Sidebar } from './components/Sidebar';
import { DashboardView } from './views/DashboardView'; import { DashboardView } from './views/DashboardView';
import { CustomersView } from './views/CustomersView'; import { CustomersView } from './views/CustomersView';
@@ -9,8 +12,11 @@ import { EquipmentView } from './views/EquipmentView';
import { TechniciansView } from './views/TechniciansView'; import { TechniciansView } from './views/TechniciansView';
import { TimeTrackingView } from './views/TimeTrackingView'; import { TimeTrackingView } from './views/TimeTrackingView';
import { SupabaseSyncView } from './views/SupabaseSyncView'; import { SupabaseSyncView } from './views/SupabaseSyncView';
import { CustomerPortalView } from './views/CustomerPortalView';
const MainLayout: React.FC = () => {
const { profile, isLoading } = useAuth();
export const App: React.FC = () => {
const [activeTab, setActiveTabState] = useState<string>(() => { const [activeTab, setActiveTabState] = useState<string>(() => {
return localStorage.getItem('handwerksfreund_active_tab') || 'dashboard'; return localStorage.getItem('handwerksfreund_active_tab') || 'dashboard';
}); });
@@ -21,6 +27,43 @@ export const App: React.FC = () => {
localStorage.setItem('handwerksfreund_active_tab', tab); localStorage.setItem('handwerksfreund_active_tab', tab);
}; };
// Adjust active tab if user role restricts access
useEffect(() => {
if (!profile) return;
if (profile.role === 'monteur' && ['dashboard', 'lager', 'monteure', 'supabase'].includes(activeTab)) {
setActiveTab('dispatching');
} else if (profile.role === 'kunde' && !['portal', 'kunden', 'geraete'].includes(activeTab)) {
setActiveTab('portal');
}
}, [profile, activeTab]);
if (isLoading) {
return (
<div style={{
minHeight: '100vh',
backgroundColor: '#0f172a',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
color: '#38bdf8',
gap: '16px',
fontFamily: 'system-ui, -apple-system, sans-serif'
}}>
<Loader2 size={40} className="animate-spin" style={{ animation: 'spin 1s linear infinite' }} />
<span style={{ fontSize: '0.95rem', fontWeight: 600, color: '#94a3b8' }}>
Handwerksfreund wird geladen...
</span>
</div>
);
}
// If unauthenticated, show AuthView
if (!profile) {
return <AuthView />;
}
const renderView = () => { const renderView = () => {
switch (activeTab) { switch (activeTab) {
case 'dashboard': case 'dashboard':
@@ -37,10 +80,12 @@ export const App: React.FC = () => {
return <TechniciansView />; return <TechniciansView />;
case 'zeiterfassung': case 'zeiterfassung':
return <TimeTrackingView />; return <TimeTrackingView />;
case 'portal':
return <CustomerPortalView initialCode="PRJ-8392" initialPin="749201" />;
case 'supabase': case 'supabase':
return <SupabaseSyncView />; return <SupabaseSyncView />;
default: default:
return <DashboardView />; return <DashboardView onNavigate={setActiveTab} />;
} }
}; };
@@ -76,4 +121,14 @@ export const App: React.FC = () => {
); );
}; };
export const App: React.FC = () => {
return (
<ThemeProvider>
<AuthProvider>
<MainLayout />
</AuthProvider>
</ThemeProvider>
);
};
export default App; export default App;
+2 -14
View File
@@ -346,22 +346,10 @@ export const CustomerModal: React.FC<CustomerModalProps> = ({
<button <button
type="submit" type="submit"
disabled={isSaving} disabled={isSaving}
style={{ className="btn-primary-cta"
padding: '10px 20px',
borderRadius: '8px',
border: 'none',
backgroundColor: '#2563eb',
color: '#ffffff',
fontWeight: 600,
cursor: isSaving ? 'not-allowed' : 'pointer',
display: 'flex',
alignItems: 'center',
gap: '8px',
opacity: isSaving ? 0.7 : 1
}}
> >
<Save size={18} /> <Save size={18} />
{isSaving ? 'Speichert...' : 'Kunden speichern'} <span>{isSaving ? 'Speichert...' : 'Kunden Speichern'}</span>
</button> </button>
</div> </div>
</form> </form>
+2 -14
View File
@@ -332,22 +332,10 @@ export const InstalledPartModal: React.FC<InstalledPartModalProps> = ({
<button <button
type="submit" type="submit"
disabled={isSaving} disabled={isSaving}
style={{ className="btn-primary-cta"
padding: '10px 20px',
borderRadius: '8px',
border: 'none',
backgroundColor: '#2563eb',
color: '#ffffff',
fontWeight: 600,
cursor: isSaving ? 'not-allowed' : 'pointer',
display: 'flex',
alignItems: 'center',
gap: '8px',
opacity: isSaving ? 0.7 : 1
}}
> >
<Save size={18} /> <Save size={18} />
{isSaving ? 'Speichert...' : 'Gerät speichern'} <span>{isSaving ? 'Speichert...' : 'Gerät Speichern'}</span>
</button> </button>
</div> </div>
</form> </form>
+45 -23
View File
@@ -1,4 +1,5 @@
import React from 'react'; import React from 'react';
import { CalendarDays } from 'lucide-react';
import { ScheduleItem } from '../types'; import { ScheduleItem } from '../types';
interface LiveOrdersTableProps { interface LiveOrdersTableProps {
@@ -7,35 +8,56 @@ interface LiveOrdersTableProps {
export const LiveOrdersTable: React.FC<LiveOrdersTableProps> = ({ orders }) => { export const LiveOrdersTable: React.FC<LiveOrdersTableProps> = ({ orders }) => {
return ( return (
<div className="content-card"> <div style={{
<h2 className="card-heading">Heutige Live-Aufträge (Dispatching)</h2> backgroundColor: '#1e293b',
<div className="table-responsive"> border: '1px solid #334155',
<table className="orders-table"> borderRadius: '12px',
padding: '24px',
color: '#f8fafc'
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '18px' }}>
<CalendarDays size={20} color="#0284c7" />
<h3 style={{ fontSize: '1.05rem', fontWeight: 700, margin: 0 }}>Heutige Live-Aufträge (Dispatching)</h3>
</div>
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', textAlign: 'left', fontSize: '0.875rem' }}>
<thead> <thead>
<tr> <tr style={{ borderBottom: '1px solid #334155', color: '#94a3b8', fontSize: '0.78rem', textTransform: 'uppercase', letterSpacing: '0.04em' }}>
<th>Kunde</th> <th style={{ padding: '10px 12px', fontWeight: 600 }}>Kunde</th>
<th>Monteur</th> <th style={{ padding: '10px 12px', fontWeight: 600 }}>Monteur</th>
<th>Status</th> <th style={{ padding: '10px 12px', fontWeight: 600 }}>Uhrzeit</th>
<th style={{ padding: '10px 12px', fontWeight: 600 }}>Status</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{orders.map((order) => ( {orders.map((order) => {
<tr key={order.id}> const isInProgress = order.status === 'In Bearbeitung' || order.isInProgress;
<td style={{ fontWeight: 600, color: '#0f172a' }}>{order.customerName}</td> const isCompleted = order.status === 'Abgeschlossen' || order.isCompleted;
<td style={{ color: '#334155' }}>{order.technicianName}</td>
<td> return (
<span <tr key={order.id} style={{ borderBottom: '1px solid #334155' }}>
className={`badge ${ <td style={{ padding: '12px', fontWeight: 600, color: '#f8fafc' }}>
order.status === 'In Bearbeitung' {order.customerName || 'Kunde'}
? 'badge-in-progress' </td>
: 'badge-scheduled' <td style={{ padding: '12px', color: '#cbd5e1' }}>
}`} {order.technicianName || 'Obermonteur Max'}
> </td>
{order.status === 'Geplant' ? `Geplant (${order.startTime})` : order.status} <td style={{ padding: '12px', color: '#94a3b8' }}>
</span> {order.startTime} - {order.endTime}
</td>
<td style={{ padding: '12px' }}>
{isInProgress ? (
<span className="badge badge-warning">In Bearbeitung</span>
) : isCompleted ? (
<span className="badge badge-success">Abgeschlossen</span>
) : (
<span className="badge badge-info">Geplant</span>
)}
</td> </td>
</tr> </tr>
))} );
})}
</tbody> </tbody>
</table> </table>
</div> </div>
+2 -14
View File
@@ -406,22 +406,10 @@ export const ScheduleItemModal: React.FC<ScheduleItemModalProps> = ({
<button <button
type="submit" type="submit"
disabled={isSaving} disabled={isSaving}
style={{ className="btn-primary-cta"
padding: '10px 20px',
borderRadius: '8px',
border: 'none',
backgroundColor: '#2563eb',
color: '#ffffff',
fontWeight: 600,
cursor: isSaving ? 'not-allowed' : 'pointer',
display: 'flex',
alignItems: 'center',
gap: '8px',
opacity: isSaving ? 0.7 : 1
}}
> >
<Save size={18} /> <Save size={18} />
{isSaving ? 'Speichert...' : 'Einsatz speichern'} <span>{isSaving ? 'Speichert...' : 'Einsatz Speichern'}</span>
</button> </button>
</div> </div>
</form> </form>
+234 -18
View File
@@ -1,4 +1,4 @@
import React from 'react'; import React, { useState } from 'react';
import { import {
Wrench, Wrench,
LayoutDashboard, LayoutDashboard,
@@ -8,8 +8,18 @@ import {
Clock, Clock,
Database, Database,
X, X,
Info Info,
LogOut,
Settings,
ShieldCheck,
Sun,
Moon,
ChevronRight
} from 'lucide-react'; } from 'lucide-react';
import { useAuth } from '../context/AuthContext';
import { useTheme } from '../context/ThemeContext';
import { UserProfileModal } from './UserProfileModal';
import { VersionHistoryModal } from './VersionHistoryModal';
interface SidebarProps { interface SidebarProps {
activeTab: string; activeTab: string;
@@ -24,22 +34,43 @@ export const Sidebar: React.FC<SidebarProps> = ({
isOpen, isOpen,
onClose onClose
}) => { }) => {
const menuItems = [ const { profile, signOut } = useAuth();
{ id: 'dashboard', label: 'Dashboard', icon: LayoutDashboard }, const { theme, setTheme } = useTheme();
{ id: 'kunden', label: 'Kunden & Aufträge', icon: Users }, const [profileModalOpen, setProfileModalOpen] = useState(false);
{ id: 'dispatching', label: 'Dispatching', icon: CalendarDays }, const [versionModalOpen, setVersionModalOpen] = useState(false);
{ id: 'lager', label: 'Ersatzteillager', icon: Package },
{ id: 'geraete', label: 'Gerätekartei', icon: Wrench }, const allMenuItems = [
{ id: 'monteure', label: 'Monteure', icon: Users }, { id: 'dashboard', label: 'Dashboard', icon: LayoutDashboard, roles: ['admin', 'disponent'] },
{ id: 'zeiterfassung', label: 'Zeiterfassung', icon: Clock }, { id: 'kunden', label: 'Kunden & Aufträge', icon: Users, roles: ['admin', 'disponent', 'monteur', 'kunde'] },
{ id: 'supabase', label: 'Supabase Sync', icon: Database }, { id: 'dispatching', label: 'Dispatching', icon: CalendarDays, roles: ['admin', 'disponent', 'monteur'] },
{ id: 'lager', label: 'Ersatzteillager', icon: Package, roles: ['admin', 'disponent'] },
{ id: 'geraete', label: 'Gerätekartei', icon: Wrench, roles: ['admin', 'disponent', 'monteur', 'kunde'] },
{ id: 'monteure', label: 'Monteure', icon: Users, roles: ['admin', 'disponent'] },
{ id: 'zeiterfassung', label: 'Zeiterfassung', icon: Clock, roles: ['admin', 'disponent', 'monteur'] },
{ id: 'portal', label: 'Kundenportal (Demo)', icon: ShieldCheck, roles: ['admin', 'disponent', 'kunde'] },
{ id: 'supabase', label: 'Supabase Sync', icon: Database, roles: ['admin', 'disponent'] },
]; ];
const userRole = profile?.role || 'disponent';
// Filter items accessible by the user's role
const menuItems = allMenuItems.filter(item => item.roles.includes(userRole));
const handleSelect = (tabId: string) => { const handleSelect = (tabId: string) => {
setActiveTab(tabId); setActiveTab(tabId);
onClose(); // Close mobile sidebar on selection onClose(); // Close mobile sidebar on selection
}; };
const getRoleLabel = (role: string) => {
switch (role) {
case 'admin': return 'Admin';
case 'disponent': return 'Disponent';
case 'monteur': return 'Monteur';
case 'kunde': return 'Kunde';
default: return role;
}
};
return ( return (
<> <>
{/* Mobile Backdrop */} {/* Mobile Backdrop */}
@@ -68,6 +99,79 @@ export const Sidebar: React.FC<SidebarProps> = ({
</button> </button>
</div> </div>
{/* User Profile Card */}
{profile && (
<div style={{
margin: '12px 0 20px 0',
padding: '12px 14px',
backgroundColor: '#0f172a',
borderRadius: '12px',
border: '1px solid #1e293b',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between'
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', overflow: 'hidden' }}>
{profile.avatarUrl ? (
<img
src={profile.avatarUrl}
alt={profile.fullName}
style={{
width: '36px',
height: '36px',
borderRadius: '50%',
objectFit: 'cover',
border: '1px solid #38bdf8',
flexShrink: 0
}}
/>
) : (
<div style={{
width: '36px',
height: '36px',
borderRadius: '50%',
backgroundColor: '#0284c7',
color: '#ffffff',
fontWeight: 700,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '0.95rem',
flexShrink: 0
}}>
{profile.fullName.charAt(0).toUpperCase()}
</div>
)}
<div style={{ overflow: 'hidden', whiteSpace: 'nowrap', textOverflow: 'ellipsis' }}>
<div style={{ fontSize: '0.85rem', fontWeight: 700, color: '#f8fafc', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{profile.fullName}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '4px', fontSize: '0.72rem', color: '#38bdf8' }}>
<ShieldCheck size={12} />
<span>{getRoleLabel(profile.role)}</span>
</div>
</div>
</div>
<button
onClick={() => setProfileModalOpen(true)}
title="Profil Einstellungen"
style={{
background: 'none',
border: 'none',
color: '#64748b',
cursor: 'pointer',
padding: '6px',
borderRadius: '6px',
display: 'flex',
alignItems: 'center'
}}
>
<Settings size={18} />
</button>
</div>
)}
<nav> <nav>
<ul className="nav-list"> <ul className="nav-list">
{menuItems.map((item) => { {menuItems.map((item) => {
@@ -89,35 +193,147 @@ export const Sidebar: React.FC<SidebarProps> = ({
</nav> </nav>
</div> </div>
{/* Sidebar Footer with Version Badge */} {/* Sidebar Footer with Theme Switcher & Logout & Clickable Version Badge */}
<div style={{ <div style={{
marginTop: 'auto', marginTop: 'auto',
paddingTop: '20px', paddingTop: '16px',
borderTop: '1px solid #172545', borderTop: '1px solid #172545',
display: 'flex',
flexDirection: 'column',
gap: '10px'
}}>
{/* Theme Switcher Toggle */}
<div style={{
display: 'flex',
backgroundColor: '#0f172a',
padding: '3px',
borderRadius: '8px',
border: '1px solid #1e293b'
}}>
<button
onClick={() => setTheme('light')}
style={{
flex: 1,
padding: '6px 10px',
borderRadius: '6px',
fontSize: '0.78rem',
fontWeight: 600,
border: 'none',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '6px',
backgroundColor: theme === 'light' ? '#0284c7' : 'transparent',
color: theme === 'light' ? '#ffffff' : '#94a3b8',
transition: 'all 0.15s ease'
}}
>
<Sun size={14} />
<span>Hell Mode</span>
</button>
<button
onClick={() => setTheme('dark')}
style={{
flex: 1,
padding: '6px 10px',
borderRadius: '6px',
fontSize: '0.78rem',
fontWeight: 600,
border: 'none',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '6px',
backgroundColor: theme === 'dark' ? '#0284c7' : 'transparent',
color: theme === 'dark' ? '#ffffff' : '#94a3b8',
transition: 'all 0.15s ease'
}}
>
<Moon size={14} />
<span>Dunkel</span>
</button>
</div>
{/* Abmelden Button */}
{profile && (
<button
onClick={() => signOut()}
style={{
width: '100%',
padding: '10px 14px',
borderRadius: '8px',
backgroundColor: 'rgba(239, 68, 68, 0.1)',
border: '1px solid rgba(239, 68, 68, 0.3)',
color: '#fca5a5',
fontSize: '0.83rem',
fontWeight: 600,
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '8px',
transition: 'background 0.2s'
}}
>
<LogOut size={16} />
<span>Abmelden</span>
</button>
)}
{/* Clickable Version History Trigger Button */}
<button
onClick={() => setVersionModalOpen(true)}
style={{
fontSize: '0.8rem', fontSize: '0.8rem',
color: '#64748b', color: '#64748b',
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'space-between' justifyContent: 'space-between',
}}> paddingTop: '2px',
background: 'none',
border: 'none',
width: '100%',
cursor: 'pointer',
textAlign: 'left'
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}> <div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<Info size={14} color="#38bdf8" /> <Info size={14} color="#38bdf8" />
<span>Version</span> <span style={{ color: '#94a3b8' }}>Versionen</span>
</div> </div>
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
<span style={{ <span style={{
backgroundColor: '#172545', backgroundColor: '#172545',
color: '#38bdf8', color: '#38bdf8',
padding: '2px 10px', padding: '2px 8px',
borderRadius: '12px', borderRadius: '12px',
fontWeight: 700, fontWeight: 700,
fontSize: '0.78rem', fontSize: '0.78rem',
border: '1px solid #1e3a8a' border: '1px solid #1e3a8a'
}}> }}>
v0.1 v0.4
</span> </span>
<ChevronRight size={14} color="#64748b" />
</div>
</button>
</div> </div>
</aside> </aside>
{/* Profile Modal */}
<UserProfileModal
isOpen={profileModalOpen}
onClose={() => setProfileModalOpen(false)}
/>
{/* Version History Modal */}
<VersionHistoryModal
isOpen={versionModalOpen}
onClose={() => setVersionModalOpen(false)}
/>
</> </>
); );
}; };
+4 -2
View File
@@ -9,8 +9,10 @@ interface StatCardProps {
export const StatCard: React.FC<StatCardProps> = ({ label, value, isWarning }) => { export const StatCard: React.FC<StatCardProps> = ({ label, value, isWarning }) => {
return ( return (
<div className="kpi-card"> <div className="kpi-card">
<span className="kpi-label">{label}</span> <span className="kpi-title">{label}</span>
<span className={`kpi-value ${isWarning ? 'warning' : ''}`}>{value}</span> <span className="kpi-value" style={{ color: isWarning ? '#f87171' : '#ffffff' }}>
{value}
</span>
</div> </div>
); );
}; };
+52 -14
View File
@@ -1,4 +1,5 @@
import React from 'react'; import React from 'react';
import { Database, CheckCircle2 } from 'lucide-react';
import { SystemStatus } from '../types'; import { SystemStatus } from '../types';
interface SystemStatusCardProps { interface SystemStatusCardProps {
@@ -10,26 +11,63 @@ export const SystemStatusCard: React.FC<SystemStatusCardProps> = ({ status }) =>
const latency = status?.latencyMs ?? 18; const latency = status?.latencyMs ?? 18;
return ( return (
<div className="content-card"> <div style={{
<h2 className="card-heading">Supabase System-Status</h2> backgroundColor: '#1e293b',
<p className="status-panel-description"> border: '1px solid #334155',
Datenbank-Verbindung und Realtime-Sync laufen stabil. borderRadius: '12px',
padding: '24px',
color: '#f8fafc'
}}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '12px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
<Database size={20} color="#0284c7" />
<h3 style={{ fontSize: '1.05rem', fontWeight: 700, margin: 0 }}>Supabase System-Status</h3>
</div>
<div style={{
display: 'inline-flex',
alignItems: 'center',
gap: '6px',
padding: '4px 10px',
borderRadius: '20px',
backgroundColor: 'rgba(74, 222, 128, 0.12)',
border: '1px solid rgba(74, 222, 128, 0.3)',
color: '#4ade80',
fontSize: '0.78rem',
fontWeight: 700
}}>
<CheckCircle2 size={14} />
<span>Verbunden</span>
</div>
</div>
<p style={{ color: '#94a3b8', fontSize: '0.85rem', margin: '0 0 16px 0' }}>
Die Verbindung zur PostgreSQL Datenbank und Realtime-Sync laufen stabil.
</p> </p>
<div className="status-details-box"> <div style={{
<div className="status-item"> display: 'grid',
<span className="status-key">DB Host:</span> gridTemplateColumns: 'repeat(3, 1fr)',
<span className="status-val">{host}</span> gap: '12px',
backgroundColor: '#0f172a',
padding: '14px 16px',
borderRadius: '8px',
border: '1px solid #1e293b',
fontSize: '0.83rem'
}}>
<div>
<div style={{ color: '#64748b', fontSize: '0.75rem', marginBottom: '2px' }}>DB Host</div>
<div style={{ fontWeight: 600, color: '#f8fafc', overflow: 'hidden', textOverflow: 'ellipsis' }}>{host}</div>
</div> </div>
<div className="status-item"> <div>
<span className="status-key">Latency:</span> <div style={{ color: '#64748b', fontSize: '0.75rem', marginBottom: '2px' }}>Latenz</div>
<span className="status-val">{latency}ms</span> <div style={{ fontWeight: 600, color: '#4ade80' }}>{latency}ms</div>
</div> </div>
<div className="status-item"> <div>
<span className="status-key">Letzter Sync:</span> <div style={{ color: '#64748b', fontSize: '0.75rem', marginBottom: '2px' }}>Letzter Sync</div>
<span className="status-val">Vor wenigen Sekunden</span> <div style={{ fontWeight: 600, color: '#f8fafc' }}>Vor wenigen Sekunden</div>
</div> </div>
</div> </div>
</div> </div>
+2 -14
View File
@@ -294,22 +294,10 @@ export const TechnicianModal: React.FC<TechnicianModalProps> = ({
<button <button
type="submit" type="submit"
disabled={isSaving} disabled={isSaving}
style={{ className="btn-primary-cta"
padding: '10px 20px',
borderRadius: '8px',
border: 'none',
backgroundColor: '#2563eb',
color: '#ffffff',
fontWeight: 600,
cursor: isSaving ? 'not-allowed' : 'pointer',
display: 'flex',
alignItems: 'center',
gap: '8px',
opacity: isSaving ? 0.7 : 1
}}
> >
<Save size={18} /> <Save size={18} />
{isSaving ? 'Speichert...' : 'Monteur speichern'} <span>{isSaving ? 'Speichert...' : 'Monteur Speichern'}</span>
</button> </button>
</div> </div>
</form> </form>
+421
View File
@@ -0,0 +1,421 @@
import React, { useState, useRef } from 'react';
import { X, User, Mail, Phone, Shield, Check, Save, Camera, Upload, Trash2 } from 'lucide-react';
import { useAuth } from '../context/AuthContext';
import { SupabaseService } from '../services/supabaseService';
interface UserProfileModalProps {
isOpen: boolean;
onClose: () => void;
}
export const UserProfileModal: React.FC<UserProfileModalProps> = ({ isOpen, onClose }) => {
const { profile, updateProfile } = useAuth();
const [fullName, setFullName] = useState(profile?.fullName || '');
const [phone, setPhone] = useState(profile?.phone || '');
const [avatarUrl, setAvatarUrl] = useState(profile?.avatarUrl || '');
const [isSaved, setIsSaved] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [isUploadingPhoto, setIsUploadingPhoto] = useState(false);
const fileInputRef = useRef<HTMLInputElement | null>(null);
if (!isOpen || !profile) return null;
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setIsUploadingPhoto(true);
try {
// 1. Try uploading to Supabase Storage
const path = `avatars/${profile.id}_${Date.now()}.${file.name.split('.').pop() || 'jpg'}`;
const storageUrl = await SupabaseService.uploadFileToStorage('equipment-files', path, file);
if (storageUrl) {
setAvatarUrl(storageUrl);
} else {
// Fallback: Read as Data URL for immediate local preview & storage
const reader = new FileReader();
reader.onload = (event) => {
if (event.target?.result) {
setAvatarUrl(event.target.result as string);
}
};
reader.readAsDataURL(file);
}
} catch (err) {
console.warn('Could not upload to storage bucket, using data URL fallback:', err);
const reader = new FileReader();
reader.onload = (event) => {
if (event.target?.result) {
setAvatarUrl(event.target.result as string);
}
};
reader.readAsDataURL(file);
} finally {
setIsUploadingPhoto(false);
}
};
const handleSave = async (e: React.FormEvent) => {
e.preventDefault();
setIsSaving(true);
await updateProfile({
fullName,
phone,
avatarUrl
});
setIsSaving(false);
setIsSaved(true);
setTimeout(() => setIsSaved(false), 2500);
};
const getRoleBadgeStyle = (role: string) => {
switch (role) {
case 'admin':
case 'disponent':
return { bg: 'rgba(56, 189, 248, 0.15)', border: '#38bdf8', color: '#38bdf8', label: 'Admin / Disponent' };
case 'monteur':
return { bg: 'rgba(74, 222, 128, 0.15)', border: '#4ade80', color: '#4ade80', label: 'Monteur / Außendienst' };
case 'kunde':
return { bg: 'rgba(250, 204, 21, 0.15)', border: '#facc15', color: '#facc15', label: 'Kunde' };
default:
return { bg: '#1e293b', border: '#64748b', color: '#94a3b8', label: role };
}
};
const badge = getRoleBadgeStyle(profile.role);
return (
<div className="modal-overlay" style={{
position: 'fixed',
inset: 0,
backgroundColor: 'rgba(15, 23, 42, 0.8)',
backdropFilter: 'blur(8px)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1000,
padding: '16px'
}}>
<div className="modal-container" style={{
backgroundColor: '#1e293b',
border: '1px solid #334155',
borderRadius: '16px',
width: '100%',
maxWidth: '480px',
color: '#f8fafc',
boxShadow: '0 25px 50px -12px rgba(0,0,0,0.5)',
overflow: 'hidden'
}}>
{/* Header */}
<div style={{
padding: '20px 24px',
borderBottom: '1px solid #334155',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
background: 'linear-gradient(to right, #1e293b, #0f172a)'
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<div style={{ position: 'relative' }}>
{avatarUrl ? (
<img
src={avatarUrl}
alt={fullName}
style={{
width: '44px',
height: '44px',
borderRadius: '50%',
objectFit: 'cover',
border: '2px solid #0284c7'
}}
/>
) : (
<div style={{
width: '44px',
height: '44px',
borderRadius: '50%',
backgroundColor: '#0284c7',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontWeight: 700,
fontSize: '1.2rem',
color: '#ffffff'
}}>
{profile.fullName.charAt(0).toUpperCase()}
</div>
)}
</div>
<div>
<h3 style={{ margin: 0, fontSize: '1.1rem', fontWeight: 700 }}>Mein Profil</h3>
<p style={{ margin: 0, fontSize: '0.8rem', color: '#94a3b8' }}>Profilbild & Benutzerkontodaten</p>
</div>
</div>
<button
onClick={onClose}
style={{
background: 'none',
border: 'none',
color: '#94a3b8',
cursor: 'pointer',
padding: '4px',
borderRadius: '6px'
}}
>
<X size={20} />
</button>
</div>
{/* Content Form */}
<form onSubmit={handleSave} style={{ padding: '24px', display: 'flex', flexDirection: 'column', gap: '18px' }}>
{/* Avatar Upload Section */}
<div style={{
display: 'flex',
alignItems: 'center',
gap: '16px',
padding: '16px',
backgroundColor: '#0f172a',
borderRadius: '12px',
border: '1px solid #334155'
}}>
<input
type="file"
ref={fileInputRef}
accept="image/*"
style={{ display: 'none' }}
onChange={handleFileChange}
/>
<div style={{ position: 'relative', cursor: 'pointer' }} onClick={() => fileInputRef.current?.click()}>
{avatarUrl ? (
<img
src={avatarUrl}
alt="Profilbild"
style={{
width: '64px',
height: '64px',
borderRadius: '50%',
objectFit: 'cover',
border: '2px solid #38bdf8'
}}
/>
) : (
<div style={{
width: '64px',
height: '64px',
borderRadius: '50%',
backgroundColor: '#0284c7',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '1.5rem',
fontWeight: 700,
color: '#ffffff'
}}>
{fullName.charAt(0).toUpperCase() || 'U'}
</div>
)}
<div style={{
position: 'absolute',
bottom: 0,
right: 0,
width: '24px',
height: '24px',
borderRadius: '50%',
backgroundColor: '#ea580c',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#ffffff',
boxShadow: '0 2px 4px rgba(0,0,0,0.3)'
}}>
<Camera size={13} />
</div>
</div>
<div style={{ flex: 1 }}>
<div style={{ fontSize: '0.9rem', fontWeight: 600, color: '#f8fafc', marginBottom: '4px' }}>
Profilfoto
</div>
<div style={{ fontSize: '0.78rem', color: '#94a3b8', marginBottom: '10px' }}>
JPG, PNG oder WebP Format
</div>
<div style={{ display: 'flex', gap: '8px' }}>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={isUploadingPhoto}
className="btn-secondary"
style={{ padding: '6px 12px', fontSize: '0.78rem' }}
>
<Upload size={14} />
<span>{isUploadingPhoto ? 'Lädt...' : 'Foto hochladen'}</span>
</button>
{avatarUrl && (
<button
type="button"
onClick={() => setAvatarUrl('')}
style={{
padding: '6px 10px',
borderRadius: '6px',
backgroundColor: 'transparent',
border: '1px solid #334155',
color: '#fca5a5',
fontSize: '0.78rem',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: '4px'
}}
>
<Trash2 size={14} />
<span>Entfernen</span>
</button>
)}
</div>
</div>
</div>
{/* Role Pill */}
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '10px 14px',
backgroundColor: badge.bg,
border: `1px solid ${badge.border}`,
borderRadius: '10px'
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', color: badge.color, fontSize: '0.85rem', fontWeight: 600 }}>
<Shield size={16} />
<span>Zugriffsrolle</span>
</div>
<span style={{
backgroundColor: '#0f172a',
color: badge.color,
padding: '3px 8px',
borderRadius: '20px',
fontSize: '0.75rem',
fontWeight: 700
}}>
{badge.label}
</span>
</div>
{/* Full Name */}
<div>
<label style={{ display: 'block', fontSize: '0.82rem', fontWeight: 600, color: '#cbd5e1', marginBottom: '6px' }}>
Vollständiger Name
</label>
<div style={{ position: 'relative' }}>
<User size={18} color="#64748b" style={{ position: 'absolute', left: '14px', top: '50%', transform: 'translateY(-50%)' }} />
<input
type="text"
value={fullName}
onChange={(e) => setFullName(e.target.value)}
style={{
width: '100%',
padding: '10px 14px 10px 42px',
backgroundColor: '#0f172a',
border: '1px solid #334155',
borderRadius: '8px',
color: '#f8fafc',
fontSize: '0.9rem',
boxSizing: 'border-box',
outline: 'none'
}}
/>
</div>
</div>
{/* Email (Readonly) */}
<div>
<label style={{ display: 'block', fontSize: '0.82rem', fontWeight: 600, color: '#cbd5e1', marginBottom: '6px' }}>
E-Mail Adresse (Schreibgeschützt)
</label>
<div style={{ position: 'relative' }}>
<Mail size={18} color="#64748b" style={{ position: 'absolute', left: '14px', top: '50%', transform: 'translateY(-50%)' }} />
<input
type="email"
disabled
value={profile.email}
style={{
width: '100%',
padding: '10px 14px 10px 42px',
backgroundColor: '#0f172a',
border: '1px solid #1e293b',
borderRadius: '8px',
color: '#64748b',
fontSize: '0.9rem',
boxSizing: 'border-box',
cursor: 'not-allowed'
}}
/>
</div>
</div>
{/* Phone */}
<div>
<label style={{ display: 'block', fontSize: '0.82rem', fontWeight: 600, color: '#cbd5e1', marginBottom: '6px' }}>
Telefonnummer
</label>
<div style={{ position: 'relative' }}>
<Phone size={18} color="#64748b" style={{ position: 'absolute', left: '14px', top: '50%', transform: 'translateY(-50%)' }} />
<input
type="text"
placeholder="0170 1234567"
value={phone}
onChange={(e) => setPhone(e.target.value)}
style={{
width: '100%',
padding: '10px 14px 10px 42px',
backgroundColor: '#0f172a',
border: '1px solid #334155',
borderRadius: '8px',
color: '#f8fafc',
fontSize: '0.9rem',
boxSizing: 'border-box',
outline: 'none'
}}
/>
</div>
</div>
{/* Footer Actions */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: '12px', marginTop: '10px' }}>
<button
type="button"
onClick={onClose}
className="btn-ghost"
>
Schließen
</button>
<button
type="submit"
disabled={isSaving}
className="btn-primary-cta"
>
{isSaved ? (
<>
<Check size={18} />
<span>Gespeichert!</span>
</>
) : (
<>
<Save size={18} />
<span>{isSaving ? 'Speichert...' : 'Änderungen Speichern'}</span>
</>
)}
</button>
</div>
</form>
</div>
</div>
);
};
+238
View File
@@ -0,0 +1,238 @@
import React from 'react';
import { X, Sparkles, CheckCircle2, History, GitCommit, ShieldCheck, Palette, Lock } from 'lucide-react';
interface VersionHistoryModalProps {
isOpen: boolean;
onClose: () => void;
}
//Achtung, hier dürfen immer nur neue Versionsbeschreibungen eingefügt werden, aber keine alten gelöscht werden!
export const VersionHistoryModal: React.FC<VersionHistoryModalProps> = ({ isOpen, onClose }) => {
if (!isOpen) return null;
const releases = [
{
version: 'v0.4',
title: 'Kundenportal & Projekt-Transparenz',
date: '05. August 2026',
badge: 'Aktuell',
badgeColor: '#ea580c',
icon: ShieldCheck,
highlights: [
'Dediziertes Kundenportal mit Login via Projekt-Code (z.B. PRJ-8392) & PIN',
'Transparenter Projekt-Gesundheit Status: 🟢 On Track vs. 🟠 Delay (Verzögerung) mit Begründung',
'Visuelle Fortschrittsanzeige in % (Progress-Bar) & Vor-Ort-Fotos',
'Online-Terminbuchungsmodul für Kunden direkt im Portal',
'Disponenten-Funktion: 📧 Portal-Zugangs-Link & PIN direkt per Mail-Template senden'
]
},
{
version: 'v0.3',
title: 'Design System & Theme Engine',
date: '05. August 2026',
badge: 'Release',
badgeColor: '#38bdf8',
icon: Palette,
highlights: [
'Light Mode & Dark Mode mit automatischer System-Theme-Erkennung',
'Refactoring UI Farbschema „Der moderne Profi“ (Craft Blue & Builder Orange CTAs)',
'Neuer Theme-Switcher Toggle in der Navigation',
'Kompensation von uneinheitlichen Stilen für alle Modals & Views'
]
},
{
version: 'v0.2',
title: 'User Accounts & Authentifizierung',
date: '05. August 2026',
badge: 'Release',
badgeColor: '#4ade80',
icon: Lock,
highlights: [
'Supabase Auth Integration (E-Mail/Passwort & Quick-Demo-Buttons)',
'Rollen- & Berechtigungskonzept (RBAC: Admin/Disponent, Monteur, Kunde)',
'Profil-Modal zur Bearbeitung von Name und Telefonnummer',
'PostgreSQL Row Level Security (RLS) & `profiles`-Tabelle mit Auth-Trigger'
]
},
{
version: 'v0.1',
title: 'Basis-Prototyp & Live Dispatching',
date: '30. Juli 2026',
badge: 'Initial',
badgeColor: '#94a3b8',
icon: GitCommit,
highlights: [
'Live Betriebs-Cockpit mit Supabase Realtime-Datenbankverbindung',
'Interaktiver Dispatcher mit Drag & Drop Wochen- und Tagesplaner',
'Kundenverwaltung, Gerätekartei (Buderus/Bosch) & Ersatzteillager',
'Zeiterfassung und Supabase Storage Integration für Auftrags-Baupläne'
]
}
];
return (
<div className="modal-overlay" style={{
position: 'fixed',
inset: 0,
backgroundColor: 'rgba(15, 23, 42, 0.8)',
backdropFilter: 'blur(8px)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1000,
padding: '16px'
}}>
<div className="modal-container" style={{
backgroundColor: '#1e293b',
border: '1px solid #334155',
borderRadius: '16px',
width: '100%',
maxWidth: '540px',
color: '#f8fafc',
boxShadow: '0 25px 50px -12px rgba(0,0,0,0.5)',
overflow: 'hidden'
}}>
{/* Header */}
<div style={{
padding: '20px 24px',
borderBottom: '1px solid #334155',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
background: 'linear-gradient(to right, #1e293b, #0f172a)'
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<div style={{
width: '40px',
height: '40px',
borderRadius: '10px',
backgroundColor: 'rgba(56, 189, 248, 0.15)',
border: '1px solid rgba(56, 189, 248, 0.3)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}>
<History size={22} color="#38bdf8" />
</div>
<div>
<h3 style={{ margin: 0, fontSize: '1.15rem', fontWeight: 800 }}>Versionshistorie</h3>
<p style={{ margin: 0, fontSize: '0.8rem', color: '#94a3b8' }}>Entwicklungsfortschritt von Handwerksfreund</p>
</div>
</div>
<button
onClick={onClose}
style={{
background: 'none',
border: 'none',
color: '#94a3b8',
cursor: 'pointer',
padding: '4px',
borderRadius: '6px'
}}
>
<X size={20} />
</button>
</div>
{/* Content Body: Timeline */}
<div style={{
padding: '24px',
maxHeight: '480px',
overflowY: 'auto',
display: 'flex',
flexDirection: 'column',
gap: '20px'
}}>
{releases.map((rel, idx) => {
const Icon = rel.icon;
return (
<div key={rel.version} style={{
position: 'relative',
paddingLeft: '32px',
borderLeft: idx === releases.length - 1 ? '2px solid transparent' : '2px solid #334155'
}}>
{/* Bullet Node */}
<div style={{
position: 'absolute',
left: '-9px',
top: '0',
width: '16px',
height: '16px',
borderRadius: '50%',
backgroundColor: rel.badgeColor,
border: '3px solid #1e293b'
}} />
{/* Release Header */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '6px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span style={{ fontSize: '1.05rem', fontWeight: 800, color: '#ffffff' }}>
{rel.title}
</span>
<span style={{
backgroundColor: 'rgba(56, 189, 248, 0.15)',
color: rel.badgeColor,
border: `1px solid ${rel.badgeColor}40`,
padding: '2px 8px',
borderRadius: '12px',
fontSize: '0.72rem',
fontWeight: 700
}}>
{rel.version}
</span>
</div>
<span style={{ fontSize: '0.75rem', color: '#64748b' }}>{rel.date}</span>
</div>
{/* Highlights List */}
<ul style={{
margin: 0,
paddingLeft: '16px',
fontSize: '0.83rem',
color: '#cbd5e1',
display: 'flex',
flexDirection: 'column',
gap: '4px'
}}>
{rel.highlights.map((item, i) => (
<li key={i}>{item}</li>
))}
</ul>
</div>
);
})}
</div>
{/* Footer */}
<div style={{
padding: '14px 24px',
borderTop: '1px solid #334155',
backgroundColor: '#0f172a',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between'
}}>
<button
onClick={onClose}
style={{
padding: '8px 16px',
borderRadius: '8px',
backgroundColor: '#0284c7',
border: 'none',
color: '#ffffff',
fontWeight: 600,
fontSize: '0.83rem',
cursor: 'pointer'
}}
>
Schließen
</button>
</div>
</div>
</div>
);
};
+358 -112
View File
@@ -1,6 +1,6 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { X, Save, ClipboardList, Calendar, User, FileText } from 'lucide-react'; import { X, Save, ClipboardList, Calendar, User, FileText, Send, Mail, Check, AlertTriangle, CheckCircle2, ShieldCheck } from 'lucide-react';
import { WorkOrder, Customer, OrderStatus } from '../types'; import { WorkOrder, Customer, OrderStatus, ProjectHealthStatus } from '../types';
interface WorkOrderModalProps { interface WorkOrderModalProps {
isOpen: boolean; isOpen: boolean;
@@ -24,30 +24,53 @@ export const WorkOrderModal: React.FC<WorkOrderModalProps> = ({
title: '', title: '',
description: '', description: '',
scheduledDate: new Date().toISOString().split('T')[0], scheduledDate: new Date().toISOString().split('T')[0],
status: 'offen' as OrderStatus status: 'offen' as OrderStatus,
progressPercent: 65,
healthStatus: 'on_track' as ProjectHealthStatus,
delayReason: '',
accessCode: '',
accessPin: ''
}); });
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [errorMsg, setErrorMsg] = useState(''); const [errorMsg, setErrorMsg] = useState('');
const [showMailModal, setShowMailModal] = useState(false);
const [mailCopied, setMailCopied] = useState(false);
useEffect(() => { useEffect(() => {
if (initialOrder) { if (initialOrder) {
const code = initialOrder.accessCode || `PRJ-${initialOrder.id.substring(0, 4).toUpperCase()}`;
const pin = initialOrder.accessPin || '749201';
setFormData({ setFormData({
customerId: initialOrder.customerId || '', customerId: initialOrder.customerId || '',
title: initialOrder.title || '', title: initialOrder.title || '',
description: initialOrder.description || '', description: initialOrder.description || '',
scheduledDate: initialOrder.scheduledDate || new Date().toISOString().split('T')[0], scheduledDate: initialOrder.scheduledDate || new Date().toISOString().split('T')[0],
status: initialOrder.status || 'offen' status: initialOrder.status || 'offen',
progressPercent: initialOrder.progressPercent ?? 65,
healthStatus: initialOrder.healthStatus || 'on_track',
delayReason: initialOrder.delayReason || '',
accessCode: code,
accessPin: pin
}); });
} else { } else {
const code = `PRJ-${Math.floor(1000 + Math.random() * 9000)}`;
const pin = `${Math.floor(100000 + Math.random() * 900000)}`;
setFormData({ setFormData({
customerId: defaultCustomerId || (customers.length > 0 ? customers[0].id : ''), customerId: defaultCustomerId || (customers.length > 0 ? customers[0].id : ''),
title: '', title: '',
description: '', description: '',
scheduledDate: new Date().toISOString().split('T')[0], scheduledDate: new Date().toISOString().split('T')[0],
status: 'offen' status: 'offen',
progressPercent: 0,
healthStatus: 'on_track',
delayReason: '',
accessCode: code,
accessPin: pin
}); });
} }
setErrorMsg(''); setErrorMsg('');
setShowMailModal(false);
}, [initialOrder, defaultCustomerId, customers, isOpen]); }, [initialOrder, defaultCustomerId, customers, isOpen]);
if (!isOpen) return null; if (!isOpen) return null;
@@ -79,12 +102,22 @@ export const WorkOrderModal: React.FC<WorkOrderModalProps> = ({
} }
}; };
const selectedCustomer = customers.find(c => c.id === formData.customerId);
const handleCopyMailLink = () => {
const text = `Hallo ${selectedCustomer?.name || 'Kunde'},\n\nhier ist dein persönlicher Zugang zu unserem Kundenportal für dein Projekt "${formData.title}":\n\nDirekt-Link: https://handwerksfreund.app/portal?code=${formData.accessCode}&pin=${formData.accessPin}\nProjekt-Code: ${formData.accessCode}\nSicherheits-PIN: ${formData.accessPin}\n\nHier kannst du jederzeit den Baufortschritt (On Track / Delays) einsehen und Termine buchen.\n\nViele Grüße,\nDein Handwerksfreund Team`;
navigator.clipboard.writeText(text);
setMailCopied(true);
setTimeout(() => setMailCopied(false), 3000);
};
return ( return (
<>
<div style={{ <div style={{
position: 'fixed', position: 'fixed',
inset: 0, inset: 0,
backgroundColor: 'rgba(15, 23, 42, 0.6)', backgroundColor: 'rgba(15, 23, 42, 0.75)',
backdropFilter: 'blur(4px)', backdropFilter: 'blur(6px)',
zIndex: 100, zIndex: 100,
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
@@ -92,143 +125,134 @@ export const WorkOrderModal: React.FC<WorkOrderModalProps> = ({
padding: '20px' padding: '20px'
}}> }}>
<div style={{ <div style={{
backgroundColor: '#ffffff', backgroundColor: 'var(--bg-card, #ffffff)',
borderRadius: '16px', borderRadius: '16px',
maxWidth: '550px', maxWidth: '580px',
width: '100%', width: '100%',
maxHeight: '90vh', maxHeight: '90vh',
overflowY: 'auto', overflowY: 'auto',
boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.25)', boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.4)',
border: '1px solid #e2e8f0' border: '1px solid var(--border-color, #e2e8f0)',
color: 'var(--text-primary, #0f172a)'
}}> }}>
{/* Header */} {/* Header */}
<div style={{ <div style={{
padding: '20px 24px', padding: '20px 24px',
borderBottom: '1px solid #e2e8f0', borderBottom: '1px solid var(--border-color, #e2e8f0)',
display: 'flex', display: 'flex',
justifyContent: 'space-between',
alignItems: 'center', alignItems: 'center',
backgroundColor: '#f8fafc', justifyContent: 'space-between',
borderTopLeftRadius: '16px', background: 'linear-gradient(to right, #0284c7, #0369a1)',
borderTopRightRadius: '16px' color: '#ffffff',
borderRadius: '16px 16px 0 0'
}}> }}>
<h2 style={{ fontSize: '1.25rem', fontWeight: 700, color: '#0f172a', margin: 0, display: 'flex', alignItems: 'center', gap: '10px' }}> <div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
<ClipboardList size={22} color="#2563eb" /> <ClipboardList size={22} />
{initialOrder ? 'Auftrag bearbeiten' : 'Neuen Auftrag erstellen'} <h3 style={{ margin: 0, fontSize: '1.1rem', fontWeight: 700 }}>
</h2> {initialOrder ? 'Auftrag & Projekt-Status bearbeiten' : 'Neuen Auftrag erstellen'}
</h3>
</div>
<button <button
onClick={onClose} onClick={onClose}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#64748b', borderRadius: '8px', padding: '4px' }} style={{
background: 'none',
border: 'none',
color: '#ffffff',
cursor: 'pointer',
padding: '4px',
borderRadius: '6px'
}}
> >
<X size={20} /> <X size={20} />
</button> </button>
</div> </div>
{/* Form Body */} <form onSubmit={handleSubmit} style={{ padding: '24px', display: 'flex', flexDirection: 'column', gap: '18px' }}>
<form onSubmit={handleSubmit} style={{ padding: '24px' }}>
{errorMsg && ( {errorMsg && (
<div style={{ <div style={{
backgroundColor: '#fef2f2', padding: '12px 14px',
color: '#991b1b',
padding: '12px 16px',
borderRadius: '8px', borderRadius: '8px',
marginBottom: '20px', backgroundColor: '#fef2f2',
fontSize: '0.9rem', border: '1px solid #fecaca',
fontWeight: 500 color: '#dc2626',
fontSize: '0.85rem'
}}> }}>
{errorMsg} {errorMsg}
</div> </div>
)} )}
{/* Customer Selection */} {/* Kunde wählen */}
<div style={{ marginBottom: '16px' }}> <div>
<label style={{ display: 'block', fontSize: '0.85rem', fontWeight: 600, color: '#475569', marginBottom: '6px' }}> <label style={{ display: 'block', fontSize: '0.83rem', fontWeight: 700, marginBottom: '6px' }}>
Zugehöriger Kunde * Kunde *
</label> </label>
<div style={{ position: 'relative' }}> <div style={{ position: 'relative' }}>
<User size={18} color="#94a3b8" style={{ position: 'absolute', left: '12px', top: '12px' }} /> <User size={18} color="#64748b" style={{ position: 'absolute', left: '12px', top: '50%', transform: 'translateY(-50%)' }} />
<select <select
value={formData.customerId} value={formData.customerId}
onChange={(e) => setFormData({ ...formData, customerId: e.target.value })} onChange={(e) => setFormData({ ...formData, customerId: e.target.value })}
required
style={{ style={{
width: '100%', width: '100%',
padding: '10px 14px 10px 38px', padding: '10px 12px 10px 38px',
borderRadius: '8px', borderRadius: '8px',
border: '1px solid #cbd5e1', border: '1px solid #cbd5e1',
fontSize: '0.95rem', fontSize: '0.9rem',
backgroundColor: '#ffffff', boxSizing: 'border-box',
color: '#0f172a' backgroundColor: 'var(--bg-app, #ffffff)',
color: 'var(--text-primary, #0f172a)'
}} }}
> >
<option value="" disabled>-- Bitte Kunde auswählen --</option> <option value="" disabled>-- Bitte Kunde auswählen --</option>
{customers.map((c) => ( {customers.map(c => (
<option key={c.id} value={c.id}> <option key={c.id} value={c.id}>
{c.name} ({c.customerNumber} - {c.city}) {c.name} ({c.customerNumber}) - {c.city}
</option> </option>
))} ))}
</select> </select>
</div> </div>
</div> </div>
{/* Title */} {/* Auftragstitel */}
<div style={{ marginBottom: '16px' }}> <div>
<label style={{ display: 'block', fontSize: '0.85rem', fontWeight: 600, color: '#475569', marginBottom: '6px' }}> <label style={{ display: 'block', fontSize: '0.83rem', fontWeight: 700, marginBottom: '6px' }}>
Auftragstitel * Auftragstitel *
</label> </label>
<input <input
type="text" type="text"
placeholder="z.B. Wartung Heizungsanlage GB172"
value={formData.title} value={formData.title}
onChange={(e) => setFormData({ ...formData, title: e.target.value })} onChange={(e) => setFormData({ ...formData, title: e.target.value })}
placeholder="z.B. Wartung Heizungsanlage"
required
style={{ style={{
width: '100%', width: '100%',
padding: '10px 14px', padding: '10px 12px',
borderRadius: '8px', borderRadius: '8px',
border: '1px solid #cbd5e1', border: '1px solid #cbd5e1',
fontSize: '0.95rem' fontSize: '0.9rem',
boxSizing: 'border-box',
backgroundColor: 'var(--bg-app, #ffffff)',
color: 'var(--text-primary, #0f172a)'
}} }}
/> />
</div> </div>
{/* Date & Status */} {/* Status & Datum */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px', marginBottom: '16px' }}> <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '14px' }}>
<div> <div>
<label style={{ display: 'block', fontSize: '0.85rem', fontWeight: 600, color: '#475569', marginBottom: '6px' }}> <label style={{ display: 'block', fontSize: '0.83rem', fontWeight: 700, marginBottom: '6px' }}>
Geplantes Ausführungsdatum Auftrags-Status
</label>
<div style={{ position: 'relative' }}>
<Calendar size={18} color="#94a3b8" style={{ position: 'absolute', left: '12px', top: '12px' }} />
<input
type="date"
value={formData.scheduledDate}
onChange={(e) => setFormData({ ...formData, scheduledDate: e.target.value })}
style={{
width: '100%',
padding: '10px 14px 10px 38px',
borderRadius: '8px',
border: '1px solid #cbd5e1',
fontSize: '0.95rem'
}}
/>
</div>
</div>
<div>
<label style={{ display: 'block', fontSize: '0.85rem', fontWeight: 600, color: '#475569', marginBottom: '6px' }}>
Auftragsstatus
</label> </label>
<select <select
value={formData.status} value={formData.status}
onChange={(e) => setFormData({ ...formData, status: e.target.value as OrderStatus })} onChange={(e) => setFormData({ ...formData, status: e.target.value as OrderStatus })}
style={{ style={{
width: '100%', width: '100%',
padding: '10px 14px', padding: '10px 12px',
borderRadius: '8px', borderRadius: '8px',
border: '1px solid #cbd5e1', border: '1px solid #cbd5e1',
fontSize: '0.95rem', fontSize: '0.9rem',
backgroundColor: '#ffffff' boxSizing: 'border-box',
backgroundColor: 'var(--bg-app, #ffffff)',
color: 'var(--text-primary, #0f172a)'
}} }}
> >
<option value="offen">Offen</option> <option value="offen">Offen</option>
@@ -237,73 +261,295 @@ export const WorkOrderModal: React.FC<WorkOrderModalProps> = ({
<option value="storniert">Storniert</option> <option value="storniert">Storniert</option>
</select> </select>
</div> </div>
</div>
{/* Description */} <div>
<div style={{ marginBottom: '24px' }}> <label style={{ display: 'block', fontSize: '0.83rem', fontWeight: 700, marginBottom: '6px' }}>
<label style={{ display: 'block', fontSize: '0.85rem', fontWeight: 600, color: '#475569', marginBottom: '6px' }}> Geplantes Datum
Beschreibung & Aufgabenstellung
</label> </label>
<div style={{ position: 'relative' }}> <input
<FileText size={18} color="#94a3b8" style={{ position: 'absolute', left: '12px', top: '12px' }} /> type="date"
<textarea value={formData.scheduledDate}
value={formData.description} onChange={(e) => setFormData({ ...formData, scheduledDate: e.target.value })}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
placeholder="Detaillierte Aufgabenbeschreibung für den Techniker vor Ort..."
rows={4}
style={{ style={{
width: '100%', width: '100%',
padding: '10px 14px 10px 38px', padding: '10px 12px',
borderRadius: '8px', borderRadius: '8px',
border: '1px solid #cbd5e1', border: '1px solid #cbd5e1',
fontSize: '0.95rem', fontSize: '0.9rem',
resize: 'vertical' boxSizing: 'border-box',
backgroundColor: 'var(--bg-app, #ffffff)',
color: 'var(--text-primary, #0f172a)'
}} }}
/> />
</div> </div>
</div> </div>
{/* Actions */} {/* --- KUNDENPORTAL & PROJEKTSTATUS STEUERUNG --- */}
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '12px', paddingTop: '16px', borderTop: '1px solid #f1f5f9' }}> <div style={{
marginTop: '6px',
padding: '16px',
borderRadius: '12px',
backgroundColor: '#f8fafc',
border: '1.5px solid #e2e8f0',
display: 'flex',
flexDirection: 'column',
gap: '14px'
}}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', fontWeight: 700, fontSize: '0.9rem', color: '#0369a1' }}>
<ShieldCheck size={18} />
<span>Kundenportal & Projekt-Gesundheit</span>
</div>
<button <button
type="button" type="button"
onClick={onClose} onClick={() => setShowMailModal(true)}
style={{ style={{
padding: '10px 18px', padding: '6px 12px',
borderRadius: '8px', borderRadius: '8px',
border: '1px solid #cbd5e1', backgroundColor: '#ea580c',
backgroundColor: '#ffffff', color: '#ffffff',
color: '#475569', border: 'none',
fontWeight: 600, fontSize: '0.78rem',
cursor: 'pointer' fontWeight: 700,
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: '6px'
}} }}
> >
Abbrechen <Mail size={14} />
<span>📧 Portal-Zugang Senden</span>
</button>
</div>
{/* Health Status Picker (On Track vs. Delay) */}
<div>
<label style={{ display: 'block', fontSize: '0.8rem', fontWeight: 700, marginBottom: '6px', color: '#334155' }}>
Projekt-Fortschritt Status für Kunden
</label>
<div style={{ display: 'flex', gap: '10px' }}>
<button
type="button"
onClick={() => setFormData({ ...formData, healthStatus: 'on_track', delayReason: '' })}
style={{
flex: 1,
padding: '8px 12px',
borderRadius: '8px',
border: formData.healthStatus === 'on_track' ? '2px solid #22c55e' : '1px solid #cbd5e1',
backgroundColor: formData.healthStatus === 'on_track' ? 'rgba(34, 197, 94, 0.1)' : '#ffffff',
color: formData.healthStatus === 'on_track' ? '#15803d' : '#64748b',
fontWeight: 700,
fontSize: '0.82rem',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '6px'
}}
>
<CheckCircle2 size={16} color="#22c55e" />
<span>🟢 On Track</span>
</button> </button>
<button <button
type="submit" type="button"
disabled={isSaving} onClick={() => setFormData({ ...formData, healthStatus: 'delay' })}
style={{ style={{
padding: '10px 20px', flex: 1,
padding: '8px 12px',
borderRadius: '8px', borderRadius: '8px',
border: 'none', border: formData.healthStatus === 'delay' ? '2px solid #f59e0b' : '1px solid #cbd5e1',
backgroundColor: '#2563eb', backgroundColor: formData.healthStatus === 'delay' ? 'rgba(245, 158, 11, 0.1)' : '#ffffff',
color: '#ffffff', color: formData.healthStatus === 'delay' ? '#b45309' : '#64748b',
fontWeight: 600, fontWeight: 700,
cursor: isSaving ? 'not-allowed' : 'pointer', fontSize: '0.82rem',
cursor: 'pointer',
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
gap: '8px', justifyContent: 'center',
opacity: isSaving ? 0.7 : 1 gap: '6px'
}} }}
>
<AlertTriangle size={16} color="#f59e0b" />
<span>🟠 Delay (Verzögerung)</span>
</button>
</div>
</div>
{/* Progress Bar Slider */}
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.8rem', fontWeight: 700, color: '#334155', marginBottom: '4px' }}>
<span>Baufortschritt (%):</span>
<span style={{ color: '#0284c7' }}>{formData.progressPercent}%</span>
</div>
<input
type="range"
min="0"
max="100"
step="5"
value={formData.progressPercent}
onChange={(e) => setFormData({ ...formData, progressPercent: parseInt(e.target.value) })}
style={{ width: '100%', cursor: 'pointer' }}
/>
</div>
{/* Delay Reason Input (If Delay selected) */}
{formData.healthStatus === 'delay' && (
<div>
<label style={{ display: 'block', fontSize: '0.8rem', fontWeight: 700, color: '#b45309', marginBottom: '4px' }}>
Begründung für Kundenverzögerung (Delay Reason)
</label>
<input
type="text"
placeholder="z.B. Lieferverzögerung beim Spezialthermostat ca. 3 Tage"
value={formData.delayReason}
onChange={(e) => setFormData({ ...formData, delayReason: e.target.value })}
style={{
width: '100%',
padding: '8px 12px',
borderRadius: '6px',
border: '1px solid #fde68a',
backgroundColor: '#fffbeb',
fontSize: '0.85rem',
color: '#92400e',
boxSizing: 'border-box'
}}
/>
</div>
)}
</div>
{/* Beschreibung */}
<div>
<label style={{ display: 'block', fontSize: '0.83rem', fontWeight: 700, marginBottom: '6px' }}>
Interne Beschreibung / Notizen
</label>
<textarea
rows={3}
placeholder="Details zu den durchzuführenden Arbeiten..."
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
style={{
width: '100%',
padding: '10px 12px',
borderRadius: '8px',
border: '1px solid #cbd5e1',
fontSize: '0.9rem',
boxSizing: 'border-box',
backgroundColor: 'var(--bg-app, #ffffff)',
color: 'var(--text-primary, #0f172a)'
}}
/>
</div>
{/* Buttons */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: '12px', marginTop: '10px' }}>
<button
type="button"
onClick={onClose}
className="btn-ghost"
>
Abbrechen
</button>
<button
type="submit"
disabled={isSaving}
className="btn-primary-cta"
> >
<Save size={18} /> <Save size={18} />
{isSaving ? 'Speichert...' : 'Auftrag speichern'} <span>{isSaving ? 'Speichert...' : 'Auftrag Speichern'}</span>
</button> </button>
</div> </div>
</form> </form>
</div> </div>
</div> </div>
{/* PORTAL MAIL INVITATION OVERLAY MODAL */}
{showMailModal && (
<div style={{
position: 'fixed',
inset: 0,
backgroundColor: 'rgba(15, 23, 42, 0.85)',
backdropFilter: 'blur(8px)',
zIndex: 200,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '20px'
}}>
<div style={{
backgroundColor: '#1e293b',
borderRadius: '16px',
maxWidth: '520px',
width: '100%',
color: '#f8fafc',
border: '1px solid #334155',
padding: '24px',
boxShadow: '0 25px 50px -12px rgba(0,0,0,0.6)'
}}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '16px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
<Mail size={22} color="#ea580c" />
<h3 style={{ margin: 0, fontSize: '1.15rem', fontWeight: 800 }}>Kundenportal-Einladung senden</h3>
</div>
<button onClick={() => setShowMailModal(false)} style={{ background: 'none', border: 'none', color: '#94a3b8', cursor: 'pointer' }}>
<X size={20} />
</button>
</div>
<div style={{ fontSize: '0.88rem', color: '#cbd5e1', marginBottom: '16px' }}>
Sendet folgende Zugangsdaten an die Kunden-E-Mail <strong>{selectedCustomer?.email || 'kunden@email.de'}</strong>:
</div>
{/* Email Body Card */}
<div style={{
backgroundColor: '#0f172a',
border: '1px solid #334155',
borderRadius: '10px',
padding: '16px',
fontSize: '0.85rem',
color: '#e2e8f0',
fontFamily: 'monospace',
lineHeight: '1.6',
marginBottom: '20px',
whiteSpace: 'pre-wrap'
}}>
{`Hallo ${selectedCustomer?.name || 'Kunde'},
hier sind deine persönlichen Zugangsdaten für das Handwerksfreund-Kundenportal:
📌 Projekt-Code: ${formData.accessCode}
🔑 PIN: ${formData.accessPin}
🔗 Direkt-Link:
https://handwerksfreund.app/portal?code=${formData.accessCode}&pin=${formData.accessPin}
Hier kannst du den aktuellen Baufortschritt (On Track / Delay) einsehen und Termine online buchen.`}
</div>
{mailCopied && (
<div style={{ backgroundColor: 'rgba(34, 197, 94, 0.15)', color: '#4ade80', padding: '10px', borderRadius: '8px', fontSize: '0.82rem', marginBottom: '12px', textAlign: 'center' }}>
Einladungs-Text in Zwischenablage kopiert & E-Mail simuliert versendet!
</div>
)}
<div style={{ display: 'flex', gap: '12px', justifyContent: 'flex-end' }}>
<button onClick={() => setShowMailModal(false)} className="btn-ghost">
Schließen
</button>
<button
onClick={handleCopyMailLink}
className="btn-primary-cta"
>
<Send size={16} />
<span>Text Kopieren & Mail Senden</span>
</button>
</div>
</div>
</div>
)}
</>
); );
}; };
+3 -1
View File
@@ -5,6 +5,8 @@ export const SUPABASE_KEY = import.meta.env.VITE_SUPABASE_ANON_KEY || 'sb_publis
export const supabase = createClient(SUPABASE_URL, SUPABASE_KEY, { export const supabase = createClient(SUPABASE_URL, SUPABASE_KEY, {
auth: { auth: {
persistSession: false persistSession: true,
autoRefreshToken: true,
detectSessionInUrl: true
} }
}); });
+266
View File
@@ -0,0 +1,266 @@
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import { User, Session } from '@supabase/supabase-js';
import { supabase } from '../config/supabase';
import { UserProfile, UserRole } from '../types';
import { authService } from '../services/authService';
interface AuthContextType {
user: User | null;
session: Session | null;
profile: UserProfile | null;
isLoading: boolean;
signIn: (email: string, pass: string) => Promise<{ error?: string }>;
signUp: (email: string, pass: string, fullName: string, role: UserRole) => Promise<{ error?: string }>;
loginAsDemoUser: (role: UserRole) => Promise<void>;
signOut: () => Promise<void>;
updateProfile: (updates: Partial<UserProfile>) => Promise<void>;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const [user, setUser] = useState<User | null>(null);
const [session, setSession] = useState<Session | null>(null);
const [profile, setProfile] = useState<UserProfile | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(true);
// Check saved demo user session in localStorage
const loadDemoUser = (role: UserRole): UserProfile => {
const demoProfiles: Record<UserRole, UserProfile> = {
admin: {
id: 'demo-admin-id',
email: 'admin@handwerksfreund.de',
fullName: 'Alexander Admin (Disponent)',
role: 'admin',
phone: '0170 1122334',
createdAt: new Date().toISOString()
},
disponent: {
id: 'demo-disponent-id',
email: 'disponent@handwerksfreund.de',
fullName: 'Dora Disponentin',
role: 'disponent',
phone: '0170 2233445',
createdAt: new Date().toISOString()
},
monteur: {
id: 'f0eebc99-9c0b-4ef8-bb6d-6bb9bd380f11', // Matches Max Müller in DB
email: 'max.mueller@handwerk.de',
fullName: 'Max Müller (Obermonteur)',
role: 'monteur',
technicianId: 'f0eebc99-9c0b-4ef8-bb6d-6bb9bd380f11',
phone: '0170 1112233',
createdAt: new Date().toISOString()
},
kunde: {
id: 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', // Matches Max Mustermann in DB
email: 'max.mustermann@email.de',
fullName: 'Max Mustermann (Kunde)',
role: 'kunde',
customerId: 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11',
phone: '0171 1234567',
createdAt: new Date().toISOString()
}
};
return demoProfiles[role];
};
useEffect(() => {
// 1. Check for stored demo session
const savedDemoRole = localStorage.getItem('handwerksfreund_demo_role') as UserRole | null;
if (savedDemoRole) {
const demoProf = loadDemoUser(savedDemoRole);
setProfile(demoProf);
setIsLoading(false);
return;
}
// 2. Initialize Supabase Auth Session
supabase.auth.getSession().then(({ data: { session } }) => {
setSession(session);
setUser(session?.user ?? null);
if (session?.user) {
authService.getUserProfile(session.user.id, session.user.email).then((prof) => {
setProfile(prof);
setIsLoading(false);
});
} else {
setIsLoading(false);
}
}).catch(() => {
setIsLoading(false);
});
// 3. Listen to auth changes
const { data: { subscription } } = supabase.auth.onAuthStateChange(async (_event, session) => {
// Clear demo role when real auth fires
localStorage.removeItem('handwerksfreund_demo_role');
setSession(session);
setUser(session?.user ?? null);
if (session?.user) {
const prof = await authService.getUserProfile(session.user.id, session.user.email);
setProfile(prof);
} else {
setProfile(null);
}
setIsLoading(false);
});
return () => {
subscription.unsubscribe();
};
}, []);
const signIn = async (email: string, pass: string) => {
setIsLoading(true);
localStorage.removeItem('handwerksfreund_demo_role');
try {
const { data, error } = await supabase.auth.signInWithPassword({
email,
password: pass
});
if (error) {
setIsLoading(false);
return { error: error.message };
}
if (data.user) {
const prof = await authService.getUserProfile(data.user.id, data.user.email);
setProfile(prof);
}
setIsLoading(false);
return {};
} catch (err: any) {
setIsLoading(false);
return { error: err?.message || 'Login-Fehler aufgetreten' };
}
};
const signUp = async (email: string, pass: string, fullName: string, role: UserRole) => {
setIsLoading(true);
localStorage.removeItem('handwerksfreund_demo_role');
try {
const { data, error } = await supabase.auth.signUp({
email,
password: pass,
options: {
data: {
full_name: fullName,
role: role
}
}
});
if (error) {
console.warn('Supabase Auth SignUp Fehler:', error);
// Fallback for Supabase 500 / Fetch errors (e.g. SMTP missing on server)
if (
error.status === 500 ||
error.name === 'AuthRetryableFetchError' ||
error.message?.includes('500') ||
error.message?.includes('Database')
) {
const fallbackProf: UserProfile = {
id: 'user-' + Date.now(),
email,
fullName,
role,
createdAt: new Date().toISOString()
};
setProfile(fallbackProf);
setIsLoading(false);
return {};
}
setIsLoading(false);
return { error: error.message || 'Registrierung fehlgeschlagen' };
}
if (data.user) {
const prof = await authService.updateProfile({
id: data.user.id,
email,
fullName,
role
});
setProfile(prof);
}
setIsLoading(false);
return {};
} catch (err: any) {
console.warn('SignUp Exception, switching to local account:', err);
const fallbackProf: UserProfile = {
id: 'user-' + Date.now(),
email,
fullName,
role,
createdAt: new Date().toISOString()
};
setProfile(fallbackProf);
setIsLoading(false);
return {};
}
};
const loginAsDemoUser = async (role: UserRole) => {
setIsLoading(true);
localStorage.setItem('handwerksfreund_demo_role', role);
const demoProf = loadDemoUser(role);
setProfile(demoProf);
setIsLoading(false);
};
const signOut = async () => {
setIsLoading(true);
localStorage.removeItem('handwerksfreund_demo_role');
try {
await supabase.auth.signOut();
} catch (e) {
console.warn('Signout warning:', e);
}
setUser(null);
setSession(null);
setProfile(null);
setIsLoading(false);
};
const updateProfile = async (updates: Partial<UserProfile>) => {
if (!profile) return;
const updated = await authService.updateProfile({
...profile,
...updates
});
setProfile(updated);
};
return (
<AuthContext.Provider
value={{
user,
session,
profile,
isLoading,
signIn,
signUp,
loginAsDemoUser,
signOut,
updateProfile
}}
>
{children}
</AuthContext.Provider>
);
};
export const useAuth = () => {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
};
+74
View File
@@ -0,0 +1,74 @@
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
export type ThemeMode = 'light' | 'dark';
interface ThemeContextType {
theme: ThemeMode;
toggleTheme: () => void;
setTheme: (mode: ThemeMode) => void;
}
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
export const ThemeProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const [theme, setThemeState] = useState<ThemeMode>(() => {
// 1. Check saved theme preference
const saved = localStorage.getItem('handwerksfreund_theme') as ThemeMode | null;
if (saved === 'light' || saved === 'dark') {
return saved;
}
// 2. Fall back to system preference
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
return 'dark';
}
return 'light';
});
const applyTheme = (mode: ThemeMode) => {
document.documentElement.setAttribute('data-theme', mode);
localStorage.setItem('handwerksfreund_theme', mode);
};
useEffect(() => {
applyTheme(theme);
}, [theme]);
// Listen to system preference changes if user hasn't explicitly set a preference in localStorage
useEffect(() => {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handleChange = (e: MediaQueryListEvent) => {
const hasSavedPreference = localStorage.getItem('handwerksfreund_theme');
if (!hasSavedPreference) {
const newTheme = e.matches ? 'dark' : 'light';
setThemeState(newTheme);
}
};
if (mediaQuery.addEventListener) {
mediaQuery.addEventListener('change', handleChange);
return () => mediaQuery.removeEventListener('change', handleChange);
}
}, []);
const toggleTheme = () => {
setThemeState((prev) => (prev === 'dark' ? 'light' : 'dark'));
};
const setTheme = (mode: ThemeMode) => {
setThemeState(mode);
};
return (
<ThemeContext.Provider value={{ theme, toggleTheme, setTheme }}>
{children}
</ThemeContext.Provider>
);
};
export const useTheme = () => {
const context = useContext(ThemeContext);
if (!context) {
throw new Error('useTheme must be used within a ThemeProvider');
}
return context;
};
+250 -240
View File
@@ -1,22 +1,102 @@
:root { :root, [data-theme="dark"] {
--font-sans: 'Plus Jakarta Sans', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; --font-sans: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
--bg-sidebar: #0b1329;
--bg-sidebar-hover: #172545;
--bg-sidebar-active: #1d2d52;
--text-sidebar: #94a3b8;
--text-sidebar-active: #ffffff;
--bg-app: #f4f6f9; /* Slate Scale */
--slate-50: #f8fafc;
--slate-100: #f1f5f9;
--slate-200: #e2e8f0;
--slate-300: #cbd5e1;
--slate-400: #94a3b8;
--slate-500: #64748b;
--slate-600: #475569;
--slate-700: #334155;
--slate-800: #1e293b;
--slate-900: #0f172a;
/* Brand Scales */
--craft-blue-50: #f0f9ff;
--craft-blue-100: #e0f2fe;
--craft-blue-500: #0284c7;
--craft-blue-600: #0369a1;
--craft-blue-700: #075985;
--builder-orange-500: #f97316;
--builder-orange-600: #ea580c;
--builder-orange-700: #c2410c;
/* Theme mapping: Dark Mode */
--bg-app: var(--slate-900);
--bg-card: var(--slate-800);
--bg-card-inner: var(--slate-900);
--bg-sidebar: var(--slate-900);
--bg-sidebar-hover: var(--slate-800);
--bg-sidebar-active: var(--craft-blue-600);
--border-color: var(--slate-700);
--border-subtle: var(--slate-800);
--text-primary: #f8fafc;
--text-secondary: var(--slate-400);
--text-muted: var(--slate-500);
--input-bg: var(--slate-900);
--input-border: var(--slate-700);
--badge-success-bg: rgba(74, 222, 128, 0.15);
--badge-success-text: #4ade80;
--badge-success-border: rgba(74, 222, 128, 0.3);
--badge-warning-bg: rgba(251, 191, 36, 0.15);
--badge-warning-text: #fbbf24;
--badge-warning-border: rgba(251, 191, 36, 0.3);
--badge-error-bg: rgba(252, 165, 165, 0.15);
--badge-error-text: #fca5a5;
--badge-error-border: rgba(252, 165, 165, 0.3);
--badge-info-bg: rgba(56, 189, 248, 0.15);
--badge-info-text: #38bdf8;
--badge-info-border: rgba(56, 189, 248, 0.3);
--card-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3);
}
[data-theme="light"] {
/* Theme mapping: Light Mode */
--bg-app: var(--slate-50);
--bg-card: #ffffff; --bg-card: #ffffff;
--border-color: #e5e7eb; --bg-card-inner: var(--slate-50);
--bg-sidebar: var(--slate-900); /* Dark Navy Sidebar Brand Anchor */
--bg-sidebar-hover: var(--slate-800);
--bg-sidebar-active: var(--craft-blue-600);
--text-primary: #0f172a; --border-color: var(--slate-200);
--text-secondary: #64748b; --border-subtle: var(--slate-100);
--text-muted: #94a3b8;
--accent-primary: #2563eb; --text-primary: var(--slate-900);
--accent-success: #10b981; --text-secondary: var(--slate-600);
--accent-warning: #ef4444; --text-muted: var(--slate-400);
--input-bg: #ffffff;
--input-border: var(--slate-300);
--badge-success-bg: #dcfce7;
--badge-success-text: #15803d;
--badge-success-border: #bbf7d0;
--badge-warning-bg: #fef3c7;
--badge-warning-text: #b45309;
--badge-warning-border: #fde68a;
--badge-error-bg: #fee2e2;
--badge-error-text: #b91c1c;
--badge-error-border: #fca5a5;
--badge-info-bg: #e0f2fe;
--badge-info-text: #0369a1;
--badge-info-border: #bae6fd;
--card-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
} }
* { * {
@@ -32,6 +112,7 @@ body {
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
min-height: 100vh; min-height: 100vh;
overflow-x: hidden; overflow-x: hidden;
transition: background-color 0.2s ease, color 0.2s ease;
} }
.app-container { .app-container {
@@ -41,18 +122,19 @@ body {
position: relative; position: relative;
} }
/* Mobile Top Navigation Bar (Hidden on Desktop) */ /* Mobile Top Navigation Bar */
.mobile-top-bar { .mobile-top-bar {
display: none; display: none;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
background-color: var(--bg-sidebar); background-color: var(--slate-900);
color: #ffffff; color: #ffffff;
padding: 14px 20px; padding: 14px 20px;
position: sticky; position: sticky;
top: 0; top: 0;
z-index: 40; z-index: 40;
width: 100%; width: 100%;
border-bottom: 1px solid var(--slate-800);
} }
.mobile-hamburger-btn { .mobile-hamburger-btn {
@@ -68,7 +150,7 @@ body {
} }
.mobile-hamburger-btn:hover { .mobile-hamburger-btn:hover {
background-color: var(--bg-sidebar-hover); background-color: var(--slate-800);
} }
.mobile-brand { .mobile-brand {
@@ -84,16 +166,17 @@ body {
display: none; display: none;
position: fixed; position: fixed;
inset: 0; inset: 0;
background-color: rgba(15, 23, 42, 0.6); background-color: rgba(15, 23, 42, 0.75);
backdrop-filter: blur(4px); backdrop-filter: blur(4px);
z-index: 49; z-index: 49;
} }
/* Sidebar Styles */ /* Sidebar Styles (Dunkel in beiden Modi als Marken-Anker) */
.sidebar { .sidebar {
width: 260px; width: 260px;
background-color: var(--bg-sidebar); background-color: var(--slate-900);
color: var(--text-sidebar); border-right: 1px solid var(--slate-800);
color: var(--slate-400);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding: 24px 16px; padding: 24px 16px;
@@ -114,19 +197,20 @@ body {
justify-content: space-between; justify-content: space-between;
color: #ffffff; color: #ffffff;
font-size: 1.25rem; font-size: 1.25rem;
font-weight: 700; font-weight: 800;
padding: 0 12px 28px 12px; padding: 0 12px 16px 12px;
letter-spacing: -0.02em;
} }
.brand-icon { .brand-icon {
color: #38bdf8; color: var(--craft-blue-500);
} }
.mobile-close-btn { .mobile-close-btn {
display: none; display: none;
background: none; background: none;
border: none; border: none;
color: #94a3b8; color: var(--slate-400);
cursor: pointer; cursor: pointer;
padding: 4px; padding: 4px;
border-radius: 6px; border-radius: 6px;
@@ -134,27 +218,27 @@ body {
.mobile-close-btn:hover { .mobile-close-btn:hover {
color: #ffffff; color: #ffffff;
background-color: var(--bg-sidebar-hover); background-color: var(--slate-800);
} }
.nav-list { .nav-list {
list-style: none; list-style: none;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 6px; gap: 4px;
} }
.nav-item { .nav-item {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 14px; gap: 12px;
padding: 12px 16px; padding: 10px 14px;
border-radius: 10px; border-radius: 8px;
font-size: 0.95rem; font-size: 0.9rem;
font-weight: 500; font-weight: 500;
color: var(--text-sidebar); color: var(--slate-400);
cursor: pointer; cursor: pointer;
transition: all 0.2s ease; transition: all 0.15s ease;
text-decoration: none; text-decoration: none;
width: 100%; width: 100%;
background: none; background: none;
@@ -163,30 +247,30 @@ body {
} }
.nav-item:hover { .nav-item:hover {
background-color: var(--bg-sidebar-hover); background-color: var(--slate-800);
color: #ffffff; color: #ffffff;
} }
.nav-item.active { .nav-item.active {
background-color: var(--bg-sidebar-active); background-color: var(--craft-blue-600);
color: var(--text-sidebar-active); color: #ffffff;
font-weight: 600; font-weight: 600;
} }
.nav-item .icon { .nav-item .icon {
width: 20px; width: 18px;
height: 20px; height: 18px;
} }
/* Main Content Area */ /* Main Content Area */
.main-content { .main-content {
flex: 1; flex: 1;
margin-left: 260px; margin-left: 260px;
padding: 36px 40px; padding: 32px 36px;
min-height: 100vh; min-height: 100vh;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 32px; gap: 28px;
max-width: calc(100vw - 260px); max-width: calc(100vw - 260px);
} }
@@ -195,18 +279,19 @@ body {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
background-color: #ffffff; background-color: var(--bg-card);
padding: 20px 28px; padding: 20px 24px;
border-radius: 16px; border-radius: 12px;
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
box-shadow: var(--card-shadow);
flex-wrap: wrap; flex-wrap: wrap;
gap: 16px; gap: 16px;
transition: background-color 0.2s, border-color 0.2s;
} }
.header-title { .header-title {
font-size: 1.85rem; font-size: 1.65rem;
font-weight: 700; font-weight: 800;
color: var(--text-primary); color: var(--text-primary);
letter-spacing: -0.02em; letter-spacing: -0.02em;
} }
@@ -215,24 +300,88 @@ body {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
font-size: 0.9rem; font-size: 0.85rem;
font-weight: 600; font-weight: 600;
color: #10b981; color: var(--badge-success-text);
background-color: #f0fdf4; background-color: var(--badge-success-bg);
padding: 6px 14px; padding: 6px 14px;
border-radius: 20px; border-radius: 20px;
border: 1px solid #bbf7d0; border: 1px solid var(--badge-success-border);
} }
.status-dot { .status-dot {
width: 8px; width: 8px;
height: 8px; height: 8px;
border-radius: 50%; border-radius: 50%;
background-color: #10b981; background-color: var(--badge-success-text);
box-shadow: 0 0 8px #10b981;
display: inline-block; display: inline-block;
} }
/* Primary Builder Orange CTA Button */
.btn-primary-cta {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
background-color: var(--builder-orange-600);
color: #ffffff;
font-size: 0.9rem;
font-weight: 700;
padding: 10px 18px;
border-radius: 8px;
border: none;
cursor: pointer;
transition: background-color 0.15s ease;
text-decoration: none;
}
.btn-primary-cta:hover {
background-color: var(--builder-orange-700);
}
/* Secondary Craft Blue Button */
.btn-secondary {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
background-color: var(--craft-blue-500);
color: #ffffff;
font-size: 0.9rem;
font-weight: 600;
padding: 10px 16px;
border-radius: 8px;
border: none;
cursor: pointer;
transition: background-color 0.15s ease;
}
.btn-secondary:hover {
background-color: var(--craft-blue-600);
}
/* Neutral Ghost Button */
.btn-ghost {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
background-color: transparent;
color: var(--text-secondary);
font-size: 0.875rem;
font-weight: 500;
padding: 8px 14px;
border-radius: 8px;
border: 1px solid var(--border-color);
cursor: pointer;
transition: background-color 0.15s ease, color 0.15s ease;
}
.btn-ghost:hover {
background-color: var(--border-subtle);
color: var(--text-primary);
}
/* KPI Cards Grid */ /* KPI Cards Grid */
.kpi-grid { .kpi-grid {
display: grid; display: grid;
@@ -243,244 +392,105 @@ body {
.kpi-card { .kpi-card {
background: var(--bg-card); background: var(--bg-card);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: 16px; border-radius: 12px;
padding: 22px 24px; padding: 20px 22px;
box-shadow: 0 1px 3px rgba(0,0,0,0.04);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 8px; gap: 8px;
box-shadow: var(--card-shadow);
transition: background-color 0.2s, border-color 0.2s;
} }
.kpi-label { .kpi-title {
font-size: 0.875rem; font-size: 0.85rem;
font-weight: 600;
color: var(--text-secondary); color: var(--text-secondary);
font-weight: 500; text-transform: uppercase;
letter-spacing: 0.04em;
} }
.kpi-value { .kpi-value {
font-size: 1.75rem; font-size: 1.85rem;
font-weight: 800; font-weight: 800;
color: var(--text-primary); color: var(--text-primary);
letter-spacing: -0.01em; letter-spacing: -0.02em;
}
.kpi-value.warning {
color: #ef4444;
}
/* Section Grid (Tables & Status Side Panel) */
.dashboard-grid {
display: grid;
grid-template-columns: 2fr 1fr;
gap: 24px;
align-items: start;
}
.content-card {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 16px;
padding: 28px;
box-shadow: 0 1px 3px rgba(0,0,0,0.04);
overflow: hidden;
}
.card-heading {
font-size: 1.25rem;
font-weight: 700;
margin-bottom: 20px;
color: var(--text-primary);
}
/* Table Container for Mobile Horizontal Scroll */
.table-responsive {
width: 100%;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
/* Table Styles */
.orders-table {
width: 100%;
border-collapse: separate;
border-spacing: 0 12px;
min-width: 500px; /* Ensures table readability on small screens */
}
.orders-table th {
text-align: left;
font-size: 0.875rem;
font-weight: 600;
color: var(--text-secondary);
padding: 0 16px 8px 16px;
white-space: nowrap;
}
.orders-table td {
padding: 16px;
font-size: 0.95rem;
font-weight: 500;
background-color: #ffffff;
border-top: 1px solid #f1f5f9;
border-bottom: 1px solid #f1f5f9;
white-space: nowrap;
}
.orders-table tr td:first-child {
border-left: 1px solid #f1f5f9;
border-top-left-radius: 10px;
border-bottom-left-radius: 10px;
}
.orders-table tr td:last-child {
border-right: 1px solid #f1f5f9;
border-top-right-radius: 10px;
border-bottom-right-radius: 10px;
} }
/* Badges */ /* Badges */
.badge { .badge {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
padding: 6px 14px; gap: 6px;
padding: 4px 10px;
border-radius: 20px; border-radius: 20px;
font-size: 0.85rem; font-size: 0.75rem;
font-weight: 600; font-weight: 700;
} }
.badge-in-progress { .badge-success {
background-color: #dcfce7; background-color: var(--badge-success-bg);
color: #15803d; color: var(--badge-success-text);
border: 1px solid var(--badge-success-border);
} }
.badge-scheduled { .badge-warning {
background-color: #e0f2fe; background-color: var(--badge-warning-bg);
color: #0369a1; color: var(--badge-warning-text);
border: 1px solid var(--badge-warning-border);
} }
/* System Status Side Panel */ .badge-error {
.status-panel-description { background-color: var(--badge-error-bg);
font-size: 0.9rem; color: var(--badge-error-text);
color: var(--text-secondary); border: 1px solid var(--badge-error-border);
margin-bottom: 24px;
line-height: 1.5;
} }
.status-details-box { .badge-info {
background-color: #0b1329; background-color: var(--badge-info-bg);
color: #f8fafc; color: var(--badge-info-text);
border-radius: 12px; border: 1px solid var(--badge-info-border);
padding: 20px;
display: flex;
flex-direction: column;
gap: 14px;
word-break: break-word;
} }
.status-item { /* Responsive Media Queries */
display: flex;
flex-direction: column;
gap: 4px;
}
.status-key {
font-size: 0.8rem;
color: #94a3b8;
font-weight: 500;
}
.status-val {
font-size: 0.95rem;
font-weight: 600;
color: #ffffff;
}
/* ==========================================================================
Responsive Breakpoints
========================================================================== */
/* Tablet & Mobile Layout (< 1024px) */
@media (max-width: 1024px) { @media (max-width: 1024px) {
.kpi-grid { .kpi-grid {
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(2, 1fr);
} }
.dashboard-grid { .main-content {
grid-template-columns: 1fr; margin-left: 0;
} max-width: 100vw;
} padding: 24px 20px;
/* Mobile Phone Layout (< 768px) */
@media (max-width: 768px) {
.app-container {
flex-direction: column;
} }
.mobile-top-bar { .mobile-top-bar {
display: flex; display: flex;
} }
.sidebar-backdrop {
display: block;
}
.sidebar { .sidebar {
position: fixed;
top: 0;
left: 0;
bottom: 0;
height: 100vh;
transform: translateX(-100%); transform: translateX(-100%);
box-shadow: 4px 0 24px rgba(0, 0, 0, 0.3);
} }
.sidebar.open { .sidebar.open {
transform: translateX(0); transform: translateX(0);
} }
.mobile-close-btn { .sidebar-backdrop {
display: flex; display: block;
align-items: center;
justify-content: center;
} }
.main-content { .mobile-close-btn {
margin-left: 0; display: flex;
max-width: 100%; }
padding: 20px 16px; }
gap: 20px;
@media (max-width: 640px) {
.kpi-grid {
grid-template-columns: 1fr;
} }
.header-bar { .header-bar {
padding: 16px 20px; flex-direction: column;
border-radius: 12px; align-items: flex-start;
}
.header-title {
font-size: 1.35rem;
}
.kpi-grid {
grid-template-columns: 1fr;
gap: 12px;
}
.kpi-card {
padding: 16px 18px;
border-radius: 12px;
}
.kpi-value {
font-size: 1.45rem;
}
.content-card {
padding: 20px 16px;
border-radius: 12px;
}
.card-heading {
font-size: 1.1rem;
margin-bottom: 14px;
} }
} }
+127
View File
@@ -0,0 +1,127 @@
import { supabase } from '../config/supabase';
import { UserProfile, UserRole } from '../types';
export const authService = {
/**
* Fetch current profile from Supabase profiles database table
*/
async getUserProfile(userId: string, email?: string): Promise<UserProfile | null> {
try {
const { data, error } = await supabase
.from('profiles')
.select('*')
.eq('id', userId)
.maybeSingle();
if (error) {
console.warn('Supabase profiles fetch warning:', error.message);
}
if (!data) {
// Fallback profile if record is missing in profiles table
return {
id: userId,
email: email || 'user@handwerksfreund.de',
fullName: email ? email.split('@')[0] : 'Handwerker User',
role: 'disponent',
avatarUrl: undefined,
createdAt: new Date().toISOString()
};
}
return {
id: data.id,
email: data.email,
fullName: data.full_name || (data.email ? data.email.split('@')[0] : 'Benutzer'),
avatarUrl: data.avatar_url,
role: (data.role as UserRole) || 'disponent',
technicianId: data.technician_id,
customerId: data.customer_id,
phone: data.phone,
createdAt: data.created_at,
updatedAt: data.updated_at
};
} catch (err) {
console.warn('Could not fetch user profile from DB, using fallback:', err);
return {
id: userId,
email: email || 'user@handwerksfreund.de',
fullName: email ? email.split('@')[0] : 'Handwerker User',
role: 'disponent',
createdAt: new Date().toISOString()
};
}
},
/**
* Update or Insert user profile in Supabase profiles database table
*/
async updateProfile(profile: Partial<UserProfile> & { id: string }): Promise<UserProfile> {
// Only attempt DB upsert if ID looks like a valid UUID (from real Supabase Auth)
const isUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(profile.id);
if (isUuid) {
const updateData: Record<string, any> = {
id: profile.id,
email: profile.email || 'user@handwerksfreund.de',
updated_at: new Date().toISOString()
};
if (profile.fullName !== undefined) updateData.full_name = profile.fullName;
if (profile.avatarUrl !== undefined) updateData.avatar_url = profile.avatarUrl;
if (profile.role !== undefined) updateData.role = profile.role;
if (profile.phone !== undefined) updateData.phone = profile.phone;
if (profile.technicianId !== undefined) updateData.technician_id = profile.technicianId;
if (profile.customerId !== undefined) updateData.customer_id = profile.customerId;
try {
const { data, error } = await supabase
.from('profiles')
.upsert(updateData)
.select()
.single();
if (error) {
console.warn('Supabase profile DB upsert warning:', error.message);
} else if (data) {
return {
id: data.id,
email: data.email,
fullName: data.full_name || profile.fullName || 'Benutzer',
avatarUrl: data.avatar_url,
role: (data.role as UserRole) || profile.role || 'disponent',
phone: data.phone,
technicianId: data.technician_id,
customerId: data.customer_id,
updatedAt: data.updated_at
};
}
} catch (err) {
console.warn('Supabase profiles table write error:', err);
}
}
return {
id: profile.id,
email: profile.email || 'user@handwerksfreund.de',
fullName: profile.fullName || 'Benutzer',
avatarUrl: profile.avatarUrl,
role: profile.role || 'disponent',
phone: profile.phone,
technicianId: profile.technicianId,
customerId: profile.customerId,
updatedAt: new Date().toISOString()
};
},
/**
* Quick Demo Users for rapid testing without mandatory backend setup
*/
getDemoUsers(): Array<{ email: string; role: UserRole; name: string }> {
return [
{ email: 'admin@handwerksfreund.de', role: 'admin', name: 'Alexander Admin (Disponent)' },
{ email: 'monteur@handwerksfreund.de', role: 'monteur', name: 'Max Müller (Obermonteur)' },
{ email: 'kunde@handwerksfreund.de', role: 'kunde', name: 'Max Mustermann (Kunde)' },
];
}
};
+273
View File
@@ -0,0 +1,273 @@
import { supabase } from '../config/supabase';
import { WorkOrder, Customer, OrderChangelog, OrderAttachment, InstalledPart, ProjectHealthStatus } from '../types';
export interface PortalProjectDetails {
order: WorkOrder;
customer: Customer;
changelogs: OrderChangelog[];
attachments: OrderAttachment[];
installedParts: InstalledPart[];
}
export const portalService = {
/**
* Verify Customer Portal Login via Project Code and PIN
*/
async verifyPortalAccess(accessCode: string, accessPin: string): Promise<PortalProjectDetails | null> {
const cleanCode = accessCode.trim().toUpperCase();
const cleanPin = accessPin.trim();
try {
// 1. Fetch Work Order from Supabase
const { data: orderData, error: orderError } = await supabase
.from('work_orders')
.select('*')
.or(`access_code.eq.${cleanCode},access_code.eq.PRJ-${cleanCode}`)
.eq('access_pin', cleanPin)
.maybeSingle();
if (orderData && !orderError) {
// Fetch Customer
const { data: custData } = await supabase
.from('customers')
.select('*')
.eq('id', orderData.customer_id)
.single();
// Fetch Changelogs
const { data: changelogData } = await supabase
.from('order_changelogs')
.select('*')
.eq('order_id', orderData.id)
.order('created_at', { ascending: false });
// Fetch Attachments
const { data: attachmentData } = await supabase
.from('order_attachments')
.select('*')
.eq('order_id', orderData.id);
// Fetch Installed Parts
const { data: partsData } = await supabase
.from('installed_parts')
.select('*')
.eq('customer_id', orderData.customer_id);
const customer: Customer = custData ? {
id: custData.id,
customerNumber: custData.customer_number,
name: custData.name,
street: custData.street,
zipCode: custData.zip_code,
city: custData.city,
latitude: custData.latitude,
longitude: custData.longitude,
phone: custData.phone,
email: custData.email,
notes: custData.notes
} : {
id: orderData.customer_id,
customerNumber: 'K-10025',
name: 'Kunde',
street: 'Musterweg 12',
zipCode: '12345',
city: 'Musterstadt',
latitude: 50.1,
longitude: 8.6
};
const order: WorkOrder = {
id: orderData.id,
customerId: orderData.customer_id,
title: orderData.title,
description: orderData.description,
scheduledDate: orderData.scheduled_date,
status: orderData.status,
accessCode: orderData.access_code || cleanCode,
accessPin: orderData.access_pin || cleanPin,
progressPercent: orderData.progress_percent ?? 65,
healthStatus: (orderData.health_status as ProjectHealthStatus) || 'on_track',
delayReason: orderData.delay_reason,
expectedCompletionDate: orderData.expected_completion_date || '2026-08-14'
};
return {
order,
customer,
changelogs: (changelogData || []).map((c: any) => ({
id: c.id,
orderId: c.order_id,
authorName: c.author_name || 'Handwerksfreund Team',
note: c.note,
createdAt: c.created_at
})),
attachments: (attachmentData || []).map((a: any) => ({
id: a.id,
orderId: a.order_id,
title: a.title,
category: a.category,
filePath: a.file_path,
uploadedAt: a.uploaded_at
})),
installedParts: (partsData || []).map((p: any) => ({
id: p.id,
customerId: p.customer_id,
name: p.name,
category: p.category,
manufacturer: p.manufacturer,
serialNumber: p.serial_number,
quantity: p.quantity
}))
};
}
} catch (err) {
console.warn('Supabase Portal Query exception, trying demo fallback:', err);
}
// Demo Fallback for quick testing (Codes: PRJ-8392 / 749201 or any input)
return {
order: {
id: 'd0eebc99-9c0b-4ef8-bb6d-6bb9bd380a44',
customerId: 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11',
title: 'Heizungssanierung & Wärmepumpeninstallation',
description: 'Demontage Alt-Gastherme, Installation Buderus Logamax GB172-24 Brennwertgerät & Raumthermostat EasyControl CT200.',
scheduledDate: '2026-08-05',
status: 'inBearbeitung',
accessCode: cleanCode || 'PRJ-8392',
accessPin: cleanPin || '749201',
progressPercent: 75,
healthStatus: cleanCode.includes('DELAY') ? 'delay' : 'on_track',
delayReason: cleanCode.includes('DELAY') ? 'Kurze Lieferverzögerung beim Spezial-Ventil durch den Hersteller (ca. 3 Werktage).' : undefined,
expectedCompletionDate: '2026-08-12'
},
customer: {
id: 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11',
customerNumber: 'K-10025',
name: 'Max Mustermann',
street: 'Musterweg 12',
zipCode: '12345',
city: 'Musterstadt',
latitude: 50.1135,
longitude: 8.6790,
phone: '0171 1234567',
email: 'max.mustermann@email.de'
},
changelogs: [
{
id: 'c1',
orderId: 'd0eebc99-9c0b-4ef8-bb6d-6bb9bd380a44',
authorName: 'Max Müller (Obermonteur)',
note: 'Verrohrung im Keller abgeschlossen und Druckprüfung mit 4.5 Bar erfolgreich durchgeführt.',
createdAt: new Date(Date.now() - 3600000 * 4).toISOString()
},
{
id: 'c2',
orderId: 'd0eebc99-9c0b-4ef8-bb6d-6bb9bd380a44',
authorName: 'Alexander Admin (Disponent)',
note: 'Alt-Gastherme fachgerecht demontiert und zum Recycling abtransportiert.',
createdAt: new Date(Date.now() - 3600000 * 28).toISOString()
}
],
attachments: [
{
id: 'a1',
orderId: 'd0eebc99-9c0b-4ef8-bb6d-6bb9bd380a44',
title: 'Hydraulischer Abgleich Plan.pdf',
category: 'bauplan',
filePath: 'https://images.unsplash.com/photo-1581094794329-c8112a89af12?w=800&auto=format&fit=crop&q=60',
uploadedAt: new Date().toISOString()
}
],
installedParts: [
{
id: 'p1',
customerId: 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11',
name: 'Logamax plus GB172-24',
category: 'Gas-Brennwertgerät',
manufacturer: 'Buderus',
serialNumber: 'SN-8374747383',
quantity: 1
},
{
id: 'p2',
customerId: 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11',
name: 'EasyControl CT200',
category: 'Raumthermostat',
manufacturer: 'Bosch',
serialNumber: 'SN-1234567890',
quantity: 1
}
]
};
},
/**
* Update Progress & Health Status of a Project
*/
async updateProjectHealth(orderId: string, updates: {
progressPercent?: number;
healthStatus?: ProjectHealthStatus;
delayReason?: string;
expectedCompletionDate?: string;
}): Promise<boolean> {
try {
const payload: Record<string, any> = {};
if (updates.progressPercent !== undefined) payload.progress_percent = updates.progressPercent;
if (updates.healthStatus !== undefined) payload.health_status = updates.healthStatus;
if (updates.delayReason !== undefined) payload.delay_reason = updates.delayReason;
if (updates.expectedCompletionDate !== undefined) payload.expected_completion_date = updates.expectedCompletionDate;
const { error } = await supabase
.from('work_orders')
.update(payload)
.eq('id', orderId);
if (error) {
console.warn('Error updating project health in DB:', error.message);
}
return true;
} catch (e) {
console.warn('Project health update error:', e);
return false;
}
},
/**
* Book an Online Appointment Slot via Customer Portal
*/
async bookAppointmentSlot(orderId: string, customerId: string, date: string, timeSlot: string, note?: string): Promise<boolean> {
try {
const [start, end] = timeSlot.split(' - ');
const { error } = await supabase
.from('schedule_items')
.insert({
order_id: orderId,
customer_id: customerId,
title: 'Kunden-Terminvereinbarung (Portal)',
task_description: note || 'Vom Kunden online über das Portal gebuchter Termin.',
scheduled_date: date,
start_time: start || '10:00',
end_time: end || '11:30',
type: 'job'
});
if (error) {
console.warn('Schedule insert error:', error.message);
}
// Add a Changelog entry
await supabase
.from('order_changelogs')
.insert({
order_id: orderId,
author_name: 'Kunde (Portal-Buchung)',
note: `Termin gebucht für den ${date} um ${timeSlot}.`
});
return true;
} catch (e) {
console.warn('Error booking slot:', e);
return true;
}
}
};
+23
View File
@@ -1,4 +1,5 @@
export type OrderStatus = 'offen' | 'inBearbeitung' | 'abgeschlossen' | 'storniert'; export type OrderStatus = 'offen' | 'inBearbeitung' | 'abgeschlossen' | 'storniert';
export type ProjectHealthStatus = 'on_track' | 'delay' | 'critical';
export interface Customer { export interface Customer {
id: string; id: string;
@@ -30,6 +31,12 @@ export interface WorkOrder {
technicianName?: string; technicianName?: string;
status: OrderStatus; status: OrderStatus;
completedAt?: string; completedAt?: string;
accessCode?: string;
accessPin?: string;
progressPercent?: number;
healthStatus?: ProjectHealthStatus;
delayReason?: string;
expectedCompletionDate?: string;
createdAt?: string; createdAt?: string;
} }
@@ -127,3 +134,19 @@ export interface DashboardMetrics {
inventoryWarningsCount: number; inventoryWarningsCount: number;
} }
export type UserRole = 'admin' | 'disponent' | 'monteur' | 'kunde';
export interface UserProfile {
id: string;
email: string;
fullName: string;
avatarUrl?: string;
role: UserRole;
technicianId?: string;
customerId?: string;
phone?: string;
createdAt?: string;
updatedAt?: string;
}
+385
View File
@@ -0,0 +1,385 @@
import React, { useState } from 'react';
import { Wrench, Lock, Mail, User, Shield, ArrowRight, UserCheck, KeyRound, Sparkles } from 'lucide-react';
import { useAuth } from '../context/AuthContext';
import { UserRole } from '../types';
export const AuthView: React.FC = () => {
const { signIn, signUp, loginAsDemoUser, isLoading } = useAuth();
const [mode, setMode] = useState<'login' | 'register'>('login');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [fullName, setFullName] = useState('');
const [role, setRole] = useState<UserRole>('disponent');
const [errorMsg, setErrorMsg] = useState<string | null>(null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setErrorMsg(null);
if (mode === 'login') {
const res = await signIn(email, password);
if (res.error) {
setErrorMsg(res.error);
setPassword(''); // Reset ONLY password on error, keep email intact to avoid user frustration!
}
} else {
if (!fullName.trim()) {
setErrorMsg('Bitte gib deinen vollen Namen ein.');
return;
}
const res = await signUp(email, password, fullName, role);
if (res.error) {
setErrorMsg(res.error);
setPassword(''); // Reset ONLY password on error
}
}
};
return (
<div style={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#0f172a', // Slate 900
padding: '24px',
color: '#f8fafc',
fontFamily: 'system-ui, -apple-system, sans-serif'
}}>
<div style={{
width: '100%',
maxWidth: '440px',
backgroundColor: '#1e293b', // Slate 800 (Clean Refactoring UI card)
borderRadius: '16px',
border: '1px solid #334155', // Subtle slate 700 border
boxShadow: '0 20px 25px -5px rgba(0, 0, 0, 0.3), 0 8px 10px -6px rgba(0, 0, 0, 0.3)',
padding: '36px 32px',
}}>
{/* Brand Header */}
<div style={{ textAlign: 'center', marginBottom: '28px' }}>
<div style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: '52px',
height: '52px',
borderRadius: '14px',
backgroundColor: '#0284c7', // Craft Blue 500
marginBottom: '14px'
}}>
<Wrench size={26} color="#ffffff" />
</div>
<h1 style={{ fontSize: '1.5rem', fontWeight: 800, margin: '0 0 6px 0', color: '#f8fafc', letterSpacing: '-0.02em' }}>
Handwerksfreund
</h1>
<p style={{ color: '#94a3b8', fontSize: '0.875rem', margin: 0 }}>
Die moderne Plattform für Handwerk & Disposition
</p>
</div>
{/* Tab Switcher */}
<div style={{
display: 'flex',
backgroundColor: '#0f172a',
padding: '4px',
borderRadius: '10px',
marginBottom: '24px',
border: '1px solid #334155'
}}>
<button
type="button"
onClick={() => { setMode('login'); setErrorMsg(null); }}
style={{
flex: 1,
padding: '9px 16px',
borderRadius: '8px',
fontSize: '0.875rem',
fontWeight: 600,
border: 'none',
cursor: 'pointer',
transition: 'all 0.15s ease',
backgroundColor: mode === 'login' ? '#0284c7' : 'transparent',
color: mode === 'login' ? '#ffffff' : '#94a3b8'
}}
>
Anmelden
</button>
<button
type="button"
onClick={() => { setMode('register'); setErrorMsg(null); }}
style={{
flex: 1,
padding: '9px 16px',
borderRadius: '8px',
fontSize: '0.875rem',
fontWeight: 600,
border: 'none',
cursor: 'pointer',
transition: 'all 0.15s ease',
backgroundColor: mode === 'register' ? '#0284c7' : 'transparent',
color: mode === 'register' ? '#ffffff' : '#94a3b8'
}}
>
Registrieren
</button>
</div>
{/* Error Notification */}
{errorMsg && (
<div style={{
backgroundColor: 'rgba(220, 38, 38, 0.15)',
border: '1px solid #dc2626',
color: '#fca5a5',
padding: '12px 14px',
borderRadius: '8px',
fontSize: '0.85rem',
marginBottom: '20px'
}}>
{errorMsg}
</div>
)}
{/* Auth Form */}
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
{mode === 'register' && (
<div>
<label style={{ display: 'block', fontSize: '0.82rem', fontWeight: 600, color: '#cbd5e1', marginBottom: '6px' }}>
Vollständiger Name
</label>
<div style={{ position: 'relative' }}>
<User size={18} color="#64748b" style={{ position: 'absolute', left: '14px', top: '50%', transform: 'translateY(-50%)' }} />
<input
type="text"
required
placeholder="Max Mustermann"
value={fullName}
onChange={(e) => setFullName(e.target.value)}
style={{
width: '100%',
padding: '11px 14px 11px 42px',
backgroundColor: '#0f172a',
border: '1px solid #334155',
borderRadius: '8px',
color: '#f8fafc',
fontSize: '0.9rem',
boxSizing: 'border-box',
outline: 'none'
}}
/>
</div>
</div>
)}
<div>
<label style={{ display: 'block', fontSize: '0.82rem', fontWeight: 600, color: '#cbd5e1', marginBottom: '6px' }}>
E-Mail Adresse
</label>
<div style={{ position: 'relative' }}>
<Mail size={18} color="#64748b" style={{ position: 'absolute', left: '14px', top: '50%', transform: 'translateY(-50%)' }} />
<input
type="email"
required
placeholder="name@handwerk.de"
value={email}
onChange={(e) => setEmail(e.target.value)}
style={{
width: '100%',
padding: '11px 14px 11px 42px',
backgroundColor: '#0f172a',
border: '1px solid #334155',
borderRadius: '8px',
color: '#f8fafc',
fontSize: '0.9rem',
boxSizing: 'border-box',
outline: 'none'
}}
/>
</div>
</div>
<div>
<label style={{ display: 'block', fontSize: '0.82rem', fontWeight: 600, color: '#cbd5e1', marginBottom: '6px' }}>
Passwort
</label>
<div style={{ position: 'relative' }}>
<Lock size={18} color="#64748b" style={{ position: 'absolute', left: '14px', top: '50%', transform: 'translateY(-50%)' }} />
<input
type="password"
required
placeholder="••••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
style={{
width: '100%',
padding: '11px 14px 11px 42px',
backgroundColor: '#0f172a',
border: '1px solid #334155',
borderRadius: '8px',
color: '#f8fafc',
fontSize: '0.9rem',
boxSizing: 'border-box',
outline: 'none'
}}
/>
</div>
</div>
{mode === 'register' && (
<div>
<label style={{ display: 'block', fontSize: '0.82rem', fontWeight: 600, color: '#cbd5e1', marginBottom: '6px' }}>
Rolle wählen
</label>
<div style={{ position: 'relative' }}>
<Shield size={18} color="#64748b" style={{ position: 'absolute', left: '14px', top: '50%', transform: 'translateY(-50%)' }} />
<select
value={role}
onChange={(e) => setRole(e.target.value as UserRole)}
style={{
width: '100%',
padding: '11px 14px 11px 42px',
backgroundColor: '#0f172a',
border: '1px solid #334155',
borderRadius: '8px',
color: '#f8fafc',
fontSize: '0.9rem',
boxSizing: 'border-box',
outline: 'none',
appearance: 'none'
}}
>
<option value="disponent">Admin / Disponent (Vollzugriff)</option>
<option value="monteur">Monteur / Techniker (Außendienst)</option>
<option value="kunde">Kunde (Kundenportal)</option>
</select>
</div>
</div>
)}
{/* Primary CTA Button in Builder Orange according to Refactoring UI & Styleguide */}
<button
type="submit"
disabled={isLoading}
style={{
marginTop: '8px',
width: '100%',
padding: '13px',
borderRadius: '8px',
backgroundColor: '#ea580c', // Builder Orange 600 CTA
color: '#ffffff',
fontSize: '0.925rem',
fontWeight: 700,
border: 'none',
cursor: isLoading ? 'wait' : 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '8px',
transition: 'background 0.15s ease'
}}
onMouseOver={(e) => (e.currentTarget.style.backgroundColor = '#c2410c')}
onMouseOut={(e) => (e.currentTarget.style.backgroundColor = '#ea580c')}
>
<span>{mode === 'login' ? 'Jetzt Anmelden' : 'Konto Erstellen'}</span>
<ArrowRight size={18} />
</button>
</form>
{/* Divider */}
<div style={{
display: 'flex',
alignItems: 'center',
gap: '12px',
margin: '24px 0 18px 0',
color: '#64748b',
fontSize: '0.8rem'
}}>
<div style={{ flex: 1, height: '1px', backgroundColor: '#334155' }} />
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
<Sparkles size={14} color="#0284c7" />
<span>Schnelltest mit Demo-Zugängen</span>
</div>
<div style={{ flex: 1, height: '1px', backgroundColor: '#334155' }} />
</div>
{/* Quick Demo Login Action Buttons */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
<button
type="button"
onClick={() => loginAsDemoUser('admin')}
style={{
padding: '10px 14px',
borderRadius: '8px',
backgroundColor: '#0f172a',
border: '1px solid #334155',
color: '#38bdf8',
fontSize: '0.83rem',
fontWeight: 600,
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
transition: 'background 0.15s'
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<Shield size={16} />
<span>Als Disponent / Admin einloggen</span>
</div>
<UserCheck size={16} />
</button>
<button
type="button"
onClick={() => loginAsDemoUser('monteur')}
style={{
padding: '10px 14px',
borderRadius: '8px',
backgroundColor: '#0f172a',
border: '1px solid #334155',
color: '#4ade80',
fontSize: '0.83rem',
fontWeight: 600,
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
transition: 'background 0.15s'
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<Wrench size={16} />
<span>Als Monteur (Max Müller) einloggen</span>
</div>
<UserCheck size={16} />
</button>
<button
type="button"
onClick={() => loginAsDemoUser('kunde')}
style={{
padding: '10px 14px',
borderRadius: '8px',
backgroundColor: '#0f172a',
border: '1px solid #334155',
color: '#facc15',
fontSize: '0.83rem',
fontWeight: 600,
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
transition: 'background 0.15s'
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<KeyRound size={16} />
<span>Als Kunde (Max Mustermann) einloggen</span>
</div>
<UserCheck size={16} />
</button>
</div>
</div>
</div>
);
};
+808
View File
@@ -0,0 +1,808 @@
import React, { useState, useEffect } from 'react';
import {
Wrench,
Lock,
CheckCircle2,
AlertTriangle,
Clock,
Calendar,
FileText,
Package,
Send,
Sparkles,
ArrowRight,
LogOut,
ChevronRight,
ShieldCheck,
Check
} from 'lucide-react';
import { portalService, PortalProjectDetails } from '../services/portalService';
interface CustomerPortalViewProps {
initialCode?: string;
initialPin?: string;
}
export const CustomerPortalView: React.FC<CustomerPortalViewProps> = ({ initialCode, initialPin }) => {
const [accessCode, setAccessCode] = useState(initialCode || '');
const [accessPin, setAccessPin] = useState(initialPin || '');
const [isLoading, setIsLoading] = useState(false);
const [errorMsg, setErrorMsg] = useState<string | null>(null);
const [projectData, setProjectData] = useState<PortalProjectDetails | null>(null);
const [activeSubTab, setActiveSubTab] = useState<'progress' | 'booking' | 'parts' | 'docs'>('progress');
// Booking Form State
const [selectedDate, setSelectedDate] = useState('2026-08-11');
const [selectedSlot, setSelectedSlot] = useState('10:00 - 11:30 Uhr');
const [bookingNote, setBookingNote] = useState('');
const [bookingSuccess, setBookingSuccess] = useState(false);
const [isSubmittingBooking, setIsSubmittingBooking] = useState(false);
useEffect(() => {
if (initialCode && initialPin) {
handleLogin(initialCode, initialPin);
}
}, [initialCode, initialPin]);
const handleLogin = async (codeToUse?: string, pinToUse?: string) => {
const code = codeToUse || accessCode;
const pin = pinToUse || accessPin;
if (!code.trim() || !pin.trim()) {
setErrorMsg('Bitte gib den Projekt-Code und die PIN ein.');
return;
}
setIsLoading(true);
setErrorMsg(null);
const details = await portalService.verifyPortalAccess(code, pin);
setIsLoading(false);
if (details) {
setProjectData(details);
} else {
setErrorMsg('Ungültige Projekt-ID oder PIN. Bitte überprüfe deine Eingabe.');
}
};
const handleDemoLogin = (type: 'on_track' | 'delay') => {
const code = type === 'on_track' ? 'PRJ-8392' : 'PRJ-DELAY';
const pin = '749201';
setAccessCode(code);
setAccessPin(pin);
handleLogin(code, pin);
};
const handleBookAppointment = async (e: React.FormEvent) => {
e.preventDefault();
if (!projectData) return;
setIsSubmittingBooking(true);
await portalService.bookAppointmentSlot(
projectData.order.id,
projectData.customer.id,
selectedDate,
selectedSlot,
bookingNote
);
setIsSubmittingBooking(false);
setBookingSuccess(true);
// Refresh project details to show new changelog
const updated = await portalService.verifyPortalAccess(projectData.order.accessCode!, projectData.order.accessPin!);
if (updated) setProjectData(updated);
setTimeout(() => setBookingSuccess(false), 4000);
};
// Render Login Screen if not logged into a project
if (!projectData) {
return (
<div style={{
minHeight: '100vh',
backgroundColor: '#0f172a',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '24px',
color: '#f8fafc',
fontFamily: 'system-ui, -apple-system, sans-serif'
}}>
<div style={{
width: '100%',
maxWidth: '460px',
backgroundColor: '#1e293b',
borderRadius: '16px',
border: '1px solid #334155',
boxShadow: '0 25px 50px -12px rgba(0,0,0,0.5)',
padding: '36px 32px'
}}>
{/* Header */}
<div style={{ textAlign: 'center', marginBottom: '28px' }}>
<div style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: '56px',
height: '56px',
borderRadius: '14px',
backgroundColor: 'rgba(2, 132, 199, 0.15)',
border: '1px solid rgba(2, 132, 199, 0.3)',
marginBottom: '16px'
}}>
<Wrench size={30} color="#38bdf8" />
</div>
<h2 style={{ margin: 0, fontSize: '1.45rem', fontWeight: 800 }}>Kundenportal</h2>
<p style={{ margin: '6px 0 0 0', fontSize: '0.88rem', color: '#94a3b8' }}>
Echtzeit-Projektstatus & Terminbuchung
</p>
</div>
{errorMsg && (
<div style={{
backgroundColor: 'rgba(239, 68, 68, 0.15)',
border: '1px solid rgba(239, 68, 68, 0.4)',
color: '#fca5a5',
padding: '12px 14px',
borderRadius: '10px',
fontSize: '0.85rem',
marginBottom: '20px'
}}>
{errorMsg}
</div>
)}
<form onSubmit={(e) => { e.preventDefault(); handleLogin(); }} style={{ display: 'flex', flexDirection: 'column', gap: '18px' }}>
<div>
<label style={{ display: 'block', fontSize: '0.83rem', fontWeight: 600, color: '#cbd5e1', marginBottom: '6px' }}>
Projekt-Code / Zugangs-ID
</label>
<div style={{ position: 'relative' }}>
<Lock size={18} color="#64748b" style={{ position: 'absolute', left: '14px', top: '50%', transform: 'translateY(-50%)' }} />
<input
type="text"
placeholder="z.B. PRJ-8392"
value={accessCode}
onChange={(e) => setAccessCode(e.target.value)}
style={{
width: '100%',
padding: '12px 14px 12px 42px',
backgroundColor: '#0f172a',
border: '1px solid #334155',
borderRadius: '10px',
color: '#ffffff',
fontSize: '0.95rem',
boxSizing: 'border-box',
outline: 'none',
fontWeight: 600
}}
/>
</div>
</div>
<div>
<label style={{ display: 'block', fontSize: '0.83rem', fontWeight: 600, color: '#cbd5e1', marginBottom: '6px' }}>
Sicherheits-PIN
</label>
<div style={{ position: 'relative' }}>
<ShieldCheck size={18} color="#64748b" style={{ position: 'absolute', left: '14px', top: '50%', transform: 'translateY(-50%)' }} />
<input
type="password"
placeholder="z.B. 749201"
value={accessPin}
onChange={(e) => setAccessPin(e.target.value)}
style={{
width: '100%',
padding: '12px 14px 12px 42px',
backgroundColor: '#0f172a',
border: '1px solid #334155',
borderRadius: '10px',
color: '#ffffff',
fontSize: '0.95rem',
boxSizing: 'border-box',
outline: 'none',
letterSpacing: '2px',
fontWeight: 600
}}
/>
</div>
</div>
<button
type="submit"
disabled={isLoading}
className="btn-primary-cta"
style={{
width: '100%',
padding: '13px',
fontSize: '0.95rem',
justifyContent: 'center',
marginTop: '6px'
}}
>
<span>{isLoading ? 'Prüfe Zugangsdaten...' : 'Projekt-Status Einsehen'}</span>
<ArrowRight size={18} />
</button>
</form>
{/* Quick Demo Section */}
<div style={{
marginTop: '28px',
paddingTop: '20px',
borderTop: '1px solid #334155',
textAlign: 'center'
}}>
<div style={{ fontSize: '0.78rem', color: '#64748b', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px', marginBottom: '12px' }}>
Quick Demo-Zugänge zum Testen
</div>
<div style={{ display: 'flex', gap: '10px' }}>
<button
type="button"
onClick={() => handleDemoLogin('on_track')}
style={{
flex: 1,
padding: '9px 10px',
borderRadius: '8px',
backgroundColor: 'rgba(34, 197, 94, 0.12)',
border: '1px solid rgba(34, 197, 94, 0.3)',
color: '#4ade80',
fontSize: '0.78rem',
fontWeight: 600,
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '6px'
}}
>
<CheckCircle2 size={14} />
<span>🟢 Demo (On Track)</span>
</button>
<button
type="button"
onClick={() => handleDemoLogin('delay')}
style={{
flex: 1,
padding: '9px 10px',
borderRadius: '8px',
backgroundColor: 'rgba(245, 158, 11, 0.12)',
border: '1px solid rgba(245, 158, 11, 0.3)',
color: '#fbbf24',
fontSize: '0.78rem',
fontWeight: 600,
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '6px'
}}
>
<AlertTriangle size={14} />
<span>🟠 Demo (Verzögerung)</span>
</button>
</div>
</div>
</div>
</div>
);
}
// Active Project Dashboard View
const { order, customer, changelogs, attachments, installedParts } = projectData;
const isDelay = order.healthStatus === 'delay' || order.healthStatus === 'critical';
return (
<div style={{
minHeight: '100vh',
backgroundColor: 'var(--bg-app, #0f172a)',
color: 'var(--text-primary, #f8fafc)',
padding: '24px 16px',
boxSizing: 'border-box'
}}>
<div style={{ maxWidth: '960px', margin: '0 auto', display: 'flex', flexDirection: 'column', gap: '20px' }}>
{/* Top Navbar */}
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '14px 20px',
backgroundColor: 'var(--bg-card, #1e293b)',
borderRadius: '14px',
border: '1px solid var(--border-color, #334155)'
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<div style={{
width: '38px',
height: '38px',
borderRadius: '10px',
backgroundColor: '#0284c7',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#ffffff'
}}>
<Wrench size={20} />
</div>
<div>
<div style={{ fontSize: '1rem', fontWeight: 800 }}>Handwerksfreund Kundenportal</div>
<div style={{ fontSize: '0.78rem', color: 'var(--text-muted, #94a3b8)' }}>
Projekt-Code: <strong style={{ color: '#38bdf8' }}>{order.accessCode}</strong>
</div>
</div>
</div>
<button
onClick={() => setProjectData(null)}
style={{
padding: '8px 14px',
borderRadius: '8px',
backgroundColor: 'rgba(239, 68, 68, 0.1)',
border: '1px solid rgba(239, 68, 68, 0.3)',
color: '#fca5a5',
fontSize: '0.82rem',
fontWeight: 600,
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: '6px'
}}
>
<LogOut size={16} />
<span>Abmelden</span>
</button>
</div>
{/* Health Status Banner */}
<div style={{
padding: '24px',
borderRadius: '16px',
background: isDelay
? 'linear-gradient(135deg, rgba(245, 158, 11, 0.2) 0%, rgba(217, 119, 6, 0.1) 100%)'
: 'linear-gradient(135deg, rgba(34, 197, 94, 0.2) 0%, rgba(16, 185, 129, 0.1) 100%)',
border: isDelay
? '1.5px solid rgba(245, 158, 11, 0.5)'
: '1.5px solid rgba(34, 197, 94, 0.5)',
display: 'flex',
flexDirection: 'column',
gap: '16px'
}}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: '12px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<div style={{
width: '46px',
height: '46px',
borderRadius: '12px',
backgroundColor: isDelay ? '#f59e0b' : '#22c55e',
color: '#ffffff',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxShadow: isDelay ? '0 0 15px rgba(245, 158, 11, 0.4)' : '0 0 15px rgba(34, 197, 94, 0.4)'
}}>
{isDelay ? <AlertTriangle size={26} /> : <CheckCircle2 size={26} />}
</div>
<div>
<div style={{
fontSize: '0.78rem',
fontWeight: 700,
textTransform: 'uppercase',
letterSpacing: '1px',
color: isDelay ? '#fbbf24' : '#4ade80'
}}>
PROJEKT-STATUS STATUS-CHECK
</div>
<h2 style={{ margin: 0, fontSize: '1.35rem', fontWeight: 800, color: 'var(--text-primary, #ffffff)' }}>
{isDelay ? 'Verzögerung im Ablauf (Delay)' : '🟢 Projekt ist voll im Zeitplan (On Track)'}
</h2>
</div>
</div>
<div style={{
backgroundColor: 'rgba(15, 23, 42, 0.6)',
padding: '8px 16px',
borderRadius: '10px',
border: '1px solid rgba(255,255,255,0.1)',
display: 'flex',
alignItems: 'center',
gap: '8px',
fontSize: '0.85rem'
}}>
<Calendar size={16} color="#38bdf8" />
<span>Voraussichtliche Fertigstellung: <strong style={{ color: '#38bdf8' }}>{order.expectedCompletionDate || '12.08.2026'}</strong></span>
</div>
</div>
{/* Delay Explanation Notice */}
{isDelay && order.delayReason && (
<div style={{
backgroundColor: 'rgba(15, 23, 42, 0.7)',
padding: '14px 16px',
borderRadius: '10px',
fontSize: '0.9rem',
color: '#fef08a',
lineHeight: '1.5',
borderLeft: '4px solid #f59e0b'
}}>
<strong>Transparenz-Hinweis von deinem Handwerker:</strong> {order.delayReason}
</div>
)}
{/* Visual Progress Bar */}
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.88rem', fontWeight: 700, marginBottom: '8px' }}>
<span>Gesamter Baufortschritt</span>
<span style={{ color: '#38bdf8' }}>{order.progressPercent || 75}% Abgeschlossen</span>
</div>
<div style={{
width: '100%',
height: '14px',
backgroundColor: 'rgba(15, 23, 42, 0.6)',
borderRadius: '20px',
overflow: 'hidden',
padding: '2px',
boxSizing: 'border-box',
border: '1px solid rgba(255,255,255,0.1)'
}}>
<div style={{
width: `${order.progressPercent || 75}%`,
height: '100%',
borderRadius: '20px',
background: isDelay
? 'linear-gradient(90deg, #f59e0b 0%, #d97706 100%)'
: 'linear-gradient(90deg, #0284c7 0%, #38bdf8 100%)',
transition: 'width 0.8s ease'
}} />
</div>
</div>
</div>
{/* Navigation Tabs */}
<div style={{ display: 'flex', gap: '8px', borderBottom: '1px solid var(--border-color, #334155)', paddingBottom: '4px' }}>
<button
onClick={() => setActiveSubTab('progress')}
style={{
padding: '10px 18px',
borderRadius: '10px',
fontSize: '0.88rem',
fontWeight: 700,
border: 'none',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: '8px',
backgroundColor: activeSubTab === 'progress' ? '#0284c7' : 'transparent',
color: activeSubTab === 'progress' ? '#ffffff' : 'var(--text-muted, #94a3b8)'
}}
>
<Clock size={18} />
<span>Bau-Verlauf & Updates</span>
</button>
<button
onClick={() => setActiveSubTab('booking')}
style={{
padding: '10px 18px',
borderRadius: '10px',
fontSize: '0.88rem',
fontWeight: 700,
border: 'none',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: '8px',
backgroundColor: activeSubTab === 'booking' ? '#ea580c' : 'transparent',
color: '#ffffff'
}}
>
<Calendar size={18} />
<span>Online-Termin buchen</span>
</button>
<button
onClick={() => setActiveSubTab('parts')}
style={{
padding: '10px 18px',
borderRadius: '10px',
fontSize: '0.88rem',
fontWeight: 700,
border: 'none',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: '8px',
backgroundColor: activeSubTab === 'parts' ? '#0284c7' : 'transparent',
color: activeSubTab === 'parts' ? '#ffffff' : 'var(--text-muted, #94a3b8)'
}}
>
<Package size={18} />
<span>Geräte ({installedParts.length})</span>
</button>
<button
onClick={() => setActiveSubTab('docs')}
style={{
padding: '10px 18px',
borderRadius: '10px',
fontSize: '0.88rem',
fontWeight: 700,
border: 'none',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: '8px',
backgroundColor: activeSubTab === 'docs' ? '#0284c7' : 'transparent',
color: activeSubTab === 'docs' ? '#ffffff' : 'var(--text-muted, #94a3b8)'
}}
>
<FileText size={18} />
<span>Dokumente ({attachments.length})</span>
</button>
</div>
{/* TAB 1: Bau-Verlauf & Updates */}
{activeSubTab === 'progress' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
<h3 style={{ margin: '0 0 4px 0', fontSize: '1.1rem', fontWeight: 700 }}>Projekt-Changelog & Handwerker-Updates</h3>
{changelogs.length === 0 ? (
<div style={{ padding: '24px', textAlign: 'center', color: '#94a3b8', backgroundColor: 'var(--bg-card, #1e293b)', borderRadius: '12px' }}>
Noch keine Einträge vorhanden.
</div>
) : (
changelogs.map((item) => (
<div key={item.id} style={{
backgroundColor: 'var(--bg-card, #1e293b)',
border: '1px solid var(--border-color, #334155)',
borderRadius: '12px',
padding: '16px 20px',
display: 'flex',
flexDirection: 'column',
gap: '8px'
}}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<span style={{ fontSize: '0.85rem', fontWeight: 700, color: '#38bdf8' }}>
{item.authorName}
</span>
<span style={{ fontSize: '0.78rem', color: 'var(--text-muted, #94a3b8)' }}>
{new Date(item.createdAt).toLocaleString('de-DE')}
</span>
</div>
<p style={{ margin: 0, fontSize: '0.92rem', lineHeight: '1.5' }}>{item.note}</p>
</div>
))
)}
</div>
)}
{/* TAB 2: Online-Terminbuchung */}
{activeSubTab === 'booking' && (
<div style={{
backgroundColor: 'var(--bg-card, #1e293b)',
border: '1px solid var(--border-color, #334155)',
borderRadius: '16px',
padding: '24px'
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '16px' }}>
<div style={{
width: '40px',
height: '40px',
borderRadius: '10px',
backgroundColor: 'rgba(234, 88, 12, 0.15)',
border: '1px solid rgba(234, 88, 12, 0.3)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#ea580c'
}}>
<Calendar size={22} />
</div>
<div>
<h3 style={{ margin: 0, fontSize: '1.15rem', fontWeight: 800 }}>Termin online vereinbaren</h3>
<p style={{ margin: 0, fontSize: '0.82rem', color: 'var(--text-muted, #94a3b8)' }}>Wähle ein freies Zeitfenster für deine Wartung oder Abnahme</p>
</div>
</div>
{bookingSuccess && (
<div style={{
backgroundColor: 'rgba(34, 197, 94, 0.15)',
border: '1px solid rgba(34, 197, 94, 0.4)',
color: '#4ade80',
padding: '14px',
borderRadius: '10px',
fontSize: '0.9rem',
fontWeight: 600,
display: 'flex',
alignItems: 'center',
gap: '10px',
marginBottom: '20px'
}}>
<Check size={20} />
<span>Vielen Dank! Dein Termin wurde erfolgreich gebucht. Unser Team hat die Benachrichtigung erhalten.</span>
</div>
)}
<form onSubmit={handleBookAppointment} style={{ display: 'flex', flexDirection: 'column', gap: '18px' }}>
<div>
<label style={{ display: 'block', fontSize: '0.83rem', fontWeight: 600, color: '#cbd5e1', marginBottom: '6px' }}>
Wunschdatum
</label>
<input
type="date"
value={selectedDate}
onChange={(e) => setSelectedDate(e.target.value)}
style={{
width: '100%',
padding: '10px 14px',
backgroundColor: '#0f172a',
border: '1px solid #334155',
borderRadius: '8px',
color: '#ffffff',
fontSize: '0.9rem',
boxSizing: 'border-box'
}}
/>
</div>
<div>
<label style={{ display: 'block', fontSize: '0.83rem', fontWeight: 600, color: '#cbd5e1', marginBottom: '6px' }}>
Verfügbares Zeitfenster wählen
</label>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: '10px' }}>
{['08:30 - 10:00 Uhr', '10:00 - 11:30 Uhr', '13:00 - 14:30 Uhr', '15:00 - 16:30 Uhr'].map((slot) => (
<button
key={slot}
type="button"
onClick={() => setSelectedSlot(slot)}
style={{
padding: '12px',
borderRadius: '10px',
border: selectedSlot === slot ? '2px solid #ea580c' : '1px solid #334155',
backgroundColor: selectedSlot === slot ? 'rgba(234, 88, 12, 0.15)' : '#0f172a',
color: selectedSlot === slot ? '#ffffff' : '#94a3b8',
fontWeight: selectedSlot === slot ? 700 : 500,
cursor: 'pointer',
textAlign: 'center'
}}
>
{slot}
</button>
))}
</div>
</div>
<div>
<label style={{ display: 'block', fontSize: '0.83rem', fontWeight: 600, color: '#cbd5e1', marginBottom: '6px' }}>
Anmerkung / Notiz (optional)
</label>
<textarea
rows={3}
placeholder="z.B. Schlüssel liegt unter der Fußmatte..."
value={bookingNote}
onChange={(e) => setBookingNote(e.target.value)}
style={{
width: '100%',
padding: '10px 14px',
backgroundColor: '#0f172a',
border: '1px solid #334155',
borderRadius: '8px',
color: '#ffffff',
fontSize: '0.9rem',
boxSizing: 'border-box'
}}
/>
</div>
<button
type="submit"
disabled={isSubmittingBooking}
className="btn-primary-cta"
style={{ padding: '12px 20px', alignSelf: 'flex-start' }}
>
<Send size={18} />
<span>{isSubmittingBooking ? 'Bucht Termin...' : 'Termin Verbindlich Buchen'}</span>
</button>
</form>
</div>
)}
{/* TAB 3: Verbaute Geräte */}
{activeSubTab === 'parts' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
<h3 style={{ margin: '0 0 4px 0', fontSize: '1.1rem', fontWeight: 700 }}>Installierte Geräte & Komponenten</h3>
{installedParts.map((part) => (
<div key={part.id} style={{
backgroundColor: 'var(--bg-card, #1e293b)',
border: '1px solid var(--border-color, #334155)',
borderRadius: '12px',
padding: '16px 20px',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between'
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<Package size={24} color="#38bdf8" />
<div>
<div style={{ fontSize: '0.95rem', fontWeight: 700 }}>{part.name}</div>
<div style={{ fontSize: '0.8rem', color: 'var(--text-muted, #94a3b8)' }}>
Hersteller: {part.manufacturer} | Kategorie: {part.category}
</div>
</div>
</div>
<div style={{
backgroundColor: '#0f172a',
padding: '4px 10px',
borderRadius: '8px',
fontSize: '0.78rem',
fontFamily: 'monospace',
color: '#94a3b8',
border: '1px solid #334155'
}}>
SN: {part.serialNumber || 'Keine SN'}
</div>
</div>
))}
</div>
)}
{/* TAB 4: Dokumente */}
{activeSubTab === 'docs' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
<h3 style={{ margin: '0 0 4px 0', fontSize: '1.1rem', fontWeight: 700 }}>Dokumente & Vor-Ort-Fotos</h3>
{attachments.length === 0 ? (
<div style={{ padding: '24px', textAlign: 'center', color: '#94a3b8', backgroundColor: 'var(--bg-card, #1e293b)', borderRadius: '12px' }}>
Noch keine Dokumente freigegeben.
</div>
) : (
attachments.map((doc) => (
<div key={doc.id} style={{
backgroundColor: 'var(--bg-card, #1e293b)',
border: '1px solid var(--border-color, #334155)',
borderRadius: '12px',
padding: '16px 20px',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between'
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<FileText size={24} color="#ea580c" />
<div>
<div style={{ fontSize: '0.95rem', fontWeight: 700 }}>{doc.title}</div>
<div style={{ fontSize: '0.78rem', color: 'var(--text-muted, #94a3b8)' }}>
Kategorie: {doc.category}
</div>
</div>
</div>
<a
href={doc.filePath}
target="_blank"
rel="noreferrer"
style={{
padding: '6px 14px',
borderRadius: '8px',
backgroundColor: '#0284c7',
color: '#ffffff',
textDecoration: 'none',
fontSize: '0.8rem',
fontWeight: 600
}}
>
Ansehen
</a>
</div>
))
)}
</div>
)}
</div>
</div>
);
};
+6 -26
View File
@@ -232,20 +232,10 @@ export const CustomersView: React.FC = () => {
setEditingCustomer(null); setEditingCustomer(null);
setIsCustomerModalOpen(true); setIsCustomerModalOpen(true);
}} }}
style={{ className="btn-primary-cta"
backgroundColor: '#2563eb',
color: '#ffffff',
border: 'none',
padding: '10px 18px',
borderRadius: '10px',
fontWeight: 600,
display: 'flex',
alignItems: 'center',
gap: '8px',
cursor: 'pointer'
}}
> >
<Plus size={18} /> Neuer Kunde <Plus size={18} />
<span>Neuer Kunde</span>
</button> </button>
<button <button
@@ -253,20 +243,10 @@ export const CustomersView: React.FC = () => {
setEditingOrder(null); setEditingOrder(null);
setIsOrderModalOpen(true); setIsOrderModalOpen(true);
}} }}
style={{ className="btn-secondary"
backgroundColor: '#059669',
color: '#ffffff',
border: 'none',
padding: '10px 18px',
borderRadius: '10px',
fontWeight: 600,
display: 'flex',
alignItems: 'center',
gap: '8px',
cursor: 'pointer'
}}
> >
<ClipboardList size={18} /> Neuer Auftrag <ClipboardList size={18} />
<span>Neuer Auftrag</span>
</button> </button>
</div> </div>
</header> </header>
+46 -93
View File
@@ -1,22 +1,16 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { import { StatCard } from '../components/StatCard';
StatCard
} from '../components/StatCard';
import { LiveOrdersTable } from '../components/LiveOrdersTable'; import { LiveOrdersTable } from '../components/LiveOrdersTable';
import { SystemStatusCard } from '../components/SystemStatusCard'; import { SystemStatusCard } from '../components/SystemStatusCard';
import { SupabaseService } from '../services/supabaseService'; import { SupabaseService } from '../services/supabaseService';
import { ScheduleItem, SystemStatus, DashboardMetrics } from '../types'; import { ScheduleItem, SystemStatus, DashboardMetrics } from '../types';
import { import {
RefreshCw, RefreshCw,
Plus,
Users, Users,
Wrench, Wrench,
CalendarDays, CalendarDays,
TrendingUp, Plus,
CheckCircle2, Zap
Activity,
ArrowUpRight,
Database
} from 'lucide-react'; } from 'lucide-react';
interface DashboardViewProps { interface DashboardViewProps {
@@ -59,122 +53,81 @@ export const DashboardView: React.FC<DashboardViewProps> = ({ onNavigate }) => {
return ( return (
<> <>
{/* Header Bar */} {/* Header Bar */}
<header className="header-bar" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: '16px' }}> <header className="header-bar">
<div> <div>
<h1 className="header-title" style={{ display: 'flex', alignItems: 'center', gap: '12px' }}> <h1 className="header-title">
Betriebs-Cockpit Betriebs-Cockpit
<span style={{ fontSize: '0.85rem', fontWeight: 600, color: '#64748b', backgroundColor: '#f1f5f9', padding: '3px 10px', borderRadius: '12px' }}>
Handwerksfreund v0.1
</span>
</h1> </h1>
<p style={{ color: '#64748b', fontSize: '0.9rem', marginTop: '4px' }}> <p style={{ color: '#94a3b8', fontSize: '0.875rem', marginTop: '4px' }}>
Live-Übersicht aller Aufträge, Monteure und Supabase-Datenbankverbindung Live-Übersicht aller Aufträge, Monteure und Datenbanksynchronisation
</p> </p>
</div> </div>
<div style={{ display: 'flex', alignItems: 'center', gap: '14px', flexWrap: 'wrap' }}> <div style={{ display: 'flex', alignItems: 'center', gap: '12px', flexWrap: 'wrap' }}>
<div className="supabase-live-badge" style={{ display: 'flex', alignItems: 'center', gap: '8px', backgroundColor: '#ecfdf5', border: '1px solid #a7f3d0', padding: '6px 14px', borderRadius: '20px' }}> {/* Primary Action CTA (Builder Orange) */}
<span className="status-dot" style={{ backgroundColor: '#10b981', width: '8px', height: '8px', borderRadius: '50%' }}></span> {onNavigate && (
<span style={{ color: '#047857', fontWeight: 700, fontSize: '0.85rem' }}> <button
Supabase Live ({systemStatus?.latencyMs ?? 18}ms) onClick={() => onNavigate('dispatching')}
</span> className="btn-primary-cta"
>
<Plus size={18} />
<span>Neuen Auftrag Einplanen</span>
</button>
)}
<div className="supabase-live-badge">
<span className="status-dot"></span>
<span>Supabase Live ({systemStatus?.latencyMs ?? 18}ms)</span>
</div> </div>
<button <button
onClick={fetchData} onClick={fetchData}
style={{ className="btn-ghost"
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '8px 16px',
backgroundColor: '#ffffff',
border: '1px solid #cbd5e1',
borderRadius: '10px',
color: '#475569',
fontWeight: 600,
cursor: 'pointer',
fontSize: '0.85rem'
}}
> >
<RefreshCw size={15} className={loading ? 'spin' : ''} /> <RefreshCw size={15} className={loading ? 'spin' : ''} />
{loading ? 'Lädt...' : `Sync: ${lastUpdated || 'jetzt'}`} <span>{loading ? 'Lädt...' : `Sync: ${lastUpdated || 'jetzt'}`}</span>
</button> </button>
</div> </div>
</header> </header>
{/* Quick Action Navigation Buttons */} {/* Quick Action Navigation Buttons */}
<section style={{
display: 'flex',
gap: '12px',
flexWrap: 'wrap',
marginBottom: '10px'
}}>
{onNavigate && ( {onNavigate && (
<> <section style={{ display: 'flex', gap: '12px', flexWrap: 'wrap' }}>
<button <button
onClick={() => onNavigate('kunden')} onClick={() => onNavigate('kunden')}
style={{ className="btn-secondary"
backgroundColor: '#ffffff',
border: '1px solid #cbd5e1',
padding: '10px 16px',
borderRadius: '10px',
fontWeight: 600,
color: '#0f172a',
fontSize: '0.88rem',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: '8px',
boxShadow: '0 1px 2px rgba(0,0,0,0.04)'
}}
> >
<Users size={16} color="#2563eb" /> Kunden & Aufträge verwalten <Users size={16} />
<span>Kunden & Aufträge</span>
</button> </button>
<button <button
onClick={() => onNavigate('dispatching')} onClick={() => onNavigate('dispatching')}
style={{ className="btn-ghost"
backgroundColor: '#ffffff',
border: '1px solid #cbd5e1',
padding: '10px 16px',
borderRadius: '10px',
fontWeight: 600,
color: '#0f172a',
fontSize: '0.88rem',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: '8px',
boxShadow: '0 1px 2px rgba(0,0,0,0.04)'
}}
> >
<CalendarDays size={16} color="#059669" /> Dispatcher & Kalender <CalendarDays size={16} color="#38bdf8" />
<span>Dispatcher & Wochenplan</span>
</button> </button>
<button <button
onClick={() => onNavigate('geraete')} onClick={() => onNavigate('geraete')}
style={{ className="btn-ghost"
backgroundColor: '#ffffff',
border: '1px solid #cbd5e1',
padding: '10px 16px',
borderRadius: '10px',
fontWeight: 600,
color: '#0f172a',
fontSize: '0.88rem',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: '8px',
boxShadow: '0 1px 2px rgba(0,0,0,0.04)'
}}
> >
<Wrench size={16} color="#d97706" /> Gerätekartei öffnen <Wrench size={16} color="#38bdf8" />
<span>Gerätekartei</span>
</button> </button>
</>
)}
</section>
{/* KPI Cards Grid (Echte Supabase Daten) */} <button
onClick={() => onNavigate('zeiterfassung')}
className="btn-ghost"
>
<Zap size={16} color="#fbbf24" />
<span>Zeiterfassung</span>
</button>
</section>
)}
{/* KPI Cards Grid */}
<section className="kpi-grid"> <section className="kpi-grid">
<StatCard <StatCard
label="Aktive Aufträge" label="Aktive Aufträge"
@@ -196,7 +149,7 @@ export const DashboardView: React.FC<DashboardViewProps> = ({ onNavigate }) => {
</section> </section>
{/* Main Dashboard Section */} {/* Main Dashboard Section */}
<section className="dashboard-grid"> <section style={{ display: 'grid', gridTemplateColumns: '2fr 1fr', gap: '20px' }}>
<LiveOrdersTable orders={liveOrders} /> <LiveOrdersTable orders={liveOrders} />
<SystemStatusCard status={systemStatus} /> <SystemStatusCard status={systemStatus} />
</section> </section>
+6 -16
View File
@@ -124,20 +124,10 @@ export const DispatchingView: React.FC = () => {
setEditingItem(null); setEditingItem(null);
setIsModalOpen(true); setIsModalOpen(true);
}} }}
style={{ className="btn-primary-cta"
backgroundColor: '#2563eb',
color: '#ffffff',
border: 'none',
padding: '10px 18px',
borderRadius: '10px',
fontWeight: 600,
display: 'flex',
alignItems: 'center',
gap: '8px',
cursor: 'pointer'
}}
> >
<Plus size={18} /> Neuer Einsatz <Plus size={18} />
<span>Neuer Einsatz</span>
</button> </button>
</div> </div>
</header> </header>
@@ -145,7 +135,7 @@ export const DispatchingView: React.FC = () => {
{/* View Switcher Tabs */} {/* View Switcher Tabs */}
<div style={{ <div style={{
display: 'flex', display: 'flex',
borderBottom: '2px solid #e2e8f0', borderBottom: '1px solid #334155',
marginBottom: '20px', marginBottom: '20px',
gap: '24px' gap: '24px'
}}> }}>
@@ -154,9 +144,9 @@ export const DispatchingView: React.FC = () => {
style={{ style={{
padding: '12px 4px', padding: '12px 4px',
border: 'none', border: 'none',
borderBottom: viewMode === 'interactive' ? '3px solid #2563eb' : '3px solid transparent', borderBottom: viewMode === 'interactive' ? '3px solid #0284c7' : '3px solid transparent',
backgroundColor: 'transparent', backgroundColor: 'transparent',
color: viewMode === 'interactive' ? '#2563eb' : '#64748b', color: viewMode === 'interactive' ? '#38bdf8' : '#94a3b8',
fontWeight: 700, fontWeight: 700,
fontSize: '1rem', fontSize: '1rem',
cursor: 'pointer', cursor: 'pointer',
+6 -16
View File
@@ -275,20 +275,10 @@ export const EquipmentView: React.FC = () => {
setEditingPart(null); setEditingPart(null);
setIsModalOpen(true); setIsModalOpen(true);
}} }}
style={{ className="btn-primary-cta"
backgroundColor: '#2563eb',
color: '#ffffff',
border: 'none',
padding: '10px 18px',
borderRadius: '10px',
fontWeight: 600,
display: 'flex',
alignItems: 'center',
gap: '8px',
cursor: 'pointer'
}}
> >
<Plus size={18} /> Produkt in Masterliste anlegen <Plus size={18} />
<span>Produkt anlegen</span>
</button> </button>
</div> </div>
</header> </header>
@@ -296,7 +286,7 @@ export const EquipmentView: React.FC = () => {
{/* Sub-Tab Navigation */} {/* Sub-Tab Navigation */}
<div style={{ <div style={{
display: 'flex', display: 'flex',
borderBottom: '2px solid #e2e8f0', borderBottom: '1px solid #334155',
marginBottom: '20px', marginBottom: '20px',
gap: '24px' gap: '24px'
}}> }}>
@@ -305,9 +295,9 @@ export const EquipmentView: React.FC = () => {
style={{ style={{
padding: '12px 4px', padding: '12px 4px',
border: 'none', border: 'none',
borderBottom: subTab === 'catalog' ? '3px solid #2563eb' : '3px solid transparent', borderBottom: subTab === 'catalog' ? '3px solid #0284c7' : '3px solid transparent',
backgroundColor: 'transparent', backgroundColor: 'transparent',
color: subTab === 'catalog' ? '#2563eb' : '#64748b', color: subTab === 'catalog' ? '#38bdf8' : '#94a3b8',
fontWeight: 700, fontWeight: 700,
fontSize: '1rem', fontSize: '1rem',
cursor: 'pointer', cursor: 'pointer',
+6 -25
View File
@@ -78,37 +78,18 @@ export const InventoryView: React.FC = () => {
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}> <div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
{warningCount > 0 && ( {warningCount > 0 && (
<div style={{ <div className="badge badge-error" style={{ fontSize: '0.85rem', padding: '6px 12px' }}>
display: 'flex', <AlertTriangle size={16} />
alignItems: 'center', <span>{warningCount} Bestandswarnungen</span>
gap: '8px',
color: '#ef4444',
backgroundColor: '#fef2f2',
padding: '8px 14px',
borderRadius: '10px',
fontWeight: 700,
fontSize: '0.9rem'
}}>
<AlertTriangle size={18} /> {warningCount} Bestandswarnungen
</div> </div>
)} )}
<button <button
onClick={() => alert('Neuer Lagerartikel Anlege-Dialog')} onClick={() => alert('Neuer Lagerartikel Anlege-Dialog')}
style={{ className="btn-primary-cta"
backgroundColor: '#2563eb',
color: '#ffffff',
border: 'none',
padding: '10px 18px',
borderRadius: '10px',
fontWeight: 600,
display: 'flex',
alignItems: 'center',
gap: '8px',
cursor: 'pointer'
}}
> >
<Plus size={18} /> Artikel anlegen <Plus size={18} />
<span>Artikel Anlegen</span>
</button> </button>
</div> </div>
</header> </header>
+50 -52
View File
@@ -22,79 +22,77 @@ export const SupabaseSyncView: React.FC = () => {
return ( return (
<> <>
<header className="header-bar"> <header className="header-bar">
<h1 className="header-title">Supabase Live Synchronization</h1> <div>
<h1 className="header-title">Supabase Live Synchronisation</h1>
<p style={{ color: '#94a3b8', fontSize: '0.875rem', marginTop: '4px' }}>
Echtzeit-Diagnose der PostgreSQL Datenbankverbindung und API-Latenz
</p>
</div>
<button <button
onClick={runPing} onClick={runPing}
disabled={loading} disabled={loading}
style={{ className="btn-primary-cta"
backgroundColor: '#2563eb',
color: '#ffffff',
border: 'none',
padding: '10px 18px',
borderRadius: '10px',
fontWeight: 600,
display: 'flex',
alignItems: 'center',
gap: '8px',
cursor: 'pointer'
}}
> >
<RefreshCw size={18} className={loading ? 'spin' : ''} /> Connection Test <RefreshCw size={18} className={loading ? 'spin' : ''} />
<span>Verbindung Testen</span>
</button> </button>
</header> </header>
<section className="content-card"> <section style={{
<h2 className="card-heading">Datenbank-Diagnose & Connection Status</h2> backgroundColor: '#1e293b',
border: '1px solid #334155',
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))', gap: '20px', marginBottom: '28px' }}> borderRadius: '12px',
<div style={{ padding: '20px', backgroundColor: '#f8fafc', borderRadius: '12px', border: '1px solid #e2e8f0' }}> padding: '24px',
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', color: '#10b981', marginBottom: '8px' }}> color: '#f8fafc'
<CheckCircle2 size={20} /> }}>
<span style={{ fontWeight: 700, color: '#0f172a' }}>Verbindungsstatus</span> <div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '20px' }}>
<Database size={20} color="#0284c7" />
<h3 style={{ fontSize: '1.05rem', fontWeight: 700, margin: 0 }}>Datenbank-Diagnose & Connection Status</h3>
</div> </div>
<p style={{ fontSize: '1.2rem', fontWeight: 800, color: '#10b981' }}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))', gap: '16px', marginBottom: '24px' }}>
<div style={{ padding: '18px', backgroundColor: '#0f172a', borderRadius: '10px', border: '1px solid #334155' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', color: '#4ade80', marginBottom: '8px' }}>
<CheckCircle2 size={18} />
<span style={{ fontWeight: 700, color: '#f8fafc', fontSize: '0.85rem' }}>Verbindungsstatus</span>
</div>
<p style={{ fontSize: '1.25rem', fontWeight: 800, color: '#4ade80', margin: 0 }}>
{status?.isConnected ? 'VERBUNDEN (STABIL)' : 'OFFLINE'} {status?.isConnected ? 'VERBUNDEN (STABIL)' : 'OFFLINE'}
</p> </p>
</div> </div>
<div style={{ padding: '20px', backgroundColor: '#f8fafc', borderRadius: '12px', border: '1px solid #e2e8f0' }}> <div style={{ padding: '18px', backgroundColor: '#0f172a', borderRadius: '10px', border: '1px solid #334155' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', color: '#2563eb', marginBottom: '8px' }}> <div style={{ display: 'flex', alignItems: 'center', gap: '8px', color: '#38bdf8', marginBottom: '8px' }}>
<Server size={20} /> <Server size={18} />
<span style={{ fontWeight: 700, color: '#0f172a' }}>Antwortzeit (Ping)</span> <span style={{ fontWeight: 700, color: '#f8fafc', fontSize: '0.85rem' }}>API Endpoint</span>
</div> </div>
<p style={{ fontSize: '1.2rem', fontWeight: 800, color: '#2563eb' }}> <p style={{ fontSize: '0.9rem', fontWeight: 700, color: '#38bdf8', overflow: 'hidden', textOverflow: 'ellipsis', margin: 0 }}>
{status ? `${status.latencyMs} ms` : '18 ms'} {SUPABASE_URL}
</p> </p>
</div> </div>
<div style={{ padding: '20px', backgroundColor: '#f8fafc', borderRadius: '12px', border: '1px solid #e2e8f0' }}> <div style={{ padding: '18px', backgroundColor: '#0f172a', borderRadius: '10px', border: '1px solid #334155' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', color: '#0284c7', marginBottom: '8px' }}> <div style={{ display: 'flex', alignItems: 'center', gap: '8px', color: '#fbbf24', marginBottom: '8px' }}>
<ShieldCheck size={20} /> <ShieldCheck size={18} />
<span style={{ fontWeight: 700, color: '#0f172a' }}>Verschlüsselung</span> <span style={{ fontWeight: 700, color: '#f8fafc', fontSize: '0.85rem' }}>Antwortzeit (Ping)</span>
</div> </div>
<p style={{ fontSize: '1.2rem', fontWeight: 800, color: '#0f172a' }}> <p style={{ fontSize: '1.25rem', fontWeight: 800, color: '#f8fafc', margin: 0 }}>
SSL / TLS 1.3 {status?.latencyMs ?? 18} ms
</p> </p>
</div> </div>
</div> </div>
<div className="status-details-box"> <div style={{
<div className="status-item"> backgroundColor: '#0f172a',
<span className="status-key">Supabase Endpunkt:</span> padding: '16px 20px',
<span className="status-val">{SUPABASE_URL}</span> borderRadius: '10px',
</div> border: '1px solid #334155',
<div className="status-item"> fontSize: '0.85rem',
<span className="status-key">Aktive PostgreSQL Tabellen:</span> color: '#cbd5e1',
<span className="status-val">customers, work_orders, schedule_items, installed_parts, order_attachments</span> lineHeight: '1.6'
</div> }}>
<div className="status-item"> <strong>Hinweis:</strong> Das System kommuniziert direkt mit Supabase PostgreSQL via HTTPS. Alle Änderungen an Kunden, Aufträgen und Monteuren werden sofort im Netzwerk synchronisiert.
<span className="status-key">Realtime Subscription Protocol:</span>
<span className="status-val">WebSocket (wss://) / HTTP Rest v1</span>
</div>
<div className="status-item">
<span className="status-key">Letzte Aktualisierung:</span>
<span className="status-val">{status?.lastSync?.toLocaleTimeString() || 'Vor wenigen Sekunden'}</span>
</div>
</div> </div>
</section> </section>
</> </>
+3 -13
View File
@@ -140,20 +140,10 @@ export const TechniciansView: React.FC = () => {
setEditingTech(null); setEditingTech(null);
setIsModalOpen(true); setIsModalOpen(true);
}} }}
style={{ className="btn-primary-cta"
backgroundColor: '#2563eb',
color: '#ffffff',
border: 'none',
padding: '10px 18px',
borderRadius: '10px',
fontWeight: 600,
display: 'flex',
alignItems: 'center',
gap: '8px',
cursor: 'pointer'
}}
> >
<Plus size={18} /> Neuer Monteur <Plus size={18} />
<span>Neuer Monteur</span>
</button> </button>
</div> </div>
</header> </header>
+71 -28
View File
@@ -1,48 +1,91 @@
import React from 'react'; import React from 'react';
import { Clock, Plus, CheckCircle2 } from 'lucide-react';
export const TimeTrackingView: React.FC = () => { export const TimeTrackingView: React.FC = () => {
const timeEntries = [ const timeEntries = [
{ date: '26.07.2026', technician: 'Max Müller', customer: 'Max Mustermann', activity: 'Heizungswartung', hours: '4.5 Std.', status: 'Freigegeben' }, { date: '05.08.2026', technician: 'Max Müller', customer: 'Max Mustermann', activity: 'Heizungswartung', hours: '4.5 Std.', status: 'Freigegeben' },
{ date: '26.07.2026', technician: 'Stefan Bauer', customer: 'Anja Klein', activity: 'Thermostat-Kalibrierung', hours: '2.0 Std.', status: 'Freigegeben' }, { date: '05.08.2026', technician: 'Stefan Bauer', customer: 'Anja Klein', activity: 'Thermostat-Kalibrierung', hours: '2.0 Std.', status: 'Freigegeben' },
{ date: '26.07.2026', technician: 'Tom Gerber', customer: 'Hausverwaltung Schmidt', activity: 'Leitungsspülung', hours: '3.5 Std.', status: 'Offen zur Prüfung' }, { date: '05.08.2026', technician: 'Tom Gerber', customer: 'Hausverwaltung Schmidt', activity: 'Leitungsspülung', hours: '3.5 Std.', status: 'Offen zur Prüfung' },
{ date: '25.07.2026', technician: 'Max Müller', customer: 'Bäckerei Hoffmann GmbH', activity: 'Pumpentausch', hours: '6.0 Std.', status: 'Abgerechnet' }, { date: '04.08.2026', technician: 'Max Müller', customer: 'Bäckerei Hoffmann GmbH', activity: 'Pumpentausch', hours: '6.0 Std.', status: 'Abgerechnet' },
]; ];
return ( return (
<> <>
{/* Header Bar */}
<header className="header-bar"> <header className="header-bar">
<div>
<h1 className="header-title">Zeiterfassung & Stundenzettel</h1> <h1 className="header-title">Zeiterfassung & Stundenzettel</h1>
<span style={{ fontWeight: 600, color: '#10b981' }}>Gesamtstunden Heute: 16.0 Std.</span> <p style={{ color: '#94a3b8', fontSize: '0.875rem', marginTop: '4px' }}>
Erfassung von Arbeits-, Anfahrts- und Einsatzzeiten im Außendienst
</p>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '14px', flexWrap: 'wrap' }}>
<div style={{
display: 'inline-flex',
alignItems: 'center',
gap: '8px',
padding: '6px 14px',
borderRadius: '20px',
backgroundColor: 'rgba(56, 189, 248, 0.12)',
border: '1px solid rgba(56, 189, 248, 0.3)',
color: '#38bdf8',
fontSize: '0.85rem',
fontWeight: 700
}}>
<Clock size={16} />
<span>Gesamtstunden Heute: 16.0 Std.</span>
</div>
<button
onClick={() => alert('Neuer Stundenzettel-Eintrag')}
className="btn-primary-cta"
>
<Plus size={18} />
<span>Zeiten Buchen</span>
</button>
</div>
</header> </header>
<section className="content-card"> {/* Main Table Card */}
<h2 className="card-heading">Erfasste Arbeitszeiten der Monteure</h2> <section style={{
<div className="table-responsive"> backgroundColor: '#1e293b',
<table className="orders-table"> border: '1px solid #334155',
borderRadius: '12px',
padding: '24px',
color: '#f8fafc'
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '18px' }}>
<Clock size={20} color="#0284c7" />
<h3 style={{ fontSize: '1.05rem', fontWeight: 700, margin: 0 }}>Erfasste Arbeitszeiten der Monteure</h3>
</div>
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', textAlign: 'left', fontSize: '0.875rem' }}>
<thead> <thead>
<tr> <tr style={{ borderBottom: '1px solid #334155', color: '#94a3b8', fontSize: '0.78rem', textTransform: 'uppercase', letterSpacing: '0.04em' }}>
<th>Datum</th> <th style={{ padding: '10px 12px', fontWeight: 600 }}>Datum</th>
<th>Monteur</th> <th style={{ padding: '10px 12px', fontWeight: 600 }}>Monteur</th>
<th>Kunde / Projekt</th> <th style={{ padding: '10px 12px', fontWeight: 600 }}>Kunde / Projekt</th>
<th>Tätigkeit</th> <th style={{ padding: '10px 12px', fontWeight: 600 }}>Tätigkeit</th>
<th>Dauer</th> <th style={{ padding: '10px 12px', fontWeight: 600 }}>Dauer</th>
<th>Status</th> <th style={{ padding: '10px 12px', fontWeight: 600 }}>Status</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{timeEntries.map((e, idx) => ( {timeEntries.map((e, idx) => (
<tr key={idx}> <tr key={idx} style={{ borderBottom: '1px solid #334155' }}>
<td style={{ color: '#64748b' }}>{e.date}</td> <td style={{ padding: '12px', color: '#94a3b8' }}>{e.date}</td>
<td style={{ fontWeight: 600, color: '#0f172a' }}>{e.technician}</td> <td style={{ padding: '12px', fontWeight: 600, color: '#f8fafc' }}>{e.technician}</td>
<td>{e.customer}</td> <td style={{ padding: '12px', color: '#cbd5e1' }}>{e.customer}</td>
<td style={{ color: '#334155' }}>{e.activity}</td> <td style={{ padding: '12px', color: '#cbd5e1' }}>{e.activity}</td>
<td style={{ fontWeight: 700, color: '#2563eb' }}>{e.hours}</td> <td style={{ padding: '12px', fontWeight: 700, color: '#38bdf8' }}>{e.hours}</td>
<td> <td style={{ padding: '12px' }}>
<span className={`badge ${ {e.status === 'Freigegeben' || e.status === 'Abgerechnet' ? (
e.status === 'Freigegeben' || e.status === 'Abgerechnet' ? 'badge-in-progress' : 'badge-scheduled' <span className="badge badge-success">{e.status}</span>
}`}> ) : (
{e.status} <span className="badge badge-warning">{e.status}</span>
</span> )}
</td> </td>
</tr> </tr>
))} ))}