Neuste Version der Seite mit Drag n Drop

This commit is contained in:
MarcWieland
2026-07-29 17:24:34 +02:00
parent a405bfb91f
commit 2a0d65de2f
18 changed files with 6572 additions and 175 deletions
+371
View File
@@ -0,0 +1,371 @@
import React, { useState, useEffect } from 'react';
import { X, Save, Building, MapPin, Phone, Mail, FileText } from 'lucide-react';
import { Customer } from '../types';
interface CustomerModalProps {
isOpen: boolean;
onClose: () => void;
onSave: (customer: Omit<Customer, 'id'> | Customer) => Promise<void>;
initialCustomer?: Customer | null;
}
export const CustomerModal: React.FC<CustomerModalProps> = ({
isOpen,
onClose,
onSave,
initialCustomer
}) => {
const [formData, setFormData] = useState({
customerNumber: '',
name: '',
street: '',
zipCode: '',
city: '',
phone: '',
email: '',
notes: '',
latitude: 50.1109,
longitude: 8.6821,
distanceKm: 2.5
});
const [isSaving, setIsSaving] = useState(false);
const [errorMsg, setErrorMsg] = useState('');
useEffect(() => {
if (initialCustomer) {
setFormData({
customerNumber: initialCustomer.customerNumber || '',
name: initialCustomer.name || '',
street: initialCustomer.street || '',
zipCode: initialCustomer.zipCode || '',
city: initialCustomer.city || '',
phone: initialCustomer.phone || '',
email: initialCustomer.email || '',
notes: initialCustomer.notes || '',
latitude: initialCustomer.latitude || 50.1109,
longitude: initialCustomer.longitude || 8.6821,
distanceKm: initialCustomer.distanceKm || 2.5
});
} else {
setFormData({
customerNumber: `K-${Math.floor(10000 + Math.random() * 90000)}`,
name: '',
street: '',
zipCode: '',
city: '',
phone: '',
email: '',
notes: '',
latitude: 50.1109,
longitude: 8.6821,
distanceKm: 2.5
});
}
setErrorMsg('');
}, [initialCustomer, isOpen]);
if (!isOpen) return null;
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!formData.name.trim() || !formData.street.trim() || !formData.city.trim()) {
setErrorMsg('Bitte Name, Straße und Ort ausfüllen.');
return;
}
try {
setIsSaving(true);
setErrorMsg('');
if (initialCustomer) {
await onSave({ ...initialCustomer, ...formData });
} else {
await onSave(formData);
}
onClose();
} catch (err: any) {
setErrorMsg(err.message || 'Fehler beim Speichern des Kunden');
} finally {
setIsSaving(false);
}
};
return (
<div style={{
position: 'fixed',
inset: 0,
backgroundColor: 'rgba(15, 23, 42, 0.6)',
backdropFilter: 'blur(4px)',
zIndex: 100,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '20px'
}}>
<div style={{
backgroundColor: '#ffffff',
borderRadius: '16px',
maxWidth: '600px',
width: '100%',
maxHeight: '90vh',
overflowY: 'auto',
boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.25)',
border: '1px solid #e2e8f0'
}}>
{/* Modal Header */}
<div style={{
padding: '20px 24px',
borderBottom: '1px solid #e2e8f0',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
backgroundColor: '#f8fafc',
borderTopLeftRadius: '16px',
borderTopRightRadius: '16px'
}}>
<h2 style={{ fontSize: '1.25rem', fontWeight: 700, color: '#0f172a', margin: 0, display: 'flex', alignItems: 'center', gap: '10px' }}>
<Building size={22} color="#2563eb" />
{initialCustomer ? 'Kunde bearbeiten' : 'Neuen Kunden anlegen'}
</h2>
<button
onClick={onClose}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#64748b', borderRadius: '8px', padding: '4px' }}
>
<X size={20} />
</button>
</div>
{/* Form Body */}
<form onSubmit={handleSubmit} style={{ padding: '24px' }}>
{errorMsg && (
<div style={{
backgroundColor: '#fef2f2',
color: '#991b1b',
padding: '12px 16px',
borderRadius: '8px',
marginBottom: '20px',
fontSize: '0.9rem',
fontWeight: 500
}}>
{errorMsg}
</div>
)}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 2fr', gap: '16px', marginBottom: '16px' }}>
<div>
<label style={{ display: 'block', fontSize: '0.85rem', fontWeight: 600, color: '#475569', marginBottom: '6px' }}>
Kundennummer
</label>
<input
type="text"
value={formData.customerNumber}
onChange={(e) => setFormData({ ...formData, customerNumber: e.target.value })}
required
style={{
width: '100%',
padding: '10px 14px',
borderRadius: '8px',
border: '1px solid #cbd5e1',
fontSize: '0.95rem'
}}
/>
</div>
<div>
<label style={{ display: 'block', fontSize: '0.85rem', fontWeight: 600, color: '#475569', marginBottom: '6px' }}>
Name / Firma *
</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="z.B. Max Mustermann"
required
style={{
width: '100%',
padding: '10px 14px',
borderRadius: '8px',
border: '1px solid #cbd5e1',
fontSize: '0.95rem'
}}
/>
</div>
</div>
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', fontSize: '0.85rem', fontWeight: 600, color: '#475569', marginBottom: '6px' }}>
Straße & Hausnummer *
</label>
<div style={{ position: 'relative' }}>
<MapPin size={18} color="#94a3b8" style={{ position: 'absolute', left: '12px', top: '12px' }} />
<input
type="text"
value={formData.street}
onChange={(e) => setFormData({ ...formData, street: e.target.value })}
placeholder="Musterweg 12"
required
style={{
width: '100%',
padding: '10px 14px 10px 38px',
borderRadius: '8px',
border: '1px solid #cbd5e1',
fontSize: '0.95rem'
}}
/>
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 2fr', gap: '16px', marginBottom: '16px' }}>
<div>
<label style={{ display: 'block', fontSize: '0.85rem', fontWeight: 600, color: '#475569', marginBottom: '6px' }}>
PLZ *
</label>
<input
type="text"
value={formData.zipCode}
onChange={(e) => setFormData({ ...formData, zipCode: e.target.value })}
placeholder="12345"
required
style={{
width: '100%',
padding: '10px 14px',
borderRadius: '8px',
border: '1px solid #cbd5e1',
fontSize: '0.95rem'
}}
/>
</div>
<div>
<label style={{ display: 'block', fontSize: '0.85rem', fontWeight: 600, color: '#475569', marginBottom: '6px' }}>
Ort *
</label>
<input
type="text"
value={formData.city}
onChange={(e) => setFormData({ ...formData, city: e.target.value })}
placeholder="Musterstadt"
required
style={{
width: '100%',
padding: '10px 14px',
borderRadius: '8px',
border: '1px solid #cbd5e1',
fontSize: '0.95rem'
}}
/>
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px', marginBottom: '16px' }}>
<div>
<label style={{ display: 'block', fontSize: '0.85rem', fontWeight: 600, color: '#475569', marginBottom: '6px' }}>
Telefon
</label>
<div style={{ position: 'relative' }}>
<Phone size={18} color="#94a3b8" style={{ position: 'absolute', left: '12px', top: '12px' }} />
<input
type="text"
value={formData.phone}
onChange={(e) => setFormData({ ...formData, phone: e.target.value })}
placeholder="0171 1234567"
style={{
width: '100%',
padding: '10px 14px 10px 38px',
borderRadius: '8px',
border: '1px solid #cbd5e1',
fontSize: '0.95rem'
}}
/>
</div>
</div>
<div>
<label style={{ display: 'block', fontSize: '0.85rem', fontWeight: 600, color: '#475569', marginBottom: '6px' }}>
E-Mail
</label>
<div style={{ position: 'relative' }}>
<Mail size={18} color="#94a3b8" style={{ position: 'absolute', left: '12px', top: '12px' }} />
<input
type="email"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
placeholder="kunden@email.de"
style={{
width: '100%',
padding: '10px 14px 10px 38px',
borderRadius: '8px',
border: '1px solid #cbd5e1',
fontSize: '0.95rem'
}}
/>
</div>
</div>
</div>
<div style={{ marginBottom: '24px' }}>
<label style={{ display: 'block', fontSize: '0.85rem', fontWeight: 600, color: '#475569', marginBottom: '6px' }}>
Vor-Ort-Hinweise & Notizen
</label>
<div style={{ position: 'relative' }}>
<FileText size={18} color="#94a3b8" style={{ position: 'absolute', left: '12px', top: '12px' }} />
<textarea
value={formData.notes}
onChange={(e) => setFormData({ ...formData, notes: e.target.value })}
placeholder="z.B. Schlüsselbox an der Garage, Hund im Garten"
rows={3}
style={{
width: '100%',
padding: '10px 14px 10px 38px',
borderRadius: '8px',
border: '1px solid #cbd5e1',
fontSize: '0.95rem',
resize: 'vertical'
}}
/>
</div>
</div>
{/* Buttons */}
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '12px', paddingTop: '16px', borderTop: '1px solid #f1f5f9' }}>
<button
type="button"
onClick={onClose}
style={{
padding: '10px 18px',
borderRadius: '8px',
border: '1px solid #cbd5e1',
backgroundColor: '#ffffff',
color: '#475569',
fontWeight: 600,
cursor: 'pointer'
}}
>
Abbrechen
</button>
<button
type="submit"
disabled={isSaving}
style={{
padding: '10px 20px',
borderRadius: '8px',
border: 'none',
backgroundColor: '#2563eb',
color: '#ffffff',
fontWeight: 600,
cursor: isSaving ? 'not-allowed' : 'pointer',
display: 'flex',
alignItems: 'center',
gap: '8px',
opacity: isSaving ? 0.7 : 1
}}
>
<Save size={18} />
{isSaving ? 'Speichert...' : 'Kunden speichern'}
</button>
</div>
</form>
</div>
</div>
);
};
+215
View File
@@ -0,0 +1,215 @@
import React from 'react';
import { X, Download, FileText, Image as ImageIcon, FileCode, Paperclip, ExternalLink } from 'lucide-react';
import { PartDocument } from '../types';
interface DocumentViewerModalProps {
isOpen: boolean;
onClose: () => void;
document: PartDocument | null;
}
export const DocumentViewerModal: React.FC<DocumentViewerModalProps> = ({
isOpen,
onClose,
document
}) => {
if (!isOpen || !document) return null;
const fileName = document.title || 'Dokument';
const fileExt = fileName.split('.').pop()?.toLowerCase() || '';
const isPdf = document.mimeType?.includes('pdf') || fileExt === 'pdf';
const isImage = document.mimeType?.includes('image') || ['png', 'jpg', 'jpeg', 'webp', 'svg', 'gif'].includes(fileExt);
const formatFileSize = (bytes?: number) => {
if (!bytes) return '';
if (bytes >= 1048576) return `${(bytes / 1048576).toFixed(1)} MB`;
if (bytes >= 1024) return `${(bytes / 1024).toFixed(0)} KB`;
return `${bytes} Bytes`;
};
return (
<div style={{
position: 'fixed',
inset: 0,
backgroundColor: 'rgba(15, 23, 42, 0.75)',
backdropFilter: 'blur(6px)',
zIndex: 250,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '20px'
}}>
<div style={{
backgroundColor: '#ffffff',
borderRadius: '16px',
maxWidth: '900px',
width: '100%',
height: '85vh',
display: 'flex',
flexDirection: 'column',
boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.3)',
overflow: 'hidden',
border: '1px solid #cbd5e1'
}}>
{/* Modal Header */}
<div style={{
padding: '16px 24px',
backgroundColor: '#0f172a',
color: '#ffffff',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
borderBottom: '1px solid #1e293b'
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', overflow: 'hidden' }}>
{isPdf ? (
<FileText size={22} color="#ef4444" />
) : isImage ? (
<ImageIcon size={22} color="#10b981" />
) : (
<FileCode size={22} color="#3b82f6" />
)}
<div>
<h3 style={{
margin: 0,
fontSize: '1.05rem',
fontWeight: 700,
color: '#ffffff',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
maxWidth: '500px'
}}>
{fileName}
</h3>
<span style={{ fontSize: '0.78rem', color: '#94a3b8' }}>
{document.mimeType || 'Datei'} {document.fileSizeBytes ? `${formatFileSize(document.fileSizeBytes)}` : ''}
</span>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<a
href={document.filePath}
target="_blank"
rel="noopener noreferrer"
download={fileName}
style={{
backgroundColor: '#2563eb',
color: '#ffffff',
padding: '8px 14px',
borderRadius: '8px',
fontSize: '0.85rem',
fontWeight: 600,
textDecoration: 'none',
display: 'flex',
alignItems: 'center',
gap: '6px'
}}
>
<Download size={16} /> Herunterladen
</a>
<button
onClick={onClose}
style={{
backgroundColor: '#1e293b',
color: '#94a3b8',
border: 'none',
borderRadius: '8px',
padding: '8px',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
>
<X size={20} />
</button>
</div>
</div>
{/* Viewer Main Body */}
<div style={{
flex: 1,
backgroundColor: '#f8fafc',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
overflow: 'auto',
padding: isImage ? '20px' : '0'
}}>
{isImage ? (
<div style={{ textAlign: 'center', maxWidth: '100%', maxHeight: '100%' }}>
<img
src={document.filePath}
alt={fileName}
style={{
maxWidth: '100%',
maxHeight: '70vh',
objectFit: 'contain',
borderRadius: '8px',
boxShadow: '0 10px 25px -5px rgba(0, 0, 0, 0.1)'
}}
onError={(e) => {
// Fallback for mock/broken image URLs
(e.target as HTMLElement).style.display = 'none';
}}
/>
</div>
) : isPdf ? (
<iframe
src={document.filePath}
title={fileName}
style={{
width: '100%',
height: '100%',
border: 'none'
}}
/>
) : (
/* Document Card Fallback for Word / DOCX / Others */
<div style={{
textAlign: 'center',
padding: '40px 20px',
backgroundColor: '#ffffff',
borderRadius: '16px',
border: '1px solid #e2e8f0',
maxWidth: '450px',
boxShadow: '0 4px 12px rgba(0,0,0,0.05)'
}}>
<Paperclip size={48} color="#2563eb" style={{ marginBottom: '16px' }} />
<h4 style={{ fontSize: '1.1rem', fontWeight: 700, color: '#0f172a', marginBottom: '8px' }}>
{fileName}
</h4>
<p style={{ fontSize: '0.85rem', color: '#64748b', marginBottom: '20px' }}>
Dokumenttyp: {fileExt.toUpperCase()} ({formatFileSize(document.fileSizeBytes)})
</p>
<a
href={document.filePath}
target="_blank"
rel="noopener noreferrer"
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '8px',
backgroundColor: '#2563eb',
color: '#ffffff',
padding: '10px 20px',
borderRadius: '10px',
fontWeight: 600,
fontSize: '0.9rem',
textDecoration: 'none'
}}
>
<ExternalLink size={18} /> In neuem Tab öffnen / Herunterladen
</a>
</div>
)}
</div>
</div>
</div>
);
};
+357
View File
@@ -0,0 +1,357 @@
import React, { useState, useEffect } from 'react';
import { X, Save, Wrench, User, Calendar, Tag, Hash, Building2 } from 'lucide-react';
import { InstalledPart, Customer } from '../types';
interface InstalledPartModalProps {
isOpen: boolean;
onClose: () => void;
onSave: (part: Omit<InstalledPart, 'id'> | InstalledPart) => Promise<void>;
customers: Customer[];
initialPart?: InstalledPart | null;
defaultCustomerId?: string;
}
export const InstalledPartModal: React.FC<InstalledPartModalProps> = ({
isOpen,
onClose,
onSave,
customers,
initialPart,
defaultCustomerId
}) => {
const [formData, setFormData] = useState({
customerId: '',
name: '',
category: 'Gas-Brennwertgerät',
manufacturer: 'Buderus',
serialNumber: '',
quantity: 1,
installedAt: new Date().toISOString().split('T')[0]
});
const [isSaving, setIsSaving] = useState(false);
const [errorMsg, setErrorMsg] = useState('');
useEffect(() => {
if (initialPart) {
setFormData({
customerId: initialPart.customerId || '',
name: initialPart.name || '',
category: initialPart.category || 'Gas-Brennwertgerät',
manufacturer: initialPart.manufacturer || 'Buderus',
serialNumber: initialPart.serialNumber || '',
quantity: initialPart.quantity || 1,
installedAt: initialPart.installedAt || new Date().toISOString().split('T')[0]
});
} else {
setFormData({
customerId: defaultCustomerId || (customers.length > 0 ? customers[0].id : ''),
name: '',
category: 'Gas-Brennwertgerät',
manufacturer: 'Buderus',
serialNumber: '',
quantity: 1,
installedAt: new Date().toISOString().split('T')[0]
});
}
setErrorMsg('');
}, [initialPart, defaultCustomerId, customers, isOpen]);
if (!isOpen) return null;
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!formData.customerId) {
setErrorMsg('Bitte einen Kunden auswählen.');
return;
}
if (!formData.name.trim()) {
setErrorMsg('Bitte Produktbezeichnung angeben.');
return;
}
try {
setIsSaving(true);
setErrorMsg('');
if (initialPart) {
await onSave({ ...initialPart, ...formData });
} else {
await onSave(formData);
}
onClose();
} catch (err: any) {
setErrorMsg(err.message || 'Fehler beim Speichern des Geräts');
} finally {
setIsSaving(false);
}
};
return (
<div style={{
position: 'fixed',
inset: 0,
backgroundColor: 'rgba(15, 23, 42, 0.6)',
backdropFilter: 'blur(4px)',
zIndex: 100,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '20px'
}}>
<div style={{
backgroundColor: '#ffffff',
borderRadius: '16px',
maxWidth: '550px',
width: '100%',
maxHeight: '90vh',
overflowY: 'auto',
boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.25)',
border: '1px solid #e2e8f0'
}}>
{/* Header */}
<div style={{
padding: '20px 24px',
borderBottom: '1px solid #e2e8f0',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
backgroundColor: '#f8fafc',
borderTopLeftRadius: '16px',
borderTopRightRadius: '16px'
}}>
<h2 style={{ fontSize: '1.25rem', fontWeight: 700, color: '#0f172a', margin: 0, display: 'flex', alignItems: 'center', gap: '10px' }}>
<Wrench size={22} color="#2563eb" />
{initialPart ? 'Verbautes Gerät bearbeiten' : 'Gerät zur Kartei hinzufügen'}
</h2>
<button
onClick={onClose}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#64748b', borderRadius: '8px', padding: '4px' }}
>
<X size={20} />
</button>
</div>
{/* Form Body */}
<form onSubmit={handleSubmit} style={{ padding: '24px' }}>
{errorMsg && (
<div style={{
backgroundColor: '#fef2f2',
color: '#991b1b',
padding: '12px 16px',
borderRadius: '8px',
marginBottom: '20px',
fontSize: '0.9rem',
fontWeight: 500
}}>
{errorMsg}
</div>
)}
{/* Customer */}
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', fontSize: '0.85rem', fontWeight: 600, color: '#475569', marginBottom: '6px' }}>
Zugehöriger Kunde *
</label>
<div style={{ position: 'relative' }}>
<User size={18} color="#94a3b8" style={{ position: 'absolute', left: '12px', top: '12px' }} />
<select
value={formData.customerId}
onChange={(e) => setFormData({ ...formData, customerId: e.target.value })}
required
style={{
width: '100%',
padding: '10px 14px 10px 38px',
borderRadius: '8px',
border: '1px solid #cbd5e1',
fontSize: '0.95rem',
backgroundColor: '#ffffff'
}}
>
<option value="" disabled>-- Bitte Kunde auswählen --</option>
{customers.map((c) => (
<option key={c.id} value={c.id}>
{c.name} ({c.customerNumber})
</option>
))}
</select>
</div>
</div>
{/* Product Name */}
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', fontSize: '0.85rem', fontWeight: 600, color: '#475569', marginBottom: '6px' }}>
Produktbezeichnung / Modell *
</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="z.B. Logamax plus GB172-24"
required
style={{
width: '100%',
padding: '10px 14px',
borderRadius: '8px',
border: '1px solid #cbd5e1',
fontSize: '0.95rem'
}}
/>
</div>
{/* Manufacturer & Category */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px', marginBottom: '16px' }}>
<div>
<label style={{ display: 'block', fontSize: '0.85rem', fontWeight: 600, color: '#475569', marginBottom: '6px' }}>
Hersteller / Marke *
</label>
<div style={{ position: 'relative' }}>
<Building2 size={18} color="#94a3b8" style={{ position: 'absolute', left: '12px', top: '12px' }} />
<input
type="text"
value={formData.manufacturer}
onChange={(e) => setFormData({ ...formData, manufacturer: e.target.value })}
placeholder="z.B. Buderus, Bosch, Viessmann"
required
style={{
width: '100%',
padding: '10px 14px 10px 38px',
borderRadius: '8px',
border: '1px solid #cbd5e1',
fontSize: '0.95rem'
}}
/>
</div>
</div>
<div>
<label style={{ display: 'block', fontSize: '0.85rem', fontWeight: 600, color: '#475569', marginBottom: '6px' }}>
Geräte-Typ / Kategorie
</label>
<div style={{ position: 'relative' }}>
<Tag size={18} color="#94a3b8" style={{ position: 'absolute', left: '12px', top: '12px' }} />
<input
type="text"
value={formData.category}
onChange={(e) => setFormData({ ...formData, category: e.target.value })}
placeholder="z.B. Gas-Brennwertgerät, Raumthermostat"
style={{
width: '100%',
padding: '10px 14px 10px 38px',
borderRadius: '8px',
border: '1px solid #cbd5e1',
fontSize: '0.95rem'
}}
/>
</div>
</div>
</div>
{/* Serial Number & Quantity */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px', marginBottom: '16px' }}>
<div>
<label style={{ display: 'block', fontSize: '0.85rem', fontWeight: 600, color: '#475569', marginBottom: '6px' }}>
Seriennummer
</label>
<div style={{ position: 'relative' }}>
<Hash size={18} color="#94a3b8" style={{ position: 'absolute', left: '12px', top: '12px' }} />
<input
type="text"
value={formData.serialNumber}
onChange={(e) => setFormData({ ...formData, serialNumber: e.target.value })}
placeholder="8374747383"
style={{
width: '100%',
padding: '10px 14px 10px 38px',
borderRadius: '8px',
border: '1px solid #cbd5e1',
fontSize: '0.95rem'
}}
/>
</div>
</div>
<div>
<label style={{ display: 'block', fontSize: '0.85rem', fontWeight: 600, color: '#475569', marginBottom: '6px' }}>
Anzahl
</label>
<input
type="number"
min={1}
value={formData.quantity}
onChange={(e) => setFormData({ ...formData, quantity: parseInt(e.target.value) || 1 })}
style={{
width: '100%',
padding: '10px 14px',
borderRadius: '8px',
border: '1px solid #cbd5e1',
fontSize: '0.95rem'
}}
/>
</div>
</div>
{/* Installed At */}
<div style={{ marginBottom: '24px' }}>
<label style={{ display: 'block', fontSize: '0.85rem', fontWeight: 600, color: '#475569', marginBottom: '6px' }}>
Einbaudatum
</label>
<div style={{ position: 'relative' }}>
<Calendar size={18} color="#94a3b8" style={{ position: 'absolute', left: '12px', top: '12px' }} />
<input
type="date"
value={formData.installedAt}
onChange={(e) => setFormData({ ...formData, installedAt: e.target.value })}
style={{
width: '100%',
padding: '10px 14px 10px 38px',
borderRadius: '8px',
border: '1px solid #cbd5e1',
fontSize: '0.95rem'
}}
/>
</div>
</div>
{/* Actions */}
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '12px', paddingTop: '16px', borderTop: '1px solid #f1f5f9' }}>
<button
type="button"
onClick={onClose}
style={{
padding: '10px 18px',
borderRadius: '8px',
border: '1px solid #cbd5e1',
backgroundColor: '#ffffff',
color: '#475569',
fontWeight: 600,
cursor: 'pointer'
}}
>
Abbrechen
</button>
<button
type="submit"
disabled={isSaving}
style={{
padding: '10px 20px',
borderRadius: '8px',
border: 'none',
backgroundColor: '#2563eb',
color: '#ffffff',
fontWeight: 600,
cursor: isSaving ? 'not-allowed' : 'pointer',
display: 'flex',
alignItems: 'center',
gap: '8px',
opacity: isSaving ? 0.7 : 1
}}
>
<Save size={18} />
{isSaving ? 'Speichert...' : 'Gerät speichern'}
</button>
</div>
</form>
</div>
</div>
);
};
+431
View File
@@ -0,0 +1,431 @@
import React, { useState, useEffect } from 'react';
import { X, Save, Calendar, Clock, MapPin, User, FileText, UserCheck } from 'lucide-react';
import { ScheduleItem, ScheduleItemType, Technician } from '../types';
import { SupabaseService } from '../services/supabaseService';
interface ScheduleItemModalProps {
isOpen: boolean;
onClose: () => void;
onSave: (item: Omit<ScheduleItem, 'id'> | ScheduleItem) => Promise<void>;
initialItem?: ScheduleItem | null;
}
export const ScheduleItemModal: React.FC<ScheduleItemModalProps> = ({
isOpen,
onClose,
onSave,
initialItem
}) => {
const [technicians, setTechnicians] = useState<Technician[]>([]);
const [formData, setFormData] = useState({
title: '',
customerName: '',
technicianName: 'Max Müller',
address: '',
taskDescription: '',
startTime: '08:00',
endTime: '10:00',
type: 'job' as ScheduleItemType,
status: 'Geplant' as 'In Bearbeitung' | 'Geplant' | 'Abgeschlossen'
});
const [isSaving, setIsSaving] = useState(false);
const [errorMsg, setErrorMsg] = useState('');
useEffect(() => {
if (isOpen) {
SupabaseService.getTechnicians().then(techs => {
setTechnicians(techs);
if (!initialItem && techs.length > 0) {
setFormData(prev => ({ ...prev, technicianName: techs[0].name }));
}
}).catch(err => console.error(err));
}
}, [isOpen]);
useEffect(() => {
if (initialItem) {
setFormData({
title: initialItem.title || '',
customerName: initialItem.customerName || '',
technicianName: initialItem.technicianName || 'Max Müller',
address: initialItem.address || '',
taskDescription: initialItem.taskDescription || '',
startTime: initialItem.startTime || '08:00',
endTime: initialItem.endTime || '10:00',
type: initialItem.type || 'job',
status: initialItem.status || 'Geplant'
});
} else {
setFormData({
title: '',
customerName: '',
technicianName: technicians.length > 0 ? technicians[0].name : 'Max Müller',
address: '',
taskDescription: '',
startTime: '08:00',
endTime: '10:00',
type: 'job',
status: 'Geplant'
});
}
setErrorMsg('');
}, [initialItem, isOpen]);
if (!isOpen) return null;
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!formData.title.trim() || !formData.startTime || !formData.endTime) {
setErrorMsg('Bitte Titel, Start- und Endzeit angeben.');
return;
}
try {
setIsSaving(true);
setErrorMsg('');
const isInProgress = formData.status === 'In Bearbeitung';
const isCompleted = formData.status === 'Abgeschlossen';
if (initialItem) {
await onSave({
...initialItem,
...formData,
isInProgress,
isCompleted
});
} else {
await onSave({
...formData,
isInProgress,
isCompleted
});
}
onClose();
} catch (err: any) {
setErrorMsg(err.message || 'Fehler beim Speichern des Termin-Eintrags');
} finally {
setIsSaving(false);
}
};
return (
<div style={{
position: 'fixed',
inset: 0,
backgroundColor: 'rgba(15, 23, 42, 0.6)',
backdropFilter: 'blur(4px)',
zIndex: 100,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '20px'
}}>
<div style={{
backgroundColor: '#ffffff',
borderRadius: '16px',
maxWidth: '550px',
width: '100%',
maxHeight: '90vh',
overflowY: 'auto',
boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.25)',
border: '1px solid #e2e8f0'
}}>
{/* Header */}
<div style={{
padding: '20px 24px',
borderBottom: '1px solid #e2e8f0',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
backgroundColor: '#f8fafc',
borderTopLeftRadius: '16px',
borderTopRightRadius: '16px'
}}>
<h2 style={{ fontSize: '1.25rem', fontWeight: 700, color: '#0f172a', margin: 0, display: 'flex', alignItems: 'center', gap: '10px' }}>
<Calendar size={22} color="#2563eb" />
{initialItem ? 'Termineinsatz bearbeiten' : 'Neuen Termineinsatz planen'}
</h2>
<button
onClick={onClose}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#64748b', borderRadius: '8px', padding: '4px' }}
>
<X size={20} />
</button>
</div>
{/* Form Body */}
<form onSubmit={handleSubmit} style={{ padding: '24px' }}>
{errorMsg && (
<div style={{
backgroundColor: '#fef2f2',
color: '#991b1b',
padding: '12px 16px',
borderRadius: '8px',
marginBottom: '20px',
fontSize: '0.9rem',
fontWeight: 500
}}>
{errorMsg}
</div>
)}
{/* Title */}
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', fontSize: '0.85rem', fontWeight: 600, color: '#475569', marginBottom: '6px' }}>
Titel / Betreff *
</label>
<input
type="text"
value={formData.title}
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
placeholder="z.B. Wartung Heizungsanlage"
required
style={{
width: '100%',
padding: '10px 14px',
borderRadius: '8px',
border: '1px solid #cbd5e1',
fontSize: '0.95rem'
}}
/>
</div>
{/* Technician Selection */}
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', fontSize: '0.85rem', fontWeight: 600, color: '#475569', marginBottom: '6px' }}>
Zugewiesener Monteur *
</label>
<div style={{ position: 'relative' }}>
<UserCheck size={18} color="#94a3b8" style={{ position: 'absolute', left: '12px', top: '12px' }} />
<select
value={formData.technicianName}
onChange={(e) => setFormData({ ...formData, technicianName: e.target.value })}
required
style={{
width: '100%',
padding: '10px 14px 10px 38px',
borderRadius: '8px',
border: '1px solid #cbd5e1',
fontSize: '0.95rem',
backgroundColor: '#ffffff'
}}
>
{technicians.map(t => (
<option key={t.id} value={t.name}>
{t.name} ({t.role})
</option>
))}
</select>
</div>
</div>
{/* Customer & Address */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px', marginBottom: '16px' }}>
<div>
<label style={{ display: 'block', fontSize: '0.85rem', fontWeight: 600, color: '#475569', marginBottom: '6px' }}>
Kundenname
</label>
<div style={{ position: 'relative' }}>
<User size={18} color="#94a3b8" style={{ position: 'absolute', left: '12px', top: '12px' }} />
<input
type="text"
value={formData.customerName}
onChange={(e) => setFormData({ ...formData, customerName: e.target.value })}
placeholder="Max Mustermann"
style={{
width: '100%',
padding: '10px 14px 10px 38px',
borderRadius: '8px',
border: '1px solid #cbd5e1',
fontSize: '0.95rem'
}}
/>
</div>
</div>
<div>
<label style={{ display: 'block', fontSize: '0.85rem', fontWeight: 600, color: '#475569', marginBottom: '6px' }}>
Einsatzadresse
</label>
<div style={{ position: 'relative' }}>
<MapPin size={18} color="#94a3b8" style={{ position: 'absolute', left: '12px', top: '12px' }} />
<input
type="text"
value={formData.address}
onChange={(e) => setFormData({ ...formData, address: e.target.value })}
placeholder="Musterweg 12"
style={{
width: '100%',
padding: '10px 14px 10px 38px',
borderRadius: '8px',
border: '1px solid #cbd5e1',
fontSize: '0.95rem'
}}
/>
</div>
</div>
</div>
{/* Times */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px', marginBottom: '16px' }}>
<div>
<label style={{ display: 'block', fontSize: '0.85rem', fontWeight: 600, color: '#475569', marginBottom: '6px' }}>
Startzeit *
</label>
<div style={{ position: 'relative' }}>
<Clock size={18} color="#94a3b8" style={{ position: 'absolute', left: '12px', top: '12px' }} />
<input
type="time"
value={formData.startTime}
onChange={(e) => setFormData({ ...formData, startTime: e.target.value })}
required
style={{
width: '100%',
padding: '10px 14px 10px 38px',
borderRadius: '8px',
border: '1px solid #cbd5e1',
fontSize: '0.95rem'
}}
/>
</div>
</div>
<div>
<label style={{ display: 'block', fontSize: '0.85rem', fontWeight: 600, color: '#475569', marginBottom: '6px' }}>
Endzeit *
</label>
<div style={{ position: 'relative' }}>
<Clock size={18} color="#94a3b8" style={{ position: 'absolute', left: '12px', top: '12px' }} />
<input
type="time"
value={formData.endTime}
onChange={(e) => setFormData({ ...formData, endTime: e.target.value })}
required
style={{
width: '100%',
padding: '10px 14px 10px 38px',
borderRadius: '8px',
border: '1px solid #cbd5e1',
fontSize: '0.95rem'
}}
/>
</div>
</div>
</div>
{/* Type & Status */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px', marginBottom: '16px' }}>
<div>
<label style={{ display: 'block', fontSize: '0.85rem', fontWeight: 600, color: '#475569', marginBottom: '6px' }}>
Einsatz-Typ
</label>
<select
value={formData.type}
onChange={(e) => setFormData({ ...formData, type: e.target.value as ScheduleItemType })}
style={{
width: '100%',
padding: '10px 14px',
borderRadius: '8px',
border: '1px solid #cbd5e1',
fontSize: '0.95rem',
backgroundColor: '#ffffff'
}}
>
<option value="job">Kundenauftrag</option>
<option value="routine">Wartungsroutine</option>
<option value="lunchBreak">Mittagspause</option>
<option value="feierabend">Feierabend</option>
</select>
</div>
<div>
<label style={{ display: 'block', fontSize: '0.85rem', fontWeight: 600, color: '#475569', marginBottom: '6px' }}>
Status
</label>
<select
value={formData.status}
onChange={(e) => setFormData({ ...formData, status: e.target.value as any })}
style={{
width: '100%',
padding: '10px 14px',
borderRadius: '8px',
border: '1px solid #cbd5e1',
fontSize: '0.95rem',
backgroundColor: '#ffffff'
}}
>
<option value="Geplant">Geplant</option>
<option value="In Bearbeitung">In Bearbeitung</option>
<option value="Abgeschlossen">Abgeschlossen</option>
</select>
</div>
</div>
{/* Description */}
<div style={{ marginBottom: '24px' }}>
<label style={{ display: 'block', fontSize: '0.85rem', fontWeight: 600, color: '#475569', marginBottom: '6px' }}>
Tätigkeitsbeschreibung
</label>
<div style={{ position: 'relative' }}>
<FileText size={18} color="#94a3b8" style={{ position: 'absolute', left: '12px', top: '12px' }} />
<textarea
value={formData.taskDescription}
onChange={(e) => setFormData({ ...formData, taskDescription: e.target.value })}
placeholder="Hinweise zur Ausführung..."
rows={3}
style={{
width: '100%',
padding: '10px 14px 10px 38px',
borderRadius: '8px',
border: '1px solid #cbd5e1',
fontSize: '0.95rem',
resize: 'vertical'
}}
/>
</div>
</div>
{/* Actions */}
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '12px', paddingTop: '16px', borderTop: '1px solid #f1f5f9' }}>
<button
type="button"
onClick={onClose}
style={{
padding: '10px 18px',
borderRadius: '8px',
border: '1px solid #cbd5e1',
backgroundColor: '#ffffff',
color: '#475569',
fontWeight: 600,
cursor: 'pointer'
}}
>
Abbrechen
</button>
<button
type="submit"
disabled={isSaving}
style={{
padding: '10px 20px',
borderRadius: '8px',
border: 'none',
backgroundColor: '#2563eb',
color: '#ffffff',
fontWeight: 600,
cursor: isSaving ? 'not-allowed' : 'pointer',
display: 'flex',
alignItems: 'center',
gap: '8px',
opacity: isSaving ? 0.7 : 1
}}
>
<Save size={18} />
{isSaving ? 'Speichert...' : 'Einsatz speichern'}
</button>
</div>
</form>
</div>
</div>
);
};
+66 -32
View File
@@ -7,7 +7,8 @@ import {
Users,
Clock,
Database,
X
X,
Info
} from 'lucide-react';
interface SidebarProps {
@@ -25,8 +26,10 @@ export const Sidebar: React.FC<SidebarProps> = ({
}) => {
const menuItems = [
{ id: 'dashboard', label: 'Dashboard', icon: LayoutDashboard },
{ id: 'kunden', label: 'Kunden & Aufträge', icon: Users },
{ id: 'dispatching', label: 'Dispatching', icon: CalendarDays },
{ id: 'lager', label: 'Lager & Teile', icon: Package },
{ id: 'lager', label: 'Ersatzteillager', icon: Package },
{ id: 'geraete', label: 'Gerätekartei', icon: Wrench },
{ id: 'monteure', label: 'Monteure', icon: Users },
{ id: 'zeiterfassung', label: 'Zeiterfassung', icon: Clock },
{ id: 'supabase', label: 'Supabase Sync', icon: Database },
@@ -49,40 +52,71 @@ export const Sidebar: React.FC<SidebarProps> = ({
)}
<aside className={`sidebar ${isOpen ? 'open' : ''}`}>
<div className="brand">
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<Wrench className="brand-icon" size={24} />
<span>Handwerksfreund</span>
<div>
<div className="brand">
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<Wrench className="brand-icon" size={24} />
<span>Handwerksfreund</span>
</div>
<button
className="mobile-close-btn"
onClick={onClose}
aria-label="Menü schließen"
>
<X size={24} />
</button>
</div>
<button
className="mobile-close-btn"
onClick={onClose}
aria-label="Menü schließen"
>
<X size={24} />
</button>
<nav>
<ul className="nav-list">
{menuItems.map((item) => {
const Icon = item.icon;
const isActive = activeTab === item.id;
return (
<li key={item.id}>
<button
className={`nav-item ${isActive ? 'active' : ''}`}
onClick={() => handleSelect(item.id)}
>
<Icon className="icon" size={20} />
<span>{item.label}</span>
</button>
</li>
);
})}
</ul>
</nav>
</div>
<nav>
<ul className="nav-list">
{menuItems.map((item) => {
const Icon = item.icon;
const isActive = activeTab === item.id;
return (
<li key={item.id}>
<button
className={`nav-item ${isActive ? 'active' : ''}`}
onClick={() => handleSelect(item.id)}
>
<Icon className="icon" size={20} />
<span>{item.label}</span>
</button>
</li>
);
})}
</ul>
</nav>
{/* Sidebar Footer with Version Badge */}
<div style={{
marginTop: 'auto',
paddingTop: '20px',
borderTop: '1px solid #172545',
fontSize: '0.8rem',
color: '#64748b',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between'
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<Info size={14} color="#38bdf8" />
<span>Version</span>
</div>
<span style={{
backgroundColor: '#172545',
color: '#38bdf8',
padding: '2px 10px',
borderRadius: '12px',
fontWeight: 700,
fontSize: '0.78rem',
border: '1px solid #1e3a8a'
}}>
v0.1
</span>
</div>
</aside>
</>
);
+319
View File
@@ -0,0 +1,319 @@
import React, { useState, useEffect } from 'react';
import { X, Save, UserCheck, Phone, Mail, MapPin, Briefcase } from 'lucide-react';
import { Technician } from '../types';
interface TechnicianModalProps {
isOpen: boolean;
onClose: () => void;
onSave: (tech: Omit<Technician, 'id'> | Technician) => Promise<void>;
initialTech?: Technician | null;
}
export const TechnicianModal: React.FC<TechnicianModalProps> = ({
isOpen,
onClose,
onSave,
initialTech
}) => {
const [formData, setFormData] = useState({
name: '',
role: 'Servicetechniker',
phone: '',
email: '',
status: 'verfuegbar' as 'verfuegbar' | 'imEinsatz' | 'abwesend',
currentLocation: 'Werkstatt Hauptsitz'
});
const [isSaving, setIsSaving] = useState(false);
const [errorMsg, setErrorMsg] = useState('');
useEffect(() => {
if (initialTech) {
setFormData({
name: initialTech.name || '',
role: initialTech.role || 'Servicetechniker',
phone: initialTech.phone || '',
email: initialTech.email || '',
status: initialTech.status || 'verfuegbar',
currentLocation: initialTech.currentLocation || 'Werkstatt Hauptsitz'
});
} else {
setFormData({
name: '',
role: 'Servicetechniker',
phone: '',
email: '',
status: 'verfuegbar',
currentLocation: 'Werkstatt Hauptsitz'
});
}
setErrorMsg('');
}, [initialTech, isOpen]);
if (!isOpen) return null;
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!formData.name.trim()) {
setErrorMsg('Bitte Name des Monteurs angeben.');
return;
}
try {
setIsSaving(true);
setErrorMsg('');
if (initialTech) {
await onSave({ ...initialTech, ...formData });
} else {
await onSave(formData);
}
onClose();
} catch (err: any) {
setErrorMsg(err.message || 'Fehler beim Speichern des Monteurs');
} finally {
setIsSaving(false);
}
};
return (
<div style={{
position: 'fixed',
inset: 0,
backgroundColor: 'rgba(15, 23, 42, 0.6)',
backdropFilter: 'blur(4px)',
zIndex: 100,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '20px'
}}>
<div style={{
backgroundColor: '#ffffff',
borderRadius: '16px',
maxWidth: '550px',
width: '100%',
maxHeight: '90vh',
overflowY: 'auto',
boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.25)',
border: '1px solid #e2e8f0'
}}>
{/* Header */}
<div style={{
padding: '20px 24px',
borderBottom: '1px solid #e2e8f0',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
backgroundColor: '#f8fafc',
borderTopLeftRadius: '16px',
borderTopRightRadius: '16px'
}}>
<h2 style={{ fontSize: '1.25rem', fontWeight: 700, color: '#0f172a', margin: 0, display: 'flex', alignItems: 'center', gap: '10px' }}>
<UserCheck size={22} color="#2563eb" />
{initialTech ? 'Monteur bearbeiten' : 'Neuen Monteur / Techniker anlegen'}
</h2>
<button
onClick={onClose}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#64748b', borderRadius: '8px', padding: '4px' }}
>
<X size={20} />
</button>
</div>
{/* Form Body */}
<form onSubmit={handleSubmit} style={{ padding: '24px' }}>
{errorMsg && (
<div style={{
backgroundColor: '#fef2f2',
color: '#991b1b',
padding: '12px 16px',
borderRadius: '8px',
marginBottom: '20px',
fontSize: '0.9rem',
fontWeight: 500
}}>
{errorMsg}
</div>
)}
{/* Name */}
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', fontSize: '0.85rem', fontWeight: 600, color: '#475569', marginBottom: '6px' }}>
Vollständiger Name *
</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="z.B. Max Müller"
required
style={{
width: '100%',
padding: '10px 14px',
borderRadius: '8px',
border: '1px solid #cbd5e1',
fontSize: '0.95rem'
}}
/>
</div>
{/* Role & Status */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px', marginBottom: '16px' }}>
<div>
<label style={{ display: 'block', fontSize: '0.85rem', fontWeight: 600, color: '#475569', marginBottom: '6px' }}>
Qualifikation / Rolle
</label>
<div style={{ position: 'relative' }}>
<Briefcase size={18} color="#94a3b8" style={{ position: 'absolute', left: '12px', top: '12px' }} />
<input
type="text"
value={formData.role}
onChange={(e) => setFormData({ ...formData, role: e.target.value })}
placeholder="Heizungsbaumeister"
style={{
width: '100%',
padding: '10px 14px 10px 38px',
borderRadius: '8px',
border: '1px solid #cbd5e1',
fontSize: '0.95rem'
}}
/>
</div>
</div>
<div>
<label style={{ display: 'block', fontSize: '0.85rem', fontWeight: 600, color: '#475569', marginBottom: '6px' }}>
Status
</label>
<select
value={formData.status}
onChange={(e) => setFormData({ ...formData, status: e.target.value as any })}
style={{
width: '100%',
padding: '10px 14px',
borderRadius: '8px',
border: '1px solid #cbd5e1',
fontSize: '0.95rem',
backgroundColor: '#ffffff'
}}
>
<option value="verfuegbar">Verfügbar / Werkstatt</option>
<option value="imEinsatz">Im Einsatz / Unterwegs</option>
<option value="abwesend">Abwesend / Urlaub</option>
</select>
</div>
</div>
{/* Phone & Email */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px', marginBottom: '16px' }}>
<div>
<label style={{ display: 'block', fontSize: '0.85rem', fontWeight: 600, color: '#475569', marginBottom: '6px' }}>
Mobiltelefon
</label>
<div style={{ position: 'relative' }}>
<Phone size={18} color="#94a3b8" style={{ position: 'absolute', left: '12px', top: '12px' }} />
<input
type="text"
value={formData.phone}
onChange={(e) => setFormData({ ...formData, phone: e.target.value })}
placeholder="0170 1112233"
style={{
width: '100%',
padding: '10px 14px 10px 38px',
borderRadius: '8px',
border: '1px solid #cbd5e1',
fontSize: '0.95rem'
}}
/>
</div>
</div>
<div>
<label style={{ display: 'block', fontSize: '0.85rem', fontWeight: 600, color: '#475569', marginBottom: '6px' }}>
E-Mail-Adresse
</label>
<div style={{ position: 'relative' }}>
<Mail size={18} color="#94a3b8" style={{ position: 'absolute', left: '12px', top: '12px' }} />
<input
type="email"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
placeholder="m.mueller@handwerksfreund.de"
style={{
width: '100%',
padding: '10px 14px 10px 38px',
borderRadius: '8px',
border: '1px solid #cbd5e1',
fontSize: '0.95rem'
}}
/>
</div>
</div>
</div>
{/* Current Location */}
<div style={{ marginBottom: '24px' }}>
<label style={{ display: 'block', fontSize: '0.85rem', fontWeight: 600, color: '#475569', marginBottom: '6px' }}>
Aktueller Standort / Einsatzort
</label>
<div style={{ position: 'relative' }}>
<MapPin size={18} color="#94a3b8" style={{ position: 'absolute', left: '12px', top: '12px' }} />
<input
type="text"
value={formData.currentLocation}
onChange={(e) => setFormData({ ...formData, currentLocation: e.target.value })}
placeholder="Musterweg 12 oder Werkstatt"
style={{
width: '100%',
padding: '10px 14px 10px 38px',
borderRadius: '8px',
border: '1px solid #cbd5e1',
fontSize: '0.95rem'
}}
/>
</div>
</div>
{/* Actions */}
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '12px', paddingTop: '16px', borderTop: '1px solid #f1f5f9' }}>
<button
type="button"
onClick={onClose}
style={{
padding: '10px 18px',
borderRadius: '8px',
border: '1px solid #cbd5e1',
backgroundColor: '#ffffff',
color: '#475569',
fontWeight: 600,
cursor: 'pointer'
}}
>
Abbrechen
</button>
<button
type="submit"
disabled={isSaving}
style={{
padding: '10px 20px',
borderRadius: '8px',
border: 'none',
backgroundColor: '#2563eb',
color: '#ffffff',
fontWeight: 600,
cursor: isSaving ? 'not-allowed' : 'pointer',
display: 'flex',
alignItems: 'center',
gap: '8px',
opacity: isSaving ? 0.7 : 1
}}
>
<Save size={18} />
{isSaving ? 'Speichert...' : 'Monteur speichern'}
</button>
</div>
</form>
</div>
</div>
);
};
+309
View File
@@ -0,0 +1,309 @@
import React, { useState, useEffect } from 'react';
import { X, Save, ClipboardList, Calendar, User, FileText } from 'lucide-react';
import { WorkOrder, Customer, OrderStatus } from '../types';
interface WorkOrderModalProps {
isOpen: boolean;
onClose: () => void;
onSave: (order: Omit<WorkOrder, 'id'> | WorkOrder) => Promise<void>;
customers: Customer[];
initialOrder?: WorkOrder | null;
defaultCustomerId?: string;
}
export const WorkOrderModal: React.FC<WorkOrderModalProps> = ({
isOpen,
onClose,
onSave,
customers,
initialOrder,
defaultCustomerId
}) => {
const [formData, setFormData] = useState({
customerId: '',
title: '',
description: '',
scheduledDate: new Date().toISOString().split('T')[0],
status: 'offen' as OrderStatus
});
const [isSaving, setIsSaving] = useState(false);
const [errorMsg, setErrorMsg] = useState('');
useEffect(() => {
if (initialOrder) {
setFormData({
customerId: initialOrder.customerId || '',
title: initialOrder.title || '',
description: initialOrder.description || '',
scheduledDate: initialOrder.scheduledDate || new Date().toISOString().split('T')[0],
status: initialOrder.status || 'offen'
});
} else {
setFormData({
customerId: defaultCustomerId || (customers.length > 0 ? customers[0].id : ''),
title: '',
description: '',
scheduledDate: new Date().toISOString().split('T')[0],
status: 'offen'
});
}
setErrorMsg('');
}, [initialOrder, defaultCustomerId, customers, isOpen]);
if (!isOpen) return null;
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!formData.customerId) {
setErrorMsg('Bitte wählen Sie einen Kunden aus.');
return;
}
if (!formData.title.trim()) {
setErrorMsg('Bitte geben Sie einen Auftragstitel an.');
return;
}
try {
setIsSaving(true);
setErrorMsg('');
if (initialOrder) {
await onSave({ ...initialOrder, ...formData });
} else {
await onSave(formData);
}
onClose();
} catch (err: any) {
setErrorMsg(err.message || 'Fehler beim Speichern des Auftrags');
} finally {
setIsSaving(false);
}
};
return (
<div style={{
position: 'fixed',
inset: 0,
backgroundColor: 'rgba(15, 23, 42, 0.6)',
backdropFilter: 'blur(4px)',
zIndex: 100,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '20px'
}}>
<div style={{
backgroundColor: '#ffffff',
borderRadius: '16px',
maxWidth: '550px',
width: '100%',
maxHeight: '90vh',
overflowY: 'auto',
boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.25)',
border: '1px solid #e2e8f0'
}}>
{/* Header */}
<div style={{
padding: '20px 24px',
borderBottom: '1px solid #e2e8f0',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
backgroundColor: '#f8fafc',
borderTopLeftRadius: '16px',
borderTopRightRadius: '16px'
}}>
<h2 style={{ fontSize: '1.25rem', fontWeight: 700, color: '#0f172a', margin: 0, display: 'flex', alignItems: 'center', gap: '10px' }}>
<ClipboardList size={22} color="#2563eb" />
{initialOrder ? 'Auftrag bearbeiten' : 'Neuen Auftrag erstellen'}
</h2>
<button
onClick={onClose}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#64748b', borderRadius: '8px', padding: '4px' }}
>
<X size={20} />
</button>
</div>
{/* Form Body */}
<form onSubmit={handleSubmit} style={{ padding: '24px' }}>
{errorMsg && (
<div style={{
backgroundColor: '#fef2f2',
color: '#991b1b',
padding: '12px 16px',
borderRadius: '8px',
marginBottom: '20px',
fontSize: '0.9rem',
fontWeight: 500
}}>
{errorMsg}
</div>
)}
{/* Customer Selection */}
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', fontSize: '0.85rem', fontWeight: 600, color: '#475569', marginBottom: '6px' }}>
Zugehöriger Kunde *
</label>
<div style={{ position: 'relative' }}>
<User size={18} color="#94a3b8" style={{ position: 'absolute', left: '12px', top: '12px' }} />
<select
value={formData.customerId}
onChange={(e) => setFormData({ ...formData, customerId: e.target.value })}
required
style={{
width: '100%',
padding: '10px 14px 10px 38px',
borderRadius: '8px',
border: '1px solid #cbd5e1',
fontSize: '0.95rem',
backgroundColor: '#ffffff',
color: '#0f172a'
}}
>
<option value="" disabled>-- Bitte Kunde auswählen --</option>
{customers.map((c) => (
<option key={c.id} value={c.id}>
{c.name} ({c.customerNumber} - {c.city})
</option>
))}
</select>
</div>
</div>
{/* Title */}
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', fontSize: '0.85rem', fontWeight: 600, color: '#475569', marginBottom: '6px' }}>
Auftragstitel *
</label>
<input
type="text"
value={formData.title}
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
placeholder="z.B. Wartung Heizungsanlage"
required
style={{
width: '100%',
padding: '10px 14px',
borderRadius: '8px',
border: '1px solid #cbd5e1',
fontSize: '0.95rem'
}}
/>
</div>
{/* Date & Status */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px', marginBottom: '16px' }}>
<div>
<label style={{ display: 'block', fontSize: '0.85rem', fontWeight: 600, color: '#475569', marginBottom: '6px' }}>
Geplantes Ausführungsdatum
</label>
<div style={{ position: 'relative' }}>
<Calendar size={18} color="#94a3b8" style={{ position: 'absolute', left: '12px', top: '12px' }} />
<input
type="date"
value={formData.scheduledDate}
onChange={(e) => setFormData({ ...formData, scheduledDate: e.target.value })}
style={{
width: '100%',
padding: '10px 14px 10px 38px',
borderRadius: '8px',
border: '1px solid #cbd5e1',
fontSize: '0.95rem'
}}
/>
</div>
</div>
<div>
<label style={{ display: 'block', fontSize: '0.85rem', fontWeight: 600, color: '#475569', marginBottom: '6px' }}>
Auftragsstatus
</label>
<select
value={formData.status}
onChange={(e) => setFormData({ ...formData, status: e.target.value as OrderStatus })}
style={{
width: '100%',
padding: '10px 14px',
borderRadius: '8px',
border: '1px solid #cbd5e1',
fontSize: '0.95rem',
backgroundColor: '#ffffff'
}}
>
<option value="offen">Offen</option>
<option value="inBearbeitung">In Bearbeitung</option>
<option value="abgeschlossen">Abgeschlossen</option>
<option value="storniert">Storniert</option>
</select>
</div>
</div>
{/* Description */}
<div style={{ marginBottom: '24px' }}>
<label style={{ display: 'block', fontSize: '0.85rem', fontWeight: 600, color: '#475569', marginBottom: '6px' }}>
Beschreibung & Aufgabenstellung
</label>
<div style={{ position: 'relative' }}>
<FileText size={18} color="#94a3b8" style={{ position: 'absolute', left: '12px', top: '12px' }} />
<textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
placeholder="Detaillierte Aufgabenbeschreibung für den Techniker vor Ort..."
rows={4}
style={{
width: '100%',
padding: '10px 14px 10px 38px',
borderRadius: '8px',
border: '1px solid #cbd5e1',
fontSize: '0.95rem',
resize: 'vertical'
}}
/>
</div>
</div>
{/* Actions */}
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '12px', paddingTop: '16px', borderTop: '1px solid #f1f5f9' }}>
<button
type="button"
onClick={onClose}
style={{
padding: '10px 18px',
borderRadius: '8px',
border: '1px solid #cbd5e1',
backgroundColor: '#ffffff',
color: '#475569',
fontWeight: 600,
cursor: 'pointer'
}}
>
Abbrechen
</button>
<button
type="submit"
disabled={isSaving}
style={{
padding: '10px 20px',
borderRadius: '8px',
border: 'none',
backgroundColor: '#2563eb',
color: '#ffffff',
fontWeight: 600,
cursor: isSaving ? 'not-allowed' : 'pointer',
display: 'flex',
alignItems: 'center',
gap: '8px',
opacity: isSaving ? 0.7 : 1
}}
>
<Save size={18} />
{isSaving ? 'Speichert...' : 'Auftrag speichern'}
</button>
</div>
</form>
</div>
</div>
);
};