Planboard: echte Termine aus den Tickets statt leerer Wochenansicht
Das Planboard war eine Huelle - sieben Spalten mit "Keine Termine". Es zeigt
jetzt die terminierten Tickets der Woche: Uhrzeit, Kunde, Betreff, Kuerzel des
Zustaendigen. Klick fuehrt ueber den vorhandenen Deep-Link /tickets?woid=... ins
Ticket. Heute ist hervorgehoben, "Heute" springt zurueck.
Ein Termin ist bewusst kein eigener Datensatz, sondern die Ticketfelder
startDate/startTime/endTime/assignedTo. Die Rechnungsanalyse im Kundenportal
schreibt genau diese Felder, deshalb steht ein dort vereinbarter Termin ohne
Umweg hier - und Ticket und Termin koennen nicht auseinanderlaufen.
Die Aufbereitung liegt in src/lib/planboard.js, damit sie ohne UI testbar ist:
Datum kommt als Text und real auch einstellig ("22.5.2026"), Uhrzeit als
"hhmm"; beides wird tolerant gelesen, Unsinn faellt raus statt zu verrutschen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
75
src/lib/planboard.js
Normal file
75
src/lib/planboard.js
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
/* Planboard-Logik: aus Tickets werden Termine.
|
||||||
|
Termine sind kein eigener Datensatz - sie haengen an den Tickets selbst
|
||||||
|
(startDate/startTime/endTime/assignedTo). Die Rechnungsanalyse im
|
||||||
|
Kundenportal schreibt genau diese Felder, deshalb erscheint ein dort
|
||||||
|
vereinbarter Termin hier ohne Umweg.
|
||||||
|
WOMS speichert Datum als Text "dd.mm.yyyy" (auch einstellig: "2.5.2026")
|
||||||
|
und Uhrzeit als "hhmm". Beides wird hier tolerant gelesen. */
|
||||||
|
|
||||||
|
export function parseGermanDate(value) {
|
||||||
|
const m = /^(\d{1,2})\.(\d{1,2})\.(\d{4})$/.exec(String(value || '').trim())
|
||||||
|
if (!m) return null
|
||||||
|
const day = Number(m[1])
|
||||||
|
const month = Number(m[2])
|
||||||
|
const year = Number(m[3])
|
||||||
|
const date = new Date(year, month - 1, day)
|
||||||
|
// "31.02.2026" wuerde sonst still zum 3. Maerz werden
|
||||||
|
if (date.getDate() !== day || date.getMonth() !== month - 1 || date.getFullYear() !== year) return null
|
||||||
|
return date
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatTime(value) {
|
||||||
|
const m = /^(\d{1,2}):?(\d{2})$/.exec(String(value || '').trim())
|
||||||
|
if (!m) return ''
|
||||||
|
const hour = Number(m[1])
|
||||||
|
const minute = Number(m[2])
|
||||||
|
if (hour > 23 || minute > 59) return ''
|
||||||
|
return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dayKey(date) {
|
||||||
|
const pad = (n) => String(n).padStart(2, '0')
|
||||||
|
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toAppointment(workorder, employeeByUserId = {}) {
|
||||||
|
const date = parseGermanDate(workorder.startDate)
|
||||||
|
if (!date) return null
|
||||||
|
const employee = employeeByUserId[workorder.assignedTo]
|
||||||
|
return {
|
||||||
|
id: workorder.$id,
|
||||||
|
woid: workorder.woid || '',
|
||||||
|
date,
|
||||||
|
key: dayKey(date),
|
||||||
|
time: formatTime(workorder.startTime),
|
||||||
|
endTime: formatTime(workorder.endTime),
|
||||||
|
topic: workorder.topic || workorder.title || 'Ohne Betreff',
|
||||||
|
type: workorder.type || '',
|
||||||
|
status: workorder.status || '',
|
||||||
|
customerName: workorder.customerName || '',
|
||||||
|
employeeName: employee?.displayName || workorder.assignedName || '',
|
||||||
|
employeeShort: employee?.shortcode || '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Termine nach Tag ("yyyy-mm-dd"), innerhalb des Tages nach Uhrzeit.
|
||||||
|
Eintraege ohne Uhrzeit landen am Ende des Tages. */
|
||||||
|
export function groupAppointmentsByDay(workorders = [], employees = []) {
|
||||||
|
const employeeByUserId = {}
|
||||||
|
for (const e of employees) employeeByUserId[e.userId] = e
|
||||||
|
|
||||||
|
const map = {}
|
||||||
|
for (const wo of workorders) {
|
||||||
|
const appointment = toAppointment(wo, employeeByUserId)
|
||||||
|
if (!appointment) continue
|
||||||
|
;(map[appointment.key] = map[appointment.key] || []).push(appointment)
|
||||||
|
}
|
||||||
|
for (const key of Object.keys(map)) {
|
||||||
|
map[key].sort((a, b) => (a.time || '99:99').localeCompare(b.time || '99:99'))
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
}
|
||||||
|
|
||||||
|
export function countInRange(byDay, days = []) {
|
||||||
|
return days.reduce((sum, d) => sum + (byDay[dayKey(d)]?.length || 0), 0)
|
||||||
|
}
|
||||||
@@ -1,19 +1,40 @@
|
|||||||
import { useState } from 'react'
|
import { useState, useMemo } from 'react'
|
||||||
import { format, addDays, startOfWeek } from 'date-fns'
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { format, addDays, startOfWeek, isSameDay } from 'date-fns'
|
||||||
import { de } from 'date-fns/locale'
|
import { de } from 'date-fns/locale'
|
||||||
import { FaChevronLeft, FaChevronRight } from 'react-icons/fa6'
|
import { FaChevronLeft, FaChevronRight } from 'react-icons/fa6'
|
||||||
import { PageHeader, Card, Button } from '../components/ui'
|
import { PageHeader, Card, Button } from '../components/ui'
|
||||||
|
import { useWorkorders } from '../hooks/useWorkorders'
|
||||||
|
import { useEmployees } from '../hooks/useEmployees'
|
||||||
|
import { groupAppointmentsByDay, countInRange, dayKey } from '../lib/planboard'
|
||||||
|
|
||||||
|
/* Termine haengen an den Tickets (startDate/startTime/endTime/assignedTo);
|
||||||
|
die Aufbereitung steckt in src/lib/planboard.js. */
|
||||||
|
|
||||||
|
const TYPE_COLORS = {
|
||||||
|
akquise: { bg: 'rgba(198,113,57,0.16)', fg: '#c67139' },
|
||||||
|
webpage: { bg: 'rgba(122,138,94,0.18)', fg: '#7a8a5e' },
|
||||||
|
project: { bg: 'rgba(122,138,94,0.18)', fg: '#7a8a5e' },
|
||||||
|
}
|
||||||
|
|
||||||
export default function PlanboardPage() {
|
export default function PlanboardPage() {
|
||||||
|
const navigate = useNavigate()
|
||||||
const [currentWeek, setCurrentWeek] = useState(startOfWeek(new Date(), { weekStartsOn: 1 }))
|
const [currentWeek, setCurrentWeek] = useState(startOfWeek(new Date(), { weekStartsOn: 1 }))
|
||||||
|
const { workorders, loading } = useWorkorders({ limit: 500 })
|
||||||
|
const { employees } = useEmployees()
|
||||||
|
|
||||||
const weekDays = Array.from({ length: 7 }, (_, i) => addDays(currentWeek, i))
|
const weekDays = Array.from({ length: 7 }, (_, i) => addDays(currentWeek, i))
|
||||||
const navigateWeek = (dir) => setCurrentWeek((prev) => addDays(prev, dir * 7))
|
const navigateWeek = (dir) => setCurrentWeek((prev) => addDays(prev, dir * 7))
|
||||||
|
const goToday = () => setCurrentWeek(startOfWeek(new Date(), { weekStartsOn: 1 }))
|
||||||
|
|
||||||
|
const byDay = useMemo(() => groupAppointmentsByDay(workorders || [], employees || []), [workorders, employees])
|
||||||
|
const weekCount = countInRange(byDay, weekDays)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page">
|
<div className="page">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Planboard"
|
title="Planboard"
|
||||||
subtitle="Wochenplanung"
|
subtitle={loading ? 'Wochenplanung – lädt…' : `Wochenplanung – ${weekCount} ${weekCount === 1 ? 'Termin' : 'Termine'} diese Woche`}
|
||||||
actions={
|
actions={
|
||||||
<div className="flex gap-2 items-center">
|
<div className="flex gap-2 items-center">
|
||||||
<Button variant="ghost" onClick={() => navigateWeek(-1)}><FaChevronLeft /></Button>
|
<Button variant="ghost" onClick={() => navigateWeek(-1)}><FaChevronLeft /></Button>
|
||||||
@@ -21,22 +42,77 @@ export default function PlanboardPage() {
|
|||||||
{format(currentWeek, 'dd.MM.yyyy')} - {format(addDays(currentWeek, 6), 'dd.MM.yyyy')}
|
{format(currentWeek, 'dd.MM.yyyy')} - {format(addDays(currentWeek, 6), 'dd.MM.yyyy')}
|
||||||
</span>
|
</span>
|
||||||
<Button variant="ghost" onClick={() => navigateWeek(1)}><FaChevronRight /></Button>
|
<Button variant="ghost" onClick={() => navigateWeek(1)}><FaChevronRight /></Button>
|
||||||
|
<Button variant="ghost" onClick={goToday}>Heute</Button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: 12 }}>
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: 12 }}>
|
||||||
{weekDays.map((day) => (
|
{weekDays.map((day) => {
|
||||||
<Card key={day.toISOString()} pad={false}>
|
const entries = byDay[dayKey(day)] || []
|
||||||
<div style={{ padding: '10px 12px', borderBottom: '1px solid var(--border)', display: 'flex', flexDirection: 'column' }}>
|
const today = isSameDay(day, new Date())
|
||||||
<strong style={{ fontSize: 13 }}>{format(day, 'EEEE', { locale: de })}</strong>
|
return (
|
||||||
<span className="faint" style={{ fontSize: 12 }}>{format(day, 'dd.MM')}</span>
|
<Card key={day.toISOString()} pad={false}>
|
||||||
</div>
|
<div style={{
|
||||||
<div style={{ padding: 12, minHeight: 120 }}>
|
padding: '10px 12px',
|
||||||
<p className="faint" style={{ fontSize: 12 }}>Keine Termine</p>
|
borderBottom: '1px solid var(--border)',
|
||||||
</div>
|
display: 'flex',
|
||||||
</Card>
|
flexDirection: 'column',
|
||||||
))}
|
background: today ? 'rgba(198,113,57,0.10)' : undefined,
|
||||||
|
}}>
|
||||||
|
<strong style={{ fontSize: 13 }}>{format(day, 'EEEE', { locale: de })}</strong>
|
||||||
|
<span className="faint" style={{ fontSize: 12 }}>{format(day, 'dd.MM')}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ padding: 8, minHeight: 120, display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||||
|
{entries.length === 0 && (
|
||||||
|
<p className="faint" style={{ fontSize: 12, padding: '4px 4px 0' }}>Keine Termine</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{entries.map((entry) => {
|
||||||
|
const color = TYPE_COLORS[String(entry.type).toLowerCase()]
|
||||||
|
|| { bg: 'rgba(127,127,127,0.10)', fg: 'var(--border)' }
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={entry.id}
|
||||||
|
type="button"
|
||||||
|
title={`Ticket #${entry.woid} öffnen`}
|
||||||
|
onClick={() => navigate(`/tickets?woid=${encodeURIComponent(entry.woid)}`)}
|
||||||
|
style={{
|
||||||
|
textAlign: 'left',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderLeft: `3px solid ${color.fg}`,
|
||||||
|
borderRadius: 8,
|
||||||
|
background: color.bg,
|
||||||
|
color: 'inherit',
|
||||||
|
padding: '8px 10px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
font: 'inherit',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: 12, fontWeight: 700 }}>
|
||||||
|
{entry.time ? `${entry.time}${entry.endTime ? `–${entry.endTime}` : ''}` : 'ohne Uhrzeit'}
|
||||||
|
</span>
|
||||||
|
<span style={{ fontSize: 12.5, fontWeight: 600, lineHeight: 1.25 }}>
|
||||||
|
{entry.customerName || entry.topic}
|
||||||
|
</span>
|
||||||
|
{entry.customerName && (
|
||||||
|
<span className="faint" style={{ fontSize: 11.5, lineHeight: 1.25 }}>{entry.topic}</span>
|
||||||
|
)}
|
||||||
|
<span className="faint" style={{ fontSize: 11 }}>
|
||||||
|
#{entry.woid}
|
||||||
|
{entry.employeeName ? ` · ${entry.employeeShort || entry.employeeName}` : ' · niemand zugewiesen'}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user