Neuste Version der Seite mit Drag n Drop
This commit is contained in:
+15
-2
@@ -2,24 +2,37 @@ import React, { useState } from 'react';
|
|||||||
import { Menu, Wrench } from 'lucide-react';
|
import { Menu, Wrench } from 'lucide-react';
|
||||||
import { Sidebar } from './components/Sidebar';
|
import { Sidebar } from './components/Sidebar';
|
||||||
import { DashboardView } from './views/DashboardView';
|
import { DashboardView } from './views/DashboardView';
|
||||||
|
import { CustomersView } from './views/CustomersView';
|
||||||
import { DispatchingView } from './views/DispatchingView';
|
import { DispatchingView } from './views/DispatchingView';
|
||||||
import { InventoryView } from './views/InventoryView';
|
import { InventoryView } from './views/InventoryView';
|
||||||
|
import { EquipmentView } from './views/EquipmentView';
|
||||||
import { TechniciansView } from './views/TechniciansView';
|
import { TechniciansView } from './views/TechniciansView';
|
||||||
import { TimeTrackingView } from './views/TimeTrackingView';
|
import { TimeTrackingView } from './views/TimeTrackingView';
|
||||||
import { SupabaseSyncView } from './views/SupabaseSyncView';
|
import { SupabaseSyncView } from './views/SupabaseSyncView';
|
||||||
|
|
||||||
export const App: React.FC = () => {
|
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 [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||||
|
|
||||||
|
const setActiveTab = (tab: string) => {
|
||||||
|
setActiveTabState(tab);
|
||||||
|
localStorage.setItem('handwerksfreund_active_tab', tab);
|
||||||
|
};
|
||||||
|
|
||||||
const renderView = () => {
|
const renderView = () => {
|
||||||
switch (activeTab) {
|
switch (activeTab) {
|
||||||
case 'dashboard':
|
case 'dashboard':
|
||||||
return <DashboardView />;
|
return <DashboardView onNavigate={setActiveTab} />;
|
||||||
|
case 'kunden':
|
||||||
|
return <CustomersView />;
|
||||||
case 'dispatching':
|
case 'dispatching':
|
||||||
return <DispatchingView />;
|
return <DispatchingView />;
|
||||||
case 'lager':
|
case 'lager':
|
||||||
return <InventoryView />;
|
return <InventoryView />;
|
||||||
|
case 'geraete':
|
||||||
|
return <EquipmentView />;
|
||||||
case 'monteure':
|
case 'monteure':
|
||||||
return <TechniciansView />;
|
return <TechniciansView />;
|
||||||
case 'zeiterfassung':
|
case 'zeiterfassung':
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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
@@ -7,7 +7,8 @@ import {
|
|||||||
Users,
|
Users,
|
||||||
Clock,
|
Clock,
|
||||||
Database,
|
Database,
|
||||||
X
|
X,
|
||||||
|
Info
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
@@ -25,8 +26,10 @@ export const Sidebar: React.FC<SidebarProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const menuItems = [
|
const menuItems = [
|
||||||
{ id: 'dashboard', label: 'Dashboard', icon: LayoutDashboard },
|
{ id: 'dashboard', label: 'Dashboard', icon: LayoutDashboard },
|
||||||
|
{ id: 'kunden', label: 'Kunden & Aufträge', icon: Users },
|
||||||
{ id: 'dispatching', label: 'Dispatching', icon: CalendarDays },
|
{ 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: 'monteure', label: 'Monteure', icon: Users },
|
||||||
{ id: 'zeiterfassung', label: 'Zeiterfassung', icon: Clock },
|
{ id: 'zeiterfassung', label: 'Zeiterfassung', icon: Clock },
|
||||||
{ id: 'supabase', label: 'Supabase Sync', icon: Database },
|
{ id: 'supabase', label: 'Supabase Sync', icon: Database },
|
||||||
@@ -49,40 +52,71 @@ export const Sidebar: React.FC<SidebarProps> = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<aside className={`sidebar ${isOpen ? 'open' : ''}`}>
|
<aside className={`sidebar ${isOpen ? 'open' : ''}`}>
|
||||||
<div className="brand">
|
<div>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
<div className="brand">
|
||||||
<Wrench className="brand-icon" size={24} />
|
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||||
<span>Handwerksfreund</span>
|
<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>
|
</div>
|
||||||
|
|
||||||
<button
|
<nav>
|
||||||
className="mobile-close-btn"
|
<ul className="nav-list">
|
||||||
onClick={onClose}
|
{menuItems.map((item) => {
|
||||||
aria-label="Menü schließen"
|
const Icon = item.icon;
|
||||||
>
|
const isActive = activeTab === item.id;
|
||||||
<X size={24} />
|
return (
|
||||||
</button>
|
<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>
|
</div>
|
||||||
|
|
||||||
<nav>
|
{/* Sidebar Footer with Version Badge */}
|
||||||
<ul className="nav-list">
|
<div style={{
|
||||||
{menuItems.map((item) => {
|
marginTop: 'auto',
|
||||||
const Icon = item.icon;
|
paddingTop: '20px',
|
||||||
const isActive = activeTab === item.id;
|
borderTop: '1px solid #172545',
|
||||||
return (
|
fontSize: '0.8rem',
|
||||||
<li key={item.id}>
|
color: '#64748b',
|
||||||
<button
|
display: 'flex',
|
||||||
className={`nav-item ${isActive ? 'active' : ''}`}
|
alignItems: 'center',
|
||||||
onClick={() => handleSelect(item.id)}
|
justifyContent: 'space-between'
|
||||||
>
|
}}>
|
||||||
<Icon className="icon" size={20} />
|
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||||
<span>{item.label}</span>
|
<Info size={14} color="#38bdf8" />
|
||||||
</button>
|
<span>Version</span>
|
||||||
</li>
|
</div>
|
||||||
);
|
|
||||||
})}
|
<span style={{
|
||||||
</ul>
|
backgroundColor: '#172545',
|
||||||
</nav>
|
color: '#38bdf8',
|
||||||
|
padding: '2px 10px',
|
||||||
|
borderRadius: '12px',
|
||||||
|
fontWeight: 700,
|
||||||
|
fontSize: '0.78rem',
|
||||||
|
border: '1px solid #1e3a8a'
|
||||||
|
}}>
|
||||||
|
v0.1
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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
@@ -100,6 +100,12 @@ body {
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
z-index: 50;
|
z-index: 50;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
bottom: 0;
|
||||||
|
height: 100vh;
|
||||||
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand {
|
.brand {
|
||||||
@@ -175,12 +181,13 @@ body {
|
|||||||
/* Main Content Area */
|
/* Main Content Area */
|
||||||
.main-content {
|
.main-content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
margin-left: 260px;
|
||||||
padding: 36px 40px;
|
padding: 36px 40px;
|
||||||
overflow-y: auto;
|
min-height: 100vh;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 32px;
|
gap: 32px;
|
||||||
max-width: 100%;
|
max-width: calc(100vw - 260px);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Top Header Bar */
|
/* Top Header Bar */
|
||||||
@@ -438,6 +445,8 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.main-content {
|
.main-content {
|
||||||
|
margin-left: 0;
|
||||||
|
max-width: 100%;
|
||||||
padding: 20px 16px;
|
padding: 20px 16px;
|
||||||
gap: 20px;
|
gap: 20px;
|
||||||
}
|
}
|
||||||
|
|||||||
+1119
-6
File diff suppressed because it is too large
Load Diff
+72
-3
@@ -9,13 +9,14 @@ export interface Customer {
|
|||||||
city: string;
|
city: string;
|
||||||
latitude: number;
|
latitude: number;
|
||||||
longitude: number;
|
longitude: number;
|
||||||
distanceKm: number;
|
distanceKm?: number;
|
||||||
phone?: string;
|
phone?: string;
|
||||||
email?: string;
|
email?: string;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
firstContactDate?: string;
|
firstContactDate?: string;
|
||||||
openOrdersCount: number;
|
openOrdersCount?: number;
|
||||||
lastOrderDate?: string;
|
lastOrderDate?: string;
|
||||||
|
createdAt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WorkOrder {
|
export interface WorkOrder {
|
||||||
@@ -28,10 +29,25 @@ export interface WorkOrder {
|
|||||||
scheduledTime?: string;
|
scheduledTime?: string;
|
||||||
technicianName?: string;
|
technicianName?: string;
|
||||||
status: OrderStatus;
|
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 {
|
export interface ScheduleItem {
|
||||||
id: string;
|
id: string;
|
||||||
|
orderId?: string;
|
||||||
|
customerId?: string;
|
||||||
|
technicianId?: string;
|
||||||
title: string;
|
title: string;
|
||||||
customerName?: string;
|
customerName?: string;
|
||||||
technicianName?: string;
|
technicianName?: string;
|
||||||
@@ -39,7 +55,59 @@ export interface ScheduleItem {
|
|||||||
taskDescription?: string;
|
taskDescription?: string;
|
||||||
startTime: string;
|
startTime: string;
|
||||||
endTime: 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 {
|
export interface SystemStatus {
|
||||||
@@ -58,3 +126,4 @@ export interface DashboardMetrics {
|
|||||||
openInvoicesAmount: number;
|
openInvoicesAmount: number;
|
||||||
inventoryWarningsCount: number;
|
inventoryWarningsCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
+163
-20
@@ -1,58 +1,201 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { StatCard } from '../components/StatCard';
|
import {
|
||||||
|
StatCard
|
||||||
|
} from '../components/StatCard';
|
||||||
import { LiveOrdersTable } from '../components/LiveOrdersTable';
|
import { LiveOrdersTable } from '../components/LiveOrdersTable';
|
||||||
import { SystemStatusCard } from '../components/SystemStatusCard';
|
import { SystemStatusCard } from '../components/SystemStatusCard';
|
||||||
import { SupabaseService } from '../services/supabaseService';
|
import { SupabaseService } from '../services/supabaseService';
|
||||||
import { ScheduleItem, SystemStatus, DashboardMetrics } from '../types';
|
import { ScheduleItem, SystemStatus, DashboardMetrics } from '../types';
|
||||||
|
import {
|
||||||
|
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 [metrics, setMetrics] = useState<DashboardMetrics | null>(null);
|
||||||
const [liveOrders, setLiveOrders] = useState<ScheduleItem[]>([]);
|
const [liveOrders, setLiveOrders] = useState<ScheduleItem[]>([]);
|
||||||
const [systemStatus, setSystemStatus] = useState<SystemStatus | null>(null);
|
const [systemStatus, setSystemStatus] = useState<SystemStatus | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [lastUpdated, setLastUpdated] = useState<string>('');
|
||||||
|
|
||||||
useEffect(() => {
|
const fetchData = async () => {
|
||||||
const fetchData = async () => {
|
setLoading(true);
|
||||||
const metricsData = await SupabaseService.getMetrics();
|
try {
|
||||||
const ordersData = await SupabaseService.getLiveOrders();
|
const [metricsData, ordersData, statusData] = await Promise.all([
|
||||||
const statusData = await SupabaseService.testConnection();
|
SupabaseService.getMetrics(),
|
||||||
|
SupabaseService.getLiveOrders(),
|
||||||
|
SupabaseService.testConnection()
|
||||||
|
]);
|
||||||
|
|
||||||
setMetrics(metricsData);
|
setMetrics(metricsData);
|
||||||
setLiveOrders(ordersData);
|
setLiveOrders(ordersData);
|
||||||
setSystemStatus(statusData);
|
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();
|
fetchData();
|
||||||
const interval = setInterval(fetchData, 10000);
|
const interval = setInterval(fetchData, 12000);
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<header className="header-bar">
|
{/* Header Bar */}
|
||||||
<h1 className="header-title">Cockpit</h1>
|
<header className="header-bar" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: '16px' }}>
|
||||||
<div className="supabase-live-badge">
|
<div>
|
||||||
<span className="status-dot"></span>
|
<h1 className="header-title" style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||||
<span>Verbunden</span>
|
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>
|
</div>
|
||||||
</header>
|
</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">
|
<section className="kpi-grid">
|
||||||
<StatCard label="Aktive Aufträge" value={metrics ? `${metrics.activeOrdersCount}` : '12'} />
|
|
||||||
<StatCard
|
<StatCard
|
||||||
label="Monteure im Einsatz"
|
label="Aktive Aufträge"
|
||||||
value={metrics ? `${metrics.techniciansInField.active} / ${metrics.techniciansInField.total}` : '8 / 10'}
|
value={metrics ? `${metrics.activeOrdersCount}` : '...'}
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
label="Offene Rechnungen"
|
label="Monteure im Einsatz"
|
||||||
value={metrics ? `€ ${metrics.openInvoicesAmount.toLocaleString('de-DE')}` : '€ 14.250'}
|
value={metrics ? `${metrics.techniciansInField.active} / ${metrics.techniciansInField.total}` : '...'}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Geschätzter Auftragswert"
|
||||||
|
value={metrics ? `€ ${metrics.openInvoicesAmount.toLocaleString('de-DE')}` : '...'}
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
label="Lagerwarnungen"
|
label="Lagerwarnungen"
|
||||||
value={metrics ? `${metrics.inventoryWarningsCount} Teile` : '2 Teile'}
|
value={metrics ? `${metrics.inventoryWarningsCount} Teile` : '...'}
|
||||||
isWarning={true}
|
isWarning={true}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* Main Dashboard Section */}
|
||||||
<section className="dashboard-grid">
|
<section className="dashboard-grid">
|
||||||
<LiveOrdersTable orders={liveOrders} />
|
<LiveOrdersTable orders={liveOrders} />
|
||||||
<SystemStatusCard status={systemStatus} />
|
<SystemStatusCard status={systemStatus} />
|
||||||
|
|||||||
@@ -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
@@ -1,38 +1,202 @@
|
|||||||
import React from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { Plus, UserCheck } from 'lucide-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 = () => {
|
export const DispatchingView: React.FC = () => {
|
||||||
const scheduleData = [
|
const [scheduleData, setScheduleData] = useState<ScheduleItem[]>([]);
|
||||||
{ time: '08:00 - 10:00', technician: 'Max Müller', customer: 'Max Mustermann', task: 'Wartung Heizungsanlage', status: 'In Bearbeitung' },
|
const [loading, setLoading] = useState(true);
|
||||||
{ time: '10:30 - 12:30', technician: 'Stefan Bauer', customer: 'Anja Klein', task: 'Thermostat Einstellung & Kalibrierung', status: 'Geplant' },
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
{ time: '13:00 - 15:00', technician: 'Tom Gerber', customer: 'Hausverwaltung Schmidt', task: 'Sanitär-Rohrreinigung', status: 'In Bearbeitung' },
|
const [editingItem, setEditingItem] = useState<ScheduleItem | null>(null);
|
||||||
{ time: '15:30 - 17:30', technician: 'Max Müller', customer: 'Bäckerei Hoffmann GmbH', task: 'Austausch Umwälzpumpe', status: 'Geplant' },
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<header className="header-bar">
|
{/* Toast Notification */}
|
||||||
<h1 className="header-title">Dispatching & Auftragsdisposition</h1>
|
{toastMessage && (
|
||||||
<button style={{
|
<div style={{
|
||||||
backgroundColor: '#2563eb',
|
position: 'fixed',
|
||||||
|
bottom: '24px',
|
||||||
|
right: '24px',
|
||||||
|
backgroundColor: '#0f172a',
|
||||||
color: '#ffffff',
|
color: '#ffffff',
|
||||||
border: 'none',
|
padding: '14px 20px',
|
||||||
padding: '10px 18px',
|
|
||||||
borderRadius: '10px',
|
borderRadius: '10px',
|
||||||
fontWeight: 600,
|
boxShadow: '0 10px 25px -5px rgba(0, 0, 0, 0.3)',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: '8px',
|
gap: '12px',
|
||||||
cursor: 'pointer'
|
zIndex: 200,
|
||||||
|
fontSize: '0.95rem',
|
||||||
|
fontWeight: 600
|
||||||
}}>
|
}}>
|
||||||
<Plus size={18} /> Neuer Einsatz
|
<CheckCircle size={20} color="#10b981" />
|
||||||
</button>
|
<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>
|
</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">
|
<section className="content-card">
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px', flexWrap: 'wrap', gap: '10px' }}>
|
<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>
|
<h2 className="card-heading" style={{ margin: 0 }}>Tagesdisposition ({scheduleData.length} Einsätze)</h2>
|
||||||
<span style={{ fontSize: '0.9rem', color: '#64748b', fontWeight: 600 }}>10 Monteure im System</span>
|
<span style={{ fontSize: '0.9rem', color: '#64748b', fontWeight: 600 }}>Supabase Sync aktiv</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="table-responsive">
|
<div className="table-responsive">
|
||||||
@@ -41,34 +205,99 @@ export const DispatchingView: React.FC = () => {
|
|||||||
<tr>
|
<tr>
|
||||||
<th>Uhrzeit</th>
|
<th>Uhrzeit</th>
|
||||||
<th>Monteur</th>
|
<th>Monteur</th>
|
||||||
<th>Kunde</th>
|
<th>Kunde & Adresse</th>
|
||||||
<th>Aufgabe / Tätigkeit</th>
|
<th>Aufgabe / Tätigkeit</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
|
<th style={{ textAlign: 'right' }}>Aktionen</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{scheduleData.map((item, idx) => (
|
{scheduleData.map((item) => (
|
||||||
<tr key={idx}>
|
<tr key={item.id}>
|
||||||
<td style={{ fontWeight: 600, color: '#2563eb' }}>{item.time}</td>
|
<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' }}>
|
<td style={{ fontWeight: 600, color: '#0f172a' }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
<UserCheck size={16} color="#64748b" />
|
<UserCheck size={16} color="#64748b" />
|
||||||
{item.technician}
|
{item.technicianName || 'Monteur'}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>{item.customer}</td>
|
|
||||||
<td style={{ color: '#475569' }}>{item.task}</td>
|
|
||||||
<td>
|
<td>
|
||||||
<span className={`badge ${item.status === 'In Bearbeitung' ? 'badge-in-progress' : 'badge-scheduled'}`}>
|
<div style={{ fontWeight: 600 }}>{item.customerName}</div>
|
||||||
{item.status}
|
{item.address && <div style={{ fontSize: '0.8rem', color: '#64748b' }}>{item.address}</div>}
|
||||||
</span>
|
</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>
|
</td>
|
||||||
</tr>
|
</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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ScheduleItemModal
|
||||||
|
isOpen={isModalOpen}
|
||||||
|
onClose={() => setIsModalOpen(false)}
|
||||||
|
onSave={handleSaveItem}
|
||||||
|
initialItem={editingItem}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
@@ -1,60 +1,234 @@
|
|||||||
import React from 'react';
|
import React, { useState } from 'react';
|
||||||
import { AlertTriangle } from 'lucide-react';
|
import { Package, AlertTriangle, Plus, Search, Tag, CheckCircle } from 'lucide-react';
|
||||||
|
|
||||||
export const InventoryView: React.FC = () => {
|
export const InventoryView: React.FC = () => {
|
||||||
const parts = [
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
{ name: 'Buderus Logamax plus GB172-24', category: 'Gas-Brennwertgerät', stock: 4, minStock: 2, status: 'Ausreichend' },
|
const [parts, setParts] = useState([
|
||||||
{ name: 'Bosch EasyControl CT200', category: 'Raumthermostat', stock: 1, minStock: 3, status: 'Nachbestellen' },
|
{ id: '1', name: 'Buderus Logamax plus GB172-24 Brennwert-Set', category: 'Heizgeräte', stock: 4, minStock: 2, price: '1.850,00 €', status: 'Ausreichend' },
|
||||||
{ name: 'Honeywell Hauswasserfilter FF06', category: 'Wasserfilter', stock: 0, minStock: 2, status: 'Kritisch' },
|
{ id: '2', name: 'Bosch EasyControl CT200 Thermostat', category: 'Thermostate & Regelung', stock: 1, minStock: 3, price: '210,00 €', status: 'Nachbestellen' },
|
||||||
{ name: 'Grundfos Magna3 32-120 F', category: 'Heizungsumwälzpumpe', stock: 6, minStock: 3, status: 'Ausreichend' },
|
{ id: '3', name: 'Honeywell Hauswasserfilter FF06 1"', category: 'Sanitär & Filter', stock: 0, minStock: 2, price: '65,00 €', status: 'Kritisch' },
|
||||||
{ name: 'Viessmann Vitodens 200-W', category: 'Gas-Brennwertgerät', stock: 2, minStock: 2, status: 'Ausreichend' },
|
{ 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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<header className="header-bar">
|
{/* Toast Notification */}
|
||||||
<h1 className="header-title">Lager & Teileverwaltung</h1>
|
{toastMessage && (
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', color: '#ef4444', fontWeight: 600, fontSize: '0.9rem' }}>
|
<div style={{
|
||||||
<AlertTriangle size={18} /> 2 Bestandswarnungen
|
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>
|
</div>
|
||||||
</header>
|
</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">
|
<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">
|
<div className="table-responsive">
|
||||||
<table className="orders-table">
|
<table className="orders-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Artikelbezeichnung</th>
|
<th>Artikelbezeichnung</th>
|
||||||
<th>Kategorie</th>
|
<th>Kategorie</th>
|
||||||
|
<th>Einzelpreis</th>
|
||||||
<th>Lagerbestand</th>
|
<th>Lagerbestand</th>
|
||||||
<th>Mindestbestand</th>
|
<th>Mindestbestand</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
|
<th style={{ textAlign: 'right' }}>Bestandsanpassung</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{parts.map((p, idx) => (
|
{filteredParts.map((p) => (
|
||||||
<tr key={idx}>
|
<tr key={p.id}>
|
||||||
<td style={{ fontWeight: 600, color: '#0f172a' }}>{p.name}</td>
|
<td style={{ fontWeight: 700, color: '#0f172a' }}>{p.name}</td>
|
||||||
<td style={{ color: '#64748b' }}>{p.category}</td>
|
<td>
|
||||||
<td style={{ fontWeight: 700, color: p.stock <= p.minStock ? '#ef4444' : '#0f172a' }}>
|
<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.
|
{p.stock} Stk.
|
||||||
</td>
|
</td>
|
||||||
<td>{p.minStock} Stk.</td>
|
<td style={{ color: '#64748b' }}>{p.minStock} Stk.</td>
|
||||||
<td>
|
<td>
|
||||||
<span className={`badge ${
|
<span className="badge" style={{
|
||||||
p.status === 'Kritisch' || p.status === 'Nachbestellen'
|
|
||||||
? 'badge-scheduled'
|
|
||||||
: 'badge-in-progress'
|
|
||||||
}`} style={{
|
|
||||||
backgroundColor: p.status === 'Kritisch' ? '#fef2f2' : p.status === 'Nachbestellen' ? '#fffbeb' : '#dcfce7',
|
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}
|
{p.status}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</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>
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
{filteredParts.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={7} style={{ textAlign: 'center', padding: '30px', color: '#64748b' }}>
|
||||||
|
Keine Ersatzteile gefunden.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+282
-52
@@ -1,69 +1,299 @@
|
|||||||
import React from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { Users, Phone, Mail, MapPin } from 'lucide-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 = () => {
|
export const TechniciansView: React.FC = () => {
|
||||||
const technicians = [
|
const [technicians, setTechnicians] = useState<Technician[]>([]);
|
||||||
{ name: 'Max Müller', role: 'Heizungsbaumeister', status: 'Im Einsatz (Musterweg 12)', phone: '0170 1112233', email: 'm.mueller@handwerksfreund.de' },
|
const [loading, setLoading] = useState(true);
|
||||||
{ name: 'Stefan Bauer', role: 'Anlagenmechaniker SHK', status: 'Unterwegs zu Anja Klein', phone: '0170 2223344', email: 's.bauer@handwerksfreund.de' },
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
{ name: 'Tom Gerber', role: 'Servicetechniker', status: 'Im Einsatz (Hausverwaltung Schmidt)', phone: '0170 3334455', email: 't.gerber@handwerksfreund.de' },
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
{ name: 'Janina Wagner', role: 'Elektrotechnikerin', status: 'Verfügbar / Werkstatt', phone: '0170 4445566', email: 'j.wagner@handwerksfreund.de' },
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<header className="header-bar">
|
{/* Toast Notification */}
|
||||||
<h1 className="header-title">Monteure & Servicekräfte</h1>
|
{toastMessage && (
|
||||||
<span style={{ fontWeight: 600, color: '#2563eb' }}>8 von 10 im Außeneinsatz</span>
|
<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>
|
</header>
|
||||||
|
|
||||||
<section className="content-card">
|
{/* Search Bar */}
|
||||||
<h2 className="card-heading">Mitarbeiterübersicht & Status</h2>
|
<div style={{ marginBottom: '24px', position: 'relative', maxWidth: '400px' }}>
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))', gap: '20px' }}>
|
<Search size={18} color="#94a3b8" style={{ position: 'absolute', left: '14px', top: '12px' }} />
|
||||||
{technicians.map((t, idx) => (
|
<input
|
||||||
<div key={idx} style={{
|
type="text"
|
||||||
border: '1px solid #e2e8f0',
|
placeholder="Monteur nach Name oder Qualifikation suchen..."
|
||||||
borderRadius: '12px',
|
value={searchQuery}
|
||||||
padding: '20px',
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
backgroundColor: '#ffffff'
|
style={{
|
||||||
}}>
|
width: '100%',
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '12px' }}>
|
padding: '10px 14px 10px 42px',
|
||||||
<div style={{
|
borderRadius: '10px',
|
||||||
width: '44px',
|
border: '1px solid #cbd5e1',
|
||||||
height: '44px',
|
backgroundColor: '#ffffff',
|
||||||
borderRadius: '50%',
|
fontSize: '0.95rem'
|
||||||
backgroundColor: '#eff6ff',
|
}}
|
||||||
color: '#2563eb',
|
/>
|
||||||
display: 'flex',
|
</div>
|
||||||
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>
|
|
||||||
|
|
||||||
<div style={{ fontSize: '0.875rem', color: '#334155', display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
<section className="content-card">
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
<h2 className="card-heading">Mitarbeiterübersicht ({filteredTechnicians.length})</h2>
|
||||||
<MapPin size={16} color="#2563eb" />
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))', gap: '20px' }}>
|
||||||
<span style={{ fontWeight: 600 }}>{t.status}</span>
|
{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>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
|
||||||
<Phone size={16} color="#64748b" />
|
<div style={{ paddingTop: '12px', borderTop: '1px solid #f1f5f9', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
<span>{t.phone}</span>
|
<button
|
||||||
</div>
|
onClick={() => handleStatusToggle(t)}
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
style={{ border: 'none', background: 'none', cursor: 'pointer', padding: 0 }}
|
||||||
<Mail size={16} color="#64748b" />
|
>
|
||||||
<span>{t.email}</span>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{filteredTechnicians.length === 0 && (
|
||||||
|
<div style={{ gridColumn: '1 / -1', padding: '40px', textAlign: 'center', color: '#64748b' }}>
|
||||||
|
Keine Monteure gefunden.
|
||||||
</div>
|
</div>
|
||||||
))}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<TechnicianModal
|
||||||
|
isOpen={isModalOpen}
|
||||||
|
onClose={() => setIsModalOpen(false)}
|
||||||
|
onSave={handleSaveTechnician}
|
||||||
|
initialTech={editingTech}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user