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 { Sidebar } from './components/Sidebar';
|
||||
import { DashboardView } from './views/DashboardView';
|
||||
import { CustomersView } from './views/CustomersView';
|
||||
import { DispatchingView } from './views/DispatchingView';
|
||||
import { InventoryView } from './views/InventoryView';
|
||||
import { EquipmentView } from './views/EquipmentView';
|
||||
import { TechniciansView } from './views/TechniciansView';
|
||||
import { TimeTrackingView } from './views/TimeTrackingView';
|
||||
import { SupabaseSyncView } from './views/SupabaseSyncView';
|
||||
|
||||
export const App: React.FC = () => {
|
||||
const [activeTab, setActiveTab] = useState('dashboard');
|
||||
const [activeTab, setActiveTabState] = useState<string>(() => {
|
||||
return localStorage.getItem('handwerksfreund_active_tab') || 'dashboard';
|
||||
});
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
|
||||
const setActiveTab = (tab: string) => {
|
||||
setActiveTabState(tab);
|
||||
localStorage.setItem('handwerksfreund_active_tab', tab);
|
||||
};
|
||||
|
||||
const renderView = () => {
|
||||
switch (activeTab) {
|
||||
case 'dashboard':
|
||||
return <DashboardView />;
|
||||
return <DashboardView onNavigate={setActiveTab} />;
|
||||
case 'kunden':
|
||||
return <CustomersView />;
|
||||
case 'dispatching':
|
||||
return <DispatchingView />;
|
||||
case 'lager':
|
||||
return <InventoryView />;
|
||||
case 'geraete':
|
||||
return <EquipmentView />;
|
||||
case 'monteure':
|
||||
return <TechniciansView />;
|
||||
case 'zeiterfassung':
|
||||
|
||||
@@ -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,
|
||||
Clock,
|
||||
Database,
|
||||
X
|
||||
X,
|
||||
Info
|
||||
} from 'lucide-react';
|
||||
|
||||
interface SidebarProps {
|
||||
@@ -25,8 +26,10 @@ export const Sidebar: React.FC<SidebarProps> = ({
|
||||
}) => {
|
||||
const menuItems = [
|
||||
{ id: 'dashboard', label: 'Dashboard', icon: LayoutDashboard },
|
||||
{ id: 'kunden', label: 'Kunden & Aufträge', icon: Users },
|
||||
{ id: 'dispatching', label: 'Dispatching', icon: CalendarDays },
|
||||
{ id: 'lager', label: 'Lager & Teile', icon: Package },
|
||||
{ id: 'lager', label: 'Ersatzteillager', icon: Package },
|
||||
{ id: 'geraete', label: 'Gerätekartei', icon: Wrench },
|
||||
{ id: 'monteure', label: 'Monteure', icon: Users },
|
||||
{ id: 'zeiterfassung', label: 'Zeiterfassung', icon: Clock },
|
||||
{ id: 'supabase', label: 'Supabase Sync', icon: Database },
|
||||
@@ -49,40 +52,71 @@ export const Sidebar: React.FC<SidebarProps> = ({
|
||||
)}
|
||||
|
||||
<aside className={`sidebar ${isOpen ? 'open' : ''}`}>
|
||||
<div className="brand">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||
<Wrench className="brand-icon" size={24} />
|
||||
<span>Handwerksfreund</span>
|
||||
<div>
|
||||
<div className="brand">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||
<Wrench className="brand-icon" size={24} />
|
||||
<span>Handwerksfreund</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="mobile-close-btn"
|
||||
onClick={onClose}
|
||||
aria-label="Menü schließen"
|
||||
>
|
||||
<X size={24} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="mobile-close-btn"
|
||||
onClick={onClose}
|
||||
aria-label="Menü schließen"
|
||||
>
|
||||
<X size={24} />
|
||||
</button>
|
||||
<nav>
|
||||
<ul className="nav-list">
|
||||
{menuItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = activeTab === item.id;
|
||||
return (
|
||||
<li key={item.id}>
|
||||
<button
|
||||
className={`nav-item ${isActive ? 'active' : ''}`}
|
||||
onClick={() => handleSelect(item.id)}
|
||||
>
|
||||
<Icon className="icon" size={20} />
|
||||
<span>{item.label}</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<nav>
|
||||
<ul className="nav-list">
|
||||
{menuItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = activeTab === item.id;
|
||||
return (
|
||||
<li key={item.id}>
|
||||
<button
|
||||
className={`nav-item ${isActive ? 'active' : ''}`}
|
||||
onClick={() => handleSelect(item.id)}
|
||||
>
|
||||
<Icon className="icon" size={20} />
|
||||
<span>{item.label}</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</nav>
|
||||
{/* Sidebar Footer with Version Badge */}
|
||||
<div style={{
|
||||
marginTop: 'auto',
|
||||
paddingTop: '20px',
|
||||
borderTop: '1px solid #172545',
|
||||
fontSize: '0.8rem',
|
||||
color: '#64748b',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between'
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||
<Info size={14} color="#38bdf8" />
|
||||
<span>Version</span>
|
||||
</div>
|
||||
|
||||
<span style={{
|
||||
backgroundColor: '#172545',
|
||||
color: '#38bdf8',
|
||||
padding: '2px 10px',
|
||||
borderRadius: '12px',
|
||||
fontWeight: 700,
|
||||
fontSize: '0.78rem',
|
||||
border: '1px solid #1e3a8a'
|
||||
}}>
|
||||
v0.1
|
||||
</span>
|
||||
</div>
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
z-index: 50;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
height: 100vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.brand {
|
||||
@@ -175,12 +181,13 @@ body {
|
||||
/* Main Content Area */
|
||||
.main-content {
|
||||
flex: 1;
|
||||
margin-left: 260px;
|
||||
padding: 36px 40px;
|
||||
overflow-y: auto;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 32px;
|
||||
max-width: 100%;
|
||||
max-width: calc(100vw - 260px);
|
||||
}
|
||||
|
||||
/* Top Header Bar */
|
||||
@@ -438,6 +445,8 @@ body {
|
||||
}
|
||||
|
||||
.main-content {
|
||||
margin-left: 0;
|
||||
max-width: 100%;
|
||||
padding: 20px 16px;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
+1119
-6
File diff suppressed because it is too large
Load Diff
+72
-3
@@ -9,13 +9,14 @@ export interface Customer {
|
||||
city: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
distanceKm: number;
|
||||
distanceKm?: number;
|
||||
phone?: string;
|
||||
email?: string;
|
||||
notes?: string;
|
||||
firstContactDate?: string;
|
||||
openOrdersCount: number;
|
||||
openOrdersCount?: number;
|
||||
lastOrderDate?: string;
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
export interface WorkOrder {
|
||||
@@ -28,10 +29,25 @@ export interface WorkOrder {
|
||||
scheduledTime?: string;
|
||||
technicianName?: string;
|
||||
status: OrderStatus;
|
||||
completedAt?: string;
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
export interface OrderChangelog {
|
||||
id: string;
|
||||
orderId: string;
|
||||
authorName: string;
|
||||
note: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export type ScheduleItemType = 'job' | 'routine' | 'lunchBreak' | 'feierabend';
|
||||
|
||||
export interface ScheduleItem {
|
||||
id: string;
|
||||
orderId?: string;
|
||||
customerId?: string;
|
||||
technicianId?: string;
|
||||
title: string;
|
||||
customerName?: string;
|
||||
technicianName?: string;
|
||||
@@ -39,7 +55,59 @@ export interface ScheduleItem {
|
||||
taskDescription?: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
status: 'In Bearbeitung' | 'Geplant' | 'Abgeschlossen';
|
||||
type?: ScheduleItemType;
|
||||
isInProgress?: boolean;
|
||||
isCompleted?: boolean;
|
||||
scheduledDate?: string;
|
||||
status?: 'In Bearbeitung' | 'Geplant' | 'Abgeschlossen';
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
export interface PartDocument {
|
||||
id: string;
|
||||
partId: string;
|
||||
title: string;
|
||||
filePath: string;
|
||||
fileSizeBytes?: number;
|
||||
mimeType?: string;
|
||||
uploadedAt?: string;
|
||||
}
|
||||
|
||||
export interface InstalledPart {
|
||||
id: string;
|
||||
customerId: string;
|
||||
customerName?: string;
|
||||
orderId?: string;
|
||||
name: string;
|
||||
category: string;
|
||||
manufacturer?: string;
|
||||
serialNumber?: string;
|
||||
quantity: number;
|
||||
installedAt?: string;
|
||||
documents?: PartDocument[];
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
export interface OrderAttachment {
|
||||
id: string;
|
||||
orderId: string;
|
||||
title: string;
|
||||
category: 'bauplan' | 'schaltplan' | 'foto_vor_ort' | 'abnahmeprotokoll' | 'sonstiges';
|
||||
filePath: string;
|
||||
fileSizeBytes?: number;
|
||||
mimeType?: string;
|
||||
uploadedAt?: string;
|
||||
}
|
||||
|
||||
export interface Technician {
|
||||
id: string;
|
||||
name: string;
|
||||
role: string;
|
||||
phone?: string;
|
||||
email?: string;
|
||||
status: 'verfuegbar' | 'imEinsatz' | 'abwesend';
|
||||
currentLocation?: string;
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
export interface SystemStatus {
|
||||
@@ -58,3 +126,4 @@ export interface DashboardMetrics {
|
||||
openInvoicesAmount: number;
|
||||
inventoryWarningsCount: number;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,890 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Plus,
|
||||
Search,
|
||||
User,
|
||||
MapPin,
|
||||
Phone,
|
||||
Mail,
|
||||
Edit3,
|
||||
Trash2,
|
||||
ClipboardList,
|
||||
Wrench,
|
||||
ChevronRight,
|
||||
RefreshCw,
|
||||
CheckCircle,
|
||||
FileText,
|
||||
History,
|
||||
MessageSquare,
|
||||
Send,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
UserCheck
|
||||
} from 'lucide-react';
|
||||
import { Customer, WorkOrder, InstalledPart, OrderChangelog } from '../types';
|
||||
import { SupabaseService } from '../services/supabaseService';
|
||||
import { CustomerModal } from '../components/CustomerModal';
|
||||
import { WorkOrderModal } from '../components/WorkOrderModal';
|
||||
import { InstalledPartModal } from '../components/InstalledPartModal';
|
||||
|
||||
export const CustomersView: React.FC = () => {
|
||||
const [customers, setCustomers] = useState<Customer[]>([]);
|
||||
const [workOrders, setWorkOrders] = useState<WorkOrder[]>([]);
|
||||
const [installedParts, setInstalledParts] = useState<InstalledPart[]>([]);
|
||||
const [orderChangelogsMap, setOrderChangelogsMap] = useState<Record<string, OrderChangelog[]>>({});
|
||||
const [expandedChangelogOrders, setExpandedChangelogOrders] = useState<Record<string, boolean>>({});
|
||||
const [newChangelogNoteMap, setNewChangelogNoteMap] = useState<Record<string, string>>({});
|
||||
const [newChangelogAuthorMap, setNewChangelogAuthorMap] = useState<Record<string, string>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
// Selected Customer Detail View
|
||||
const [selectedCustomer, setSelectedCustomer] = useState<Customer | null>(null);
|
||||
|
||||
// Modals state
|
||||
const [isCustomerModalOpen, setIsCustomerModalOpen] = useState(false);
|
||||
const [editingCustomer, setEditingCustomer] = useState<Customer | null>(null);
|
||||
|
||||
const [isOrderModalOpen, setIsOrderModalOpen] = useState(false);
|
||||
const [editingOrder, setEditingOrder] = useState<WorkOrder | null>(null);
|
||||
|
||||
const [isPartModalOpen, setIsPartModalOpen] = useState(false);
|
||||
const [editingPart, setEditingPart] = useState<InstalledPart | null>(null);
|
||||
|
||||
// Toast / Notification
|
||||
const [toastMessage, setToastMessage] = useState<string | null>(null);
|
||||
|
||||
const showNotification = (msg: string) => {
|
||||
setToastMessage(msg);
|
||||
setTimeout(() => setToastMessage(null), 4000);
|
||||
};
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [custData, ordersData, partsData] = await Promise.all([
|
||||
SupabaseService.getCustomers(),
|
||||
SupabaseService.getWorkOrders(),
|
||||
SupabaseService.getInstalledParts()
|
||||
]);
|
||||
setCustomers(custData);
|
||||
setWorkOrders(ordersData);
|
||||
setInstalledParts(partsData);
|
||||
|
||||
// Load changelogs for each order
|
||||
const logsMap: Record<string, OrderChangelog[]> = {};
|
||||
await Promise.all(
|
||||
ordersData.map(async (ord) => {
|
||||
const logs = await SupabaseService.getOrderChangelogs(ord.id);
|
||||
logsMap[ord.id] = logs;
|
||||
})
|
||||
);
|
||||
setOrderChangelogsMap(logsMap);
|
||||
} catch (err) {
|
||||
console.error('Fehler beim Laden der Kundendaten:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddChangelogEntry = async (orderId: string) => {
|
||||
const noteText = newChangelogNoteMap[orderId] || '';
|
||||
const authorName = newChangelogAuthorMap[orderId] || 'Innendienst';
|
||||
if (!noteText.trim()) return;
|
||||
|
||||
try {
|
||||
await SupabaseService.createOrderChangelog({
|
||||
orderId,
|
||||
authorName,
|
||||
note: noteText.trim()
|
||||
});
|
||||
|
||||
showNotification(`Eintrag zum Auftragsverlauf hinzugefügt.`);
|
||||
setNewChangelogNoteMap(prev => ({ ...prev, [orderId]: '' }));
|
||||
await loadData();
|
||||
} catch (err) {
|
||||
console.error('Fehler beim Erstellen des Verlaufeintrags:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteChangelogEntry = async (logId: string) => {
|
||||
if (window.confirm('Verlaufeintrag löschen?')) {
|
||||
await SupabaseService.deleteOrderChangelog(logId);
|
||||
showNotification('Verlaufeintrag gelöscht.');
|
||||
await loadData();
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
// Customer CRUD Handlers
|
||||
const handleSaveCustomer = async (data: Omit<Customer, 'id'> | Customer) => {
|
||||
if ('id' in data) {
|
||||
const updated = await SupabaseService.updateCustomer(data.id, data);
|
||||
showNotification(`Kunde "${updated.name}" erfolgreich aktualisiert.`);
|
||||
} else {
|
||||
const created = await SupabaseService.createCustomer(data);
|
||||
showNotification(`Neuer Kunde "${created.name}" wurde in Supabase angelegt.`);
|
||||
}
|
||||
await loadData();
|
||||
};
|
||||
|
||||
const handleDeleteCustomer = async (cust: Customer) => {
|
||||
if (window.confirm(`Möchten Sie den Kunden "${cust.name}" wirklich löschen?`)) {
|
||||
await SupabaseService.deleteCustomer(cust.id);
|
||||
showNotification(`Kunde "${cust.name}" wurde gelöscht.`);
|
||||
if (selectedCustomer?.id === cust.id) {
|
||||
setSelectedCustomer(null);
|
||||
}
|
||||
await loadData();
|
||||
}
|
||||
};
|
||||
|
||||
// Order CRUD Handlers
|
||||
const handleSaveOrder = async (data: Omit<WorkOrder, 'id'> | WorkOrder) => {
|
||||
if ('id' in data) {
|
||||
const updated = await SupabaseService.updateWorkOrder(data.id, data);
|
||||
showNotification(`Auftrag "${updated.title}" aktualisiert.`);
|
||||
} else {
|
||||
const created = await SupabaseService.createWorkOrder(data);
|
||||
showNotification(`Auftrag "${created.title}" für Kunde erstellt.`);
|
||||
}
|
||||
await loadData();
|
||||
};
|
||||
|
||||
const handleDeleteOrder = async (order: WorkOrder) => {
|
||||
if (window.confirm(`Auftrag "${order.title}" löschen?`)) {
|
||||
await SupabaseService.deleteWorkOrder(order.id);
|
||||
showNotification(`Auftrag "${order.title}" gelöscht.`);
|
||||
await loadData();
|
||||
}
|
||||
};
|
||||
|
||||
// Installed Part CRUD Handlers
|
||||
const handleSavePart = async (data: Omit<InstalledPart, 'id'> | InstalledPart) => {
|
||||
if ('id' in data) {
|
||||
const updated = await SupabaseService.updateInstalledPart(data.id, data);
|
||||
showNotification(`Gerät "${updated.name}" aktualisiert.`);
|
||||
} else {
|
||||
const created = await SupabaseService.createInstalledPart(data);
|
||||
showNotification(`Gerät "${created.name}" zur Kartei hinzugefügt.`);
|
||||
}
|
||||
await loadData();
|
||||
};
|
||||
|
||||
const handleDeletePart = async (part: InstalledPart) => {
|
||||
if (window.confirm(`Gerät "${part.name}" aus Kartei entfernen?`)) {
|
||||
await SupabaseService.deleteInstalledPart(part.id);
|
||||
showNotification(`Gerät "${part.name}" gelöscht.`);
|
||||
await loadData();
|
||||
}
|
||||
};
|
||||
|
||||
// Filtered customers
|
||||
const filteredCustomers = customers.filter(c =>
|
||||
c.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
c.customerNumber.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
c.city.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
const selectedCustomerOrders = workOrders.filter(o => o.customerId === selectedCustomer?.id);
|
||||
const selectedCustomerParts = installedParts.filter(p => p.customerId === selectedCustomer?.id);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Toast Notification */}
|
||||
{toastMessage && (
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
bottom: '24px',
|
||||
right: '24px',
|
||||
backgroundColor: '#0f172a',
|
||||
color: '#ffffff',
|
||||
padding: '14px 20px',
|
||||
borderRadius: '10px',
|
||||
boxShadow: '0 10px 25px -5px rgba(0, 0, 0, 0.3)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
zIndex: 200,
|
||||
fontSize: '0.95rem',
|
||||
fontWeight: 600
|
||||
}}>
|
||||
<CheckCircle size={20} color="#10b981" />
|
||||
<span>{toastMessage}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Header Bar */}
|
||||
<header className="header-bar" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: '16px' }}>
|
||||
<div>
|
||||
<h1 className="header-title">Kunden & Auftragsverwaltung</h1>
|
||||
<p style={{ color: '#64748b', fontSize: '0.9rem', marginTop: '4px' }}>
|
||||
Interaktion mit der Supabase-Datenbank (Vollständiges CRUD)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '12px', flexWrap: 'wrap' }}>
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingCustomer(null);
|
||||
setIsCustomerModalOpen(true);
|
||||
}}
|
||||
style={{
|
||||
backgroundColor: '#2563eb',
|
||||
color: '#ffffff',
|
||||
border: 'none',
|
||||
padding: '10px 18px',
|
||||
borderRadius: '10px',
|
||||
fontWeight: 600,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
<Plus size={18} /> Neuer Kunde
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingOrder(null);
|
||||
setIsOrderModalOpen(true);
|
||||
}}
|
||||
style={{
|
||||
backgroundColor: '#059669',
|
||||
color: '#ffffff',
|
||||
border: 'none',
|
||||
padding: '10px 18px',
|
||||
borderRadius: '10px',
|
||||
fontWeight: 600,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
<ClipboardList size={18} /> Neuer Auftrag
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Search & Actions Bar */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: '24px',
|
||||
gap: '16px',
|
||||
flexWrap: 'wrap'
|
||||
}}>
|
||||
<div style={{ position: 'relative', minWidth: '300px', flex: 1 }}>
|
||||
<Search size={18} color="#94a3b8" style={{ position: 'absolute', left: '14px', top: '12px' }} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Kunde suchen nach Name, Kundennr. oder Stadt..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px 14px 10px 42px',
|
||||
borderRadius: '10px',
|
||||
border: '1px solid #cbd5e1',
|
||||
backgroundColor: '#ffffff',
|
||||
fontSize: '0.95rem'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={loadData}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
padding: '10px 16px',
|
||||
backgroundColor: '#ffffff',
|
||||
border: '1px solid #cbd5e1',
|
||||
borderRadius: '10px',
|
||||
color: '#475569',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
<RefreshCw size={16} className={loading ? 'spin' : ''} />
|
||||
{loading ? 'Lädt...' : 'Aktualisieren'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Main Grid: Customer List + Detail View */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: selectedCustomer ? '1fr 1fr' : '1fr', gap: '24px' }}>
|
||||
{/* Customer List Card */}
|
||||
<section className="content-card">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px' }}>
|
||||
<h2 className="card-heading" style={{ margin: 0 }}>
|
||||
Kundenstamm ({filteredCustomers.length})
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||
{filteredCustomers.map((cust) => {
|
||||
const isSelected = selectedCustomer?.id === cust.id;
|
||||
const custOrdersCount = workOrders.filter(o => o.customerId === cust.id).length;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={cust.id}
|
||||
onClick={() => setSelectedCustomer(cust)}
|
||||
style={{
|
||||
backgroundColor: isSelected ? '#eff6ff' : '#ffffff',
|
||||
border: `1px solid ${isSelected ? '#3b82f6' : '#e2e8f0'}`,
|
||||
borderRadius: '12px',
|
||||
padding: '16px 20px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center'
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '6px' }}>
|
||||
<span style={{
|
||||
backgroundColor: '#e0f2fe',
|
||||
color: '#0369a1',
|
||||
padding: '2px 8px',
|
||||
borderRadius: '6px',
|
||||
fontSize: '0.8rem',
|
||||
fontWeight: 700
|
||||
}}>
|
||||
{cust.customerNumber}
|
||||
</span>
|
||||
<h3 style={{ fontSize: '1.05rem', fontWeight: 700, color: '#0f172a', margin: 0 }}>
|
||||
{cust.name}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '16px', fontSize: '0.85rem', color: '#64748b', flexWrap: 'wrap' }}>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||
<MapPin size={14} color="#94a3b8" />
|
||||
{cust.street}, {cust.zipCode} {cust.city}
|
||||
</span>
|
||||
{cust.phone && (
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||
<Phone size={14} color="#94a3b8" />
|
||||
{cust.phone}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||
<span style={{
|
||||
backgroundColor: custOrdersCount > 0 ? '#fef3c7' : '#f1f5f9',
|
||||
color: custOrdersCount > 0 ? '#b45309' : '#64748b',
|
||||
padding: '4px 10px',
|
||||
borderRadius: '20px',
|
||||
fontSize: '0.8rem',
|
||||
fontWeight: 600
|
||||
}}>
|
||||
{custOrdersCount} Aufträge
|
||||
</span>
|
||||
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setEditingCustomer(cust);
|
||||
setIsCustomerModalOpen(true);
|
||||
}}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
color: '#64748b',
|
||||
padding: '6px',
|
||||
borderRadius: '6px'
|
||||
}}
|
||||
title="Kunde bearbeiten"
|
||||
>
|
||||
<Edit3 size={18} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDeleteCustomer(cust);
|
||||
}}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
color: '#ef4444',
|
||||
padding: '6px',
|
||||
borderRadius: '6px'
|
||||
}}
|
||||
title="Kunde löschen"
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
|
||||
<ChevronRight size={18} color="#94a3b8" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{filteredCustomers.length === 0 && (
|
||||
<div style={{ padding: '40px', textAlign: 'center', color: '#64748b' }}>
|
||||
Keine Kunden gefunden.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Selected Customer Details */}
|
||||
{selectedCustomer && (
|
||||
<section className="content-card">
|
||||
{/* Header */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
borderBottom: '1px solid #e2e8f0',
|
||||
paddingBottom: '16px',
|
||||
marginBottom: '20px'
|
||||
}}>
|
||||
<div>
|
||||
<span style={{ fontSize: '0.85rem', fontWeight: 600, color: '#2563eb' }}>
|
||||
{selectedCustomer.customerNumber}
|
||||
</span>
|
||||
<h2 style={{ fontSize: '1.3rem', fontWeight: 800, color: '#0f172a', margin: '2px 0 6px 0' }}>
|
||||
{selectedCustomer.name}
|
||||
</h2>
|
||||
<div style={{ color: '#475569', fontSize: '0.9rem', display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||
<MapPin size={16} color="#64748b" />
|
||||
{selectedCustomer.street}, {selectedCustomer.zipCode} {selectedCustomer.city}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setSelectedCustomer(null)}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: '#64748b',
|
||||
fontSize: '0.85rem',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
Schließen ✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Quick Contact & Info */}
|
||||
<div style={{
|
||||
backgroundColor: '#f8fafc',
|
||||
borderRadius: '10px',
|
||||
padding: '14px',
|
||||
marginBottom: '24px',
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr 1fr',
|
||||
gap: '12px',
|
||||
fontSize: '0.9rem'
|
||||
}}>
|
||||
<div>
|
||||
<span style={{ color: '#64748b', fontSize: '0.8rem', display: 'block' }}>Telefon:</span>
|
||||
<strong>{selectedCustomer.phone || 'Keine Angabe'}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span style={{ color: '#64748b', fontSize: '0.8rem', display: 'block' }}>E-Mail:</span>
|
||||
<strong>{selectedCustomer.email || 'Keine Angabe'}</strong>
|
||||
</div>
|
||||
{selectedCustomer.notes && (
|
||||
<div style={{ gridColumn: '1 / -1', marginTop: '4px' }}>
|
||||
<span style={{ color: '#64748b', fontSize: '0.8rem', display: 'block' }}>Vor-Ort Notiz:</span>
|
||||
<span style={{ color: '#b45309', fontWeight: 600 }}>{selectedCustomer.notes}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Work Orders Section */}
|
||||
<div style={{ marginBottom: '28px' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '14px' }}>
|
||||
<h3 style={{ fontSize: '1.05rem', fontWeight: 700, color: '#0f172a', margin: 0, display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<ClipboardList size={18} color="#2563eb" />
|
||||
Aufträge ({selectedCustomerOrders.length})
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingOrder(null);
|
||||
setIsOrderModalOpen(true);
|
||||
}}
|
||||
style={{
|
||||
backgroundColor: '#eff6ff',
|
||||
color: '#2563eb',
|
||||
border: '1px solid #bfdbfe',
|
||||
padding: '6px 12px',
|
||||
borderRadius: '8px',
|
||||
fontSize: '0.85rem',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px'
|
||||
}}
|
||||
>
|
||||
<Plus size={14} /> Auftrag hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{selectedCustomerOrders.length > 0 ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
||||
{selectedCustomerOrders.map((ord) => {
|
||||
const changelogs = orderChangelogsMap[ord.id] || [];
|
||||
const isExpanded = Boolean(expandedChangelogOrders[ord.id]);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={ord.id}
|
||||
style={{
|
||||
borderRadius: '12px',
|
||||
border: '1px solid #e2e8f0',
|
||||
backgroundColor: '#ffffff',
|
||||
overflow: 'hidden',
|
||||
boxShadow: '0 2px 4px rgba(0,0,0,0.02)'
|
||||
}}
|
||||
>
|
||||
{/* Order Main Header Bar */}
|
||||
<div style={{
|
||||
padding: '14px 18px',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#f8fafc',
|
||||
borderBottom: isExpanded ? '1px solid #e2e8f0' : 'none'
|
||||
}}>
|
||||
<div>
|
||||
<div style={{ fontWeight: 700, color: '#0f172a', fontSize: '1rem' }}>{ord.title}</div>
|
||||
{ord.description && (
|
||||
<div style={{ fontSize: '0.85rem', color: '#64748b', marginTop: '2px' }}>
|
||||
{ord.description}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ fontSize: '0.8rem', color: '#94a3b8', marginTop: '4px' }}>
|
||||
Ausführungsdatum: {ord.scheduledDate}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
|
||||
<span className={`badge ${
|
||||
ord.status === 'abgeschlossen' ? 'badge-in-progress' : 'badge-scheduled'
|
||||
}`} style={{
|
||||
backgroundColor: ord.status === 'abgeschlossen' ? '#dcfce7' : ord.status === 'inBearbeitung' ? '#dbeafe' : '#fef3c7',
|
||||
color: ord.status === 'abgeschlossen' ? '#15803d' : ord.status === 'inBearbeitung' ? '#1d4ed8' : '#b45309'
|
||||
}}>
|
||||
{ord.status}
|
||||
</span>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingOrder(ord);
|
||||
setIsOrderModalOpen(true);
|
||||
}}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#64748b' }}
|
||||
title="Auftrag bearbeiten"
|
||||
>
|
||||
<Edit3 size={16} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleDeleteOrder(ord)}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#ef4444' }}
|
||||
title="Auftrag löschen"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Changelog Toggle Bar */}
|
||||
<div style={{
|
||||
padding: '8px 18px',
|
||||
backgroundColor: '#ffffff',
|
||||
borderTop: '1px solid #f1f5f9',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center'
|
||||
}}>
|
||||
<button
|
||||
onClick={() => setExpandedChangelogOrders(prev => ({ ...prev, [ord.id]: !prev[ord.id] }))}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: '#2563eb',
|
||||
fontSize: '0.82rem',
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px'
|
||||
}}
|
||||
>
|
||||
<History size={15} />
|
||||
Interner Verlaufs-Changelog ({changelogs.length} Einträge)
|
||||
{isExpanded ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Expandable Changelog Section */}
|
||||
{isExpanded && (
|
||||
<div style={{
|
||||
padding: '16px 18px',
|
||||
backgroundColor: '#f8fafc',
|
||||
borderTop: '1px solid #e2e8f0'
|
||||
}}>
|
||||
{/* New Entry Input Form */}
|
||||
<div style={{
|
||||
backgroundColor: '#ffffff',
|
||||
border: '1px solid #cbd5e1',
|
||||
borderRadius: '10px',
|
||||
padding: '12px',
|
||||
marginBottom: '16px'
|
||||
}}>
|
||||
<div style={{ display: 'flex', gap: '8px', marginBottom: '8px' }}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Autor (z.B. Marc - Innendienst)"
|
||||
value={newChangelogAuthorMap[ord.id] || ''}
|
||||
onChange={(e) => setNewChangelogAuthorMap({ ...newChangelogAuthorMap, [ord.id]: e.target.value })}
|
||||
style={{
|
||||
width: '200px',
|
||||
padding: '6px 10px',
|
||||
borderRadius: '6px',
|
||||
border: '1px solid #cbd5e1',
|
||||
fontSize: '0.82rem'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<textarea
|
||||
placeholder="z.B. Kunden angerufen: Rückmeldung wegen Terminvereinbarung erhalten..."
|
||||
rows={2}
|
||||
value={newChangelogNoteMap[ord.id] || ''}
|
||||
onChange={(e) => setNewChangelogNoteMap({ ...newChangelogNoteMap, [ord.id]: e.target.value })}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '8px 10px',
|
||||
borderRadius: '6px',
|
||||
border: '1px solid #cbd5e1',
|
||||
fontSize: '0.85rem',
|
||||
resize: 'vertical'
|
||||
}}
|
||||
/>
|
||||
|
||||
<button
|
||||
onClick={() => handleAddChangelogEntry(ord.id)}
|
||||
style={{
|
||||
backgroundColor: '#2563eb',
|
||||
color: '#ffffff',
|
||||
border: 'none',
|
||||
padding: '8px 14px',
|
||||
borderRadius: '6px',
|
||||
fontWeight: 600,
|
||||
fontSize: '0.82rem',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
alignSelf: 'flex-end'
|
||||
}}
|
||||
>
|
||||
<Send size={14} /> Eintragen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Changelog Timeline List (Absteigend sortiert) */}
|
||||
{changelogs.length > 0 ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
|
||||
{changelogs.map((log) => {
|
||||
const dateStr = new Date(log.createdAt).toLocaleString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
|
||||
return (
|
||||
<div key={log.id} style={{
|
||||
backgroundColor: '#ffffff',
|
||||
borderRadius: '8px',
|
||||
padding: '10px 14px',
|
||||
border: '1px solid #e2e8f0',
|
||||
borderLeft: '4px solid #2563eb',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start'
|
||||
}}>
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '4px' }}>
|
||||
<span style={{ fontWeight: 700, fontSize: '0.85rem', color: '#0f172a' }}>
|
||||
{log.authorName}
|
||||
</span>
|
||||
<span style={{ fontSize: '0.75rem', color: '#94a3b8' }}>
|
||||
{dateStr}
|
||||
</span>
|
||||
</div>
|
||||
<p style={{ margin: 0, fontSize: '0.875rem', color: '#334155', lineHeight: 1.4 }}>
|
||||
{log.note}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => handleDeleteChangelogEntry(log.id)}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#ef4444', padding: '2px' }}
|
||||
title="Verlaufeintrag löschen"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p style={{ color: '#94a3b8', fontSize: '0.8rem', margin: 0, fontStyle: 'italic' }}>
|
||||
Noch keine Verlaufs-Notizen für diesen Auftrag eingetragen.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p style={{ color: '#94a3b8', fontSize: '0.85rem', fontStyle: 'italic' }}>
|
||||
Noch keine Aufträge für diesen Kunden angelegt.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Installed Parts / Equipment Section */}
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '14px' }}>
|
||||
<h3 style={{ fontSize: '1.05rem', fontWeight: 700, color: '#0f172a', margin: 0, display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<Wrench size={18} color="#059669" />
|
||||
Gerätekartei ({selectedCustomerParts.length})
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingPart(null);
|
||||
setIsPartModalOpen(true);
|
||||
}}
|
||||
style={{
|
||||
backgroundColor: '#ecfdf5',
|
||||
color: '#059669',
|
||||
border: '1px solid #a7f3d0',
|
||||
padding: '6px 12px',
|
||||
borderRadius: '8px',
|
||||
fontSize: '0.85rem',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px'
|
||||
}}
|
||||
>
|
||||
<Plus size={14} /> Gerät hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{selectedCustomerParts.length > 0 ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
|
||||
{selectedCustomerParts.map((part) => (
|
||||
<div
|
||||
key={part.id}
|
||||
style={{
|
||||
padding: '12px 16px',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #e2e8f0',
|
||||
backgroundColor: '#ffffff',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center'
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div style={{ fontWeight: 700, color: '#0f172a' }}>{part.name}</div>
|
||||
<div style={{ fontSize: '0.85rem', color: '#64748b' }}>
|
||||
Kategorie: {part.category} {part.serialNumber && `| S/N: ${part.serialNumber}`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
|
||||
<span style={{ fontSize: '0.85rem', fontWeight: 600, color: '#475569' }}>
|
||||
{part.quantity}x
|
||||
</span>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingPart(part);
|
||||
setIsPartModalOpen(true);
|
||||
}}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#64748b' }}
|
||||
>
|
||||
<Edit3 size={16} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleDeletePart(part)}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#ef4444' }}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p style={{ color: '#94a3b8', fontSize: '0.85rem' }}>
|
||||
Keine verbauten Geräte registriert.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Modals */}
|
||||
<CustomerModal
|
||||
isOpen={isCustomerModalOpen}
|
||||
onClose={() => setIsCustomerModalOpen(false)}
|
||||
onSave={handleSaveCustomer}
|
||||
initialCustomer={editingCustomer}
|
||||
/>
|
||||
|
||||
<WorkOrderModal
|
||||
isOpen={isOrderModalOpen}
|
||||
onClose={() => setIsOrderModalOpen(false)}
|
||||
onSave={handleSaveOrder}
|
||||
customers={customers}
|
||||
initialOrder={editingOrder}
|
||||
defaultCustomerId={selectedCustomer?.id}
|
||||
/>
|
||||
|
||||
<InstalledPartModal
|
||||
isOpen={isPartModalOpen}
|
||||
onClose={() => setIsPartModalOpen(false)}
|
||||
onSave={handleSavePart}
|
||||
customers={customers}
|
||||
initialPart={editingPart}
|
||||
defaultCustomerId={selectedCustomer?.id}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+164
-21
@@ -1,58 +1,201 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { StatCard } from '../components/StatCard';
|
||||
import {
|
||||
StatCard
|
||||
} from '../components/StatCard';
|
||||
import { LiveOrdersTable } from '../components/LiveOrdersTable';
|
||||
import { SystemStatusCard } from '../components/SystemStatusCard';
|
||||
import { SupabaseService } from '../services/supabaseService';
|
||||
import { ScheduleItem, SystemStatus, DashboardMetrics } from '../types';
|
||||
import {
|
||||
RefreshCw,
|
||||
Plus,
|
||||
Users,
|
||||
Wrench,
|
||||
CalendarDays,
|
||||
TrendingUp,
|
||||
CheckCircle2,
|
||||
Activity,
|
||||
ArrowUpRight,
|
||||
Database
|
||||
} from 'lucide-react';
|
||||
|
||||
export const DashboardView: React.FC = () => {
|
||||
interface DashboardViewProps {
|
||||
onNavigate?: (tab: string) => void;
|
||||
}
|
||||
|
||||
export const DashboardView: React.FC<DashboardViewProps> = ({ onNavigate }) => {
|
||||
const [metrics, setMetrics] = useState<DashboardMetrics | null>(null);
|
||||
const [liveOrders, setLiveOrders] = useState<ScheduleItem[]>([]);
|
||||
const [systemStatus, setSystemStatus] = useState<SystemStatus | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [lastUpdated, setLastUpdated] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
const metricsData = await SupabaseService.getMetrics();
|
||||
const ordersData = await SupabaseService.getLiveOrders();
|
||||
const statusData = await SupabaseService.testConnection();
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [metricsData, ordersData, statusData] = await Promise.all([
|
||||
SupabaseService.getMetrics(),
|
||||
SupabaseService.getLiveOrders(),
|
||||
SupabaseService.testConnection()
|
||||
]);
|
||||
|
||||
setMetrics(metricsData);
|
||||
setLiveOrders(ordersData);
|
||||
setSystemStatus(statusData);
|
||||
};
|
||||
setLastUpdated(new Date().toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', second: '2-digit' }));
|
||||
} catch (err) {
|
||||
console.error('Fehler beim Laden der Dashboard-Daten:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
const interval = setInterval(fetchData, 10000);
|
||||
const interval = setInterval(fetchData, 12000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="header-bar">
|
||||
<h1 className="header-title">Cockpit</h1>
|
||||
<div className="supabase-live-badge">
|
||||
<span className="status-dot"></span>
|
||||
<span>Verbunden</span>
|
||||
{/* Header Bar */}
|
||||
<header className="header-bar" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: '16px' }}>
|
||||
<div>
|
||||
<h1 className="header-title" style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||
Betriebs-Cockpit
|
||||
<span style={{ fontSize: '0.85rem', fontWeight: 600, color: '#64748b', backgroundColor: '#f1f5f9', padding: '3px 10px', borderRadius: '12px' }}>
|
||||
Handwerksfreund v0.1
|
||||
</span>
|
||||
</h1>
|
||||
<p style={{ color: '#64748b', fontSize: '0.9rem', marginTop: '4px' }}>
|
||||
Live-Übersicht aller Aufträge, Monteure und Supabase-Datenbankverbindung
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '14px', flexWrap: 'wrap' }}>
|
||||
<div className="supabase-live-badge" style={{ display: 'flex', alignItems: 'center', gap: '8px', backgroundColor: '#ecfdf5', border: '1px solid #a7f3d0', padding: '6px 14px', borderRadius: '20px' }}>
|
||||
<span className="status-dot" style={{ backgroundColor: '#10b981', width: '8px', height: '8px', borderRadius: '50%' }}></span>
|
||||
<span style={{ color: '#047857', fontWeight: 700, fontSize: '0.85rem' }}>
|
||||
Supabase Live ({systemStatus?.latencyMs ?? 18}ms)
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={fetchData}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
padding: '8px 16px',
|
||||
backgroundColor: '#ffffff',
|
||||
border: '1px solid #cbd5e1',
|
||||
borderRadius: '10px',
|
||||
color: '#475569',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
fontSize: '0.85rem'
|
||||
}}
|
||||
>
|
||||
<RefreshCw size={15} className={loading ? 'spin' : ''} />
|
||||
{loading ? 'Lädt...' : `Sync: ${lastUpdated || 'jetzt'}`}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Quick Action Navigation Buttons */}
|
||||
<section style={{
|
||||
display: 'flex',
|
||||
gap: '12px',
|
||||
flexWrap: 'wrap',
|
||||
marginBottom: '10px'
|
||||
}}>
|
||||
{onNavigate && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => onNavigate('kunden')}
|
||||
style={{
|
||||
backgroundColor: '#ffffff',
|
||||
border: '1px solid #cbd5e1',
|
||||
padding: '10px 16px',
|
||||
borderRadius: '10px',
|
||||
fontWeight: 600,
|
||||
color: '#0f172a',
|
||||
fontSize: '0.88rem',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
boxShadow: '0 1px 2px rgba(0,0,0,0.04)'
|
||||
}}
|
||||
>
|
||||
<Users size={16} color="#2563eb" /> Kunden & Aufträge verwalten
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => onNavigate('dispatching')}
|
||||
style={{
|
||||
backgroundColor: '#ffffff',
|
||||
border: '1px solid #cbd5e1',
|
||||
padding: '10px 16px',
|
||||
borderRadius: '10px',
|
||||
fontWeight: 600,
|
||||
color: '#0f172a',
|
||||
fontSize: '0.88rem',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
boxShadow: '0 1px 2px rgba(0,0,0,0.04)'
|
||||
}}
|
||||
>
|
||||
<CalendarDays size={16} color="#059669" /> Dispatcher & Kalender
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => onNavigate('geraete')}
|
||||
style={{
|
||||
backgroundColor: '#ffffff',
|
||||
border: '1px solid #cbd5e1',
|
||||
padding: '10px 16px',
|
||||
borderRadius: '10px',
|
||||
fontWeight: 600,
|
||||
color: '#0f172a',
|
||||
fontSize: '0.88rem',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
boxShadow: '0 1px 2px rgba(0,0,0,0.04)'
|
||||
}}
|
||||
>
|
||||
<Wrench size={16} color="#d97706" /> Gerätekartei öffnen
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* KPI Cards Grid (Echte Supabase Daten) */}
|
||||
<section className="kpi-grid">
|
||||
<StatCard label="Aktive Aufträge" value={metrics ? `${metrics.activeOrdersCount}` : '12'} />
|
||||
<StatCard
|
||||
label="Monteure im Einsatz"
|
||||
value={metrics ? `${metrics.techniciansInField.active} / ${metrics.techniciansInField.total}` : '8 / 10'}
|
||||
<StatCard
|
||||
label="Aktive Aufträge"
|
||||
value={metrics ? `${metrics.activeOrdersCount}` : '...'}
|
||||
/>
|
||||
<StatCard
|
||||
label="Offene Rechnungen"
|
||||
value={metrics ? `€ ${metrics.openInvoicesAmount.toLocaleString('de-DE')}` : '€ 14.250'}
|
||||
label="Monteure im Einsatz"
|
||||
value={metrics ? `${metrics.techniciansInField.active} / ${metrics.techniciansInField.total}` : '...'}
|
||||
/>
|
||||
<StatCard
|
||||
label="Geschätzter Auftragswert"
|
||||
value={metrics ? `€ ${metrics.openInvoicesAmount.toLocaleString('de-DE')}` : '...'}
|
||||
/>
|
||||
<StatCard
|
||||
label="Lagerwarnungen"
|
||||
value={metrics ? `${metrics.inventoryWarningsCount} Teile` : '2 Teile'}
|
||||
value={metrics ? `${metrics.inventoryWarningsCount} Teile` : '...'}
|
||||
isWarning={true}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Main Dashboard Section */}
|
||||
<section className="dashboard-grid">
|
||||
<LiveOrdersTable orders={liveOrders} />
|
||||
<SystemStatusCard status={systemStatus} />
|
||||
|
||||
@@ -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 { Plus, UserCheck } from 'lucide-react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Plus, UserCheck, RefreshCw, Trash2, Edit3, CheckCircle, Clock, CalendarDays, MoveHorizontal } from 'lucide-react';
|
||||
import { ScheduleItem } from '../types';
|
||||
import { SupabaseService } from '../services/supabaseService';
|
||||
import { ScheduleItemModal } from '../components/ScheduleItemModal';
|
||||
import { DispatchingInteractiveView } from './DispatchingInteractiveView';
|
||||
|
||||
export const DispatchingView: React.FC = () => {
|
||||
const scheduleData = [
|
||||
{ time: '08:00 - 10:00', technician: 'Max Müller', customer: 'Max Mustermann', task: 'Wartung Heizungsanlage', status: 'In Bearbeitung' },
|
||||
{ time: '10:30 - 12:30', technician: 'Stefan Bauer', customer: 'Anja Klein', task: 'Thermostat Einstellung & Kalibrierung', status: 'Geplant' },
|
||||
{ time: '13:00 - 15:00', technician: 'Tom Gerber', customer: 'Hausverwaltung Schmidt', task: 'Sanitär-Rohrreinigung', status: 'In Bearbeitung' },
|
||||
{ time: '15:30 - 17:30', technician: 'Max Müller', customer: 'Bäckerei Hoffmann GmbH', task: 'Austausch Umwälzpumpe', status: 'Geplant' },
|
||||
];
|
||||
const [scheduleData, setScheduleData] = useState<ScheduleItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [editingItem, setEditingItem] = useState<ScheduleItem | null>(null);
|
||||
const [toastMessage, setToastMessage] = useState<string | null>(null);
|
||||
|
||||
// View Mode Switcher: 'list' (Standard Tabelle) vs 'interactive' (Drag & Drop Timeline)
|
||||
const [viewMode, setViewMode] = useState<'list' | 'interactive'>('interactive');
|
||||
|
||||
const showNotification = (msg: string) => {
|
||||
setToastMessage(msg);
|
||||
setTimeout(() => setToastMessage(null), 4000);
|
||||
};
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const items = await SupabaseService.getScheduleItems();
|
||||
setScheduleData(items);
|
||||
} catch (err) {
|
||||
console.error('Fehler beim Laden des Tagesplans:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
const handleSaveItem = async (data: Omit<ScheduleItem, 'id'> | ScheduleItem) => {
|
||||
if ('id' in data) {
|
||||
const updated = await SupabaseService.updateScheduleItem(data.id, data);
|
||||
showNotification(`Einsatz "${updated.title}" aktualisiert.`);
|
||||
} else {
|
||||
const created = await SupabaseService.createScheduleItem(data);
|
||||
showNotification(`Neuer Einsatz "${created.title}" in Supabase eingeplant.`);
|
||||
}
|
||||
await loadData();
|
||||
};
|
||||
|
||||
const handleStatusToggle = async (item: ScheduleItem) => {
|
||||
let nextStatus: 'Geplant' | 'In Bearbeitung' | 'Abgeschlossen' = 'In Bearbeitung';
|
||||
if (item.status === 'In Bearbeitung') nextStatus = 'Abgeschlossen';
|
||||
else if (item.status === 'Abgeschlossen') nextStatus = 'Geplant';
|
||||
|
||||
await SupabaseService.updateScheduleItem(item.id, { status: nextStatus });
|
||||
showNotification(`Status von "${item.title}" geändert auf ${nextStatus}.`);
|
||||
await loadData();
|
||||
};
|
||||
|
||||
const handleDeleteItem = async (item: ScheduleItem) => {
|
||||
if (window.confirm(`Einsatz "${item.title}" aus dem Tagesplan löschen?`)) {
|
||||
await SupabaseService.deleteScheduleItem(item.id);
|
||||
showNotification(`Einsatz "${item.title}" gelöscht.`);
|
||||
await loadData();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="header-bar">
|
||||
<h1 className="header-title">Dispatching & Auftragsdisposition</h1>
|
||||
<button style={{
|
||||
backgroundColor: '#2563eb',
|
||||
{/* Toast Notification */}
|
||||
{toastMessage && (
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
bottom: '24px',
|
||||
right: '24px',
|
||||
backgroundColor: '#0f172a',
|
||||
color: '#ffffff',
|
||||
border: 'none',
|
||||
padding: '10px 18px',
|
||||
padding: '14px 20px',
|
||||
borderRadius: '10px',
|
||||
fontWeight: 600,
|
||||
boxShadow: '0 10px 25px -5px rgba(0, 0, 0, 0.3)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
cursor: 'pointer'
|
||||
gap: '12px',
|
||||
zIndex: 200,
|
||||
fontSize: '0.95rem',
|
||||
fontWeight: 600
|
||||
}}>
|
||||
<Plus size={18} /> Neuer Einsatz
|
||||
</button>
|
||||
<CheckCircle size={20} color="#10b981" />
|
||||
<span>{toastMessage}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Header Bar */}
|
||||
<header className="header-bar" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: '16px' }}>
|
||||
<div>
|
||||
<h1 className="header-title">Dispatching & Auftragsdisposition</h1>
|
||||
<p style={{ color: '#64748b', fontSize: '0.9rem', marginTop: '4px' }}>
|
||||
Interaktives Timeline-Board mit Drag & Drop und Zeitraster (06:00 - 20:00 Uhr)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '12px' }}>
|
||||
<button
|
||||
onClick={loadData}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
padding: '10px 16px',
|
||||
backgroundColor: '#ffffff',
|
||||
border: '1px solid #cbd5e1',
|
||||
borderRadius: '10px',
|
||||
color: '#475569',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
<RefreshCw size={16} className={loading ? 'spin' : ''} />
|
||||
{loading ? 'Lädt...' : 'Aktualisieren'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingItem(null);
|
||||
setIsModalOpen(true);
|
||||
}}
|
||||
style={{
|
||||
backgroundColor: '#2563eb',
|
||||
color: '#ffffff',
|
||||
border: 'none',
|
||||
padding: '10px 18px',
|
||||
borderRadius: '10px',
|
||||
fontWeight: 600,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
<Plus size={18} /> Neuer Einsatz
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* View Switcher Tabs */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
borderBottom: '2px solid #e2e8f0',
|
||||
marginBottom: '20px',
|
||||
gap: '24px'
|
||||
}}>
|
||||
<button
|
||||
onClick={() => setViewMode('interactive')}
|
||||
style={{
|
||||
padding: '12px 4px',
|
||||
border: 'none',
|
||||
borderBottom: viewMode === 'interactive' ? '3px solid #2563eb' : '3px solid transparent',
|
||||
backgroundColor: 'transparent',
|
||||
color: viewMode === 'interactive' ? '#2563eb' : '#64748b',
|
||||
fontWeight: 700,
|
||||
fontSize: '1rem',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px'
|
||||
}}
|
||||
>
|
||||
<MoveHorizontal size={18} /> Interaktives Timeline-Board (6:00 - 20:00 Uhr)
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setViewMode('list')}
|
||||
style={{
|
||||
padding: '12px 4px',
|
||||
border: 'none',
|
||||
borderBottom: viewMode === 'list' ? '3px solid #2563eb' : '3px solid transparent',
|
||||
backgroundColor: 'transparent',
|
||||
color: viewMode === 'list' ? '#2563eb' : '#64748b',
|
||||
fontWeight: 700,
|
||||
fontSize: '1rem',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px'
|
||||
}}
|
||||
>
|
||||
<CalendarDays size={18} /> Tabellarische Liste (Standard)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content Rendering based on selected view mode */}
|
||||
{viewMode === 'interactive' ? (
|
||||
<DispatchingInteractiveView />
|
||||
) : (
|
||||
|
||||
<section className="content-card">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px', flexWrap: 'wrap', gap: '10px' }}>
|
||||
<h2 className="card-heading" style={{ margin: 0 }}>Tagesdisposition (Heute)</h2>
|
||||
<span style={{ fontSize: '0.9rem', color: '#64748b', fontWeight: 600 }}>10 Monteure im System</span>
|
||||
<h2 className="card-heading" style={{ margin: 0 }}>Tagesdisposition ({scheduleData.length} Einsätze)</h2>
|
||||
<span style={{ fontSize: '0.9rem', color: '#64748b', fontWeight: 600 }}>Supabase Sync aktiv</span>
|
||||
</div>
|
||||
|
||||
<div className="table-responsive">
|
||||
@@ -41,34 +205,99 @@ export const DispatchingView: React.FC = () => {
|
||||
<tr>
|
||||
<th>Uhrzeit</th>
|
||||
<th>Monteur</th>
|
||||
<th>Kunde</th>
|
||||
<th>Kunde & Adresse</th>
|
||||
<th>Aufgabe / Tätigkeit</th>
|
||||
<th>Status</th>
|
||||
<th style={{ textAlign: 'right' }}>Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{scheduleData.map((item, idx) => (
|
||||
<tr key={idx}>
|
||||
<td style={{ fontWeight: 600, color: '#2563eb' }}>{item.time}</td>
|
||||
{scheduleData.map((item) => (
|
||||
<tr key={item.id}>
|
||||
<td style={{ fontWeight: 600, color: '#2563eb', whiteSpace: 'nowrap' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||
<Clock size={16} />
|
||||
{item.startTime} - {item.endTime}
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ fontWeight: 600, color: '#0f172a' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<UserCheck size={16} color="#64748b" />
|
||||
{item.technician}
|
||||
{item.technicianName || 'Monteur'}
|
||||
</div>
|
||||
</td>
|
||||
<td>{item.customer}</td>
|
||||
<td style={{ color: '#475569' }}>{item.task}</td>
|
||||
<td>
|
||||
<span className={`badge ${item.status === 'In Bearbeitung' ? 'badge-in-progress' : 'badge-scheduled'}`}>
|
||||
{item.status}
|
||||
</span>
|
||||
<div style={{ fontWeight: 600 }}>{item.customerName}</div>
|
||||
{item.address && <div style={{ fontSize: '0.8rem', color: '#64748b' }}>{item.address}</div>}
|
||||
</td>
|
||||
<td>
|
||||
<div style={{ fontWeight: 600, color: '#0f172a' }}>{item.title}</div>
|
||||
{item.taskDescription && <div style={{ fontSize: '0.85rem', color: '#475569' }}>{item.taskDescription}</div>}
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
onClick={() => handleStatusToggle(item)}
|
||||
style={{ border: 'none', background: 'none', cursor: 'pointer', padding: 0 }}
|
||||
title="Klicken zum Ändern des Status"
|
||||
>
|
||||
<span className={`badge ${
|
||||
item.status === 'Abgeschlossen'
|
||||
? 'badge-in-progress'
|
||||
: item.status === 'In Bearbeitung'
|
||||
? 'badge-scheduled'
|
||||
: ''
|
||||
}`} style={{
|
||||
backgroundColor: item.status === 'Abgeschlossen' ? '#dcfce7' : item.status === 'In Bearbeitung' ? '#dbeafe' : '#fef3c7',
|
||||
color: item.status === 'Abgeschlossen' ? '#15803d' : item.status === 'In Bearbeitung' ? '#1d4ed8' : '#b45309'
|
||||
}}>
|
||||
{item.status || 'Geplant'} 🔄
|
||||
</span>
|
||||
</button>
|
||||
</td>
|
||||
<td style={{ textAlign: 'right' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '8px' }}>
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingItem(item);
|
||||
setIsModalOpen(true);
|
||||
}}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#64748b' }}
|
||||
title="Einsatz bearbeiten"
|
||||
>
|
||||
<Edit3 size={18} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleDeleteItem(item)}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#ef4444' }}
|
||||
title="Einsatz löschen"
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
{scheduleData.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={6} style={{ textAlign: 'center', padding: '30px', color: '#64748b' }}>
|
||||
Keine Einsätze für heute vorhanden.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<ScheduleItemModal
|
||||
isOpen={isModalOpen}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
onSave={handleSaveItem}
|
||||
initialItem={editingItem}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 { AlertTriangle } from 'lucide-react';
|
||||
import React, { useState } from 'react';
|
||||
import { Package, AlertTriangle, Plus, Search, Tag, CheckCircle } from 'lucide-react';
|
||||
|
||||
export const InventoryView: React.FC = () => {
|
||||
const parts = [
|
||||
{ name: 'Buderus Logamax plus GB172-24', category: 'Gas-Brennwertgerät', stock: 4, minStock: 2, status: 'Ausreichend' },
|
||||
{ name: 'Bosch EasyControl CT200', category: 'Raumthermostat', stock: 1, minStock: 3, status: 'Nachbestellen' },
|
||||
{ name: 'Honeywell Hauswasserfilter FF06', category: 'Wasserfilter', stock: 0, minStock: 2, status: 'Kritisch' },
|
||||
{ name: 'Grundfos Magna3 32-120 F', category: 'Heizungsumwälzpumpe', stock: 6, minStock: 3, status: 'Ausreichend' },
|
||||
{ name: 'Viessmann Vitodens 200-W', category: 'Gas-Brennwertgerät', stock: 2, minStock: 2, status: 'Ausreichend' },
|
||||
];
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [parts, setParts] = useState([
|
||||
{ id: '1', name: 'Buderus Logamax plus GB172-24 Brennwert-Set', category: 'Heizgeräte', stock: 4, minStock: 2, price: '1.850,00 €', status: 'Ausreichend' },
|
||||
{ id: '2', name: 'Bosch EasyControl CT200 Thermostat', category: 'Thermostate & Regelung', stock: 1, minStock: 3, price: '210,00 €', status: 'Nachbestellen' },
|
||||
{ id: '3', name: 'Honeywell Hauswasserfilter FF06 1"', category: 'Sanitär & Filter', stock: 0, minStock: 2, price: '65,00 €', status: 'Kritisch' },
|
||||
{ id: '4', name: 'Grundfos Magna3 32-120 F Hocheffizienzpumpe', category: 'Pumpen', stock: 6, minStock: 3, price: '540,00 €', status: 'Ausreichend' },
|
||||
{ id: '5', name: 'Viessmann Vitodens 200-W Wandgerät', category: 'Heizgeräte', stock: 2, minStock: 2, price: '2.100,00 €', status: 'Ausreichend' },
|
||||
{ id: '6', name: 'Dichtungs-Set Heizungsbau 50-teilig', category: 'Verbrauchsmaterial', stock: 12, minStock: 5, price: '34,90 €', status: 'Ausreichend' },
|
||||
{ id: '7', name: 'Kupferrohr 22mm x 1mm (2m Stange)', category: 'Rohre & Fittinge', stock: 3, minStock: 10, price: '18,50 €', status: 'Nachbestellen' }
|
||||
]);
|
||||
|
||||
const [toastMessage, setToastMessage] = useState<string | null>(null);
|
||||
|
||||
const showNotification = (msg: string) => {
|
||||
setToastMessage(msg);
|
||||
setTimeout(() => setToastMessage(null), 4000);
|
||||
};
|
||||
|
||||
const handleStockUpdate = (id: string, delta: number) => {
|
||||
setParts(prev => prev.map(p => {
|
||||
if (p.id === id) {
|
||||
const newStock = Math.max(0, p.stock + delta);
|
||||
let newStatus = 'Ausreichend';
|
||||
if (newStock === 0) newStatus = 'Kritisch';
|
||||
else if (newStock <= p.minStock) newStatus = 'Nachbestellen';
|
||||
|
||||
showNotification(`Bestand von "${p.name}" aktualisiert auf ${newStock} Stk.`);
|
||||
return { ...p, stock: newStock, status: newStatus };
|
||||
}
|
||||
return p;
|
||||
}));
|
||||
};
|
||||
|
||||
const filteredParts = parts.filter(p =>
|
||||
p.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
p.category.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
const warningCount = parts.filter(p => p.stock <= p.minStock).length;
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="header-bar">
|
||||
<h1 className="header-title">Lager & Teileverwaltung</h1>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', color: '#ef4444', fontWeight: 600, fontSize: '0.9rem' }}>
|
||||
<AlertTriangle size={18} /> 2 Bestandswarnungen
|
||||
{/* Toast Notification */}
|
||||
{toastMessage && (
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
bottom: '24px',
|
||||
right: '24px',
|
||||
backgroundColor: '#0f172a',
|
||||
color: '#ffffff',
|
||||
padding: '14px 20px',
|
||||
borderRadius: '10px',
|
||||
boxShadow: '0 10px 25px -5px rgba(0, 0, 0, 0.3)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
zIndex: 200,
|
||||
fontSize: '0.95rem',
|
||||
fontWeight: 600
|
||||
}}>
|
||||
<CheckCircle size={20} color="#10b981" />
|
||||
<span>{toastMessage}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Header Bar */}
|
||||
<header className="header-bar" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: '16px' }}>
|
||||
<div>
|
||||
<h1 className="header-title">Ersatzteillager & Bestand</h1>
|
||||
<p style={{ color: '#64748b', fontSize: '0.9rem', marginTop: '4px' }}>
|
||||
Ersatzteilbestände, Mindestbestände und automatisierte Nachbestellwarnungen
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
|
||||
{warningCount > 0 && (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
color: '#ef4444',
|
||||
backgroundColor: '#fef2f2',
|
||||
padding: '8px 14px',
|
||||
borderRadius: '10px',
|
||||
fontWeight: 700,
|
||||
fontSize: '0.9rem'
|
||||
}}>
|
||||
<AlertTriangle size={18} /> {warningCount} Bestandswarnungen
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => alert('Neuer Lagerartikel Anlege-Dialog')}
|
||||
style={{
|
||||
backgroundColor: '#2563eb',
|
||||
color: '#ffffff',
|
||||
border: 'none',
|
||||
padding: '10px 18px',
|
||||
borderRadius: '10px',
|
||||
fontWeight: 600,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
<Plus size={18} /> Artikel anlegen
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Search Bar */}
|
||||
<div style={{ marginBottom: '24px', position: 'relative', maxWidth: '400px' }}>
|
||||
<Search size={18} color="#94a3b8" style={{ position: 'absolute', left: '14px', top: '12px' }} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Lagerartikel oder Kategorie suchen..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px 14px 10px 42px',
|
||||
borderRadius: '10px',
|
||||
border: '1px solid #cbd5e1',
|
||||
backgroundColor: '#ffffff',
|
||||
fontSize: '0.95rem'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<section className="content-card">
|
||||
<h2 className="card-heading">Ersatzteilbestand & Bestellwarnungen</h2>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px' }}>
|
||||
<h2 className="card-heading" style={{ margin: 0 }}>Ersatzteilbestand ({filteredParts.length} Artikel)</h2>
|
||||
<span style={{ fontSize: '0.85rem', color: '#64748b', fontWeight: 600 }}>Lagerort: Hauptlager Halle A</span>
|
||||
</div>
|
||||
|
||||
<div className="table-responsive">
|
||||
<table className="orders-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Artikelbezeichnung</th>
|
||||
<th>Kategorie</th>
|
||||
<th>Einzelpreis</th>
|
||||
<th>Lagerbestand</th>
|
||||
<th>Mindestbestand</th>
|
||||
<th>Status</th>
|
||||
<th style={{ textAlign: 'right' }}>Bestandsanpassung</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{parts.map((p, idx) => (
|
||||
<tr key={idx}>
|
||||
<td style={{ fontWeight: 600, color: '#0f172a' }}>{p.name}</td>
|
||||
<td style={{ color: '#64748b' }}>{p.category}</td>
|
||||
<td style={{ fontWeight: 700, color: p.stock <= p.minStock ? '#ef4444' : '#0f172a' }}>
|
||||
{filteredParts.map((p) => (
|
||||
<tr key={p.id}>
|
||||
<td style={{ fontWeight: 700, color: '#0f172a' }}>{p.name}</td>
|
||||
<td>
|
||||
<span style={{
|
||||
backgroundColor: '#f1f5f9',
|
||||
color: '#475569',
|
||||
padding: '3px 8px',
|
||||
borderRadius: '6px',
|
||||
fontSize: '0.8rem',
|
||||
fontWeight: 600,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px'
|
||||
}}>
|
||||
<Tag size={12} /> {p.category}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ fontWeight: 600, color: '#0f172a' }}>{p.price}</td>
|
||||
<td style={{ fontWeight: 800, fontSize: '1rem', color: p.stock <= p.minStock ? '#ef4444' : '#0f172a' }}>
|
||||
{p.stock} Stk.
|
||||
</td>
|
||||
<td>{p.minStock} Stk.</td>
|
||||
<td style={{ color: '#64748b' }}>{p.minStock} Stk.</td>
|
||||
<td>
|
||||
<span className={`badge ${
|
||||
p.status === 'Kritisch' || p.status === 'Nachbestellen'
|
||||
? 'badge-scheduled'
|
||||
: 'badge-in-progress'
|
||||
}`} style={{
|
||||
<span className="badge" style={{
|
||||
backgroundColor: p.status === 'Kritisch' ? '#fef2f2' : p.status === 'Nachbestellen' ? '#fffbeb' : '#dcfce7',
|
||||
color: p.status === 'Kritisch' ? '#991b1b' : p.status === 'Nachbestellen' ? '#b45309' : '#15803d'
|
||||
color: p.status === 'Kritisch' ? '#991b1b' : p.status === 'Nachbestellen' ? '#b45309' : '#15803d',
|
||||
fontWeight: 700
|
||||
}}>
|
||||
{p.status}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ textAlign: 'right' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '6px' }}>
|
||||
<button
|
||||
onClick={() => handleStockUpdate(p.id, -1)}
|
||||
style={{
|
||||
width: '28px',
|
||||
height: '28px',
|
||||
borderRadius: '6px',
|
||||
border: '1px solid #cbd5e1',
|
||||
backgroundColor: '#ffffff',
|
||||
cursor: 'pointer',
|
||||
fontWeight: 700
|
||||
}}
|
||||
title="1 Stk. entnehmen"
|
||||
>
|
||||
-
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleStockUpdate(p.id, 1)}
|
||||
style={{
|
||||
width: '28px',
|
||||
height: '28px',
|
||||
borderRadius: '6px',
|
||||
border: '1px solid #cbd5e1',
|
||||
backgroundColor: '#ffffff',
|
||||
cursor: 'pointer',
|
||||
fontWeight: 700
|
||||
}}
|
||||
title="1 Stk. zubuchen"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
{filteredParts.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={7} style={{ textAlign: 'center', padding: '30px', color: '#64748b' }}>
|
||||
Keine Ersatzteile gefunden.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
+282
-52
@@ -1,69 +1,299 @@
|
||||
import React from 'react';
|
||||
import { Users, Phone, Mail, MapPin } from 'lucide-react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Users,
|
||||
Phone,
|
||||
Mail,
|
||||
MapPin,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Edit3,
|
||||
Trash2,
|
||||
CheckCircle,
|
||||
Search,
|
||||
UserCheck
|
||||
} from 'lucide-react';
|
||||
import { Technician } from '../types';
|
||||
import { SupabaseService } from '../services/supabaseService';
|
||||
import { TechnicianModal } from '../components/TechnicianModal';
|
||||
|
||||
export const TechniciansView: React.FC = () => {
|
||||
const technicians = [
|
||||
{ name: 'Max Müller', role: 'Heizungsbaumeister', status: 'Im Einsatz (Musterweg 12)', phone: '0170 1112233', email: 'm.mueller@handwerksfreund.de' },
|
||||
{ name: 'Stefan Bauer', role: 'Anlagenmechaniker SHK', status: 'Unterwegs zu Anja Klein', phone: '0170 2223344', email: 's.bauer@handwerksfreund.de' },
|
||||
{ name: 'Tom Gerber', role: 'Servicetechniker', status: 'Im Einsatz (Hausverwaltung Schmidt)', phone: '0170 3334455', email: 't.gerber@handwerksfreund.de' },
|
||||
{ name: 'Janina Wagner', role: 'Elektrotechnikerin', status: 'Verfügbar / Werkstatt', phone: '0170 4445566', email: 'j.wagner@handwerksfreund.de' },
|
||||
];
|
||||
const [technicians, setTechnicians] = useState<Technician[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [editingTech, setEditingTech] = useState<Technician | null>(null);
|
||||
const [toastMessage, setToastMessage] = useState<string | null>(null);
|
||||
|
||||
const showNotification = (msg: string) => {
|
||||
setToastMessage(msg);
|
||||
setTimeout(() => setToastMessage(null), 4000);
|
||||
};
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await SupabaseService.getTechnicians();
|
||||
setTechnicians(data);
|
||||
} catch (err) {
|
||||
console.error('Fehler beim Laden der Techniker:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
const handleSaveTechnician = async (data: Omit<Technician, 'id'> | Technician) => {
|
||||
if ('id' in data) {
|
||||
const updated = await SupabaseService.updateTechnician(data.id, data);
|
||||
showNotification(`Monteur "${updated.name}" aktualisiert.`);
|
||||
} else {
|
||||
const created = await SupabaseService.createTechnician(data);
|
||||
showNotification(`Neuer Monteur "${created.name}" in Supabase registriert.`);
|
||||
}
|
||||
await loadData();
|
||||
};
|
||||
|
||||
const handleStatusToggle = async (tech: Technician) => {
|
||||
let nextStatus: 'verfuegbar' | 'imEinsatz' | 'abwesend' = 'imEinsatz';
|
||||
if (tech.status === 'imEinsatz') nextStatus = 'verfuegbar';
|
||||
else if (tech.status === 'verfuegbar') nextStatus = 'abwesend';
|
||||
|
||||
await SupabaseService.updateTechnician(tech.id, { status: nextStatus });
|
||||
showNotification(`Status von "${tech.name}" geändert.`);
|
||||
await loadData();
|
||||
};
|
||||
|
||||
const handleDeleteTechnician = async (tech: Technician) => {
|
||||
if (window.confirm(`Möchten Sie den Monteur "${tech.name}" wirklich löschen?`)) {
|
||||
await SupabaseService.deleteTechnician(tech.id);
|
||||
showNotification(`Monteur "${tech.name}" gelöscht.`);
|
||||
await loadData();
|
||||
}
|
||||
};
|
||||
|
||||
const filteredTechnicians = technicians.filter(t =>
|
||||
t.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
t.role.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
const activeInField = technicians.filter(t => t.status === 'imEinsatz').length;
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="header-bar">
|
||||
<h1 className="header-title">Monteure & Servicekräfte</h1>
|
||||
<span style={{ fontWeight: 600, color: '#2563eb' }}>8 von 10 im Außeneinsatz</span>
|
||||
{/* Toast Notification */}
|
||||
{toastMessage && (
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
bottom: '24px',
|
||||
right: '24px',
|
||||
backgroundColor: '#0f172a',
|
||||
color: '#ffffff',
|
||||
padding: '14px 20px',
|
||||
borderRadius: '10px',
|
||||
boxShadow: '0 10px 25px -5px rgba(0, 0, 0, 0.3)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
zIndex: 200,
|
||||
fontSize: '0.95rem',
|
||||
fontWeight: 600
|
||||
}}>
|
||||
<CheckCircle size={20} color="#10b981" />
|
||||
<span>{toastMessage}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Header Bar */}
|
||||
<header className="header-bar" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: '16px' }}>
|
||||
<div>
|
||||
<h1 className="header-title">Monteure & Servicekräfte</h1>
|
||||
<p style={{ color: '#64748b', fontSize: '0.9rem', marginTop: '4px' }}>
|
||||
<span style={{ fontWeight: 600, color: '#2563eb' }}>{activeInField} von {technicians.length} Monteuren</span> aktuell im Außeneinsatz
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '12px' }}>
|
||||
<button
|
||||
onClick={loadData}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
padding: '10px 16px',
|
||||
backgroundColor: '#ffffff',
|
||||
border: '1px solid #cbd5e1',
|
||||
borderRadius: '10px',
|
||||
color: '#475569',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
<RefreshCw size={16} className={loading ? 'spin' : ''} />
|
||||
{loading ? 'Lädt...' : 'Aktualisieren'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingTech(null);
|
||||
setIsModalOpen(true);
|
||||
}}
|
||||
style={{
|
||||
backgroundColor: '#2563eb',
|
||||
color: '#ffffff',
|
||||
border: 'none',
|
||||
padding: '10px 18px',
|
||||
borderRadius: '10px',
|
||||
fontWeight: 600,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
<Plus size={18} /> Neuer Monteur
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="content-card">
|
||||
<h2 className="card-heading">Mitarbeiterübersicht & Status</h2>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))', gap: '20px' }}>
|
||||
{technicians.map((t, idx) => (
|
||||
<div key={idx} style={{
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: '12px',
|
||||
padding: '20px',
|
||||
backgroundColor: '#ffffff'
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '12px' }}>
|
||||
<div style={{
|
||||
width: '44px',
|
||||
height: '44px',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: '#eff6ff',
|
||||
color: '#2563eb',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontWeight: '700'
|
||||
}}>
|
||||
{t.name.split(' ').map(n => n[0]).join('')}
|
||||
</div>
|
||||
<div>
|
||||
<h3 style={{ fontSize: '1.05rem', fontWeight: 700, color: '#0f172a' }}>{t.name}</h3>
|
||||
<p style={{ fontSize: '0.85rem', color: '#64748b' }}>{t.role}</p>
|
||||
</div>
|
||||
</div>
|
||||
{/* Search Bar */}
|
||||
<div style={{ marginBottom: '24px', position: 'relative', maxWidth: '400px' }}>
|
||||
<Search size={18} color="#94a3b8" style={{ position: 'absolute', left: '14px', top: '12px' }} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Monteur nach Name oder Qualifikation suchen..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px 14px 10px 42px',
|
||||
borderRadius: '10px',
|
||||
border: '1px solid #cbd5e1',
|
||||
backgroundColor: '#ffffff',
|
||||
fontSize: '0.95rem'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ fontSize: '0.875rem', color: '#334155', display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<MapPin size={16} color="#2563eb" />
|
||||
<span style={{ fontWeight: 600 }}>{t.status}</span>
|
||||
<section className="content-card">
|
||||
<h2 className="card-heading">Mitarbeiterübersicht ({filteredTechnicians.length})</h2>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))', gap: '20px' }}>
|
||||
{filteredTechnicians.map((t) => {
|
||||
const initials = t.name.split(' ').map(n => n[0]).join('');
|
||||
|
||||
return (
|
||||
<div key={t.id} style={{
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: '12px',
|
||||
padding: '20px',
|
||||
backgroundColor: '#ffffff',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'space-between',
|
||||
boxShadow: '0 2px 4px rgba(0,0,0,0.02)'
|
||||
}}>
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '12px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||
<div style={{
|
||||
width: '44px',
|
||||
height: '44px',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: t.status === 'imEinsatz' ? '#dbeafe' : t.status === 'verfuegbar' ? '#dcfce7' : '#f1f5f9',
|
||||
color: t.status === 'imEinsatz' ? '#1d4ed8' : t.status === 'verfuegbar' ? '#15803d' : '#64748b',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontWeight: '700',
|
||||
fontSize: '1rem'
|
||||
}}>
|
||||
{initials}
|
||||
</div>
|
||||
<div>
|
||||
<h3 style={{ fontSize: '1.05rem', fontWeight: 700, color: '#0f172a', margin: 0 }}>{t.name}</h3>
|
||||
<p style={{ fontSize: '0.85rem', color: '#64748b', margin: '2px 0 0 0' }}>{t.role}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '4px' }}>
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingTech(t);
|
||||
setIsModalOpen(true);
|
||||
}}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#64748b', padding: '4px' }}
|
||||
title="Monteur bearbeiten"
|
||||
>
|
||||
<Edit3 size={16} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleDeleteTechnician(t)}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#ef4444', padding: '4px' }}
|
||||
title="Monteur löschen"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ fontSize: '0.875rem', color: '#334155', display: 'flex', flexDirection: 'column', gap: '8px', margin: '14px 0' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<MapPin size={16} color="#2563eb" />
|
||||
<span style={{ fontWeight: 600 }}>{t.currentLocation || 'Werkstatt'}</span>
|
||||
</div>
|
||||
{t.phone && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<Phone size={16} color="#64748b" />
|
||||
<span>{t.phone}</span>
|
||||
</div>
|
||||
)}
|
||||
{t.email && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<Mail size={16} color="#64748b" />
|
||||
<span>{t.email}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<Phone size={16} color="#64748b" />
|
||||
<span>{t.phone}</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<Mail size={16} color="#64748b" />
|
||||
<span>{t.email}</span>
|
||||
|
||||
<div style={{ paddingTop: '12px', borderTop: '1px solid #f1f5f9', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<button
|
||||
onClick={() => handleStatusToggle(t)}
|
||||
style={{ border: 'none', background: 'none', cursor: 'pointer', padding: 0 }}
|
||||
>
|
||||
<span style={{
|
||||
backgroundColor: t.status === 'imEinsatz' ? '#dbeafe' : t.status === 'verfuegbar' ? '#dcfce7' : '#fef3c7',
|
||||
color: t.status === 'imEinsatz' ? '#1d4ed8' : t.status === 'verfuegbar' ? '#15803d' : '#b45309',
|
||||
padding: '4px 10px',
|
||||
borderRadius: '20px',
|
||||
fontSize: '0.8rem',
|
||||
fontWeight: 700
|
||||
}}>
|
||||
{t.status === 'imEinsatz' ? 'Im Einsatz' : t.status === 'verfuegbar' ? 'Verfügbar' : 'Abwesend'} 🔄
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{filteredTechnicians.length === 0 && (
|
||||
<div style={{ gridColumn: '1 / -1', padding: '40px', textAlign: 'center', color: '#64748b' }}>
|
||||
Keine Monteure gefunden.
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<TechnicianModal
|
||||
isOpen={isModalOpen}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
onSave={handleSaveTechnician}
|
||||
initialTech={editingTech}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user