Latest Version
This commit is contained in:
+59
-4
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
</tr>
|
<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>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
+247
-31
@@ -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',
|
||||||
fontSize: '0.8rem',
|
|
||||||
color: '#64748b',
|
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
flexDirection: 'column',
|
||||||
justifyContent: 'space-between'
|
gap: '10px'
|
||||||
}}>
|
}}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
{/* Theme Switcher Toggle */}
|
||||||
<Info size={14} color="#38bdf8" />
|
<div style={{
|
||||||
<span>Version</span>
|
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>
|
</div>
|
||||||
|
|
||||||
<span style={{
|
{/* Abmelden Button */}
|
||||||
backgroundColor: '#172545',
|
{profile && (
|
||||||
color: '#38bdf8',
|
<button
|
||||||
padding: '2px 10px',
|
onClick={() => signOut()}
|
||||||
borderRadius: '12px',
|
style={{
|
||||||
fontWeight: 700,
|
width: '100%',
|
||||||
fontSize: '0.78rem',
|
padding: '10px 14px',
|
||||||
border: '1px solid #1e3a8a'
|
borderRadius: '8px',
|
||||||
}}>
|
backgroundColor: 'rgba(239, 68, 68, 0.1)',
|
||||||
v0.1
|
border: '1px solid rgba(239, 68, 68, 0.3)',
|
||||||
</span>
|
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',
|
||||||
|
color: '#64748b',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
paddingTop: '2px',
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
width: '100%',
|
||||||
|
cursor: 'pointer',
|
||||||
|
textAlign: 'left'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||||
|
<Info size={14} color="#38bdf8" />
|
||||||
|
<span style={{ color: '#94a3b8' }}>Versionen</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||||
|
<span style={{
|
||||||
|
backgroundColor: '#172545',
|
||||||
|
color: '#38bdf8',
|
||||||
|
padding: '2px 8px',
|
||||||
|
borderRadius: '12px',
|
||||||
|
fontWeight: 700,
|
||||||
|
fontSize: '0.78rem',
|
||||||
|
border: '1px solid #1e3a8a'
|
||||||
|
}}>
|
||||||
|
v0.4
|
||||||
|
</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)}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
+440
-194
@@ -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,231 +102,454 @@ 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={{
|
<>
|
||||||
position: 'fixed',
|
|
||||||
inset: 0,
|
|
||||||
backgroundColor: 'rgba(15, 23, 42, 0.6)',
|
|
||||||
backdropFilter: 'blur(4px)',
|
|
||||||
zIndex: 100,
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
padding: '20px'
|
|
||||||
}}>
|
|
||||||
<div style={{
|
<div style={{
|
||||||
backgroundColor: '#ffffff',
|
position: 'fixed',
|
||||||
borderRadius: '16px',
|
inset: 0,
|
||||||
maxWidth: '550px',
|
backgroundColor: 'rgba(15, 23, 42, 0.75)',
|
||||||
width: '100%',
|
backdropFilter: 'blur(6px)',
|
||||||
maxHeight: '90vh',
|
zIndex: 100,
|
||||||
overflowY: 'auto',
|
display: 'flex',
|
||||||
boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.25)',
|
alignItems: 'center',
|
||||||
border: '1px solid #e2e8f0'
|
justifyContent: 'center',
|
||||||
|
padding: '20px'
|
||||||
}}>
|
}}>
|
||||||
{/* Header */}
|
|
||||||
<div style={{
|
<div style={{
|
||||||
padding: '20px 24px',
|
backgroundColor: 'var(--bg-card, #ffffff)',
|
||||||
borderBottom: '1px solid #e2e8f0',
|
borderRadius: '16px',
|
||||||
display: 'flex',
|
maxWidth: '580px',
|
||||||
justifyContent: 'space-between',
|
width: '100%',
|
||||||
alignItems: 'center',
|
maxHeight: '90vh',
|
||||||
backgroundColor: '#f8fafc',
|
overflowY: 'auto',
|
||||||
borderTopLeftRadius: '16px',
|
boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.4)',
|
||||||
borderTopRightRadius: '16px'
|
border: '1px solid var(--border-color, #e2e8f0)',
|
||||||
|
color: 'var(--text-primary, #0f172a)'
|
||||||
}}>
|
}}>
|
||||||
<h2 style={{ fontSize: '1.25rem', fontWeight: 700, color: '#0f172a', margin: 0, display: 'flex', alignItems: 'center', gap: '10px' }}>
|
{/* Header */}
|
||||||
<ClipboardList size={22} color="#2563eb" />
|
<div style={{
|
||||||
{initialOrder ? 'Auftrag bearbeiten' : 'Neuen Auftrag erstellen'}
|
padding: '20px 24px',
|
||||||
</h2>
|
borderBottom: '1px solid var(--border-color, #e2e8f0)',
|
||||||
<button
|
display: 'flex',
|
||||||
onClick={onClose}
|
alignItems: 'center',
|
||||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#64748b', borderRadius: '8px', padding: '4px' }}
|
justifyContent: 'space-between',
|
||||||
>
|
background: 'linear-gradient(to right, #0284c7, #0369a1)',
|
||||||
<X size={20} />
|
color: '#ffffff',
|
||||||
</button>
|
borderRadius: '16px 16px 0 0'
|
||||||
</div>
|
}}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
|
||||||
{/* Form Body */}
|
<ClipboardList size={22} />
|
||||||
<form onSubmit={handleSubmit} style={{ padding: '24px' }}>
|
<h3 style={{ margin: 0, fontSize: '1.1rem', fontWeight: 700 }}>
|
||||||
{errorMsg && (
|
{initialOrder ? 'Auftrag & Projekt-Status bearbeiten' : 'Neuen Auftrag erstellen'}
|
||||||
<div style={{
|
</h3>
|
||||||
backgroundColor: '#fef2f2',
|
|
||||||
color: '#991b1b',
|
|
||||||
padding: '12px 16px',
|
|
||||||
borderRadius: '8px',
|
|
||||||
marginBottom: '20px',
|
|
||||||
fontSize: '0.9rem',
|
|
||||||
fontWeight: 500
|
|
||||||
}}>
|
|
||||||
{errorMsg}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
<button
|
||||||
|
onClick={onClose}
|
||||||
{/* Customer Selection */}
|
|
||||||
<div style={{ marginBottom: '16px' }}>
|
|
||||||
<label style={{ display: 'block', fontSize: '0.85rem', fontWeight: 600, color: '#475569', marginBottom: '6px' }}>
|
|
||||||
Zugehöriger Kunde *
|
|
||||||
</label>
|
|
||||||
<div style={{ position: 'relative' }}>
|
|
||||||
<User size={18} color="#94a3b8" style={{ position: 'absolute', left: '12px', top: '12px' }} />
|
|
||||||
<select
|
|
||||||
value={formData.customerId}
|
|
||||||
onChange={(e) => setFormData({ ...formData, customerId: e.target.value })}
|
|
||||||
required
|
|
||||||
style={{
|
|
||||||
width: '100%',
|
|
||||||
padding: '10px 14px 10px 38px',
|
|
||||||
borderRadius: '8px',
|
|
||||||
border: '1px solid #cbd5e1',
|
|
||||||
fontSize: '0.95rem',
|
|
||||||
backgroundColor: '#ffffff',
|
|
||||||
color: '#0f172a'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<option value="" disabled>-- Bitte Kunde auswählen --</option>
|
|
||||||
{customers.map((c) => (
|
|
||||||
<option key={c.id} value={c.id}>
|
|
||||||
{c.name} ({c.customerNumber} - {c.city})
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Title */}
|
|
||||||
<div style={{ marginBottom: '16px' }}>
|
|
||||||
<label style={{ display: 'block', fontSize: '0.85rem', fontWeight: 600, color: '#475569', marginBottom: '6px' }}>
|
|
||||||
Auftragstitel *
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={formData.title}
|
|
||||||
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
|
|
||||||
placeholder="z.B. Wartung Heizungsanlage"
|
|
||||||
required
|
|
||||||
style={{
|
style={{
|
||||||
width: '100%',
|
background: 'none',
|
||||||
padding: '10px 14px',
|
border: 'none',
|
||||||
borderRadius: '8px',
|
color: '#ffffff',
|
||||||
border: '1px solid #cbd5e1',
|
cursor: 'pointer',
|
||||||
fontSize: '0.95rem'
|
padding: '4px',
|
||||||
|
borderRadius: '6px'
|
||||||
}}
|
}}
|
||||||
/>
|
>
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Date & Status */}
|
<form onSubmit={handleSubmit} style={{ padding: '24px', display: 'flex', flexDirection: 'column', gap: '18px' }}>
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px', marginBottom: '16px' }}>
|
{errorMsg && (
|
||||||
|
<div style={{
|
||||||
|
padding: '12px 14px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
backgroundColor: '#fef2f2',
|
||||||
|
border: '1px solid #fecaca',
|
||||||
|
color: '#dc2626',
|
||||||
|
fontSize: '0.85rem'
|
||||||
|
}}>
|
||||||
|
{errorMsg}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Kunde wählen */}
|
||||||
<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
|
Kunde *
|
||||||
</label>
|
</label>
|
||||||
<div style={{ position: 'relative' }}>
|
<div style={{ position: 'relative' }}>
|
||||||
<Calendar 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%)' }} />
|
||||||
<input
|
<select
|
||||||
|
value={formData.customerId}
|
||||||
|
onChange={(e) => setFormData({ ...formData, customerId: e.target.value })}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '10px 12px 10px 38px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
border: '1px solid #cbd5e1',
|
||||||
|
fontSize: '0.9rem',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
backgroundColor: 'var(--bg-app, #ffffff)',
|
||||||
|
color: 'var(--text-primary, #0f172a)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="" disabled>-- Bitte Kunde auswählen --</option>
|
||||||
|
{customers.map(c => (
|
||||||
|
<option key={c.id} value={c.id}>
|
||||||
|
{c.name} ({c.customerNumber}) - {c.city}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Auftragstitel */}
|
||||||
|
<div>
|
||||||
|
<label style={{ display: 'block', fontSize: '0.83rem', fontWeight: 700, marginBottom: '6px' }}>
|
||||||
|
Auftragstitel *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="z.B. Wartung Heizungsanlage GB172"
|
||||||
|
value={formData.title}
|
||||||
|
onChange={(e) => setFormData({ ...formData, title: 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>
|
||||||
|
|
||||||
|
{/* Status & Datum */}
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '14px' }}>
|
||||||
|
<div>
|
||||||
|
<label style={{ display: 'block', fontSize: '0.83rem', fontWeight: 700, marginBottom: '6px' }}>
|
||||||
|
Auftrags-Status
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={formData.status}
|
||||||
|
onChange={(e) => setFormData({ ...formData, status: e.target.value as OrderStatus })}
|
||||||
|
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)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="offen">Offen</option>
|
||||||
|
<option value="inBearbeitung">In Bearbeitung</option>
|
||||||
|
<option value="abgeschlossen">Abgeschlossen</option>
|
||||||
|
<option value="storniert">Storniert</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label style={{ display: 'block', fontSize: '0.83rem', fontWeight: 700, marginBottom: '6px' }}>
|
||||||
|
Geplantes Datum
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
value={formData.scheduledDate}
|
value={formData.scheduledDate}
|
||||||
onChange={(e) => setFormData({ ...formData, scheduledDate: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, scheduledDate: e.target.value })}
|
||||||
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',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
backgroundColor: 'var(--bg-app, #ffffff)',
|
||||||
|
color: 'var(--text-primary, #0f172a)'
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
{/* --- KUNDENPORTAL & PROJEKTSTATUS STEUERUNG --- */}
|
||||||
<label style={{ display: 'block', fontSize: '0.85rem', fontWeight: 600, color: '#475569', marginBottom: '6px' }}>
|
<div style={{
|
||||||
Auftragsstatus
|
marginTop: '6px',
|
||||||
</label>
|
padding: '16px',
|
||||||
<select
|
borderRadius: '12px',
|
||||||
value={formData.status}
|
backgroundColor: '#f8fafc',
|
||||||
onChange={(e) => setFormData({ ...formData, status: e.target.value as OrderStatus })}
|
border: '1.5px solid #e2e8f0',
|
||||||
style={{
|
display: 'flex',
|
||||||
width: '100%',
|
flexDirection: 'column',
|
||||||
padding: '10px 14px',
|
gap: '14px'
|
||||||
borderRadius: '8px',
|
}}>
|
||||||
border: '1px solid #cbd5e1',
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
fontSize: '0.95rem',
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', fontWeight: 700, fontSize: '0.9rem', color: '#0369a1' }}>
|
||||||
backgroundColor: '#ffffff'
|
<ShieldCheck size={18} />
|
||||||
}}
|
<span>Kundenportal & Projekt-Gesundheit</span>
|
||||||
>
|
</div>
|
||||||
<option value="offen">Offen</option>
|
|
||||||
<option value="inBearbeitung">In Bearbeitung</option>
|
|
||||||
<option value="abgeschlossen">Abgeschlossen</option>
|
|
||||||
<option value="storniert">Storniert</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Description */}
|
<button
|
||||||
<div style={{ marginBottom: '24px' }}>
|
type="button"
|
||||||
<label style={{ display: 'block', fontSize: '0.85rem', fontWeight: 600, color: '#475569', marginBottom: '6px' }}>
|
onClick={() => setShowMailModal(true)}
|
||||||
Beschreibung & Aufgabenstellung
|
style={{
|
||||||
</label>
|
padding: '6px 12px',
|
||||||
<div style={{ position: 'relative' }}>
|
borderRadius: '8px',
|
||||||
<FileText size={18} color="#94a3b8" style={{ position: 'absolute', left: '12px', top: '12px' }} />
|
backgroundColor: '#ea580c',
|
||||||
<textarea
|
color: '#ffffff',
|
||||||
|
border: 'none',
|
||||||
|
fontSize: '0.78rem',
|
||||||
|
fontWeight: 700,
|
||||||
|
cursor: 'pointer',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '6px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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
|
||||||
|
type="button"
|
||||||
|
onClick={() => setFormData({ ...formData, healthStatus: 'delay' })}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
padding: '8px 12px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
border: formData.healthStatus === 'delay' ? '2px solid #f59e0b' : '1px solid #cbd5e1',
|
||||||
|
backgroundColor: formData.healthStatus === 'delay' ? 'rgba(245, 158, 11, 0.1)' : '#ffffff',
|
||||||
|
color: formData.healthStatus === 'delay' ? '#b45309' : '#64748b',
|
||||||
|
fontWeight: 700,
|
||||||
|
fontSize: '0.82rem',
|
||||||
|
cursor: 'pointer',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
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}
|
value={formData.description}
|
||||||
onChange={(e) => setFormData({ ...formData, description: 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>
|
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Buttons */}
|
||||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '12px', paddingTop: '16px', borderTop: '1px solid #f1f5f9' }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: '12px', marginTop: '10px' }}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
style={{
|
className="btn-ghost"
|
||||||
padding: '10px 18px',
|
>
|
||||||
borderRadius: '8px',
|
Abbrechen
|
||||||
border: '1px solid #cbd5e1',
|
</button>
|
||||||
backgroundColor: '#ffffff',
|
<button
|
||||||
color: '#475569',
|
type="submit"
|
||||||
fontWeight: 600,
|
disabled={isSaving}
|
||||||
cursor: 'pointer'
|
className="btn-primary-cta"
|
||||||
}}
|
>
|
||||||
>
|
<Save size={18} />
|
||||||
Abbrechen
|
<span>{isSaving ? 'Speichert...' : 'Auftrag Speichern'}</span>
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
<button
|
</form>
|
||||||
type="submit"
|
</div>
|
||||||
disabled={isSaving}
|
|
||||||
style={{
|
|
||||||
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} />
|
|
||||||
{isSaving ? 'Speichert...' : 'Auftrag speichern'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -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
@@ -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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)' },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
|||||||
+59
-106
@@ -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={{
|
{onNavigate && (
|
||||||
display: 'flex',
|
<section style={{ display: 'flex', gap: '12px', flexWrap: 'wrap' }}>
|
||||||
gap: '12px',
|
<button
|
||||||
flexWrap: 'wrap',
|
onClick={() => onNavigate('kunden')}
|
||||||
marginBottom: '10px'
|
className="btn-secondary"
|
||||||
}}>
|
>
|
||||||
{onNavigate && (
|
<Users size={16} />
|
||||||
<>
|
<span>Kunden & Aufträge</span>
|
||||||
<button
|
</button>
|
||||||
onClick={() => onNavigate('kunden')}
|
|
||||||
style={{
|
|
||||||
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
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => onNavigate('dispatching')}
|
onClick={() => onNavigate('dispatching')}
|
||||||
style={{
|
className="btn-ghost"
|
||||||
backgroundColor: '#ffffff',
|
>
|
||||||
border: '1px solid #cbd5e1',
|
<CalendarDays size={16} color="#38bdf8" />
|
||||||
padding: '10px 16px',
|
<span>Dispatcher & Wochenplan</span>
|
||||||
borderRadius: '10px',
|
</button>
|
||||||
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
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => onNavigate('geraete')}
|
onClick={() => onNavigate('geraete')}
|
||||||
style={{
|
className="btn-ghost"
|
||||||
backgroundColor: '#ffffff',
|
>
|
||||||
border: '1px solid #cbd5e1',
|
<Wrench size={16} color="#38bdf8" />
|
||||||
padding: '10px 16px',
|
<span>Gerätekartei</span>
|
||||||
borderRadius: '10px',
|
</button>
|
||||||
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
|
|
||||||
</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>
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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',
|
||||||
|
borderRadius: '12px',
|
||||||
|
padding: '24px',
|
||||||
|
color: '#f8fafc'
|
||||||
|
}}>
|
||||||
|
<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 style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))', gap: '20px', marginBottom: '28px' }}>
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))', gap: '16px', marginBottom: '24px' }}>
|
||||||
<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: '#10b981', marginBottom: '8px' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', color: '#4ade80', marginBottom: '8px' }}>
|
||||||
<CheckCircle2 size={20} />
|
<CheckCircle2 size={18} />
|
||||||
<span style={{ fontWeight: 700, color: '#0f172a' }}>Verbindungsstatus</span>
|
<span style={{ fontWeight: 700, color: '#f8fafc', fontSize: '0.85rem' }}>Verbindungsstatus</span>
|
||||||
</div>
|
</div>
|
||||||
<p style={{ fontSize: '1.2rem', fontWeight: 800, color: '#10b981' }}>
|
<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>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
<h1 className="header-title">Zeiterfassung & Stundenzettel</h1>
|
<div>
|
||||||
<span style={{ fontWeight: 600, color: '#10b981' }}>Gesamtstunden Heute: 16.0 Std.</span>
|
<h1 className="header-title">Zeiterfassung & Stundenzettel</h1>
|
||||||
|
<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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
Reference in New Issue
Block a user