304 lines
11 KiB
TypeScript
304 lines
11 KiB
TypeScript
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, 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 (
|
|
<>
|
|
{/* 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">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 ({scheduleData.length} Einsätze)</h2>
|
|
<span style={{ fontSize: '0.9rem', color: '#64748b', fontWeight: 600 }}>Supabase Sync aktiv</span>
|
|
</div>
|
|
|
|
<div className="table-responsive">
|
|
<table className="orders-table">
|
|
<thead>
|
|
<tr>
|
|
<th>Uhrzeit</th>
|
|
<th>Monteur</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) => (
|
|
<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.technicianName || 'Monteur'}
|
|
</div>
|
|
</td>
|
|
<td>
|
|
<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}
|
|
/>
|
|
</>
|
|
);
|
|
};
|