Neuste Version der Seite mit Drag n Drop

This commit is contained in:
MarcWieland
2026-07-29 17:24:34 +02:00
parent a405bfb91f
commit 2a0d65de2f
18 changed files with 6572 additions and 175 deletions
+15 -2
View File
@@ -2,24 +2,37 @@ import React, { useState } from 'react';
import { Menu, Wrench } from 'lucide-react';
import { Sidebar } from './components/Sidebar';
import { DashboardView } from './views/DashboardView';
import { CustomersView } from './views/CustomersView';
import { DispatchingView } from './views/DispatchingView';
import { InventoryView } from './views/InventoryView';
import { EquipmentView } from './views/EquipmentView';
import { TechniciansView } from './views/TechniciansView';
import { TimeTrackingView } from './views/TimeTrackingView';
import { SupabaseSyncView } from './views/SupabaseSyncView';
export const App: React.FC = () => {
const [activeTab, setActiveTab] = useState('dashboard');
const [activeTab, setActiveTabState] = useState<string>(() => {
return localStorage.getItem('handwerksfreund_active_tab') || 'dashboard';
});
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const setActiveTab = (tab: string) => {
setActiveTabState(tab);
localStorage.setItem('handwerksfreund_active_tab', tab);
};
const renderView = () => {
switch (activeTab) {
case 'dashboard':
return <DashboardView />;
return <DashboardView onNavigate={setActiveTab} />;
case 'kunden':
return <CustomersView />;
case 'dispatching':
return <DispatchingView />;
case 'lager':
return <InventoryView />;
case 'geraete':
return <EquipmentView />;
case 'monteure':
return <TechniciansView />;
case 'zeiterfassung':
+371
View File
@@ -0,0 +1,371 @@
import React, { useState, useEffect } from 'react';
import { X, Save, Building, MapPin, Phone, Mail, FileText } from 'lucide-react';
import { Customer } from '../types';
interface CustomerModalProps {
isOpen: boolean;
onClose: () => void;
onSave: (customer: Omit<Customer, 'id'> | Customer) => Promise<void>;
initialCustomer?: Customer | null;
}
export const CustomerModal: React.FC<CustomerModalProps> = ({
isOpen,
onClose,
onSave,
initialCustomer
}) => {
const [formData, setFormData] = useState({
customerNumber: '',
name: '',
street: '',
zipCode: '',
city: '',
phone: '',
email: '',
notes: '',
latitude: 50.1109,
longitude: 8.6821,
distanceKm: 2.5
});
const [isSaving, setIsSaving] = useState(false);
const [errorMsg, setErrorMsg] = useState('');
useEffect(() => {
if (initialCustomer) {
setFormData({
customerNumber: initialCustomer.customerNumber || '',
name: initialCustomer.name || '',
street: initialCustomer.street || '',
zipCode: initialCustomer.zipCode || '',
city: initialCustomer.city || '',
phone: initialCustomer.phone || '',
email: initialCustomer.email || '',
notes: initialCustomer.notes || '',
latitude: initialCustomer.latitude || 50.1109,
longitude: initialCustomer.longitude || 8.6821,
distanceKm: initialCustomer.distanceKm || 2.5
});
} else {
setFormData({
customerNumber: `K-${Math.floor(10000 + Math.random() * 90000)}`,
name: '',
street: '',
zipCode: '',
city: '',
phone: '',
email: '',
notes: '',
latitude: 50.1109,
longitude: 8.6821,
distanceKm: 2.5
});
}
setErrorMsg('');
}, [initialCustomer, isOpen]);
if (!isOpen) return null;
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!formData.name.trim() || !formData.street.trim() || !formData.city.trim()) {
setErrorMsg('Bitte Name, Straße und Ort ausfüllen.');
return;
}
try {
setIsSaving(true);
setErrorMsg('');
if (initialCustomer) {
await onSave({ ...initialCustomer, ...formData });
} else {
await onSave(formData);
}
onClose();
} catch (err: any) {
setErrorMsg(err.message || 'Fehler beim Speichern des Kunden');
} finally {
setIsSaving(false);
}
};
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={{
backgroundColor: '#ffffff',
borderRadius: '16px',
maxWidth: '600px',
width: '100%',
maxHeight: '90vh',
overflowY: 'auto',
boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.25)',
border: '1px solid #e2e8f0'
}}>
{/* Modal Header */}
<div style={{
padding: '20px 24px',
borderBottom: '1px solid #e2e8f0',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
backgroundColor: '#f8fafc',
borderTopLeftRadius: '16px',
borderTopRightRadius: '16px'
}}>
<h2 style={{ fontSize: '1.25rem', fontWeight: 700, color: '#0f172a', margin: 0, display: 'flex', alignItems: 'center', gap: '10px' }}>
<Building size={22} color="#2563eb" />
{initialCustomer ? 'Kunde bearbeiten' : 'Neuen Kunden anlegen'}
</h2>
<button
onClick={onClose}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#64748b', borderRadius: '8px', padding: '4px' }}
>
<X size={20} />
</button>
</div>
{/* Form Body */}
<form onSubmit={handleSubmit} style={{ padding: '24px' }}>
{errorMsg && (
<div style={{
backgroundColor: '#fef2f2',
color: '#991b1b',
padding: '12px 16px',
borderRadius: '8px',
marginBottom: '20px',
fontSize: '0.9rem',
fontWeight: 500
}}>
{errorMsg}
</div>
)}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 2fr', gap: '16px', marginBottom: '16px' }}>
<div>
<label style={{ display: 'block', fontSize: '0.85rem', fontWeight: 600, color: '#475569', marginBottom: '6px' }}>
Kundennummer
</label>
<input
type="text"
value={formData.customerNumber}
onChange={(e) => setFormData({ ...formData, customerNumber: e.target.value })}
required
style={{
width: '100%',
padding: '10px 14px',
borderRadius: '8px',
border: '1px solid #cbd5e1',
fontSize: '0.95rem'
}}
/>
</div>
<div>
<label style={{ display: 'block', fontSize: '0.85rem', fontWeight: 600, color: '#475569', marginBottom: '6px' }}>
Name / Firma *
</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="z.B. Max Mustermann"
required
style={{
width: '100%',
padding: '10px 14px',
borderRadius: '8px',
border: '1px solid #cbd5e1',
fontSize: '0.95rem'
}}
/>
</div>
</div>
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', fontSize: '0.85rem', fontWeight: 600, color: '#475569', marginBottom: '6px' }}>
Straße & Hausnummer *
</label>
<div style={{ position: 'relative' }}>
<MapPin size={18} color="#94a3b8" style={{ position: 'absolute', left: '12px', top: '12px' }} />
<input
type="text"
value={formData.street}
onChange={(e) => setFormData({ ...formData, street: e.target.value })}
placeholder="Musterweg 12"
required
style={{
width: '100%',
padding: '10px 14px 10px 38px',
borderRadius: '8px',
border: '1px solid #cbd5e1',
fontSize: '0.95rem'
}}
/>
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 2fr', gap: '16px', marginBottom: '16px' }}>
<div>
<label style={{ display: 'block', fontSize: '0.85rem', fontWeight: 600, color: '#475569', marginBottom: '6px' }}>
PLZ *
</label>
<input
type="text"
value={formData.zipCode}
onChange={(e) => setFormData({ ...formData, zipCode: e.target.value })}
placeholder="12345"
required
style={{
width: '100%',
padding: '10px 14px',
borderRadius: '8px',
border: '1px solid #cbd5e1',
fontSize: '0.95rem'
}}
/>
</div>
<div>
<label style={{ display: 'block', fontSize: '0.85rem', fontWeight: 600, color: '#475569', marginBottom: '6px' }}>
Ort *
</label>
<input
type="text"
value={formData.city}
onChange={(e) => setFormData({ ...formData, city: e.target.value })}
placeholder="Musterstadt"
required
style={{
width: '100%',
padding: '10px 14px',
borderRadius: '8px',
border: '1px solid #cbd5e1',
fontSize: '0.95rem'
}}
/>
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px', marginBottom: '16px' }}>
<div>
<label style={{ display: 'block', fontSize: '0.85rem', fontWeight: 600, color: '#475569', marginBottom: '6px' }}>
Telefon
</label>
<div style={{ position: 'relative' }}>
<Phone size={18} color="#94a3b8" style={{ position: 'absolute', left: '12px', top: '12px' }} />
<input
type="text"
value={formData.phone}
onChange={(e) => setFormData({ ...formData, phone: e.target.value })}
placeholder="0171 1234567"
style={{
width: '100%',
padding: '10px 14px 10px 38px',
borderRadius: '8px',
border: '1px solid #cbd5e1',
fontSize: '0.95rem'
}}
/>
</div>
</div>
<div>
<label style={{ display: 'block', fontSize: '0.85rem', fontWeight: 600, color: '#475569', marginBottom: '6px' }}>
E-Mail
</label>
<div style={{ position: 'relative' }}>
<Mail size={18} color="#94a3b8" style={{ position: 'absolute', left: '12px', top: '12px' }} />
<input
type="email"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
placeholder="kunden@email.de"
style={{
width: '100%',
padding: '10px 14px 10px 38px',
borderRadius: '8px',
border: '1px solid #cbd5e1',
fontSize: '0.95rem'
}}
/>
</div>
</div>
</div>
<div style={{ marginBottom: '24px' }}>
<label style={{ display: 'block', fontSize: '0.85rem', fontWeight: 600, color: '#475569', marginBottom: '6px' }}>
Vor-Ort-Hinweise & Notizen
</label>
<div style={{ position: 'relative' }}>
<FileText size={18} color="#94a3b8" style={{ position: 'absolute', left: '12px', top: '12px' }} />
<textarea
value={formData.notes}
onChange={(e) => setFormData({ ...formData, notes: e.target.value })}
placeholder="z.B. Schlüsselbox an der Garage, Hund im Garten"
rows={3}
style={{
width: '100%',
padding: '10px 14px 10px 38px',
borderRadius: '8px',
border: '1px solid #cbd5e1',
fontSize: '0.95rem',
resize: 'vertical'
}}
/>
</div>
</div>
{/* Buttons */}
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '12px', paddingTop: '16px', borderTop: '1px solid #f1f5f9' }}>
<button
type="button"
onClick={onClose}
style={{
padding: '10px 18px',
borderRadius: '8px',
border: '1px solid #cbd5e1',
backgroundColor: '#ffffff',
color: '#475569',
fontWeight: 600,
cursor: 'pointer'
}}
>
Abbrechen
</button>
<button
type="submit"
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...' : 'Kunden speichern'}
</button>
</div>
</form>
</div>
</div>
);
};
+215
View File
@@ -0,0 +1,215 @@
import React from 'react';
import { X, Download, FileText, Image as ImageIcon, FileCode, Paperclip, ExternalLink } from 'lucide-react';
import { PartDocument } from '../types';
interface DocumentViewerModalProps {
isOpen: boolean;
onClose: () => void;
document: PartDocument | null;
}
export const DocumentViewerModal: React.FC<DocumentViewerModalProps> = ({
isOpen,
onClose,
document
}) => {
if (!isOpen || !document) return null;
const fileName = document.title || 'Dokument';
const fileExt = fileName.split('.').pop()?.toLowerCase() || '';
const isPdf = document.mimeType?.includes('pdf') || fileExt === 'pdf';
const isImage = document.mimeType?.includes('image') || ['png', 'jpg', 'jpeg', 'webp', 'svg', 'gif'].includes(fileExt);
const formatFileSize = (bytes?: number) => {
if (!bytes) return '';
if (bytes >= 1048576) return `${(bytes / 1048576).toFixed(1)} MB`;
if (bytes >= 1024) return `${(bytes / 1024).toFixed(0)} KB`;
return `${bytes} Bytes`;
};
return (
<div style={{
position: 'fixed',
inset: 0,
backgroundColor: 'rgba(15, 23, 42, 0.75)',
backdropFilter: 'blur(6px)',
zIndex: 250,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '20px'
}}>
<div style={{
backgroundColor: '#ffffff',
borderRadius: '16px',
maxWidth: '900px',
width: '100%',
height: '85vh',
display: 'flex',
flexDirection: 'column',
boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.3)',
overflow: 'hidden',
border: '1px solid #cbd5e1'
}}>
{/* Modal Header */}
<div style={{
padding: '16px 24px',
backgroundColor: '#0f172a',
color: '#ffffff',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
borderBottom: '1px solid #1e293b'
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', overflow: 'hidden' }}>
{isPdf ? (
<FileText size={22} color="#ef4444" />
) : isImage ? (
<ImageIcon size={22} color="#10b981" />
) : (
<FileCode size={22} color="#3b82f6" />
)}
<div>
<h3 style={{
margin: 0,
fontSize: '1.05rem',
fontWeight: 700,
color: '#ffffff',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
maxWidth: '500px'
}}>
{fileName}
</h3>
<span style={{ fontSize: '0.78rem', color: '#94a3b8' }}>
{document.mimeType || 'Datei'} {document.fileSizeBytes ? `${formatFileSize(document.fileSizeBytes)}` : ''}
</span>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<a
href={document.filePath}
target="_blank"
rel="noopener noreferrer"
download={fileName}
style={{
backgroundColor: '#2563eb',
color: '#ffffff',
padding: '8px 14px',
borderRadius: '8px',
fontSize: '0.85rem',
fontWeight: 600,
textDecoration: 'none',
display: 'flex',
alignItems: 'center',
gap: '6px'
}}
>
<Download size={16} /> Herunterladen
</a>
<button
onClick={onClose}
style={{
backgroundColor: '#1e293b',
color: '#94a3b8',
border: 'none',
borderRadius: '8px',
padding: '8px',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
>
<X size={20} />
</button>
</div>
</div>
{/* Viewer Main Body */}
<div style={{
flex: 1,
backgroundColor: '#f8fafc',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
overflow: 'auto',
padding: isImage ? '20px' : '0'
}}>
{isImage ? (
<div style={{ textAlign: 'center', maxWidth: '100%', maxHeight: '100%' }}>
<img
src={document.filePath}
alt={fileName}
style={{
maxWidth: '100%',
maxHeight: '70vh',
objectFit: 'contain',
borderRadius: '8px',
boxShadow: '0 10px 25px -5px rgba(0, 0, 0, 0.1)'
}}
onError={(e) => {
// Fallback for mock/broken image URLs
(e.target as HTMLElement).style.display = 'none';
}}
/>
</div>
) : isPdf ? (
<iframe
src={document.filePath}
title={fileName}
style={{
width: '100%',
height: '100%',
border: 'none'
}}
/>
) : (
/* Document Card Fallback for Word / DOCX / Others */
<div style={{
textAlign: 'center',
padding: '40px 20px',
backgroundColor: '#ffffff',
borderRadius: '16px',
border: '1px solid #e2e8f0',
maxWidth: '450px',
boxShadow: '0 4px 12px rgba(0,0,0,0.05)'
}}>
<Paperclip size={48} color="#2563eb" style={{ marginBottom: '16px' }} />
<h4 style={{ fontSize: '1.1rem', fontWeight: 700, color: '#0f172a', marginBottom: '8px' }}>
{fileName}
</h4>
<p style={{ fontSize: '0.85rem', color: '#64748b', marginBottom: '20px' }}>
Dokumenttyp: {fileExt.toUpperCase()} ({formatFileSize(document.fileSizeBytes)})
</p>
<a
href={document.filePath}
target="_blank"
rel="noopener noreferrer"
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '8px',
backgroundColor: '#2563eb',
color: '#ffffff',
padding: '10px 20px',
borderRadius: '10px',
fontWeight: 600,
fontSize: '0.9rem',
textDecoration: 'none'
}}
>
<ExternalLink size={18} /> In neuem Tab öffnen / Herunterladen
</a>
</div>
)}
</div>
</div>
</div>
);
};
+357
View File
@@ -0,0 +1,357 @@
import React, { useState, useEffect } from 'react';
import { X, Save, Wrench, User, Calendar, Tag, Hash, Building2 } from 'lucide-react';
import { InstalledPart, Customer } from '../types';
interface InstalledPartModalProps {
isOpen: boolean;
onClose: () => void;
onSave: (part: Omit<InstalledPart, 'id'> | InstalledPart) => Promise<void>;
customers: Customer[];
initialPart?: InstalledPart | null;
defaultCustomerId?: string;
}
export const InstalledPartModal: React.FC<InstalledPartModalProps> = ({
isOpen,
onClose,
onSave,
customers,
initialPart,
defaultCustomerId
}) => {
const [formData, setFormData] = useState({
customerId: '',
name: '',
category: 'Gas-Brennwertgerät',
manufacturer: 'Buderus',
serialNumber: '',
quantity: 1,
installedAt: new Date().toISOString().split('T')[0]
});
const [isSaving, setIsSaving] = useState(false);
const [errorMsg, setErrorMsg] = useState('');
useEffect(() => {
if (initialPart) {
setFormData({
customerId: initialPart.customerId || '',
name: initialPart.name || '',
category: initialPart.category || 'Gas-Brennwertgerät',
manufacturer: initialPart.manufacturer || 'Buderus',
serialNumber: initialPart.serialNumber || '',
quantity: initialPart.quantity || 1,
installedAt: initialPart.installedAt || new Date().toISOString().split('T')[0]
});
} else {
setFormData({
customerId: defaultCustomerId || (customers.length > 0 ? customers[0].id : ''),
name: '',
category: 'Gas-Brennwertgerät',
manufacturer: 'Buderus',
serialNumber: '',
quantity: 1,
installedAt: new Date().toISOString().split('T')[0]
});
}
setErrorMsg('');
}, [initialPart, defaultCustomerId, customers, isOpen]);
if (!isOpen) return null;
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!formData.customerId) {
setErrorMsg('Bitte einen Kunden auswählen.');
return;
}
if (!formData.name.trim()) {
setErrorMsg('Bitte Produktbezeichnung angeben.');
return;
}
try {
setIsSaving(true);
setErrorMsg('');
if (initialPart) {
await onSave({ ...initialPart, ...formData });
} else {
await onSave(formData);
}
onClose();
} catch (err: any) {
setErrorMsg(err.message || 'Fehler beim Speichern des Geräts');
} finally {
setIsSaving(false);
}
};
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={{
backgroundColor: '#ffffff',
borderRadius: '16px',
maxWidth: '550px',
width: '100%',
maxHeight: '90vh',
overflowY: 'auto',
boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.25)',
border: '1px solid #e2e8f0'
}}>
{/* Header */}
<div style={{
padding: '20px 24px',
borderBottom: '1px solid #e2e8f0',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
backgroundColor: '#f8fafc',
borderTopLeftRadius: '16px',
borderTopRightRadius: '16px'
}}>
<h2 style={{ fontSize: '1.25rem', fontWeight: 700, color: '#0f172a', margin: 0, display: 'flex', alignItems: 'center', gap: '10px' }}>
<Wrench size={22} color="#2563eb" />
{initialPart ? 'Verbautes Gerät bearbeiten' : 'Gerät zur Kartei hinzufügen'}
</h2>
<button
onClick={onClose}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#64748b', borderRadius: '8px', padding: '4px' }}
>
<X size={20} />
</button>
</div>
{/* Form Body */}
<form onSubmit={handleSubmit} style={{ padding: '24px' }}>
{errorMsg && (
<div style={{
backgroundColor: '#fef2f2',
color: '#991b1b',
padding: '12px 16px',
borderRadius: '8px',
marginBottom: '20px',
fontSize: '0.9rem',
fontWeight: 500
}}>
{errorMsg}
</div>
)}
{/* Customer */}
<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'
}}
>
<option value="" disabled>-- Bitte Kunde auswählen --</option>
{customers.map((c) => (
<option key={c.id} value={c.id}>
{c.name} ({c.customerNumber})
</option>
))}
</select>
</div>
</div>
{/* Product Name */}
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', fontSize: '0.85rem', fontWeight: 600, color: '#475569', marginBottom: '6px' }}>
Produktbezeichnung / Modell *
</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="z.B. Logamax plus GB172-24"
required
style={{
width: '100%',
padding: '10px 14px',
borderRadius: '8px',
border: '1px solid #cbd5e1',
fontSize: '0.95rem'
}}
/>
</div>
{/* Manufacturer & Category */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px', marginBottom: '16px' }}>
<div>
<label style={{ display: 'block', fontSize: '0.85rem', fontWeight: 600, color: '#475569', marginBottom: '6px' }}>
Hersteller / Marke *
</label>
<div style={{ position: 'relative' }}>
<Building2 size={18} color="#94a3b8" style={{ position: 'absolute', left: '12px', top: '12px' }} />
<input
type="text"
value={formData.manufacturer}
onChange={(e) => setFormData({ ...formData, manufacturer: e.target.value })}
placeholder="z.B. Buderus, Bosch, Viessmann"
required
style={{
width: '100%',
padding: '10px 14px 10px 38px',
borderRadius: '8px',
border: '1px solid #cbd5e1',
fontSize: '0.95rem'
}}
/>
</div>
</div>
<div>
<label style={{ display: 'block', fontSize: '0.85rem', fontWeight: 600, color: '#475569', marginBottom: '6px' }}>
Geräte-Typ / Kategorie
</label>
<div style={{ position: 'relative' }}>
<Tag size={18} color="#94a3b8" style={{ position: 'absolute', left: '12px', top: '12px' }} />
<input
type="text"
value={formData.category}
onChange={(e) => setFormData({ ...formData, category: e.target.value })}
placeholder="z.B. Gas-Brennwertgerät, Raumthermostat"
style={{
width: '100%',
padding: '10px 14px 10px 38px',
borderRadius: '8px',
border: '1px solid #cbd5e1',
fontSize: '0.95rem'
}}
/>
</div>
</div>
</div>
{/* Serial Number & Quantity */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px', marginBottom: '16px' }}>
<div>
<label style={{ display: 'block', fontSize: '0.85rem', fontWeight: 600, color: '#475569', marginBottom: '6px' }}>
Seriennummer
</label>
<div style={{ position: 'relative' }}>
<Hash size={18} color="#94a3b8" style={{ position: 'absolute', left: '12px', top: '12px' }} />
<input
type="text"
value={formData.serialNumber}
onChange={(e) => setFormData({ ...formData, serialNumber: e.target.value })}
placeholder="8374747383"
style={{
width: '100%',
padding: '10px 14px 10px 38px',
borderRadius: '8px',
border: '1px solid #cbd5e1',
fontSize: '0.95rem'
}}
/>
</div>
</div>
<div>
<label style={{ display: 'block', fontSize: '0.85rem', fontWeight: 600, color: '#475569', marginBottom: '6px' }}>
Anzahl
</label>
<input
type="number"
min={1}
value={formData.quantity}
onChange={(e) => setFormData({ ...formData, quantity: parseInt(e.target.value) || 1 })}
style={{
width: '100%',
padding: '10px 14px',
borderRadius: '8px',
border: '1px solid #cbd5e1',
fontSize: '0.95rem'
}}
/>
</div>
</div>
{/* Installed At */}
<div style={{ marginBottom: '24px' }}>
<label style={{ display: 'block', fontSize: '0.85rem', fontWeight: 600, color: '#475569', marginBottom: '6px' }}>
Einbaudatum
</label>
<div style={{ position: 'relative' }}>
<Calendar size={18} color="#94a3b8" style={{ position: 'absolute', left: '12px', top: '12px' }} />
<input
type="date"
value={formData.installedAt}
onChange={(e) => setFormData({ ...formData, installedAt: e.target.value })}
style={{
width: '100%',
padding: '10px 14px 10px 38px',
borderRadius: '8px',
border: '1px solid #cbd5e1',
fontSize: '0.95rem'
}}
/>
</div>
</div>
{/* Actions */}
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '12px', paddingTop: '16px', borderTop: '1px solid #f1f5f9' }}>
<button
type="button"
onClick={onClose}
style={{
padding: '10px 18px',
borderRadius: '8px',
border: '1px solid #cbd5e1',
backgroundColor: '#ffffff',
color: '#475569',
fontWeight: 600,
cursor: 'pointer'
}}
>
Abbrechen
</button>
<button
type="submit"
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...' : 'Gerät speichern'}
</button>
</div>
</form>
</div>
</div>
);
};
+431
View File
@@ -0,0 +1,431 @@
import React, { useState, useEffect } from 'react';
import { X, Save, Calendar, Clock, MapPin, User, FileText, UserCheck } from 'lucide-react';
import { ScheduleItem, ScheduleItemType, Technician } from '../types';
import { SupabaseService } from '../services/supabaseService';
interface ScheduleItemModalProps {
isOpen: boolean;
onClose: () => void;
onSave: (item: Omit<ScheduleItem, 'id'> | ScheduleItem) => Promise<void>;
initialItem?: ScheduleItem | null;
}
export const ScheduleItemModal: React.FC<ScheduleItemModalProps> = ({
isOpen,
onClose,
onSave,
initialItem
}) => {
const [technicians, setTechnicians] = useState<Technician[]>([]);
const [formData, setFormData] = useState({
title: '',
customerName: '',
technicianName: 'Max Müller',
address: '',
taskDescription: '',
startTime: '08:00',
endTime: '10:00',
type: 'job' as ScheduleItemType,
status: 'Geplant' as 'In Bearbeitung' | 'Geplant' | 'Abgeschlossen'
});
const [isSaving, setIsSaving] = useState(false);
const [errorMsg, setErrorMsg] = useState('');
useEffect(() => {
if (isOpen) {
SupabaseService.getTechnicians().then(techs => {
setTechnicians(techs);
if (!initialItem && techs.length > 0) {
setFormData(prev => ({ ...prev, technicianName: techs[0].name }));
}
}).catch(err => console.error(err));
}
}, [isOpen]);
useEffect(() => {
if (initialItem) {
setFormData({
title: initialItem.title || '',
customerName: initialItem.customerName || '',
technicianName: initialItem.technicianName || 'Max Müller',
address: initialItem.address || '',
taskDescription: initialItem.taskDescription || '',
startTime: initialItem.startTime || '08:00',
endTime: initialItem.endTime || '10:00',
type: initialItem.type || 'job',
status: initialItem.status || 'Geplant'
});
} else {
setFormData({
title: '',
customerName: '',
technicianName: technicians.length > 0 ? technicians[0].name : 'Max Müller',
address: '',
taskDescription: '',
startTime: '08:00',
endTime: '10:00',
type: 'job',
status: 'Geplant'
});
}
setErrorMsg('');
}, [initialItem, isOpen]);
if (!isOpen) return null;
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!formData.title.trim() || !formData.startTime || !formData.endTime) {
setErrorMsg('Bitte Titel, Start- und Endzeit angeben.');
return;
}
try {
setIsSaving(true);
setErrorMsg('');
const isInProgress = formData.status === 'In Bearbeitung';
const isCompleted = formData.status === 'Abgeschlossen';
if (initialItem) {
await onSave({
...initialItem,
...formData,
isInProgress,
isCompleted
});
} else {
await onSave({
...formData,
isInProgress,
isCompleted
});
}
onClose();
} catch (err: any) {
setErrorMsg(err.message || 'Fehler beim Speichern des Termin-Eintrags');
} finally {
setIsSaving(false);
}
};
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={{
backgroundColor: '#ffffff',
borderRadius: '16px',
maxWidth: '550px',
width: '100%',
maxHeight: '90vh',
overflowY: 'auto',
boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.25)',
border: '1px solid #e2e8f0'
}}>
{/* Header */}
<div style={{
padding: '20px 24px',
borderBottom: '1px solid #e2e8f0',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
backgroundColor: '#f8fafc',
borderTopLeftRadius: '16px',
borderTopRightRadius: '16px'
}}>
<h2 style={{ fontSize: '1.25rem', fontWeight: 700, color: '#0f172a', margin: 0, display: 'flex', alignItems: 'center', gap: '10px' }}>
<Calendar size={22} color="#2563eb" />
{initialItem ? 'Termineinsatz bearbeiten' : 'Neuen Termineinsatz planen'}
</h2>
<button
onClick={onClose}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#64748b', borderRadius: '8px', padding: '4px' }}
>
<X size={20} />
</button>
</div>
{/* Form Body */}
<form onSubmit={handleSubmit} style={{ padding: '24px' }}>
{errorMsg && (
<div style={{
backgroundColor: '#fef2f2',
color: '#991b1b',
padding: '12px 16px',
borderRadius: '8px',
marginBottom: '20px',
fontSize: '0.9rem',
fontWeight: 500
}}>
{errorMsg}
</div>
)}
{/* Title */}
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', fontSize: '0.85rem', fontWeight: 600, color: '#475569', marginBottom: '6px' }}>
Titel / Betreff *
</label>
<input
type="text"
value={formData.title}
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
placeholder="z.B. Wartung Heizungsanlage"
required
style={{
width: '100%',
padding: '10px 14px',
borderRadius: '8px',
border: '1px solid #cbd5e1',
fontSize: '0.95rem'
}}
/>
</div>
{/* Technician Selection */}
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', fontSize: '0.85rem', fontWeight: 600, color: '#475569', marginBottom: '6px' }}>
Zugewiesener Monteur *
</label>
<div style={{ position: 'relative' }}>
<UserCheck size={18} color="#94a3b8" style={{ position: 'absolute', left: '12px', top: '12px' }} />
<select
value={formData.technicianName}
onChange={(e) => setFormData({ ...formData, technicianName: e.target.value })}
required
style={{
width: '100%',
padding: '10px 14px 10px 38px',
borderRadius: '8px',
border: '1px solid #cbd5e1',
fontSize: '0.95rem',
backgroundColor: '#ffffff'
}}
>
{technicians.map(t => (
<option key={t.id} value={t.name}>
{t.name} ({t.role})
</option>
))}
</select>
</div>
</div>
{/* Customer & Address */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px', marginBottom: '16px' }}>
<div>
<label style={{ display: 'block', fontSize: '0.85rem', fontWeight: 600, color: '#475569', marginBottom: '6px' }}>
Kundenname
</label>
<div style={{ position: 'relative' }}>
<User size={18} color="#94a3b8" style={{ position: 'absolute', left: '12px', top: '12px' }} />
<input
type="text"
value={formData.customerName}
onChange={(e) => setFormData({ ...formData, customerName: e.target.value })}
placeholder="Max Mustermann"
style={{
width: '100%',
padding: '10px 14px 10px 38px',
borderRadius: '8px',
border: '1px solid #cbd5e1',
fontSize: '0.95rem'
}}
/>
</div>
</div>
<div>
<label style={{ display: 'block', fontSize: '0.85rem', fontWeight: 600, color: '#475569', marginBottom: '6px' }}>
Einsatzadresse
</label>
<div style={{ position: 'relative' }}>
<MapPin size={18} color="#94a3b8" style={{ position: 'absolute', left: '12px', top: '12px' }} />
<input
type="text"
value={formData.address}
onChange={(e) => setFormData({ ...formData, address: e.target.value })}
placeholder="Musterweg 12"
style={{
width: '100%',
padding: '10px 14px 10px 38px',
borderRadius: '8px',
border: '1px solid #cbd5e1',
fontSize: '0.95rem'
}}
/>
</div>
</div>
</div>
{/* Times */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px', marginBottom: '16px' }}>
<div>
<label style={{ display: 'block', fontSize: '0.85rem', fontWeight: 600, color: '#475569', marginBottom: '6px' }}>
Startzeit *
</label>
<div style={{ position: 'relative' }}>
<Clock size={18} color="#94a3b8" style={{ position: 'absolute', left: '12px', top: '12px' }} />
<input
type="time"
value={formData.startTime}
onChange={(e) => setFormData({ ...formData, startTime: e.target.value })}
required
style={{
width: '100%',
padding: '10px 14px 10px 38px',
borderRadius: '8px',
border: '1px solid #cbd5e1',
fontSize: '0.95rem'
}}
/>
</div>
</div>
<div>
<label style={{ display: 'block', fontSize: '0.85rem', fontWeight: 600, color: '#475569', marginBottom: '6px' }}>
Endzeit *
</label>
<div style={{ position: 'relative' }}>
<Clock size={18} color="#94a3b8" style={{ position: 'absolute', left: '12px', top: '12px' }} />
<input
type="time"
value={formData.endTime}
onChange={(e) => setFormData({ ...formData, endTime: e.target.value })}
required
style={{
width: '100%',
padding: '10px 14px 10px 38px',
borderRadius: '8px',
border: '1px solid #cbd5e1',
fontSize: '0.95rem'
}}
/>
</div>
</div>
</div>
{/* Type & Status */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px', marginBottom: '16px' }}>
<div>
<label style={{ display: 'block', fontSize: '0.85rem', fontWeight: 600, color: '#475569', marginBottom: '6px' }}>
Einsatz-Typ
</label>
<select
value={formData.type}
onChange={(e) => setFormData({ ...formData, type: e.target.value as ScheduleItemType })}
style={{
width: '100%',
padding: '10px 14px',
borderRadius: '8px',
border: '1px solid #cbd5e1',
fontSize: '0.95rem',
backgroundColor: '#ffffff'
}}
>
<option value="job">Kundenauftrag</option>
<option value="routine">Wartungsroutine</option>
<option value="lunchBreak">Mittagspause</option>
<option value="feierabend">Feierabend</option>
</select>
</div>
<div>
<label style={{ display: 'block', fontSize: '0.85rem', fontWeight: 600, color: '#475569', marginBottom: '6px' }}>
Status
</label>
<select
value={formData.status}
onChange={(e) => setFormData({ ...formData, status: e.target.value as any })}
style={{
width: '100%',
padding: '10px 14px',
borderRadius: '8px',
border: '1px solid #cbd5e1',
fontSize: '0.95rem',
backgroundColor: '#ffffff'
}}
>
<option value="Geplant">Geplant</option>
<option value="In Bearbeitung">In Bearbeitung</option>
<option value="Abgeschlossen">Abgeschlossen</option>
</select>
</div>
</div>
{/* Description */}
<div style={{ marginBottom: '24px' }}>
<label style={{ display: 'block', fontSize: '0.85rem', fontWeight: 600, color: '#475569', marginBottom: '6px' }}>
Tätigkeitsbeschreibung
</label>
<div style={{ position: 'relative' }}>
<FileText size={18} color="#94a3b8" style={{ position: 'absolute', left: '12px', top: '12px' }} />
<textarea
value={formData.taskDescription}
onChange={(e) => setFormData({ ...formData, taskDescription: e.target.value })}
placeholder="Hinweise zur Ausführung..."
rows={3}
style={{
width: '100%',
padding: '10px 14px 10px 38px',
borderRadius: '8px',
border: '1px solid #cbd5e1',
fontSize: '0.95rem',
resize: 'vertical'
}}
/>
</div>
</div>
{/* Actions */}
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '12px', paddingTop: '16px', borderTop: '1px solid #f1f5f9' }}>
<button
type="button"
onClick={onClose}
style={{
padding: '10px 18px',
borderRadius: '8px',
border: '1px solid #cbd5e1',
backgroundColor: '#ffffff',
color: '#475569',
fontWeight: 600,
cursor: 'pointer'
}}
>
Abbrechen
</button>
<button
type="submit"
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...' : 'Einsatz speichern'}
</button>
</div>
</form>
</div>
</div>
);
};
+66 -32
View File
@@ -7,7 +7,8 @@ import {
Users,
Clock,
Database,
X
X,
Info
} from 'lucide-react';
interface SidebarProps {
@@ -25,8 +26,10 @@ export const Sidebar: React.FC<SidebarProps> = ({
}) => {
const menuItems = [
{ id: 'dashboard', label: 'Dashboard', icon: LayoutDashboard },
{ id: 'kunden', label: 'Kunden & Aufträge', icon: Users },
{ id: 'dispatching', label: 'Dispatching', icon: CalendarDays },
{ id: 'lager', label: 'Lager & Teile', icon: Package },
{ id: 'lager', label: 'Ersatzteillager', icon: Package },
{ id: 'geraete', label: 'Gerätekartei', icon: Wrench },
{ id: 'monteure', label: 'Monteure', icon: Users },
{ id: 'zeiterfassung', label: 'Zeiterfassung', icon: Clock },
{ id: 'supabase', label: 'Supabase Sync', icon: Database },
@@ -49,40 +52,71 @@ export const Sidebar: React.FC<SidebarProps> = ({
)}
<aside className={`sidebar ${isOpen ? 'open' : ''}`}>
<div className="brand">
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<Wrench className="brand-icon" size={24} />
<span>Handwerksfreund</span>
<div>
<div className="brand">
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<Wrench className="brand-icon" size={24} />
<span>Handwerksfreund</span>
</div>
<button
className="mobile-close-btn"
onClick={onClose}
aria-label="Menü schließen"
>
<X size={24} />
</button>
</div>
<button
className="mobile-close-btn"
onClick={onClose}
aria-label="Menü schließen"
>
<X size={24} />
</button>
<nav>
<ul className="nav-list">
{menuItems.map((item) => {
const Icon = item.icon;
const isActive = activeTab === item.id;
return (
<li key={item.id}>
<button
className={`nav-item ${isActive ? 'active' : ''}`}
onClick={() => handleSelect(item.id)}
>
<Icon className="icon" size={20} />
<span>{item.label}</span>
</button>
</li>
);
})}
</ul>
</nav>
</div>
<nav>
<ul className="nav-list">
{menuItems.map((item) => {
const Icon = item.icon;
const isActive = activeTab === item.id;
return (
<li key={item.id}>
<button
className={`nav-item ${isActive ? 'active' : ''}`}
onClick={() => handleSelect(item.id)}
>
<Icon className="icon" size={20} />
<span>{item.label}</span>
</button>
</li>
);
})}
</ul>
</nav>
{/* Sidebar Footer with Version Badge */}
<div style={{
marginTop: 'auto',
paddingTop: '20px',
borderTop: '1px solid #172545',
fontSize: '0.8rem',
color: '#64748b',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between'
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<Info size={14} color="#38bdf8" />
<span>Version</span>
</div>
<span style={{
backgroundColor: '#172545',
color: '#38bdf8',
padding: '2px 10px',
borderRadius: '12px',
fontWeight: 700,
fontSize: '0.78rem',
border: '1px solid #1e3a8a'
}}>
v0.1
</span>
</div>
</aside>
</>
);
+319
View File
@@ -0,0 +1,319 @@
import React, { useState, useEffect } from 'react';
import { X, Save, UserCheck, Phone, Mail, MapPin, Briefcase } from 'lucide-react';
import { Technician } from '../types';
interface TechnicianModalProps {
isOpen: boolean;
onClose: () => void;
onSave: (tech: Omit<Technician, 'id'> | Technician) => Promise<void>;
initialTech?: Technician | null;
}
export const TechnicianModal: React.FC<TechnicianModalProps> = ({
isOpen,
onClose,
onSave,
initialTech
}) => {
const [formData, setFormData] = useState({
name: '',
role: 'Servicetechniker',
phone: '',
email: '',
status: 'verfuegbar' as 'verfuegbar' | 'imEinsatz' | 'abwesend',
currentLocation: 'Werkstatt Hauptsitz'
});
const [isSaving, setIsSaving] = useState(false);
const [errorMsg, setErrorMsg] = useState('');
useEffect(() => {
if (initialTech) {
setFormData({
name: initialTech.name || '',
role: initialTech.role || 'Servicetechniker',
phone: initialTech.phone || '',
email: initialTech.email || '',
status: initialTech.status || 'verfuegbar',
currentLocation: initialTech.currentLocation || 'Werkstatt Hauptsitz'
});
} else {
setFormData({
name: '',
role: 'Servicetechniker',
phone: '',
email: '',
status: 'verfuegbar',
currentLocation: 'Werkstatt Hauptsitz'
});
}
setErrorMsg('');
}, [initialTech, isOpen]);
if (!isOpen) return null;
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!formData.name.trim()) {
setErrorMsg('Bitte Name des Monteurs angeben.');
return;
}
try {
setIsSaving(true);
setErrorMsg('');
if (initialTech) {
await onSave({ ...initialTech, ...formData });
} else {
await onSave(formData);
}
onClose();
} catch (err: any) {
setErrorMsg(err.message || 'Fehler beim Speichern des Monteurs');
} finally {
setIsSaving(false);
}
};
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={{
backgroundColor: '#ffffff',
borderRadius: '16px',
maxWidth: '550px',
width: '100%',
maxHeight: '90vh',
overflowY: 'auto',
boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.25)',
border: '1px solid #e2e8f0'
}}>
{/* Header */}
<div style={{
padding: '20px 24px',
borderBottom: '1px solid #e2e8f0',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
backgroundColor: '#f8fafc',
borderTopLeftRadius: '16px',
borderTopRightRadius: '16px'
}}>
<h2 style={{ fontSize: '1.25rem', fontWeight: 700, color: '#0f172a', margin: 0, display: 'flex', alignItems: 'center', gap: '10px' }}>
<UserCheck size={22} color="#2563eb" />
{initialTech ? 'Monteur bearbeiten' : 'Neuen Monteur / Techniker anlegen'}
</h2>
<button
onClick={onClose}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#64748b', borderRadius: '8px', padding: '4px' }}
>
<X size={20} />
</button>
</div>
{/* Form Body */}
<form onSubmit={handleSubmit} style={{ padding: '24px' }}>
{errorMsg && (
<div style={{
backgroundColor: '#fef2f2',
color: '#991b1b',
padding: '12px 16px',
borderRadius: '8px',
marginBottom: '20px',
fontSize: '0.9rem',
fontWeight: 500
}}>
{errorMsg}
</div>
)}
{/* Name */}
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', fontSize: '0.85rem', fontWeight: 600, color: '#475569', marginBottom: '6px' }}>
Vollständiger Name *
</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="z.B. Max Müller"
required
style={{
width: '100%',
padding: '10px 14px',
borderRadius: '8px',
border: '1px solid #cbd5e1',
fontSize: '0.95rem'
}}
/>
</div>
{/* Role & Status */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px', marginBottom: '16px' }}>
<div>
<label style={{ display: 'block', fontSize: '0.85rem', fontWeight: 600, color: '#475569', marginBottom: '6px' }}>
Qualifikation / Rolle
</label>
<div style={{ position: 'relative' }}>
<Briefcase size={18} color="#94a3b8" style={{ position: 'absolute', left: '12px', top: '12px' }} />
<input
type="text"
value={formData.role}
onChange={(e) => setFormData({ ...formData, role: e.target.value })}
placeholder="Heizungsbaumeister"
style={{
width: '100%',
padding: '10px 14px 10px 38px',
borderRadius: '8px',
border: '1px solid #cbd5e1',
fontSize: '0.95rem'
}}
/>
</div>
</div>
<div>
<label style={{ display: 'block', fontSize: '0.85rem', fontWeight: 600, color: '#475569', marginBottom: '6px' }}>
Status
</label>
<select
value={formData.status}
onChange={(e) => setFormData({ ...formData, status: e.target.value as any })}
style={{
width: '100%',
padding: '10px 14px',
borderRadius: '8px',
border: '1px solid #cbd5e1',
fontSize: '0.95rem',
backgroundColor: '#ffffff'
}}
>
<option value="verfuegbar">Verfügbar / Werkstatt</option>
<option value="imEinsatz">Im Einsatz / Unterwegs</option>
<option value="abwesend">Abwesend / Urlaub</option>
</select>
</div>
</div>
{/* Phone & Email */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px', marginBottom: '16px' }}>
<div>
<label style={{ display: 'block', fontSize: '0.85rem', fontWeight: 600, color: '#475569', marginBottom: '6px' }}>
Mobiltelefon
</label>
<div style={{ position: 'relative' }}>
<Phone size={18} color="#94a3b8" style={{ position: 'absolute', left: '12px', top: '12px' }} />
<input
type="text"
value={formData.phone}
onChange={(e) => setFormData({ ...formData, phone: e.target.value })}
placeholder="0170 1112233"
style={{
width: '100%',
padding: '10px 14px 10px 38px',
borderRadius: '8px',
border: '1px solid #cbd5e1',
fontSize: '0.95rem'
}}
/>
</div>
</div>
<div>
<label style={{ display: 'block', fontSize: '0.85rem', fontWeight: 600, color: '#475569', marginBottom: '6px' }}>
E-Mail-Adresse
</label>
<div style={{ position: 'relative' }}>
<Mail size={18} color="#94a3b8" style={{ position: 'absolute', left: '12px', top: '12px' }} />
<input
type="email"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
placeholder="m.mueller@handwerksfreund.de"
style={{
width: '100%',
padding: '10px 14px 10px 38px',
borderRadius: '8px',
border: '1px solid #cbd5e1',
fontSize: '0.95rem'
}}
/>
</div>
</div>
</div>
{/* Current Location */}
<div style={{ marginBottom: '24px' }}>
<label style={{ display: 'block', fontSize: '0.85rem', fontWeight: 600, color: '#475569', marginBottom: '6px' }}>
Aktueller Standort / Einsatzort
</label>
<div style={{ position: 'relative' }}>
<MapPin size={18} color="#94a3b8" style={{ position: 'absolute', left: '12px', top: '12px' }} />
<input
type="text"
value={formData.currentLocation}
onChange={(e) => setFormData({ ...formData, currentLocation: e.target.value })}
placeholder="Musterweg 12 oder Werkstatt"
style={{
width: '100%',
padding: '10px 14px 10px 38px',
borderRadius: '8px',
border: '1px solid #cbd5e1',
fontSize: '0.95rem'
}}
/>
</div>
</div>
{/* Actions */}
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '12px', paddingTop: '16px', borderTop: '1px solid #f1f5f9' }}>
<button
type="button"
onClick={onClose}
style={{
padding: '10px 18px',
borderRadius: '8px',
border: '1px solid #cbd5e1',
backgroundColor: '#ffffff',
color: '#475569',
fontWeight: 600,
cursor: 'pointer'
}}
>
Abbrechen
</button>
<button
type="submit"
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...' : 'Monteur speichern'}
</button>
</div>
</form>
</div>
</div>
);
};
+309
View File
@@ -0,0 +1,309 @@
import React, { useState, useEffect } from 'react';
import { X, Save, ClipboardList, Calendar, User, FileText } from 'lucide-react';
import { WorkOrder, Customer, OrderStatus } from '../types';
interface WorkOrderModalProps {
isOpen: boolean;
onClose: () => void;
onSave: (order: Omit<WorkOrder, 'id'> | WorkOrder) => Promise<void>;
customers: Customer[];
initialOrder?: WorkOrder | null;
defaultCustomerId?: string;
}
export const WorkOrderModal: React.FC<WorkOrderModalProps> = ({
isOpen,
onClose,
onSave,
customers,
initialOrder,
defaultCustomerId
}) => {
const [formData, setFormData] = useState({
customerId: '',
title: '',
description: '',
scheduledDate: new Date().toISOString().split('T')[0],
status: 'offen' as OrderStatus
});
const [isSaving, setIsSaving] = useState(false);
const [errorMsg, setErrorMsg] = useState('');
useEffect(() => {
if (initialOrder) {
setFormData({
customerId: initialOrder.customerId || '',
title: initialOrder.title || '',
description: initialOrder.description || '',
scheduledDate: initialOrder.scheduledDate || new Date().toISOString().split('T')[0],
status: initialOrder.status || 'offen'
});
} else {
setFormData({
customerId: defaultCustomerId || (customers.length > 0 ? customers[0].id : ''),
title: '',
description: '',
scheduledDate: new Date().toISOString().split('T')[0],
status: 'offen'
});
}
setErrorMsg('');
}, [initialOrder, defaultCustomerId, customers, isOpen]);
if (!isOpen) return null;
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!formData.customerId) {
setErrorMsg('Bitte wählen Sie einen Kunden aus.');
return;
}
if (!formData.title.trim()) {
setErrorMsg('Bitte geben Sie einen Auftragstitel an.');
return;
}
try {
setIsSaving(true);
setErrorMsg('');
if (initialOrder) {
await onSave({ ...initialOrder, ...formData });
} else {
await onSave(formData);
}
onClose();
} catch (err: any) {
setErrorMsg(err.message || 'Fehler beim Speichern des Auftrags');
} finally {
setIsSaving(false);
}
};
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={{
backgroundColor: '#ffffff',
borderRadius: '16px',
maxWidth: '550px',
width: '100%',
maxHeight: '90vh',
overflowY: 'auto',
boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.25)',
border: '1px solid #e2e8f0'
}}>
{/* Header */}
<div style={{
padding: '20px 24px',
borderBottom: '1px solid #e2e8f0',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
backgroundColor: '#f8fafc',
borderTopLeftRadius: '16px',
borderTopRightRadius: '16px'
}}>
<h2 style={{ fontSize: '1.25rem', fontWeight: 700, color: '#0f172a', margin: 0, display: 'flex', alignItems: 'center', gap: '10px' }}>
<ClipboardList size={22} color="#2563eb" />
{initialOrder ? 'Auftrag bearbeiten' : 'Neuen Auftrag erstellen'}
</h2>
<button
onClick={onClose}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#64748b', borderRadius: '8px', padding: '4px' }}
>
<X size={20} />
</button>
</div>
{/* Form Body */}
<form onSubmit={handleSubmit} style={{ padding: '24px' }}>
{errorMsg && (
<div style={{
backgroundColor: '#fef2f2',
color: '#991b1b',
padding: '12px 16px',
borderRadius: '8px',
marginBottom: '20px',
fontSize: '0.9rem',
fontWeight: 500
}}>
{errorMsg}
</div>
)}
{/* 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={{
width: '100%',
padding: '10px 14px',
borderRadius: '8px',
border: '1px solid #cbd5e1',
fontSize: '0.95rem'
}}
/>
</div>
{/* Date & Status */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px', marginBottom: '16px' }}>
<div>
<label style={{ display: 'block', fontSize: '0.85rem', fontWeight: 600, color: '#475569', marginBottom: '6px' }}>
Geplantes Ausführungsdatum
</label>
<div style={{ position: 'relative' }}>
<Calendar size={18} color="#94a3b8" style={{ position: 'absolute', left: '12px', top: '12px' }} />
<input
type="date"
value={formData.scheduledDate}
onChange={(e) => setFormData({ ...formData, scheduledDate: e.target.value })}
style={{
width: '100%',
padding: '10px 14px 10px 38px',
borderRadius: '8px',
border: '1px solid #cbd5e1',
fontSize: '0.95rem'
}}
/>
</div>
</div>
<div>
<label style={{ display: 'block', fontSize: '0.85rem', fontWeight: 600, color: '#475569', marginBottom: '6px' }}>
Auftragsstatus
</label>
<select
value={formData.status}
onChange={(e) => setFormData({ ...formData, status: e.target.value as OrderStatus })}
style={{
width: '100%',
padding: '10px 14px',
borderRadius: '8px',
border: '1px solid #cbd5e1',
fontSize: '0.95rem',
backgroundColor: '#ffffff'
}}
>
<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 */}
<div style={{ marginBottom: '24px' }}>
<label style={{ display: 'block', fontSize: '0.85rem', fontWeight: 600, color: '#475569', marginBottom: '6px' }}>
Beschreibung & Aufgabenstellung
</label>
<div style={{ position: 'relative' }}>
<FileText size={18} color="#94a3b8" style={{ position: 'absolute', left: '12px', top: '12px' }} />
<textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
placeholder="Detaillierte Aufgabenbeschreibung für den Techniker vor Ort..."
rows={4}
style={{
width: '100%',
padding: '10px 14px 10px 38px',
borderRadius: '8px',
border: '1px solid #cbd5e1',
fontSize: '0.95rem',
resize: 'vertical'
}}
/>
</div>
</div>
{/* Actions */}
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '12px', paddingTop: '16px', borderTop: '1px solid #f1f5f9' }}>
<button
type="button"
onClick={onClose}
style={{
padding: '10px 18px',
borderRadius: '8px',
border: '1px solid #cbd5e1',
backgroundColor: '#ffffff',
color: '#475569',
fontWeight: 600,
cursor: 'pointer'
}}
>
Abbrechen
</button>
<button
type="submit"
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>
);
};
+11 -2
View File
@@ -100,6 +100,12 @@ body {
flex-shrink: 0;
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
z-index: 50;
position: fixed;
top: 0;
left: 0;
bottom: 0;
height: 100vh;
overflow-y: auto;
}
.brand {
@@ -175,12 +181,13 @@ body {
/* Main Content Area */
.main-content {
flex: 1;
margin-left: 260px;
padding: 36px 40px;
overflow-y: auto;
min-height: 100vh;
display: flex;
flex-direction: column;
gap: 32px;
max-width: 100%;
max-width: calc(100vw - 260px);
}
/* Top Header Bar */
@@ -438,6 +445,8 @@ body {
}
.main-content {
margin-left: 0;
max-width: 100%;
padding: 20px 16px;
gap: 20px;
}
File diff suppressed because it is too large Load Diff
+72 -3
View File
@@ -9,13 +9,14 @@ export interface Customer {
city: string;
latitude: number;
longitude: number;
distanceKm: number;
distanceKm?: number;
phone?: string;
email?: string;
notes?: string;
firstContactDate?: string;
openOrdersCount: number;
openOrdersCount?: number;
lastOrderDate?: string;
createdAt?: string;
}
export interface WorkOrder {
@@ -28,10 +29,25 @@ export interface WorkOrder {
scheduledTime?: string;
technicianName?: string;
status: OrderStatus;
completedAt?: string;
createdAt?: string;
}
export interface OrderChangelog {
id: string;
orderId: string;
authorName: string;
note: string;
createdAt: string;
}
export type ScheduleItemType = 'job' | 'routine' | 'lunchBreak' | 'feierabend';
export interface ScheduleItem {
id: string;
orderId?: string;
customerId?: string;
technicianId?: string;
title: string;
customerName?: string;
technicianName?: string;
@@ -39,7 +55,59 @@ export interface ScheduleItem {
taskDescription?: string;
startTime: string;
endTime: string;
status: 'In Bearbeitung' | 'Geplant' | 'Abgeschlossen';
type?: ScheduleItemType;
isInProgress?: boolean;
isCompleted?: boolean;
scheduledDate?: string;
status?: 'In Bearbeitung' | 'Geplant' | 'Abgeschlossen';
createdAt?: string;
}
export interface PartDocument {
id: string;
partId: string;
title: string;
filePath: string;
fileSizeBytes?: number;
mimeType?: string;
uploadedAt?: string;
}
export interface InstalledPart {
id: string;
customerId: string;
customerName?: string;
orderId?: string;
name: string;
category: string;
manufacturer?: string;
serialNumber?: string;
quantity: number;
installedAt?: string;
documents?: PartDocument[];
createdAt?: string;
}
export interface OrderAttachment {
id: string;
orderId: string;
title: string;
category: 'bauplan' | 'schaltplan' | 'foto_vor_ort' | 'abnahmeprotokoll' | 'sonstiges';
filePath: string;
fileSizeBytes?: number;
mimeType?: string;
uploadedAt?: string;
}
export interface Technician {
id: string;
name: string;
role: string;
phone?: string;
email?: string;
status: 'verfuegbar' | 'imEinsatz' | 'abwesend';
currentLocation?: string;
createdAt?: string;
}
export interface SystemStatus {
@@ -58,3 +126,4 @@ export interface DashboardMetrics {
openInvoicesAmount: number;
inventoryWarningsCount: number;
}
+890
View File
@@ -0,0 +1,890 @@
import React, { useState, useEffect } from 'react';
import {
Plus,
Search,
User,
MapPin,
Phone,
Mail,
Edit3,
Trash2,
ClipboardList,
Wrench,
ChevronRight,
RefreshCw,
CheckCircle,
FileText,
History,
MessageSquare,
Send,
ChevronDown,
ChevronUp,
UserCheck
} from 'lucide-react';
import { Customer, WorkOrder, InstalledPart, OrderChangelog } from '../types';
import { SupabaseService } from '../services/supabaseService';
import { CustomerModal } from '../components/CustomerModal';
import { WorkOrderModal } from '../components/WorkOrderModal';
import { InstalledPartModal } from '../components/InstalledPartModal';
export const CustomersView: React.FC = () => {
const [customers, setCustomers] = useState<Customer[]>([]);
const [workOrders, setWorkOrders] = useState<WorkOrder[]>([]);
const [installedParts, setInstalledParts] = useState<InstalledPart[]>([]);
const [orderChangelogsMap, setOrderChangelogsMap] = useState<Record<string, OrderChangelog[]>>({});
const [expandedChangelogOrders, setExpandedChangelogOrders] = useState<Record<string, boolean>>({});
const [newChangelogNoteMap, setNewChangelogNoteMap] = useState<Record<string, string>>({});
const [newChangelogAuthorMap, setNewChangelogAuthorMap] = useState<Record<string, string>>({});
const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState('');
// Selected Customer Detail View
const [selectedCustomer, setSelectedCustomer] = useState<Customer | null>(null);
// Modals state
const [isCustomerModalOpen, setIsCustomerModalOpen] = useState(false);
const [editingCustomer, setEditingCustomer] = useState<Customer | null>(null);
const [isOrderModalOpen, setIsOrderModalOpen] = useState(false);
const [editingOrder, setEditingOrder] = useState<WorkOrder | null>(null);
const [isPartModalOpen, setIsPartModalOpen] = useState(false);
const [editingPart, setEditingPart] = useState<InstalledPart | null>(null);
// Toast / Notification
const [toastMessage, setToastMessage] = useState<string | null>(null);
const showNotification = (msg: string) => {
setToastMessage(msg);
setTimeout(() => setToastMessage(null), 4000);
};
const loadData = async () => {
setLoading(true);
try {
const [custData, ordersData, partsData] = await Promise.all([
SupabaseService.getCustomers(),
SupabaseService.getWorkOrders(),
SupabaseService.getInstalledParts()
]);
setCustomers(custData);
setWorkOrders(ordersData);
setInstalledParts(partsData);
// Load changelogs for each order
const logsMap: Record<string, OrderChangelog[]> = {};
await Promise.all(
ordersData.map(async (ord) => {
const logs = await SupabaseService.getOrderChangelogs(ord.id);
logsMap[ord.id] = logs;
})
);
setOrderChangelogsMap(logsMap);
} catch (err) {
console.error('Fehler beim Laden der Kundendaten:', err);
} finally {
setLoading(false);
}
};
const handleAddChangelogEntry = async (orderId: string) => {
const noteText = newChangelogNoteMap[orderId] || '';
const authorName = newChangelogAuthorMap[orderId] || 'Innendienst';
if (!noteText.trim()) return;
try {
await SupabaseService.createOrderChangelog({
orderId,
authorName,
note: noteText.trim()
});
showNotification(`Eintrag zum Auftragsverlauf hinzugefügt.`);
setNewChangelogNoteMap(prev => ({ ...prev, [orderId]: '' }));
await loadData();
} catch (err) {
console.error('Fehler beim Erstellen des Verlaufeintrags:', err);
}
};
const handleDeleteChangelogEntry = async (logId: string) => {
if (window.confirm('Verlaufeintrag löschen?')) {
await SupabaseService.deleteOrderChangelog(logId);
showNotification('Verlaufeintrag gelöscht.');
await loadData();
}
};
useEffect(() => {
loadData();
}, []);
// Customer CRUD Handlers
const handleSaveCustomer = async (data: Omit<Customer, 'id'> | Customer) => {
if ('id' in data) {
const updated = await SupabaseService.updateCustomer(data.id, data);
showNotification(`Kunde "${updated.name}" erfolgreich aktualisiert.`);
} else {
const created = await SupabaseService.createCustomer(data);
showNotification(`Neuer Kunde "${created.name}" wurde in Supabase angelegt.`);
}
await loadData();
};
const handleDeleteCustomer = async (cust: Customer) => {
if (window.confirm(`Möchten Sie den Kunden "${cust.name}" wirklich löschen?`)) {
await SupabaseService.deleteCustomer(cust.id);
showNotification(`Kunde "${cust.name}" wurde gelöscht.`);
if (selectedCustomer?.id === cust.id) {
setSelectedCustomer(null);
}
await loadData();
}
};
// Order CRUD Handlers
const handleSaveOrder = async (data: Omit<WorkOrder, 'id'> | WorkOrder) => {
if ('id' in data) {
const updated = await SupabaseService.updateWorkOrder(data.id, data);
showNotification(`Auftrag "${updated.title}" aktualisiert.`);
} else {
const created = await SupabaseService.createWorkOrder(data);
showNotification(`Auftrag "${created.title}" für Kunde erstellt.`);
}
await loadData();
};
const handleDeleteOrder = async (order: WorkOrder) => {
if (window.confirm(`Auftrag "${order.title}" löschen?`)) {
await SupabaseService.deleteWorkOrder(order.id);
showNotification(`Auftrag "${order.title}" gelöscht.`);
await loadData();
}
};
// Installed Part CRUD Handlers
const handleSavePart = async (data: Omit<InstalledPart, 'id'> | InstalledPart) => {
if ('id' in data) {
const updated = await SupabaseService.updateInstalledPart(data.id, data);
showNotification(`Gerät "${updated.name}" aktualisiert.`);
} else {
const created = await SupabaseService.createInstalledPart(data);
showNotification(`Gerät "${created.name}" zur Kartei hinzugefügt.`);
}
await loadData();
};
const handleDeletePart = async (part: InstalledPart) => {
if (window.confirm(`Gerät "${part.name}" aus Kartei entfernen?`)) {
await SupabaseService.deleteInstalledPart(part.id);
showNotification(`Gerät "${part.name}" gelöscht.`);
await loadData();
}
};
// Filtered customers
const filteredCustomers = customers.filter(c =>
c.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
c.customerNumber.toLowerCase().includes(searchQuery.toLowerCase()) ||
c.city.toLowerCase().includes(searchQuery.toLowerCase())
);
const selectedCustomerOrders = workOrders.filter(o => o.customerId === selectedCustomer?.id);
const selectedCustomerParts = installedParts.filter(p => p.customerId === selectedCustomer?.id);
return (
<>
{/* Toast Notification */}
{toastMessage && (
<div style={{
position: 'fixed',
bottom: '24px',
right: '24px',
backgroundColor: '#0f172a',
color: '#ffffff',
padding: '14px 20px',
borderRadius: '10px',
boxShadow: '0 10px 25px -5px rgba(0, 0, 0, 0.3)',
display: 'flex',
alignItems: 'center',
gap: '12px',
zIndex: 200,
fontSize: '0.95rem',
fontWeight: 600
}}>
<CheckCircle size={20} color="#10b981" />
<span>{toastMessage}</span>
</div>
)}
{/* Header Bar */}
<header className="header-bar" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: '16px' }}>
<div>
<h1 className="header-title">Kunden & Auftragsverwaltung</h1>
<p style={{ color: '#64748b', fontSize: '0.9rem', marginTop: '4px' }}>
Interaktion mit der Supabase-Datenbank (Vollständiges CRUD)
</p>
</div>
<div style={{ display: 'flex', gap: '12px', flexWrap: 'wrap' }}>
<button
onClick={() => {
setEditingCustomer(null);
setIsCustomerModalOpen(true);
}}
style={{
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
</button>
<button
onClick={() => {
setEditingOrder(null);
setIsOrderModalOpen(true);
}}
style={{
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
</button>
</div>
</header>
{/* Search & Actions Bar */}
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '24px',
gap: '16px',
flexWrap: 'wrap'
}}>
<div style={{ position: 'relative', minWidth: '300px', flex: 1 }}>
<Search size={18} color="#94a3b8" style={{ position: 'absolute', left: '14px', top: '12px' }} />
<input
type="text"
placeholder="Kunde suchen nach Name, Kundennr. oder Stadt..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
style={{
width: '100%',
padding: '10px 14px 10px 42px',
borderRadius: '10px',
border: '1px solid #cbd5e1',
backgroundColor: '#ffffff',
fontSize: '0.95rem'
}}
/>
</div>
<button
onClick={loadData}
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '10px 16px',
backgroundColor: '#ffffff',
border: '1px solid #cbd5e1',
borderRadius: '10px',
color: '#475569',
fontWeight: 600,
cursor: 'pointer'
}}
>
<RefreshCw size={16} className={loading ? 'spin' : ''} />
{loading ? 'Lädt...' : 'Aktualisieren'}
</button>
</div>
{/* Main Grid: Customer List + Detail View */}
<div style={{ display: 'grid', gridTemplateColumns: selectedCustomer ? '1fr 1fr' : '1fr', gap: '24px' }}>
{/* Customer List Card */}
<section className="content-card">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px' }}>
<h2 className="card-heading" style={{ margin: 0 }}>
Kundenstamm ({filteredCustomers.length})
</h2>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
{filteredCustomers.map((cust) => {
const isSelected = selectedCustomer?.id === cust.id;
const custOrdersCount = workOrders.filter(o => o.customerId === cust.id).length;
return (
<div
key={cust.id}
onClick={() => setSelectedCustomer(cust)}
style={{
backgroundColor: isSelected ? '#eff6ff' : '#ffffff',
border: `1px solid ${isSelected ? '#3b82f6' : '#e2e8f0'}`,
borderRadius: '12px',
padding: '16px 20px',
cursor: 'pointer',
transition: 'all 0.2s ease',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center'
}}
>
<div style={{ flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '6px' }}>
<span style={{
backgroundColor: '#e0f2fe',
color: '#0369a1',
padding: '2px 8px',
borderRadius: '6px',
fontSize: '0.8rem',
fontWeight: 700
}}>
{cust.customerNumber}
</span>
<h3 style={{ fontSize: '1.05rem', fontWeight: 700, color: '#0f172a', margin: 0 }}>
{cust.name}
</h3>
</div>
<div style={{ display: 'flex', gap: '16px', fontSize: '0.85rem', color: '#64748b', flexWrap: 'wrap' }}>
<span style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
<MapPin size={14} color="#94a3b8" />
{cust.street}, {cust.zipCode} {cust.city}
</span>
{cust.phone && (
<span style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
<Phone size={14} color="#94a3b8" />
{cust.phone}
</span>
)}
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<span style={{
backgroundColor: custOrdersCount > 0 ? '#fef3c7' : '#f1f5f9',
color: custOrdersCount > 0 ? '#b45309' : '#64748b',
padding: '4px 10px',
borderRadius: '20px',
fontSize: '0.8rem',
fontWeight: 600
}}>
{custOrdersCount} Aufträge
</span>
<button
onClick={(e) => {
e.stopPropagation();
setEditingCustomer(cust);
setIsCustomerModalOpen(true);
}}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
color: '#64748b',
padding: '6px',
borderRadius: '6px'
}}
title="Kunde bearbeiten"
>
<Edit3 size={18} />
</button>
<button
onClick={(e) => {
e.stopPropagation();
handleDeleteCustomer(cust);
}}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
color: '#ef4444',
padding: '6px',
borderRadius: '6px'
}}
title="Kunde löschen"
>
<Trash2 size={18} />
</button>
<ChevronRight size={18} color="#94a3b8" />
</div>
</div>
);
})}
{filteredCustomers.length === 0 && (
<div style={{ padding: '40px', textAlign: 'center', color: '#64748b' }}>
Keine Kunden gefunden.
</div>
)}
</div>
</section>
{/* Selected Customer Details */}
{selectedCustomer && (
<section className="content-card">
{/* Header */}
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start',
borderBottom: '1px solid #e2e8f0',
paddingBottom: '16px',
marginBottom: '20px'
}}>
<div>
<span style={{ fontSize: '0.85rem', fontWeight: 600, color: '#2563eb' }}>
{selectedCustomer.customerNumber}
</span>
<h2 style={{ fontSize: '1.3rem', fontWeight: 800, color: '#0f172a', margin: '2px 0 6px 0' }}>
{selectedCustomer.name}
</h2>
<div style={{ color: '#475569', fontSize: '0.9rem', display: 'flex', alignItems: 'center', gap: '6px' }}>
<MapPin size={16} color="#64748b" />
{selectedCustomer.street}, {selectedCustomer.zipCode} {selectedCustomer.city}
</div>
</div>
<button
onClick={() => setSelectedCustomer(null)}
style={{
background: 'none',
border: 'none',
color: '#64748b',
fontSize: '0.85rem',
fontWeight: 600,
cursor: 'pointer'
}}
>
Schließen
</button>
</div>
{/* Quick Contact & Info */}
<div style={{
backgroundColor: '#f8fafc',
borderRadius: '10px',
padding: '14px',
marginBottom: '24px',
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: '12px',
fontSize: '0.9rem'
}}>
<div>
<span style={{ color: '#64748b', fontSize: '0.8rem', display: 'block' }}>Telefon:</span>
<strong>{selectedCustomer.phone || 'Keine Angabe'}</strong>
</div>
<div>
<span style={{ color: '#64748b', fontSize: '0.8rem', display: 'block' }}>E-Mail:</span>
<strong>{selectedCustomer.email || 'Keine Angabe'}</strong>
</div>
{selectedCustomer.notes && (
<div style={{ gridColumn: '1 / -1', marginTop: '4px' }}>
<span style={{ color: '#64748b', fontSize: '0.8rem', display: 'block' }}>Vor-Ort Notiz:</span>
<span style={{ color: '#b45309', fontWeight: 600 }}>{selectedCustomer.notes}</span>
</div>
)}
</div>
{/* Work Orders Section */}
<div style={{ marginBottom: '28px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '14px' }}>
<h3 style={{ fontSize: '1.05rem', fontWeight: 700, color: '#0f172a', margin: 0, display: 'flex', alignItems: 'center', gap: '8px' }}>
<ClipboardList size={18} color="#2563eb" />
Aufträge ({selectedCustomerOrders.length})
</h3>
<button
onClick={() => {
setEditingOrder(null);
setIsOrderModalOpen(true);
}}
style={{
backgroundColor: '#eff6ff',
color: '#2563eb',
border: '1px solid #bfdbfe',
padding: '6px 12px',
borderRadius: '8px',
fontSize: '0.85rem',
fontWeight: 600,
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: '4px'
}}
>
<Plus size={14} /> Auftrag hinzufügen
</button>
</div>
{selectedCustomerOrders.length > 0 ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: '14px' }}>
{selectedCustomerOrders.map((ord) => {
const changelogs = orderChangelogsMap[ord.id] || [];
const isExpanded = Boolean(expandedChangelogOrders[ord.id]);
return (
<div
key={ord.id}
style={{
borderRadius: '12px',
border: '1px solid #e2e8f0',
backgroundColor: '#ffffff',
overflow: 'hidden',
boxShadow: '0 2px 4px rgba(0,0,0,0.02)'
}}
>
{/* Order Main Header Bar */}
<div style={{
padding: '14px 18px',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
backgroundColor: '#f8fafc',
borderBottom: isExpanded ? '1px solid #e2e8f0' : 'none'
}}>
<div>
<div style={{ fontWeight: 700, color: '#0f172a', fontSize: '1rem' }}>{ord.title}</div>
{ord.description && (
<div style={{ fontSize: '0.85rem', color: '#64748b', marginTop: '2px' }}>
{ord.description}
</div>
)}
<div style={{ fontSize: '0.8rem', color: '#94a3b8', marginTop: '4px' }}>
Ausführungsdatum: {ord.scheduledDate}
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
<span className={`badge ${
ord.status === 'abgeschlossen' ? 'badge-in-progress' : 'badge-scheduled'
}`} style={{
backgroundColor: ord.status === 'abgeschlossen' ? '#dcfce7' : ord.status === 'inBearbeitung' ? '#dbeafe' : '#fef3c7',
color: ord.status === 'abgeschlossen' ? '#15803d' : ord.status === 'inBearbeitung' ? '#1d4ed8' : '#b45309'
}}>
{ord.status}
</span>
<button
onClick={() => {
setEditingOrder(ord);
setIsOrderModalOpen(true);
}}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#64748b' }}
title="Auftrag bearbeiten"
>
<Edit3 size={16} />
</button>
<button
onClick={() => handleDeleteOrder(ord)}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#ef4444' }}
title="Auftrag löschen"
>
<Trash2 size={16} />
</button>
</div>
</div>
{/* Changelog Toggle Bar */}
<div style={{
padding: '8px 18px',
backgroundColor: '#ffffff',
borderTop: '1px solid #f1f5f9',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center'
}}>
<button
onClick={() => setExpandedChangelogOrders(prev => ({ ...prev, [ord.id]: !prev[ord.id] }))}
style={{
background: 'none',
border: 'none',
color: '#2563eb',
fontSize: '0.82rem',
fontWeight: 700,
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: '6px'
}}
>
<History size={15} />
Interner Verlaufs-Changelog ({changelogs.length} Einträge)
{isExpanded ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
</button>
</div>
{/* Expandable Changelog Section */}
{isExpanded && (
<div style={{
padding: '16px 18px',
backgroundColor: '#f8fafc',
borderTop: '1px solid #e2e8f0'
}}>
{/* New Entry Input Form */}
<div style={{
backgroundColor: '#ffffff',
border: '1px solid #cbd5e1',
borderRadius: '10px',
padding: '12px',
marginBottom: '16px'
}}>
<div style={{ display: 'flex', gap: '8px', marginBottom: '8px' }}>
<input
type="text"
placeholder="Autor (z.B. Marc - Innendienst)"
value={newChangelogAuthorMap[ord.id] || ''}
onChange={(e) => setNewChangelogAuthorMap({ ...newChangelogAuthorMap, [ord.id]: e.target.value })}
style={{
width: '200px',
padding: '6px 10px',
borderRadius: '6px',
border: '1px solid #cbd5e1',
fontSize: '0.82rem'
}}
/>
</div>
<div style={{ display: 'flex', gap: '8px' }}>
<textarea
placeholder="z.B. Kunden angerufen: Rückmeldung wegen Terminvereinbarung erhalten..."
rows={2}
value={newChangelogNoteMap[ord.id] || ''}
onChange={(e) => setNewChangelogNoteMap({ ...newChangelogNoteMap, [ord.id]: e.target.value })}
style={{
flex: 1,
padding: '8px 10px',
borderRadius: '6px',
border: '1px solid #cbd5e1',
fontSize: '0.85rem',
resize: 'vertical'
}}
/>
<button
onClick={() => handleAddChangelogEntry(ord.id)}
style={{
backgroundColor: '#2563eb',
color: '#ffffff',
border: 'none',
padding: '8px 14px',
borderRadius: '6px',
fontWeight: 600,
fontSize: '0.82rem',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: '6px',
alignSelf: 'flex-end'
}}
>
<Send size={14} /> Eintragen
</button>
</div>
</div>
{/* Changelog Timeline List (Absteigend sortiert) */}
{changelogs.length > 0 ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
{changelogs.map((log) => {
const dateStr = new Date(log.createdAt).toLocaleString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
return (
<div key={log.id} style={{
backgroundColor: '#ffffff',
borderRadius: '8px',
padding: '10px 14px',
border: '1px solid #e2e8f0',
borderLeft: '4px solid #2563eb',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start'
}}>
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '4px' }}>
<span style={{ fontWeight: 700, fontSize: '0.85rem', color: '#0f172a' }}>
{log.authorName}
</span>
<span style={{ fontSize: '0.75rem', color: '#94a3b8' }}>
{dateStr}
</span>
</div>
<p style={{ margin: 0, fontSize: '0.875rem', color: '#334155', lineHeight: 1.4 }}>
{log.note}
</p>
</div>
<button
onClick={() => handleDeleteChangelogEntry(log.id)}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#ef4444', padding: '2px' }}
title="Verlaufeintrag löschen"
>
<Trash2 size={14} />
</button>
</div>
);
})}
</div>
) : (
<p style={{ color: '#94a3b8', fontSize: '0.8rem', margin: 0, fontStyle: 'italic' }}>
Noch keine Verlaufs-Notizen für diesen Auftrag eingetragen.
</p>
)}
</div>
)}
</div>
);
})}
</div>
) : (
<p style={{ color: '#94a3b8', fontSize: '0.85rem', fontStyle: 'italic' }}>
Noch keine Aufträge für diesen Kunden angelegt.
</p>
)}
</div>
{/* Installed Parts / Equipment Section */}
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '14px' }}>
<h3 style={{ fontSize: '1.05rem', fontWeight: 700, color: '#0f172a', margin: 0, display: 'flex', alignItems: 'center', gap: '8px' }}>
<Wrench size={18} color="#059669" />
Gerätekartei ({selectedCustomerParts.length})
</h3>
<button
onClick={() => {
setEditingPart(null);
setIsPartModalOpen(true);
}}
style={{
backgroundColor: '#ecfdf5',
color: '#059669',
border: '1px solid #a7f3d0',
padding: '6px 12px',
borderRadius: '8px',
fontSize: '0.85rem',
fontWeight: 600,
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: '4px'
}}
>
<Plus size={14} /> Gerät hinzufügen
</button>
</div>
{selectedCustomerParts.length > 0 ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
{selectedCustomerParts.map((part) => (
<div
key={part.id}
style={{
padding: '12px 16px',
borderRadius: '8px',
border: '1px solid #e2e8f0',
backgroundColor: '#ffffff',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center'
}}
>
<div>
<div style={{ fontWeight: 700, color: '#0f172a' }}>{part.name}</div>
<div style={{ fontSize: '0.85rem', color: '#64748b' }}>
Kategorie: {part.category} {part.serialNumber && `| S/N: ${part.serialNumber}`}
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
<span style={{ fontSize: '0.85rem', fontWeight: 600, color: '#475569' }}>
{part.quantity}x
</span>
<button
onClick={() => {
setEditingPart(part);
setIsPartModalOpen(true);
}}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#64748b' }}
>
<Edit3 size={16} />
</button>
<button
onClick={() => handleDeletePart(part)}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#ef4444' }}
>
<Trash2 size={16} />
</button>
</div>
</div>
))}
</div>
) : (
<p style={{ color: '#94a3b8', fontSize: '0.85rem' }}>
Keine verbauten Geräte registriert.
</p>
)}
</div>
</section>
)}
</div>
{/* Modals */}
<CustomerModal
isOpen={isCustomerModalOpen}
onClose={() => setIsCustomerModalOpen(false)}
onSave={handleSaveCustomer}
initialCustomer={editingCustomer}
/>
<WorkOrderModal
isOpen={isOrderModalOpen}
onClose={() => setIsOrderModalOpen(false)}
onSave={handleSaveOrder}
customers={customers}
initialOrder={editingOrder}
defaultCustomerId={selectedCustomer?.id}
/>
<InstalledPartModal
isOpen={isPartModalOpen}
onClose={() => setIsPartModalOpen(false)}
onSave={handleSavePart}
customers={customers}
initialPart={editingPart}
defaultCustomerId={selectedCustomer?.id}
/>
</>
);
};
+164 -21
View File
@@ -1,58 +1,201 @@
import React, { useEffect, useState } from 'react';
import { StatCard } from '../components/StatCard';
import {
StatCard
} from '../components/StatCard';
import { LiveOrdersTable } from '../components/LiveOrdersTable';
import { SystemStatusCard } from '../components/SystemStatusCard';
import { SupabaseService } from '../services/supabaseService';
import { ScheduleItem, SystemStatus, DashboardMetrics } from '../types';
import {
RefreshCw,
Plus,
Users,
Wrench,
CalendarDays,
TrendingUp,
CheckCircle2,
Activity,
ArrowUpRight,
Database
} from 'lucide-react';
export const DashboardView: React.FC = () => {
interface DashboardViewProps {
onNavigate?: (tab: string) => void;
}
export const DashboardView: React.FC<DashboardViewProps> = ({ onNavigate }) => {
const [metrics, setMetrics] = useState<DashboardMetrics | null>(null);
const [liveOrders, setLiveOrders] = useState<ScheduleItem[]>([]);
const [systemStatus, setSystemStatus] = useState<SystemStatus | null>(null);
const [loading, setLoading] = useState(true);
const [lastUpdated, setLastUpdated] = useState<string>('');
useEffect(() => {
const fetchData = async () => {
const metricsData = await SupabaseService.getMetrics();
const ordersData = await SupabaseService.getLiveOrders();
const statusData = await SupabaseService.testConnection();
const fetchData = async () => {
setLoading(true);
try {
const [metricsData, ordersData, statusData] = await Promise.all([
SupabaseService.getMetrics(),
SupabaseService.getLiveOrders(),
SupabaseService.testConnection()
]);
setMetrics(metricsData);
setLiveOrders(ordersData);
setSystemStatus(statusData);
};
setLastUpdated(new Date().toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', second: '2-digit' }));
} catch (err) {
console.error('Fehler beim Laden der Dashboard-Daten:', err);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchData();
const interval = setInterval(fetchData, 10000);
const interval = setInterval(fetchData, 12000);
return () => clearInterval(interval);
}, []);
return (
<>
<header className="header-bar">
<h1 className="header-title">Cockpit</h1>
<div className="supabase-live-badge">
<span className="status-dot"></span>
<span>Verbunden</span>
{/* Header Bar */}
<header className="header-bar" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: '16px' }}>
<div>
<h1 className="header-title" style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
Betriebs-Cockpit
<span style={{ fontSize: '0.85rem', fontWeight: 600, color: '#64748b', backgroundColor: '#f1f5f9', padding: '3px 10px', borderRadius: '12px' }}>
Handwerksfreund v0.1
</span>
</h1>
<p style={{ color: '#64748b', fontSize: '0.9rem', marginTop: '4px' }}>
Live-Übersicht aller Aufträge, Monteure und Supabase-Datenbankverbindung
</p>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '14px', 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' }}>
<span className="status-dot" style={{ backgroundColor: '#10b981', width: '8px', height: '8px', borderRadius: '50%' }}></span>
<span style={{ color: '#047857', fontWeight: 700, fontSize: '0.85rem' }}>
Supabase Live ({systemStatus?.latencyMs ?? 18}ms)
</span>
</div>
<button
onClick={fetchData}
style={{
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' : ''} />
{loading ? 'Lädt...' : `Sync: ${lastUpdated || 'jetzt'}`}
</button>
</div>
</header>
{/* Quick Action Navigation Buttons */}
<section style={{
display: 'flex',
gap: '12px',
flexWrap: 'wrap',
marginBottom: '10px'
}}>
{onNavigate && (
<>
<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
onClick={() => onNavigate('dispatching')}
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)'
}}
>
<CalendarDays size={16} color="#059669" /> Dispatcher & Kalender
</button>
<button
onClick={() => onNavigate('geraete')}
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)'
}}
>
<Wrench size={16} color="#d97706" /> Gerätekartei öffnen
</button>
</>
)}
</section>
{/* KPI Cards Grid (Echte Supabase Daten) */}
<section className="kpi-grid">
<StatCard label="Aktive Aufträge" value={metrics ? `${metrics.activeOrdersCount}` : '12'} />
<StatCard
label="Monteure im Einsatz"
value={metrics ? `${metrics.techniciansInField.active} / ${metrics.techniciansInField.total}` : '8 / 10'}
<StatCard
label="Aktive Aufträge"
value={metrics ? `${metrics.activeOrdersCount}` : '...'}
/>
<StatCard
label="Offene Rechnungen"
value={metrics ? `${metrics.openInvoicesAmount.toLocaleString('de-DE')}` : '€ 14.250'}
label="Monteure im Einsatz"
value={metrics ? `${metrics.techniciansInField.active} / ${metrics.techniciansInField.total}` : '...'}
/>
<StatCard
label="Geschätzter Auftragswert"
value={metrics ? `${metrics.openInvoicesAmount.toLocaleString('de-DE')}` : '...'}
/>
<StatCard
label="Lagerwarnungen"
value={metrics ? `${metrics.inventoryWarningsCount} Teile` : '2 Teile'}
value={metrics ? `${metrics.inventoryWarningsCount} Teile` : '...'}
isWarning={true}
/>
</section>
{/* Main Dashboard Section */}
<section className="dashboard-grid">
<LiveOrdersTable orders={liveOrders} />
<SystemStatusCard status={systemStatus} />
+678
View File
@@ -0,0 +1,678 @@
import React, { useState, useEffect, useRef } from 'react';
import {
Users,
Clock,
CalendarDays,
Plus,
CheckCircle2,
RefreshCw,
ChevronDown,
ChevronRight,
GripVertical,
GripHorizontal,
Info,
MapPin,
Wrench,
AlertCircle,
MoveHorizontal,
Minus,
Maximize2,
Calendar
} from 'lucide-react';
import { ScheduleItem, Technician, WorkOrder } from '../types';
import { SupabaseService } from '../services/supabaseService';
declare global {
interface Window {
__draggedUnassignedOrder?: WorkOrder | null;
__draggedScheduleItem?: ScheduleItem | null;
}
}
// Timeline Range: 06:00 to 20:00 (14 Hours = 840 Minutes)
const START_HOUR = 6;
const END_HOUR = 20;
const TOTAL_HOURS = END_HOUR - START_HOUR;
const START_MINUTES = START_HOUR * 60;
const TOTAL_MINUTES = TOTAL_HOURS * 60;
const HOURS_LIST = Array.from({ length: TOTAL_HOURS + 1 }, (_, i) => START_HOUR + i);
const timeToMinutes = (timeStr: string): number => {
if (!timeStr) return START_MINUTES;
const [h, m] = timeStr.split(':').map(Number);
return (h || 0) * 60 + (m || 0);
};
const minutesToTimeStr = (totalMins: number): string => {
const clamped = Math.max(START_MINUTES, Math.min(END_HOUR * 60, totalMins));
const h = Math.floor(clamped / 60);
const m = Math.round((clamped % 60) / 15) * 15; // Snap to 15 min intervals
const finalH = m === 60 ? h + 1 : h;
const finalM = m === 60 ? 0 : m;
return `${finalH.toString().padStart(2, '0')}:${finalM.toString().padStart(2, '0')}`;
};
export const DispatchingInteractiveView: React.FC = () => {
const [technicians, setTechnicians] = useState<Technician[]>([]);
const [scheduleItems, setScheduleItems] = useState<ScheduleItem[]>([]);
const [unassignedOrders, setUnassignedOrders] = useState<WorkOrder[]>([]);
const [loading, setLoading] = useState(true);
// Expand/collapse technician rows
const [expandedTechs, setExpandedTechs] = useState<Record<string, boolean>>({});
// Active Dragged Item state
const [draggedUnassignedOrder, setDraggedUnassignedOrder] = useState<WorkOrder | null>(null);
const [draggedScheduleItem, setDraggedScheduleItem] = useState<ScheduleItem | null>(null);
// Quick Assign Dropdown for Unassigned Orders
const [quickAssignOrderId, setQuickAssignOrderId] = useState<string | null>(null);
const [toastMessage, setToastMessage] = useState<string | null>(null);
const showNotification = (msg: string) => {
setToastMessage(msg);
setTimeout(() => setToastMessage(null), 4000);
};
const loadData = async () => {
setLoading(true);
try {
const [techsData, liveData, ordersData] = await Promise.all([
SupabaseService.getTechnicians(),
SupabaseService.getLiveOrders(),
SupabaseService.getWorkOrders()
]);
setTechnicians(techsData);
setScheduleItems(liveData);
// Default expand all technicians
const initExpanded: Record<string, boolean> = {};
techsData.forEach(t => { initExpanded[t.id] = true; });
setExpandedTechs(initExpanded);
// Filter unassigned orders (status != abgeschlossen and not yet in schedule)
const scheduledOrderTitles = new Set(liveData.map(l => l.title.toLowerCase()));
const unassigned = ordersData.filter(o => o.status !== 'abgeschlossen' && !scheduledOrderTitles.has(o.title.toLowerCase()));
setUnassignedOrders(unassigned);
} catch (err) {
console.error('Fehler beim Laden der Timeline-Daten:', err);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadData();
}, []);
const toggleExpand = (techId: string) => {
setExpandedTechs(prev => ({ ...prev, [techId]: !prev[techId] }));
};
// Drag over timeline track
const handleTimelineDragOver = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
e.dataTransfer.dropEffect = 'copy';
};
// Drop on technician timeline row
const handleDropOnTimeline = async (e: React.DragEvent, technician: Technician) => {
e.preventDefault();
e.stopPropagation();
let droppedUnassigned = window.__draggedUnassignedOrder || draggedUnassignedOrder;
let droppedSchedule = window.__draggedScheduleItem || draggedScheduleItem;
// Try parsing HTML5 dataTransfer payload if state was cleared
try {
const orderId = e.dataTransfer.getData('text/plain');
if (orderId) {
const foundUnassigned = unassignedOrders.find(o => o.id === orderId);
if (foundUnassigned) droppedUnassigned = foundUnassigned;
const foundSchedule = scheduleItems.find(s => s.id === orderId);
if (foundSchedule) droppedSchedule = foundSchedule;
}
} catch (err) {
// Ignore fallback
}
const rect = e.currentTarget.getBoundingClientRect();
const clickX = e.clientX - rect.left;
const ratio = Math.max(0, Math.min(1, clickX / rect.width));
const droppedMins = START_MINUTES + ratio * TOTAL_MINUTES;
const startTime = minutesToTimeStr(droppedMins);
if (droppedUnassigned) {
const endTime = minutesToTimeStr(droppedMins + 120); // Default 2 hours duration
const newItem: Omit<ScheduleItem, 'id'> = {
title: droppedUnassigned.title,
customerName: droppedUnassigned.customerName || 'Kunde',
technicianName: technician.name,
technicianId: technician.id,
orderId: droppedUnassigned.id,
address: 'Kundensitz',
taskDescription: droppedUnassigned.description || 'Einsatz Vor-Ort',
startTime,
endTime,
type: 'job',
status: 'In Bearbeitung',
isInProgress: true,
isCompleted: false
};
const created = await SupabaseService.createScheduleItem(newItem);
setScheduleItems(prev => [...prev, created]);
setUnassignedOrders(prev => prev.filter(o => o.id !== droppedUnassigned!.id));
showNotification(`Auftrag "${droppedUnassigned.title}" für ${technician.name} um ${startTime} Uhr eingeplant!`);
} else if (droppedSchedule) {
const currentDuration = timeToMinutes(droppedSchedule.endTime) - timeToMinutes(droppedSchedule.startTime);
const newEndTime = minutesToTimeStr(droppedMins + (currentDuration > 15 ? currentDuration : 120));
const updated: ScheduleItem = {
...droppedSchedule,
technicianName: technician.name,
technicianId: technician.id,
startTime,
endTime: newEndTime
};
await SupabaseService.updateScheduleItem(droppedSchedule.id, updated);
setScheduleItems(prev => prev.map(i => i.id === droppedSchedule!.id ? updated : i));
showNotification(`Auftrag "${droppedSchedule.title}" auf ${technician.name} (${startTime} Uhr) verschoben.`);
}
// Reset drag state
window.__draggedUnassignedOrder = null;
window.__draggedScheduleItem = null;
setDraggedUnassignedOrder(null);
setDraggedScheduleItem(null);
};
// Quick Assign Unassigned Order to Technician directly via Click
const handleQuickAssign = async (ord: WorkOrder, tech: Technician, timeStr: string = '09:00') => {
const startTime = timeStr;
const endTime = minutesToTimeStr(timeToMinutes(timeStr) + 120);
const newItem: Omit<ScheduleItem, 'id'> = {
title: ord.title,
customerName: ord.customerName || 'Kunde',
technicianName: tech.name,
technicianId: tech.id,
orderId: ord.id,
address: 'Kundensitz',
taskDescription: ord.description || 'Einsatz Vor-Ort',
startTime,
endTime,
type: 'job',
status: 'In Bearbeitung',
isInProgress: true,
isCompleted: false
};
const created = await SupabaseService.createScheduleItem(newItem);
setScheduleItems(prev => [...prev, created]);
setUnassignedOrders(prev => prev.filter(o => o.id !== ord.id));
setQuickAssignOrderId(null);
showNotification(`Auftrag "${ord.title}" für ${tech.name} um ${startTime} Uhr eingeplant!`);
};
// Mouse Drag-to-Resize Handle Handler (2 kleine Punkte an den Rändern)
const handleStartResize = (e: React.MouseEvent, item: ScheduleItem, type: 'start' | 'end') => {
e.stopPropagation();
e.preventDefault();
const startX = e.clientX;
const initialStartMins = timeToMinutes(item.startTime);
const initialEndMins = timeToMinutes(item.endTime);
// Track active resizing item state synchronously in ref
const currentResizingRef = {
id: item.id,
startTime: item.startTime,
endTime: item.endTime
};
const trackElem = (e.currentTarget.closest('.timeline-track') as HTMLElement);
const trackWidth = trackElem ? trackElem.getBoundingClientRect().width : 800;
const onMouseMove = (moveEvt: MouseEvent) => {
const deltaX = moveEvt.clientX - startX;
const deltaMins = (deltaX / trackWidth) * TOTAL_MINUTES;
if (type === 'start') {
const newStartMins = Math.max(START_MINUTES, Math.min(initialEndMins - 15, initialStartMins + deltaMins));
const newStartStr = minutesToTimeStr(newStartMins);
currentResizingRef.startTime = newStartStr;
setScheduleItems(prev => prev.map(i => i.id === item.id ? { ...i, startTime: newStartStr } : i));
} else {
const newEndMins = Math.max(initialStartMins + 15, Math.min(END_HOUR * 60, initialEndMins + deltaMins));
const newEndStr = minutesToTimeStr(newEndMins);
currentResizingRef.endTime = newEndStr;
setScheduleItems(prev => prev.map(i => i.id === item.id ? { ...i, endTime: newEndStr } : i));
}
};
const onMouseUp = async () => {
window.removeEventListener('mousemove', onMouseMove);
window.removeEventListener('mouseup', onMouseUp);
// Persist updated start & end times cleanly in Supabase!
const targetItem = scheduleItems.find(i => i.id === item.id) || item;
const updatedPayload = {
...targetItem,
startTime: currentResizingRef.startTime,
endTime: currentResizingRef.endTime
};
try {
await SupabaseService.updateScheduleItem(item.id, updatedPayload);
showNotification(`Dauer von "${item.title}" gespeichert: ${currentResizingRef.startTime} - ${currentResizingRef.endTime} Uhr`);
} catch (err) {
console.error('Fehler beim Speichern der neuen Zeit:', err);
}
};
window.addEventListener('mousemove', onMouseMove);
window.addEventListener('mouseup', onMouseUp);
};
return (
<>
{/* Toast Notification */}
{toastMessage && (
<div style={{
position: 'fixed',
bottom: '24px',
right: '24px',
backgroundColor: '#0f172a',
color: '#ffffff',
padding: '14px 20px',
borderRadius: '10px',
boxShadow: '0 10px 25px -5px rgba(0, 0, 0, 0.3)',
display: 'flex',
alignItems: 'center',
gap: '12px',
zIndex: 200,
fontSize: '0.95rem',
fontWeight: 600
}}>
<CheckCircle2 size={20} color="#10b981" />
<span>{toastMessage}</span>
</div>
)}
{/* Main Layout Grid */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 340px', gap: '24px' }}>
{/* Left / Center: Interactive Timeline Matrix */}
<div>
{/* Timeline Header Row (Hours 06:00 - 20:00) */}
<div style={{
backgroundColor: '#0f172a',
color: '#ffffff',
borderRadius: '12px 12px 0 0',
padding: '14px 16px',
display: 'grid',
gridTemplateColumns: '220px 1fr',
alignItems: 'center',
boxShadow: '0 2px 4px rgba(0,0,0,0.1)'
}}>
<div style={{ fontWeight: 700, fontSize: '0.9rem', color: '#94a3b8' }}>
MONTEUR / TECHNIKER
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', position: 'relative' }}>
{HOURS_LIST.map((h) => (
<div key={h} style={{ fontSize: '0.78rem', fontWeight: 700, color: '#cbd5e1', textAlign: 'center', flex: 1 }}>
{h.toString().padStart(2, '0')}:00
</div>
))}
</div>
</div>
{/* Technicians Timeline Rows */}
<div style={{
backgroundColor: '#ffffff',
border: '1px solid #cbd5e1',
borderTop: 'none',
borderRadius: '0 0 12px 12px',
overflow: 'hidden'
}}>
{technicians.map((tech) => {
const isExpanded = Boolean(expandedTechs[tech.id]);
const techItems = scheduleItems.filter(i =>
i.technicianId === tech.id ||
(i.technicianName && i.technicianName.toLowerCase().includes(tech.name.toLowerCase()))
);
return (
<div key={tech.id} style={{ borderBottom: '1px solid #e2e8f0' }}>
{/* Technician Row Header */}
<div style={{
padding: '14px 16px',
display: 'grid',
gridTemplateColumns: '220px 1fr',
alignItems: 'center',
backgroundColor: isExpanded ? '#ffffff' : '#f8fafc'
}}>
{/* Left: Tech Info */}
<div
onClick={() => toggleExpand(tech.id)}
style={{ display: 'flex', alignItems: 'center', gap: '10px', cursor: 'pointer' }}
>
<button style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#64748b', padding: 0 }}>
{isExpanded ? <ChevronDown size={18} /> : <ChevronRight size={18} />}
</button>
<div>
<div style={{ fontWeight: 700, color: '#0f172a', fontSize: '0.95rem' }}>
{tech.name}
</div>
<div style={{ fontSize: '0.78rem', color: '#64748b', display: 'flex', alignItems: 'center', gap: '4px' }}>
<span style={{
width: '8px',
height: '8px',
borderRadius: '50%',
backgroundColor: tech.status === 'imEinsatz' ? '#10b981' : '#3b82f6'
}} />
{tech.role || 'Techniker'} ({techItems.length} Aufträge)
</div>
</div>
</div>
{/* Right: Interactive Timeline Track */}
{isExpanded ? (
<div
className="timeline-track"
onDragOver={handleTimelineDragOver}
onDrop={(e) => handleDropOnTimeline(e, tech)}
style={{
height: '68px',
backgroundColor: '#f8fafc',
border: '1px dashed #cbd5e1',
borderRadius: '8px',
position: 'relative',
overflow: 'visible',
display: 'flex',
alignItems: 'center'
}}
>
{/* Hour Grid Lines */}
{HOURS_LIST.map((h, idx) => (
<div key={h} style={{
position: 'absolute',
left: `${(idx / TOTAL_HOURS) * 100}%`,
top: 0,
bottom: 0,
borderLeft: '1px solid #e2e8f0',
zIndex: 1,
pointerEvents: 'none'
}} />
))}
{/* Scheduled Job Blocks */}
{techItems.map((item) => {
const startMins = Math.max(START_MINUTES, Math.min(END_HOUR * 60, timeToMinutes(item.startTime)));
const endMins = Math.max(startMins + 15, Math.min(END_HOUR * 60, timeToMinutes(item.endTime)));
const leftPct = ((startMins - START_MINUTES) / TOTAL_MINUTES) * 100;
const widthPct = Math.max(5, ((endMins - startMins) / TOTAL_MINUTES) * 100);
return (
<div
key={item.id}
draggable={true}
onDragStart={(e) => {
e.dataTransfer.setData('text/plain', item.id);
e.dataTransfer.effectAllowed = 'copyMove';
window.__draggedScheduleItem = item;
setDraggedScheduleItem(item);
}}
style={{
position: 'absolute',
left: `${leftPct}%`,
width: `${widthPct}%`,
top: '8px',
bottom: '8px',
backgroundColor: item.isInProgress ? '#2563eb' : item.isCompleted ? '#10b981' : '#0284c7',
color: '#ffffff',
borderRadius: '8px',
padding: '4px 10px',
zIndex: 10,
cursor: 'grab',
boxShadow: '0 3px 6px rgba(0,0,0,0.15)',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
transition: 'background-color 0.15s ease',
border: '1px solid rgba(255,255,255,0.3)'
}}
title={`${item.title} (${item.startTime} - ${item.endTime}) • Ziehen an den Rändern zum Resizen`}
>
{/* Left Resize Handle Dot (Start-Zeit verschieben) */}
<div
onMouseDown={(e) => handleStartResize(e, item, 'start')}
style={{
position: 'absolute',
left: '-6px',
top: '50%',
transform: 'translateY(-50%)',
width: '14px',
height: '14px',
borderRadius: '50%',
backgroundColor: '#ffffff',
border: '2px solid #2563eb',
cursor: 'ew-resize',
zIndex: 25,
boxShadow: '0 2px 5px rgba(0,0,0,0.25)'
}}
title="Linken Punkt ziehen ➔ Startzeit im 15-Min-Raster anpassen"
/>
{/* Block Content */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', fontSize: '0.75rem', fontWeight: 800, pointerEvents: 'none' }}>
<span style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{item.title}
</span>
<span style={{ fontSize: '0.7rem', opacity: 0.9, backgroundColor: 'rgba(0,0,0,0.25)', padding: '1px 5px', borderRadius: '4px' }}>
{item.startTime}-{item.endTime}
</span>
</div>
<div style={{ fontSize: '0.7rem', opacity: 0.85, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', pointerEvents: 'none' }}>
👤 {item.customerName}
</div>
{/* Right Resize Handle Dot (End-Zeit / Dauer verändern) */}
<div
onMouseDown={(e) => handleStartResize(e, item, 'end')}
style={{
position: 'absolute',
right: '-6px',
top: '50%',
transform: 'translateY(-50%)',
width: '14px',
height: '14px',
borderRadius: '50%',
backgroundColor: '#ffffff',
border: '2px solid #10b981',
cursor: 'ew-resize',
zIndex: 25,
boxShadow: '0 2px 5px rgba(0,0,0,0.25)'
}}
title="Rechten Punkt ziehen ➔ Dauer/Endzeit im 15-Min-Raster anpassen"
/>
</div>
);
})}
</div>
) : (
<div style={{ color: '#94a3b8', fontSize: '0.85rem', fontStyle: 'italic', paddingLeft: '8px' }}>
Zeitleiste eingeklappt.
</div>
)}
</div>
</div>
);
})}
</div>
</div>
{/* Right Sidebar: Unassigned Orders Pool (Drag Pool) */}
<div style={{
backgroundColor: '#ffffff',
borderRadius: '12px',
border: '1px solid #cbd5e1',
padding: '20px',
display: 'flex',
flexDirection: 'column',
boxShadow: '0 2px 4px rgba(0,0,0,0.03)',
height: 'fit-content'
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '14px' }}>
<h3 style={{ fontSize: '1.05rem', fontWeight: 700, color: '#0f172a', margin: 0, display: 'flex', alignItems: 'center', gap: '8px' }}>
<Clock size={18} color="#d97706" />
Offene Tagesaufträge ({unassignedOrders.length})
</h3>
</div>
<div style={{
backgroundColor: '#eff6ff',
border: '1px solid #bfdbfe',
borderRadius: '8px',
padding: '10px 12px',
fontSize: '0.8rem',
color: '#1e40af',
marginBottom: '16px',
lineHeight: 1.4
}}>
🎯 <strong>Zwei Wege zum Einplanen:</strong>
<ul style={{ margin: '4px 0 0 16px', padding: 0 }}>
<li><strong>Drag & Drop:</strong> Auftrag auf die Zeitleiste ziehen.</li>
<li><strong>Schnell-Button:</strong> Klicke <em>"+ Einplanen"</em> für 1-Klick Zuweisung.</li>
</ul>
</div>
{unassignedOrders.length > 0 ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
{unassignedOrders.map((ord) => {
const isQuickAssignOpen = quickAssignOrderId === ord.id;
return (
<div
key={ord.id}
draggable={true}
onDragStart={(e) => {
e.dataTransfer.setData('text/plain', ord.id);
e.dataTransfer.effectAllowed = 'copyMove';
window.__draggedUnassignedOrder = ord;
setDraggedUnassignedOrder(ord);
}}
style={{
backgroundColor: '#f8fafc',
border: '1px solid #cbd5e1',
borderRadius: '10px',
padding: '12px',
cursor: 'grab',
transition: 'all 0.15s ease',
boxShadow: '0 1px 3px rgba(0,0,0,0.04)'
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '4px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<GripVertical size={16} color="#94a3b8" />
<span style={{ fontWeight: 700, color: '#0f172a', fontSize: '0.9rem' }}>
{ord.title}
</span>
</div>
<button
onClick={() => setQuickAssignOrderId(isQuickAssignOpen ? null : ord.id)}
style={{
backgroundColor: '#2563eb',
color: '#ffffff',
border: 'none',
padding: '3px 8px',
borderRadius: '6px',
fontSize: '0.75rem',
fontWeight: 700,
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: '3px'
}}
>
<Plus size={12} /> {isQuickAssignOpen ? 'Zu' : 'Einplanen'}
</button>
</div>
<div style={{ fontSize: '0.82rem', color: '#475569', marginLeft: '22px', marginBottom: '6px' }}>
👤 {ord.customerName || 'Kunde'}
</div>
{/* Quick Assign Dropdown Drawer */}
{isQuickAssignOpen && (
<div style={{
marginTop: '10px',
padding: '10px',
backgroundColor: '#ffffff',
border: '1px solid #bfdbfe',
borderRadius: '8px'
}}>
<div style={{ fontSize: '0.78rem', fontWeight: 700, color: '#1e40af', marginBottom: '6px' }}>
Monteur für diesen Auftrag wählen:
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
{technicians.map((t) => (
<button
key={t.id}
onClick={() => handleQuickAssign(ord, t, '09:00')}
style={{
backgroundColor: '#f1f5f9',
color: '#0f172a',
border: '1px solid #cbd5e1',
padding: '5px 8px',
borderRadius: '6px',
fontSize: '0.78rem',
fontWeight: 600,
textAlign: 'left',
cursor: 'pointer',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center'
}}
>
<span>👤 {t.name}</span>
<span style={{ color: '#2563eb', fontSize: '0.72rem' }}>ab 09:00 Uhr </span>
</button>
))}
</div>
</div>
)}
</div>
);
})}
</div>
) : (
<div style={{
padding: '24px',
textAlign: 'center',
backgroundColor: '#f8fafc',
borderRadius: '10px',
border: '1px dashed #cbd5e1'
}}>
<CheckCircle2 size={32} color="#10b981" style={{ margin: '0 auto 8px auto' }} />
<p style={{ margin: 0, fontSize: '0.85rem', fontWeight: 600, color: '#0f172a' }}>
Alle Aufträge sind eingepflegt!
</p>
</div>
)}
</div>
</div>
</>
);
};
+260 -31
View File
@@ -1,38 +1,202 @@
import React from 'react';
import { Plus, UserCheck } from 'lucide-react';
import React, { useState, useEffect } from 'react';
import { Plus, UserCheck, RefreshCw, Trash2, Edit3, CheckCircle, Clock, CalendarDays, MoveHorizontal } from 'lucide-react';
import { ScheduleItem } from '../types';
import { SupabaseService } from '../services/supabaseService';
import { ScheduleItemModal } from '../components/ScheduleItemModal';
import { DispatchingInteractiveView } from './DispatchingInteractiveView';
export const DispatchingView: React.FC = () => {
const scheduleData = [
{ time: '08:00 - 10:00', technician: 'Max Müller', customer: 'Max Mustermann', task: 'Wartung Heizungsanlage', status: 'In Bearbeitung' },
{ time: '10:30 - 12:30', technician: 'Stefan Bauer', customer: 'Anja Klein', task: 'Thermostat Einstellung & Kalibrierung', status: 'Geplant' },
{ time: '13:00 - 15:00', technician: 'Tom Gerber', customer: 'Hausverwaltung Schmidt', task: 'Sanitär-Rohrreinigung', status: 'In Bearbeitung' },
{ time: '15:30 - 17:30', technician: 'Max Müller', customer: 'Bäckerei Hoffmann GmbH', task: 'Austausch Umwälzpumpe', status: 'Geplant' },
];
const [scheduleData, setScheduleData] = useState<ScheduleItem[]>([]);
const [loading, setLoading] = useState(true);
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingItem, setEditingItem] = useState<ScheduleItem | null>(null);
const [toastMessage, setToastMessage] = useState<string | null>(null);
// View Mode Switcher: 'list' (Standard Tabelle) vs 'interactive' (Drag & Drop Timeline)
const [viewMode, setViewMode] = useState<'list' | 'interactive'>('interactive');
const showNotification = (msg: string) => {
setToastMessage(msg);
setTimeout(() => setToastMessage(null), 4000);
};
const loadData = async () => {
setLoading(true);
try {
const items = await SupabaseService.getScheduleItems();
setScheduleData(items);
} catch (err) {
console.error('Fehler beim Laden des Tagesplans:', err);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadData();
}, []);
const handleSaveItem = async (data: Omit<ScheduleItem, 'id'> | ScheduleItem) => {
if ('id' in data) {
const updated = await SupabaseService.updateScheduleItem(data.id, data);
showNotification(`Einsatz "${updated.title}" aktualisiert.`);
} else {
const created = await SupabaseService.createScheduleItem(data);
showNotification(`Neuer Einsatz "${created.title}" in Supabase eingeplant.`);
}
await loadData();
};
const handleStatusToggle = async (item: ScheduleItem) => {
let nextStatus: 'Geplant' | 'In Bearbeitung' | 'Abgeschlossen' = 'In Bearbeitung';
if (item.status === 'In Bearbeitung') nextStatus = 'Abgeschlossen';
else if (item.status === 'Abgeschlossen') nextStatus = 'Geplant';
await SupabaseService.updateScheduleItem(item.id, { status: nextStatus });
showNotification(`Status von "${item.title}" geändert auf ${nextStatus}.`);
await loadData();
};
const handleDeleteItem = async (item: ScheduleItem) => {
if (window.confirm(`Einsatz "${item.title}" aus dem Tagesplan löschen?`)) {
await SupabaseService.deleteScheduleItem(item.id);
showNotification(`Einsatz "${item.title}" gelöscht.`);
await loadData();
}
};
return (
<>
<header className="header-bar">
<h1 className="header-title">Dispatching & Auftragsdisposition</h1>
<button style={{
backgroundColor: '#2563eb',
{/* Toast Notification */}
{toastMessage && (
<div style={{
position: 'fixed',
bottom: '24px',
right: '24px',
backgroundColor: '#0f172a',
color: '#ffffff',
border: 'none',
padding: '10px 18px',
padding: '14px 20px',
borderRadius: '10px',
fontWeight: 600,
boxShadow: '0 10px 25px -5px rgba(0, 0, 0, 0.3)',
display: 'flex',
alignItems: 'center',
gap: '8px',
cursor: 'pointer'
gap: '12px',
zIndex: 200,
fontSize: '0.95rem',
fontWeight: 600
}}>
<Plus size={18} /> Neuer Einsatz
</button>
<CheckCircle size={20} color="#10b981" />
<span>{toastMessage}</span>
</div>
)}
{/* Header Bar */}
<header className="header-bar" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: '16px' }}>
<div>
<h1 className="header-title">Dispatching & Auftragsdisposition</h1>
<p style={{ color: '#64748b', fontSize: '0.9rem', marginTop: '4px' }}>
Interaktives Timeline-Board mit Drag & Drop und Zeitraster (06:00 - 20:00 Uhr)
</p>
</div>
<div style={{ display: 'flex', gap: '12px' }}>
<button
onClick={loadData}
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '10px 16px',
backgroundColor: '#ffffff',
border: '1px solid #cbd5e1',
borderRadius: '10px',
color: '#475569',
fontWeight: 600,
cursor: 'pointer'
}}
>
<RefreshCw size={16} className={loading ? 'spin' : ''} />
{loading ? 'Lädt...' : 'Aktualisieren'}
</button>
<button
onClick={() => {
setEditingItem(null);
setIsModalOpen(true);
}}
style={{
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
</button>
</div>
</header>
{/* View Switcher Tabs */}
<div style={{
display: 'flex',
borderBottom: '2px solid #e2e8f0',
marginBottom: '20px',
gap: '24px'
}}>
<button
onClick={() => setViewMode('interactive')}
style={{
padding: '12px 4px',
border: 'none',
borderBottom: viewMode === 'interactive' ? '3px solid #2563eb' : '3px solid transparent',
backgroundColor: 'transparent',
color: viewMode === 'interactive' ? '#2563eb' : '#64748b',
fontWeight: 700,
fontSize: '1rem',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: '8px'
}}
>
<MoveHorizontal size={18} /> Interaktives Timeline-Board (6:00 - 20:00 Uhr)
</button>
<button
onClick={() => setViewMode('list')}
style={{
padding: '12px 4px',
border: 'none',
borderBottom: viewMode === 'list' ? '3px solid #2563eb' : '3px solid transparent',
backgroundColor: 'transparent',
color: viewMode === 'list' ? '#2563eb' : '#64748b',
fontWeight: 700,
fontSize: '1rem',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: '8px'
}}
>
<CalendarDays size={18} /> Tabellarische Liste (Standard)
</button>
</div>
{/* Content Rendering based on selected view mode */}
{viewMode === 'interactive' ? (
<DispatchingInteractiveView />
) : (
<section className="content-card">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px', flexWrap: 'wrap', gap: '10px' }}>
<h2 className="card-heading" style={{ margin: 0 }}>Tagesdisposition (Heute)</h2>
<span style={{ fontSize: '0.9rem', color: '#64748b', fontWeight: 600 }}>10 Monteure im System</span>
<h2 className="card-heading" style={{ margin: 0 }}>Tagesdisposition ({scheduleData.length} Einsätze)</h2>
<span style={{ fontSize: '0.9rem', color: '#64748b', fontWeight: 600 }}>Supabase Sync aktiv</span>
</div>
<div className="table-responsive">
@@ -41,34 +205,99 @@ export const DispatchingView: React.FC = () => {
<tr>
<th>Uhrzeit</th>
<th>Monteur</th>
<th>Kunde</th>
<th>Kunde & Adresse</th>
<th>Aufgabe / Tätigkeit</th>
<th>Status</th>
<th style={{ textAlign: 'right' }}>Aktionen</th>
</tr>
</thead>
<tbody>
{scheduleData.map((item, idx) => (
<tr key={idx}>
<td style={{ fontWeight: 600, color: '#2563eb' }}>{item.time}</td>
{scheduleData.map((item) => (
<tr key={item.id}>
<td style={{ fontWeight: 600, color: '#2563eb', whiteSpace: 'nowrap' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<Clock size={16} />
{item.startTime} - {item.endTime}
</div>
</td>
<td style={{ fontWeight: 600, color: '#0f172a' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<UserCheck size={16} color="#64748b" />
{item.technician}
{item.technicianName || 'Monteur'}
</div>
</td>
<td>{item.customer}</td>
<td style={{ color: '#475569' }}>{item.task}</td>
<td>
<span className={`badge ${item.status === 'In Bearbeitung' ? 'badge-in-progress' : 'badge-scheduled'}`}>
{item.status}
</span>
<div style={{ fontWeight: 600 }}>{item.customerName}</div>
{item.address && <div style={{ fontSize: '0.8rem', color: '#64748b' }}>{item.address}</div>}
</td>
<td>
<div style={{ fontWeight: 600, color: '#0f172a' }}>{item.title}</div>
{item.taskDescription && <div style={{ fontSize: '0.85rem', color: '#475569' }}>{item.taskDescription}</div>}
</td>
<td>
<button
onClick={() => handleStatusToggle(item)}
style={{ border: 'none', background: 'none', cursor: 'pointer', padding: 0 }}
title="Klicken zum Ändern des Status"
>
<span className={`badge ${
item.status === 'Abgeschlossen'
? 'badge-in-progress'
: item.status === 'In Bearbeitung'
? 'badge-scheduled'
: ''
}`} style={{
backgroundColor: item.status === 'Abgeschlossen' ? '#dcfce7' : item.status === 'In Bearbeitung' ? '#dbeafe' : '#fef3c7',
color: item.status === 'Abgeschlossen' ? '#15803d' : item.status === 'In Bearbeitung' ? '#1d4ed8' : '#b45309'
}}>
{item.status || 'Geplant'} 🔄
</span>
</button>
</td>
<td style={{ textAlign: 'right' }}>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '8px' }}>
<button
onClick={() => {
setEditingItem(item);
setIsModalOpen(true);
}}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#64748b' }}
title="Einsatz bearbeiten"
>
<Edit3 size={18} />
</button>
<button
onClick={() => handleDeleteItem(item)}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#ef4444' }}
title="Einsatz löschen"
>
<Trash2 size={18} />
</button>
</div>
</td>
</tr>
))}
{scheduleData.length === 0 && (
<tr>
<td colSpan={6} style={{ textAlign: 'center', padding: '30px', color: '#64748b' }}>
Keine Einsätze für heute vorhanden.
</td>
</tr>
)}
</tbody>
</table>
</div>
</section>
)}
<ScheduleItemModal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
onSave={handleSaveItem}
initialItem={editingItem}
/>
</>
);
};
+813
View File
@@ -0,0 +1,813 @@
import React, { useState, useEffect, useRef } from 'react';
import {
Wrench,
Plus,
Search,
RefreshCw,
Edit3,
Trash2,
FileText,
Paperclip,
Upload,
Building2,
Tag,
Hash,
User,
CheckCircle,
FileCode,
Image as ImageIcon,
ChevronDown,
ChevronUp,
Layers,
Users,
FolderPlus,
ExternalLink,
UploadCloud,
X
} from 'lucide-react';
import { InstalledPart, PartDocument, Customer } from '../types';
import { SupabaseService } from '../services/supabaseService';
import { InstalledPartModal } from '../components/InstalledPartModal';
import { DocumentViewerModal } from '../components/DocumentViewerModal';
export const EquipmentView: React.FC = () => {
const [parts, setParts] = useState<InstalledPart[]>([]);
const [customers, setCustomers] = useState<Customer[]>([]);
const [partDocsMap, setPartDocsMap] = useState<Record<string, PartDocument[]>>({});
const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState('');
// Document Viewer Modal State
const [viewerDoc, setViewerDoc] = useState<PartDocument | null>(null);
// View sub-tab: 'catalog' (Masterliste Produkte) vs 'installations' (Alle Kunden-Einbauten)
const [subTab, setSubTab] = useState<'catalog' | 'installations'>('catalog');
// Grouping mode: 'category' or 'manufacturer'
const [groupBy, setGroupBy] = useState<'category' | 'manufacturer'>('manufacturer');
// Modals & Upload state
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingPart, setEditingPart] = useState<InstalledPart | null>(null);
// Active Dropzone Upload State
const [activeUploadPartId, setActiveUploadPartId] = useState<string | null>(null);
const [isDraggingOver, setIsDraggingOver] = useState(false);
const [isUploading, setIsUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement | null>(null);
// Collapsed sections
const [collapsedGroups, setCollapsedGroups] = useState<Record<string, boolean>>({});
const [toastMessage, setToastMessage] = useState<string | null>(null);
const showNotification = (msg: string) => {
setToastMessage(msg);
setTimeout(() => setToastMessage(null), 4000);
};
const loadData = async () => {
setLoading(true);
try {
const [partsData, customersData] = await Promise.all([
SupabaseService.getInstalledParts(),
SupabaseService.getCustomers()
]);
setParts(partsData);
setCustomers(customersData);
// Load documents for each part
const docsMap: Record<string, PartDocument[]> = {};
await Promise.all(
partsData.map(async (p) => {
const docs = await SupabaseService.getPartDocuments(p.id);
docsMap[p.id] = docs;
})
);
setPartDocsMap(docsMap);
} catch (err) {
console.error('Fehler beim Laden der Gerätekartei:', err);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadData();
}, []);
const handleSavePart = async (data: Omit<InstalledPart, 'id'> | InstalledPart) => {
if ('id' in data) {
const updated = await SupabaseService.updateInstalledPart(data.id, data);
showNotification(`Produkt "${updated.name}" in Masterliste aktualisiert.`);
} else {
const created = await SupabaseService.createInstalledPart(data);
showNotification(`Neues Produkt "${created.name}" in Masterliste angelegt.`);
}
await loadData();
};
const handleDeletePart = async (part: InstalledPart) => {
if (window.confirm(`Produkt "${part.name}" wirklich aus der Masterliste löschen?`)) {
await SupabaseService.deleteInstalledPart(part.id);
showNotification(`Produkt "${part.name}" gelöscht.`);
await loadData();
}
};
// Real File Upload Handler (via File Picker or Drag & Drop)
const handleFileUpload = async (partId: string, files: FileList | File[]) => {
if (!files || files.length === 0) return;
setIsUploading(true);
try {
for (let i = 0; i < files.length; i++) {
const file = files[i];
const cleanFileName = `${Date.now()}_${file.name.replace(/\s+/g, '_')}`;
const storagePath = `parts/${partId}/${cleanFileName}`;
// 1. Upload to Supabase Storage Bucket 'equipment-files'
const publicUrl = await SupabaseService.uploadFileToStorage('equipment-files', storagePath, file);
// 2. Save metadata entry in database table 'installed_part_documents'
await SupabaseService.createPartDocument({
partId,
title: file.name,
filePath: publicUrl || storagePath,
fileSizeBytes: file.size,
mimeType: file.type || 'application/octet-stream'
});
}
showNotification(`${files.length} Datei(en) erfolgreich hochgeladen und in Supabase Storage gespeichert!`);
setActiveUploadPartId(null);
await loadData();
} catch (err: any) {
console.error('Fehler beim Dateiupload:', err);
showNotification(`Upload-Fehler: ${err.message || 'Fehler beim Hochladen'}`);
} finally {
setIsUploading(false);
setIsDraggingOver(false);
}
};
const handleDeleteDocument = async (doc: PartDocument) => {
if (window.confirm(`Dokument "${doc.title}" wirklich löschen?`)) {
await SupabaseService.deletePartDocument(doc.id);
showNotification(`Dokument "${doc.title}" gelöscht.`);
await loadData();
}
};
// Filtered Parts
const filteredParts = parts.filter(p =>
p.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
(p.manufacturer && p.manufacturer.toLowerCase().includes(searchQuery.toLowerCase())) ||
p.category.toLowerCase().includes(searchQuery.toLowerCase()) ||
(p.serialNumber && p.serialNumber.toLowerCase().includes(searchQuery.toLowerCase())) ||
(p.customerName && p.customerName.toLowerCase().includes(searchQuery.toLowerCase()))
);
// Derive Master Catalog Items (unique by model name + manufacturer)
const masterCatalogMap: Record<string, { modelName: string; manufacturer: string; category: string; installations: InstalledPart[] }> = {};
filteredParts.forEach(p => {
const key = `${p.manufacturer || 'Sonstige'}_${p.name}`;
if (!masterCatalogMap[key]) {
masterCatalogMap[key] = {
modelName: p.name,
manufacturer: p.manufacturer || 'Unbekannt',
category: p.category,
installations: []
};
}
masterCatalogMap[key].installations.push(p);
});
// Grouping for Master Catalog / Items
const groupedCatalog: Record<string, typeof masterCatalogMap[string][]> = {};
Object.values(masterCatalogMap).forEach(catItem => {
const groupKey = groupBy === 'category' ? catItem.category : catItem.manufacturer;
if (!groupedCatalog[groupKey]) groupedCatalog[groupKey] = [];
groupedCatalog[groupKey].push(catItem);
});
const toggleGroupCollapse = (key: string) => {
setCollapsedGroups(prev => ({ ...prev, [key]: !prev[key] }));
};
const formatFileSize = (bytes?: number) => {
if (!bytes) return '';
if (bytes >= 1048576) return `${(bytes / 1048576).toFixed(1)} MB`;
if (bytes >= 1024) return `${(bytes / 1024).toFixed(0)} KB`;
return `${bytes} Bytes`;
};
const getDocIcon = (mimeType?: string, title?: string) => {
const name = title?.toLowerCase() || '';
if (mimeType?.includes('pdf') || name.endsWith('.pdf')) {
return <FileText size={16} color="#ef4444" />;
}
if (mimeType?.includes('image') || name.endsWith('.png') || name.endsWith('.jpg') || name.endsWith('.jpeg')) {
return <ImageIcon size={16} color="#10b981" />;
}
if (name.endsWith('.docx') || name.endsWith('.doc')) {
return <FileCode size={16} color="#2563eb" />;
}
return <Paperclip size={16} color="#64748b" />;
};
return (
<>
{/* Toast Notification */}
{toastMessage && (
<div style={{
position: 'fixed',
bottom: '24px',
right: '24px',
backgroundColor: '#0f172a',
color: '#ffffff',
padding: '14px 20px',
borderRadius: '10px',
boxShadow: '0 10px 25px -5px rgba(0, 0, 0, 0.3)',
display: 'flex',
alignItems: 'center',
gap: '12px',
zIndex: 200,
fontSize: '0.95rem',
fontWeight: 600
}}>
<CheckCircle size={20} color="#10b981" />
<span>{toastMessage}</span>
</div>
)}
{/* Header Bar */}
<header className="header-bar" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: '16px' }}>
<div>
<h1 className="header-title">Gerätekartei & Master-Produktkatalog</h1>
<p style={{ color: '#64748b', fontSize: '0.9rem', marginTop: '4px' }}>
Masterliste verbaubarer Produkte + 1:n Supabase Storage Dokumenten-Uploads
</p>
</div>
<div style={{ display: 'flex', gap: '12px' }}>
<button
onClick={loadData}
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '10px 16px',
backgroundColor: '#ffffff',
border: '1px solid #cbd5e1',
borderRadius: '10px',
color: '#475569',
fontWeight: 600,
cursor: 'pointer'
}}
>
<RefreshCw size={16} className={loading ? 'spin' : ''} />
{loading ? 'Lädt...' : 'Aktualisieren'}
</button>
<button
onClick={() => {
setEditingPart(null);
setIsModalOpen(true);
}}
style={{
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
</button>
</div>
</header>
{/* Sub-Tab Navigation */}
<div style={{
display: 'flex',
borderBottom: '2px solid #e2e8f0',
marginBottom: '20px',
gap: '24px'
}}>
<button
onClick={() => setSubTab('catalog')}
style={{
padding: '12px 4px',
border: 'none',
borderBottom: subTab === 'catalog' ? '3px solid #2563eb' : '3px solid transparent',
backgroundColor: 'transparent',
color: subTab === 'catalog' ? '#2563eb' : '#64748b',
fontWeight: 700,
fontSize: '1rem',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: '8px'
}}
>
<Layers size={18} /> Master-Produktkatalog ({Object.keys(masterCatalogMap).length} Modelle)
</button>
<button
onClick={() => setSubTab('installations')}
style={{
padding: '12px 4px',
border: 'none',
borderBottom: subTab === 'installations' ? '3px solid #2563eb' : '3px solid transparent',
backgroundColor: 'transparent',
color: subTab === 'installations' ? '#2563eb' : '#64748b',
fontWeight: 700,
fontSize: '1rem',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: '8px'
}}
>
<Users size={18} /> Alle Kunden-Einbauten ({parts.length} Geräte bei Kunden)
</button>
</div>
{/* Controls & Search Bar */}
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '24px',
gap: '16px',
flexWrap: 'wrap'
}}>
{/* Search */}
<div style={{ position: 'relative', minWidth: '300px', flex: 1 }}>
<Search size={18} color="#94a3b8" style={{ position: 'absolute', left: '14px', top: '12px' }} />
<input
type="text"
placeholder="Produkt, Hersteller, Kunden oder Seriennummer suchen..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
style={{
width: '100%',
padding: '10px 14px 10px 42px',
borderRadius: '10px',
border: '1px solid #cbd5e1',
backgroundColor: '#ffffff',
fontSize: '0.95rem'
}}
/>
</div>
{/* Grouping Switcher */}
<div style={{
display: 'flex',
backgroundColor: '#e2e8f0',
padding: '4px',
borderRadius: '10px',
gap: '4px'
}}>
<button
onClick={() => setGroupBy('manufacturer')}
style={{
padding: '8px 14px',
borderRadius: '8px',
border: 'none',
backgroundColor: groupBy === 'manufacturer' ? '#ffffff' : 'transparent',
color: groupBy === 'manufacturer' ? '#2563eb' : '#64748b',
fontWeight: 600,
fontSize: '0.85rem',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: '6px',
boxShadow: groupBy === 'manufacturer' ? '0 2px 4px rgba(0,0,0,0.05)' : 'none'
}}
>
<Building2 size={16} /> Gruppieren nach Hersteller
</button>
<button
onClick={() => setGroupBy('category')}
style={{
padding: '8px 14px',
borderRadius: '8px',
border: 'none',
backgroundColor: groupBy === 'category' ? '#ffffff' : 'transparent',
color: groupBy === 'category' ? '#2563eb' : '#64748b',
fontWeight: 600,
fontSize: '0.85rem',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: '6px',
boxShadow: groupBy === 'category' ? '0 2px 4px rgba(0,0,0,0.05)' : 'none'
}}
>
<Tag size={16} /> Gruppieren nach Typ
</button>
</div>
</div>
{/* VIEW 1: MASTER PRODUCT CATALOG */}
{subTab === 'catalog' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '24px' }}>
{Object.entries(groupedCatalog).map(([groupTitle, catalogItems]) => {
const isCollapsed = Boolean(collapsedGroups[groupTitle]);
return (
<section key={groupTitle} className="content-card" style={{ padding: '0 0 16px 0', overflow: 'hidden' }}>
<div
onClick={() => toggleGroupCollapse(groupTitle)}
style={{
padding: '16px 24px',
backgroundColor: '#f8fafc',
borderBottom: isCollapsed ? 'none' : '1px solid #e2e8f0',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
cursor: 'pointer'
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
{groupBy === 'manufacturer' ? (
<Building2 size={20} color="#059669" />
) : (
<Tag size={20} color="#2563eb" />
)}
<h2 style={{ fontSize: '1.15rem', fontWeight: 700, color: '#0f172a', margin: 0 }}>
{groupTitle}
</h2>
<span style={{
backgroundColor: '#e0f2fe',
color: '#0369a1',
padding: '2px 8px',
borderRadius: '12px',
fontSize: '0.8rem',
fontWeight: 700
}}>
{catalogItems.length} Modelle
</span>
</div>
<button style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#64748b' }}>
{isCollapsed ? <ChevronDown size={20} /> : <ChevronUp size={20} />}
</button>
</div>
{!isCollapsed && (
<div style={{ padding: '24px', display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(380px, 1fr))', gap: '20px' }}>
{catalogItems.map((cat) => {
const samplePart = cat.installations[0];
const docs = samplePart ? (partDocsMap[samplePart.id] || []) : [];
const isDropzoneOpen = samplePart && activeUploadPartId === samplePart.id;
return (
<div key={cat.modelName} style={{
border: '1px solid #cbd5e1',
borderRadius: '12px',
padding: '20px',
backgroundColor: '#ffffff',
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
boxShadow: '0 2px 4px rgba(0,0,0,0.03)'
}}>
<div>
{/* Badges */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '10px' }}>
<div style={{ display: 'flex', gap: '6px' }}>
<span style={{
backgroundColor: '#eff6ff',
color: '#1d4ed8',
padding: '3px 8px',
borderRadius: '6px',
fontSize: '0.8rem',
fontWeight: 700
}}>
{cat.manufacturer}
</span>
<span style={{
backgroundColor: '#f1f5f9',
color: '#475569',
padding: '3px 8px',
borderRadius: '6px',
fontSize: '0.8rem',
fontWeight: 600
}}>
{cat.category}
</span>
</div>
{samplePart && (
<button
onClick={() => {
setEditingPart(samplePart);
setIsModalOpen(true);
}}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#64748b' }}
>
<Edit3 size={16} />
</button>
)}
</div>
<h3 style={{ fontSize: '1.15rem', fontWeight: 800, color: '#0f172a', margin: '0 0 10px 0' }}>
{cat.modelName}
</h3>
{/* Installed Customers List */}
<div style={{
backgroundColor: '#f8fafc',
borderRadius: '8px',
padding: '12px',
marginBottom: '16px',
border: '1px solid #f1f5f9'
}}>
<span style={{ fontSize: '0.8rem', fontWeight: 700, color: '#059669', display: 'flex', alignItems: 'center', gap: '6px', marginBottom: '8px' }}>
<User size={14} /> Verbaut bei {cat.installations.length} Kunden:
</span>
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
{cat.installations.map((inst) => (
<div key={inst.id} style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.82rem', color: '#334155' }}>
<span style={{ fontWeight: 600 }}> {inst.customerName}</span>
<span style={{ color: '#64748b', fontFamily: 'monospace' }}>S/N: {inst.serialNumber || '—'}</span>
</div>
))}
</div>
</div>
{/* 1:N Attached Documents Section */}
{samplePart && (
<div style={{ borderTop: '1px dashed #e2e8f0', paddingTop: '12px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '10px' }}>
<span style={{ fontSize: '0.85rem', fontWeight: 700, color: '#0f172a', display: 'flex', alignItems: 'center', gap: '6px' }}>
<Paperclip size={14} color="#2563eb" />
Stamm-Dokumente & Anleitungen ({docs.length})
</span>
<button
onClick={() => {
if (isDropzoneOpen) {
setActiveUploadPartId(null);
} else {
setActiveUploadPartId(samplePart.id);
}
}}
style={{
backgroundColor: isDropzoneOpen ? '#f1f5f9' : '#eff6ff',
color: isDropzoneOpen ? '#64748b' : '#2563eb',
border: 'none',
padding: '5px 10px',
borderRadius: '6px',
fontSize: '0.8rem',
fontWeight: 600,
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: '4px'
}}
>
{isDropzoneOpen ? <X size={14} /> : <UploadCloud size={14} />}
{isDropzoneOpen ? 'Schließen' : 'Datei hochladen'}
</button>
</div>
{/* Drag & Drop Dropzone Box */}
{isDropzoneOpen && (
<div
onDragOver={(e) => {
e.preventDefault();
setIsDraggingOver(true);
}}
onDragLeave={(e) => {
e.preventDefault();
setIsDraggingOver(false);
}}
onDrop={(e) => {
e.preventDefault();
setIsDraggingOver(false);
if (e.dataTransfer.files) {
handleFileUpload(samplePart.id, e.dataTransfer.files);
}
}}
onClick={() => fileInputRef.current?.click()}
style={{
backgroundColor: isDraggingOver ? '#eff6ff' : '#f8fafc',
border: `2px dashed ${isDraggingOver ? '#2563eb' : '#cbd5e1'}`,
borderRadius: '10px',
padding: '16px',
textAlign: 'center',
cursor: 'pointer',
marginBottom: '12px',
transition: 'all 0.2s ease'
}}
>
<input
type="file"
ref={fileInputRef}
multiple
accept=".pdf,.doc,.docx,.png,.jpg,.jpeg"
onChange={(e) => {
if (e.target.files) {
handleFileUpload(samplePart.id, e.target.files);
}
}}
style={{ display: 'none' }}
/>
<UploadCloud size={28} color={isDraggingOver ? '#2563eb' : '#94a3b8'} style={{ margin: '0 auto 6px auto' }} />
<div style={{ fontSize: '0.85rem', fontWeight: 600, color: '#0f172a' }}>
{isUploading ? 'Lädt nach Supabase Storage...' : 'Klicken oder Dateien hierhin ziehen'}
</div>
<div style={{ fontSize: '0.75rem', color: '#64748b', marginTop: '2px' }}>
PDF, Word (DOCX), PNG, JPG bis 25 MB
</div>
</div>
)}
{/* List of Attached Documents */}
{docs.length > 0 ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
{docs.map((doc) => (
<div key={doc.id} style={{
backgroundColor: '#f8fafc',
padding: '8px 12px',
borderRadius: '8px',
border: '1px solid #f1f5f9',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
fontSize: '0.82rem'
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', overflow: 'hidden' }}>
{getDocIcon(doc.mimeType, doc.title)}
<div>
<span style={{
fontWeight: 600,
color: '#0f172a',
display: 'block',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
maxWidth: '180px'
}} title={doc.title}>
{doc.title}
</span>
{doc.fileSizeBytes && (
<span style={{ fontSize: '0.72rem', color: '#94a3b8' }}>
{formatFileSize(doc.fileSizeBytes)}
</span>
)}
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<button
onClick={() => setViewerDoc(doc)}
style={{
backgroundColor: '#eff6ff',
color: '#2563eb',
border: '1px solid #bfdbfe',
borderRadius: '6px',
padding: '4px 8px',
fontSize: '0.75rem',
fontWeight: 700,
cursor: 'pointer'
}}
>
Vorschau
</button>
<a
href={doc.filePath}
target="_blank"
rel="noopener noreferrer"
style={{
color: '#64748b',
textDecoration: 'none',
fontWeight: 600,
fontSize: '0.75rem',
display: 'flex',
alignItems: 'center',
gap: '3px'
}}
title="In neuem Tab öffnen / Download"
>
<ExternalLink size={14} />
</a>
<button
onClick={() => handleDeleteDocument(doc)}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#ef4444', padding: '2px' }}
title="Dokument löschen"
>
<Trash2 size={14} />
</button>
</div>
</div>
))}
</div>
) : (
<p style={{ color: '#94a3b8', fontSize: '0.78rem', margin: 0, fontStyle: 'italic' }}>
Keine Stamm-Dokumente im Storage angehängt.
</p>
)}
</div>
)}
</div>
</div>
);
})}
</div>
)}
</section>
);
})}
</div>
)}
{/* VIEW 2: INDIVIDUAL CUSTOMER INSTALLATIONS */}
{subTab === 'installations' && (
<section className="content-card">
<h2 className="card-heading">Alle Kunden-Einbauten ({filteredParts.length})</h2>
<div className="table-responsive">
<table className="orders-table">
<thead>
<tr>
<th>Modellbezeichnung</th>
<th>Hersteller</th>
<th>Kategorie</th>
<th>Kunde / Besitzer</th>
<th>Seriennummer</th>
<th>Einbaudatum</th>
<th style={{ textAlign: 'right' }}>Aktionen</th>
</tr>
</thead>
<tbody>
{filteredParts.map((p) => (
<tr key={p.id}>
<td style={{ fontWeight: 700, color: '#0f172a' }}>{p.name}</td>
<td>
<span style={{ fontWeight: 600, color: '#2563eb' }}>{p.manufacturer || '—'}</span>
</td>
<td>
<span style={{ backgroundColor: '#f1f5f9', color: '#475569', padding: '3px 8px', borderRadius: '6px', fontSize: '0.8rem', fontWeight: 600 }}>
{p.category}
</span>
</td>
<td style={{ fontWeight: 600, color: '#0f172a' }}>{p.customerName || 'Kunde'}</td>
<td style={{ fontFamily: 'monospace', color: '#64748b' }}>{p.serialNumber || '—'}</td>
<td style={{ color: '#64748b', fontSize: '0.85rem' }}>{p.installedAt || 'Unbekannt'}</td>
<td style={{ textAlign: 'right' }}>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '8px' }}>
<button
onClick={() => {
setEditingPart(p);
setIsModalOpen(true);
}}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#64748b' }}
>
<Edit3 size={18} />
</button>
<button
onClick={() => handleDeletePart(p)}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#ef4444' }}
>
<Trash2 size={18} />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
)}
<InstalledPartModal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
onSave={handleSavePart}
customers={customers}
initialPart={editingPart}
/>
<DocumentViewerModal
isOpen={Boolean(viewerDoc)}
onClose={() => setViewerDoc(null)}
document={viewerDoc}
/>
</>
);
};
+200 -26
View File
@@ -1,60 +1,234 @@
import React from 'react';
import { AlertTriangle } from 'lucide-react';
import React, { useState } from 'react';
import { Package, AlertTriangle, Plus, Search, Tag, CheckCircle } from 'lucide-react';
export const InventoryView: React.FC = () => {
const parts = [
{ name: 'Buderus Logamax plus GB172-24', category: 'Gas-Brennwertgerät', stock: 4, minStock: 2, status: 'Ausreichend' },
{ name: 'Bosch EasyControl CT200', category: 'Raumthermostat', stock: 1, minStock: 3, status: 'Nachbestellen' },
{ name: 'Honeywell Hauswasserfilter FF06', category: 'Wasserfilter', stock: 0, minStock: 2, status: 'Kritisch' },
{ name: 'Grundfos Magna3 32-120 F', category: 'Heizungsumwälzpumpe', stock: 6, minStock: 3, status: 'Ausreichend' },
{ name: 'Viessmann Vitodens 200-W', category: 'Gas-Brennwertgerät', stock: 2, minStock: 2, status: 'Ausreichend' },
];
const [searchQuery, setSearchQuery] = useState('');
const [parts, setParts] = useState([
{ id: '1', name: 'Buderus Logamax plus GB172-24 Brennwert-Set', category: 'Heizgeräte', stock: 4, minStock: 2, price: '1.850,00 €', status: 'Ausreichend' },
{ id: '2', name: 'Bosch EasyControl CT200 Thermostat', category: 'Thermostate & Regelung', stock: 1, minStock: 3, price: '210,00 €', status: 'Nachbestellen' },
{ id: '3', name: 'Honeywell Hauswasserfilter FF06 1"', category: 'Sanitär & Filter', stock: 0, minStock: 2, price: '65,00 €', status: 'Kritisch' },
{ id: '4', name: 'Grundfos Magna3 32-120 F Hocheffizienzpumpe', category: 'Pumpen', stock: 6, minStock: 3, price: '540,00 €', status: 'Ausreichend' },
{ id: '5', name: 'Viessmann Vitodens 200-W Wandgerät', category: 'Heizgeräte', stock: 2, minStock: 2, price: '2.100,00 €', status: 'Ausreichend' },
{ id: '6', name: 'Dichtungs-Set Heizungsbau 50-teilig', category: 'Verbrauchsmaterial', stock: 12, minStock: 5, price: '34,90 €', status: 'Ausreichend' },
{ id: '7', name: 'Kupferrohr 22mm x 1mm (2m Stange)', category: 'Rohre & Fittinge', stock: 3, minStock: 10, price: '18,50 €', status: 'Nachbestellen' }
]);
const [toastMessage, setToastMessage] = useState<string | null>(null);
const showNotification = (msg: string) => {
setToastMessage(msg);
setTimeout(() => setToastMessage(null), 4000);
};
const handleStockUpdate = (id: string, delta: number) => {
setParts(prev => prev.map(p => {
if (p.id === id) {
const newStock = Math.max(0, p.stock + delta);
let newStatus = 'Ausreichend';
if (newStock === 0) newStatus = 'Kritisch';
else if (newStock <= p.minStock) newStatus = 'Nachbestellen';
showNotification(`Bestand von "${p.name}" aktualisiert auf ${newStock} Stk.`);
return { ...p, stock: newStock, status: newStatus };
}
return p;
}));
};
const filteredParts = parts.filter(p =>
p.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
p.category.toLowerCase().includes(searchQuery.toLowerCase())
);
const warningCount = parts.filter(p => p.stock <= p.minStock).length;
return (
<>
<header className="header-bar">
<h1 className="header-title">Lager & Teileverwaltung</h1>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', color: '#ef4444', fontWeight: 600, fontSize: '0.9rem' }}>
<AlertTriangle size={18} /> 2 Bestandswarnungen
{/* Toast Notification */}
{toastMessage && (
<div style={{
position: 'fixed',
bottom: '24px',
right: '24px',
backgroundColor: '#0f172a',
color: '#ffffff',
padding: '14px 20px',
borderRadius: '10px',
boxShadow: '0 10px 25px -5px rgba(0, 0, 0, 0.3)',
display: 'flex',
alignItems: 'center',
gap: '12px',
zIndex: 200,
fontSize: '0.95rem',
fontWeight: 600
}}>
<CheckCircle size={20} color="#10b981" />
<span>{toastMessage}</span>
</div>
)}
{/* Header Bar */}
<header className="header-bar" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: '16px' }}>
<div>
<h1 className="header-title">Ersatzteillager & Bestand</h1>
<p style={{ color: '#64748b', fontSize: '0.9rem', marginTop: '4px' }}>
Ersatzteilbestände, Mindestbestände und automatisierte Nachbestellwarnungen
</p>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
{warningCount > 0 && (
<div style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
color: '#ef4444',
backgroundColor: '#fef2f2',
padding: '8px 14px',
borderRadius: '10px',
fontWeight: 700,
fontSize: '0.9rem'
}}>
<AlertTriangle size={18} /> {warningCount} Bestandswarnungen
</div>
)}
<button
onClick={() => alert('Neuer Lagerartikel Anlege-Dialog')}
style={{
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
</button>
</div>
</header>
{/* Search Bar */}
<div style={{ marginBottom: '24px', position: 'relative', maxWidth: '400px' }}>
<Search size={18} color="#94a3b8" style={{ position: 'absolute', left: '14px', top: '12px' }} />
<input
type="text"
placeholder="Lagerartikel oder Kategorie suchen..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
style={{
width: '100%',
padding: '10px 14px 10px 42px',
borderRadius: '10px',
border: '1px solid #cbd5e1',
backgroundColor: '#ffffff',
fontSize: '0.95rem'
}}
/>
</div>
<section className="content-card">
<h2 className="card-heading">Ersatzteilbestand & Bestellwarnungen</h2>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px' }}>
<h2 className="card-heading" style={{ margin: 0 }}>Ersatzteilbestand ({filteredParts.length} Artikel)</h2>
<span style={{ fontSize: '0.85rem', color: '#64748b', fontWeight: 600 }}>Lagerort: Hauptlager Halle A</span>
</div>
<div className="table-responsive">
<table className="orders-table">
<thead>
<tr>
<th>Artikelbezeichnung</th>
<th>Kategorie</th>
<th>Einzelpreis</th>
<th>Lagerbestand</th>
<th>Mindestbestand</th>
<th>Status</th>
<th style={{ textAlign: 'right' }}>Bestandsanpassung</th>
</tr>
</thead>
<tbody>
{parts.map((p, idx) => (
<tr key={idx}>
<td style={{ fontWeight: 600, color: '#0f172a' }}>{p.name}</td>
<td style={{ color: '#64748b' }}>{p.category}</td>
<td style={{ fontWeight: 700, color: p.stock <= p.minStock ? '#ef4444' : '#0f172a' }}>
{filteredParts.map((p) => (
<tr key={p.id}>
<td style={{ fontWeight: 700, color: '#0f172a' }}>{p.name}</td>
<td>
<span style={{
backgroundColor: '#f1f5f9',
color: '#475569',
padding: '3px 8px',
borderRadius: '6px',
fontSize: '0.8rem',
fontWeight: 600,
display: 'inline-flex',
alignItems: 'center',
gap: '4px'
}}>
<Tag size={12} /> {p.category}
</span>
</td>
<td style={{ fontWeight: 600, color: '#0f172a' }}>{p.price}</td>
<td style={{ fontWeight: 800, fontSize: '1rem', color: p.stock <= p.minStock ? '#ef4444' : '#0f172a' }}>
{p.stock} Stk.
</td>
<td>{p.minStock} Stk.</td>
<td style={{ color: '#64748b' }}>{p.minStock} Stk.</td>
<td>
<span className={`badge ${
p.status === 'Kritisch' || p.status === 'Nachbestellen'
? 'badge-scheduled'
: 'badge-in-progress'
}`} style={{
<span className="badge" style={{
backgroundColor: p.status === 'Kritisch' ? '#fef2f2' : p.status === 'Nachbestellen' ? '#fffbeb' : '#dcfce7',
color: p.status === 'Kritisch' ? '#991b1b' : p.status === 'Nachbestellen' ? '#b45309' : '#15803d'
color: p.status === 'Kritisch' ? '#991b1b' : p.status === 'Nachbestellen' ? '#b45309' : '#15803d',
fontWeight: 700
}}>
{p.status}
</span>
</td>
<td style={{ textAlign: 'right' }}>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '6px' }}>
<button
onClick={() => handleStockUpdate(p.id, -1)}
style={{
width: '28px',
height: '28px',
borderRadius: '6px',
border: '1px solid #cbd5e1',
backgroundColor: '#ffffff',
cursor: 'pointer',
fontWeight: 700
}}
title="1 Stk. entnehmen"
>
-
</button>
<button
onClick={() => handleStockUpdate(p.id, 1)}
style={{
width: '28px',
height: '28px',
borderRadius: '6px',
border: '1px solid #cbd5e1',
backgroundColor: '#ffffff',
cursor: 'pointer',
fontWeight: 700
}}
title="1 Stk. zubuchen"
>
+
</button>
</div>
</td>
</tr>
))}
{filteredParts.length === 0 && (
<tr>
<td colSpan={7} style={{ textAlign: 'center', padding: '30px', color: '#64748b' }}>
Keine Ersatzteile gefunden.
</td>
</tr>
)}
</tbody>
</table>
</div>
+282 -52
View File
@@ -1,69 +1,299 @@
import React from 'react';
import { Users, Phone, Mail, MapPin } from 'lucide-react';
import React, { useState, useEffect } from 'react';
import {
Users,
Phone,
Mail,
MapPin,
Plus,
RefreshCw,
Edit3,
Trash2,
CheckCircle,
Search,
UserCheck
} from 'lucide-react';
import { Technician } from '../types';
import { SupabaseService } from '../services/supabaseService';
import { TechnicianModal } from '../components/TechnicianModal';
export const TechniciansView: React.FC = () => {
const technicians = [
{ name: 'Max Müller', role: 'Heizungsbaumeister', status: 'Im Einsatz (Musterweg 12)', phone: '0170 1112233', email: 'm.mueller@handwerksfreund.de' },
{ name: 'Stefan Bauer', role: 'Anlagenmechaniker SHK', status: 'Unterwegs zu Anja Klein', phone: '0170 2223344', email: 's.bauer@handwerksfreund.de' },
{ name: 'Tom Gerber', role: 'Servicetechniker', status: 'Im Einsatz (Hausverwaltung Schmidt)', phone: '0170 3334455', email: 't.gerber@handwerksfreund.de' },
{ name: 'Janina Wagner', role: 'Elektrotechnikerin', status: 'Verfügbar / Werkstatt', phone: '0170 4445566', email: 'j.wagner@handwerksfreund.de' },
];
const [technicians, setTechnicians] = useState<Technician[]>([]);
const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState('');
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingTech, setEditingTech] = useState<Technician | null>(null);
const [toastMessage, setToastMessage] = useState<string | null>(null);
const showNotification = (msg: string) => {
setToastMessage(msg);
setTimeout(() => setToastMessage(null), 4000);
};
const loadData = async () => {
setLoading(true);
try {
const data = await SupabaseService.getTechnicians();
setTechnicians(data);
} catch (err) {
console.error('Fehler beim Laden der Techniker:', err);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadData();
}, []);
const handleSaveTechnician = async (data: Omit<Technician, 'id'> | Technician) => {
if ('id' in data) {
const updated = await SupabaseService.updateTechnician(data.id, data);
showNotification(`Monteur "${updated.name}" aktualisiert.`);
} else {
const created = await SupabaseService.createTechnician(data);
showNotification(`Neuer Monteur "${created.name}" in Supabase registriert.`);
}
await loadData();
};
const handleStatusToggle = async (tech: Technician) => {
let nextStatus: 'verfuegbar' | 'imEinsatz' | 'abwesend' = 'imEinsatz';
if (tech.status === 'imEinsatz') nextStatus = 'verfuegbar';
else if (tech.status === 'verfuegbar') nextStatus = 'abwesend';
await SupabaseService.updateTechnician(tech.id, { status: nextStatus });
showNotification(`Status von "${tech.name}" geändert.`);
await loadData();
};
const handleDeleteTechnician = async (tech: Technician) => {
if (window.confirm(`Möchten Sie den Monteur "${tech.name}" wirklich löschen?`)) {
await SupabaseService.deleteTechnician(tech.id);
showNotification(`Monteur "${tech.name}" gelöscht.`);
await loadData();
}
};
const filteredTechnicians = technicians.filter(t =>
t.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
t.role.toLowerCase().includes(searchQuery.toLowerCase())
);
const activeInField = technicians.filter(t => t.status === 'imEinsatz').length;
return (
<>
<header className="header-bar">
<h1 className="header-title">Monteure & Servicekräfte</h1>
<span style={{ fontWeight: 600, color: '#2563eb' }}>8 von 10 im Außeneinsatz</span>
{/* Toast Notification */}
{toastMessage && (
<div style={{
position: 'fixed',
bottom: '24px',
right: '24px',
backgroundColor: '#0f172a',
color: '#ffffff',
padding: '14px 20px',
borderRadius: '10px',
boxShadow: '0 10px 25px -5px rgba(0, 0, 0, 0.3)',
display: 'flex',
alignItems: 'center',
gap: '12px',
zIndex: 200,
fontSize: '0.95rem',
fontWeight: 600
}}>
<CheckCircle size={20} color="#10b981" />
<span>{toastMessage}</span>
</div>
)}
{/* Header Bar */}
<header className="header-bar" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: '16px' }}>
<div>
<h1 className="header-title">Monteure & Servicekräfte</h1>
<p style={{ color: '#64748b', fontSize: '0.9rem', marginTop: '4px' }}>
<span style={{ fontWeight: 600, color: '#2563eb' }}>{activeInField} von {technicians.length} Monteuren</span> aktuell im Außeneinsatz
</p>
</div>
<div style={{ display: 'flex', gap: '12px' }}>
<button
onClick={loadData}
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '10px 16px',
backgroundColor: '#ffffff',
border: '1px solid #cbd5e1',
borderRadius: '10px',
color: '#475569',
fontWeight: 600,
cursor: 'pointer'
}}
>
<RefreshCw size={16} className={loading ? 'spin' : ''} />
{loading ? 'Lädt...' : 'Aktualisieren'}
</button>
<button
onClick={() => {
setEditingTech(null);
setIsModalOpen(true);
}}
style={{
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
</button>
</div>
</header>
<section className="content-card">
<h2 className="card-heading">Mitarbeiterübersicht & Status</h2>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))', gap: '20px' }}>
{technicians.map((t, idx) => (
<div key={idx} style={{
border: '1px solid #e2e8f0',
borderRadius: '12px',
padding: '20px',
backgroundColor: '#ffffff'
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '12px' }}>
<div style={{
width: '44px',
height: '44px',
borderRadius: '50%',
backgroundColor: '#eff6ff',
color: '#2563eb',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontWeight: '700'
}}>
{t.name.split(' ').map(n => n[0]).join('')}
</div>
<div>
<h3 style={{ fontSize: '1.05rem', fontWeight: 700, color: '#0f172a' }}>{t.name}</h3>
<p style={{ fontSize: '0.85rem', color: '#64748b' }}>{t.role}</p>
</div>
</div>
{/* Search Bar */}
<div style={{ marginBottom: '24px', position: 'relative', maxWidth: '400px' }}>
<Search size={18} color="#94a3b8" style={{ position: 'absolute', left: '14px', top: '12px' }} />
<input
type="text"
placeholder="Monteur nach Name oder Qualifikation suchen..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
style={{
width: '100%',
padding: '10px 14px 10px 42px',
borderRadius: '10px',
border: '1px solid #cbd5e1',
backgroundColor: '#ffffff',
fontSize: '0.95rem'
}}
/>
</div>
<div style={{ fontSize: '0.875rem', color: '#334155', display: 'flex', flexDirection: 'column', gap: '8px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<MapPin size={16} color="#2563eb" />
<span style={{ fontWeight: 600 }}>{t.status}</span>
<section className="content-card">
<h2 className="card-heading">Mitarbeiterübersicht ({filteredTechnicians.length})</h2>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))', gap: '20px' }}>
{filteredTechnicians.map((t) => {
const initials = t.name.split(' ').map(n => n[0]).join('');
return (
<div key={t.id} style={{
border: '1px solid #e2e8f0',
borderRadius: '12px',
padding: '20px',
backgroundColor: '#ffffff',
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
boxShadow: '0 2px 4px rgba(0,0,0,0.02)'
}}>
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '12px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<div style={{
width: '44px',
height: '44px',
borderRadius: '50%',
backgroundColor: t.status === 'imEinsatz' ? '#dbeafe' : t.status === 'verfuegbar' ? '#dcfce7' : '#f1f5f9',
color: t.status === 'imEinsatz' ? '#1d4ed8' : t.status === 'verfuegbar' ? '#15803d' : '#64748b',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontWeight: '700',
fontSize: '1rem'
}}>
{initials}
</div>
<div>
<h3 style={{ fontSize: '1.05rem', fontWeight: 700, color: '#0f172a', margin: 0 }}>{t.name}</h3>
<p style={{ fontSize: '0.85rem', color: '#64748b', margin: '2px 0 0 0' }}>{t.role}</p>
</div>
</div>
<div style={{ display: 'flex', gap: '4px' }}>
<button
onClick={() => {
setEditingTech(t);
setIsModalOpen(true);
}}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#64748b', padding: '4px' }}
title="Monteur bearbeiten"
>
<Edit3 size={16} />
</button>
<button
onClick={() => handleDeleteTechnician(t)}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#ef4444', padding: '4px' }}
title="Monteur löschen"
>
<Trash2 size={16} />
</button>
</div>
</div>
<div style={{ fontSize: '0.875rem', color: '#334155', display: 'flex', flexDirection: 'column', gap: '8px', margin: '14px 0' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<MapPin size={16} color="#2563eb" />
<span style={{ fontWeight: 600 }}>{t.currentLocation || 'Werkstatt'}</span>
</div>
{t.phone && (
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<Phone size={16} color="#64748b" />
<span>{t.phone}</span>
</div>
)}
{t.email && (
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<Mail size={16} color="#64748b" />
<span>{t.email}</span>
</div>
)}
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<Phone size={16} color="#64748b" />
<span>{t.phone}</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<Mail size={16} color="#64748b" />
<span>{t.email}</span>
<div style={{ paddingTop: '12px', borderTop: '1px solid #f1f5f9', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<button
onClick={() => handleStatusToggle(t)}
style={{ border: 'none', background: 'none', cursor: 'pointer', padding: 0 }}
>
<span style={{
backgroundColor: t.status === 'imEinsatz' ? '#dbeafe' : t.status === 'verfuegbar' ? '#dcfce7' : '#fef3c7',
color: t.status === 'imEinsatz' ? '#1d4ed8' : t.status === 'verfuegbar' ? '#15803d' : '#b45309',
padding: '4px 10px',
borderRadius: '20px',
fontSize: '0.8rem',
fontWeight: 700
}}>
{t.status === 'imEinsatz' ? 'Im Einsatz' : t.status === 'verfuegbar' ? 'Verfügbar' : 'Abwesend'} 🔄
</span>
</button>
</div>
</div>
);
})}
{filteredTechnicians.length === 0 && (
<div style={{ gridColumn: '1 / -1', padding: '40px', textAlign: 'center', color: '#64748b' }}>
Keine Monteure gefunden.
</div>
))}
)}
</div>
</section>
<TechnicianModal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
onSave={handleSaveTechnician}
initialTech={editingTech}
/>
</>
);
};