Journal für abholungen

This commit is contained in:
2026-01-04 01:37:36 +01:00
parent 1c12bf6bd1
commit 8b0326bac9
8 changed files with 1501 additions and 3 deletions

View File

@@ -24,6 +24,7 @@ import StoreSyncOverlay from './components/StoreSyncOverlay';
import RangePickerModal from './components/RangePickerModal';
import StoreWatchPage from './components/StoreWatchPage';
import DebugPage from './components/DebugPage';
import JournalPage from './components/JournalPage';
function App() {
const [credentials, setCredentials] = useState({ email: '', password: '' });
@@ -803,6 +804,10 @@ function App() {
/>
}
/>
<Route
path="/journal"
element={<JournalPage authorizedFetch={authorizedFetch} stores={stores} />}
/>
<Route
path="/debug"
element={

View File

@@ -0,0 +1,107 @@
.journal-content {
transition: filter 160ms ease;
}
.journal-content--blur {
filter: blur(6px);
}
.journal-image-hover {
position: relative;
height: 100%;
width: 100%;
overflow: visible;
}
.journal-entry-row {
position: relative;
overflow: visible;
}
.journal-image-hover__card {
background: #fff;
border-radius: 8px;
box-shadow: 0 18px 32px rgba(15, 23, 42, 0.24);
border: 1px solid #e5e7eb;
padding: 8px;
}
.journal-hover-preview {
position: fixed;
z-index: 80;
pointer-events: none;
background: #fff;
border-radius: 10px;
border: 1px solid #e5e7eb;
box-shadow: 0 24px 48px rgba(15, 23, 42, 0.25);
padding: 10px;
opacity: 0;
transform: scale(0.96);
}
.journal-hover-preview--open {
animation: journal-hover-in 140ms ease-out forwards;
}
@keyframes journal-hover-in {
from {
opacity: 0;
transform: scale(0.96);
}
to {
opacity: 1;
transform: scale(1);
}
}
.journal-hover-preview img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 8px;
}
.journal-modal {
width: 100%;
max-width: 64rem;
max-height: 90vh;
overflow-y: auto;
overflow-x: visible;
background: #fff;
border-radius: 12px;
box-shadow: 0 24px 48px rgba(15, 23, 42, 0.2);
transform-origin: center;
}
.journal-modal-wrap--open .journal-modal,
.journal-modal--open {
animation: journal-modal-in 180ms ease-out forwards;
}
.journal-modal-wrap--closing .journal-modal,
.journal-modal--closing {
animation: journal-modal-out 160ms ease-in forwards;
}
@keyframes journal-modal-in {
from {
opacity: 0;
transform: translateY(10px) scale(0.98);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes journal-modal-out {
from {
opacity: 1;
transform: translateY(0) scale(1);
}
to {
opacity: 0;
transform: translateY(10px) scale(0.98);
}
}

View File

@@ -0,0 +1,897 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import ConfirmationDialog from './ConfirmationDialog';
import './JournalPage.css';
const intervalLabels = {
monthly: 'Monatlich',
quarterly: 'Vierteljährlich',
yearly: 'Jährlich'
};
const JournalPage = ({ authorizedFetch, stores }) => {
const [entries, setEntries] = useState([]);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const [editingEntryId, setEditingEntryId] = useState(null);
const [deleteDialog, setDeleteDialog] = useState({ open: false, entry: null });
const [formOpen, setFormOpen] = useState(false);
const [closingForm, setClosingForm] = useState(false);
const [editingStoreLabel, setEditingStoreLabel] = useState('');
const [filters, setFilters] = useState({
query: '',
storeId: '',
reminderOnly: false,
withImagesOnly: false,
dateFrom: '',
dateTo: ''
});
const [sortBy, setSortBy] = useState('pickupDateDesc');
const [hoverPreview, setHoverPreview] = useState({
open: false,
src: '',
alt: '',
left: 0,
top: 0,
width: 0,
height: 0
});
const [form, setForm] = useState({
storeId: '',
pickupDate: '',
note: '',
reminderEnabled: true,
reminderInterval: 'yearly',
reminderDaysBefore: 42
});
const [images, setImages] = useState([]);
const [existingImages, setExistingImages] = useState([]);
const sortedStores = useMemo(() => {
return [...(stores || [])].sort((a, b) => (a.name || '').localeCompare(b.name || ''));
}, [stores]);
const storeOptions = useMemo(() => {
const options = [...sortedStores];
const hasSelection = form.storeId && options.some((store) => String(store.id) === String(form.storeId));
if (!hasSelection && form.storeId) {
options.unshift({
id: form.storeId,
name: editingStoreLabel || `Store ${form.storeId}`
});
}
return options;
}, [sortedStores, form.storeId, editingStoreLabel]);
const storeLabelMap = useMemo(() => {
const map = new Map();
sortedStores.forEach((store) => {
if (store?.id) {
map.set(String(store.id), store.name || `Store ${store.id}`);
}
});
entries.forEach((entry) => {
if (entry?.storeId && entry?.storeName) {
map.set(String(entry.storeId), entry.storeName);
}
});
return map;
}, [sortedStores, entries]);
const storeFilterOptions = useMemo(() => {
const list = Array.from(storeLabelMap.entries()).map(([id, name]) => ({ id, name }));
list.sort((a, b) => a.name.localeCompare(b.name));
return list;
}, [storeLabelMap]);
const parseDateValue = useCallback((value) => {
if (!value) {
return null;
}
const parsed = new Date(`${value}T00:00:00`);
if (Number.isNaN(parsed.getTime())) {
return null;
}
return parsed.getTime();
}, []);
const filteredEntries = useMemo(() => {
const query = filters.query.trim().toLowerCase();
const fromValue = parseDateValue(filters.dateFrom);
const toValue = parseDateValue(filters.dateTo);
const results = entries.filter((entry) => {
if (filters.storeId && String(entry.storeId) !== String(filters.storeId)) {
return false;
}
if (filters.reminderOnly && !entry.reminder?.enabled) {
return false;
}
if (filters.withImagesOnly && (!Array.isArray(entry.images) || entry.images.length === 0)) {
return false;
}
if (fromValue || toValue) {
const entryDate = parseDateValue(entry.pickupDate);
if (fromValue && (!entryDate || entryDate < fromValue)) {
return false;
}
if (toValue && (!entryDate || entryDate > toValue)) {
return false;
}
}
if (query) {
const storeLabel = entry.storeName || storeLabelMap.get(String(entry.storeId)) || '';
const haystack = `${storeLabel} ${entry.note || ''}`.toLowerCase();
if (!haystack.includes(query)) {
return false;
}
}
return true;
});
const sorters = {
pickupDateDesc: (a, b) => (b.pickupDate || '').localeCompare(a.pickupDate || ''),
pickupDateAsc: (a, b) => (a.pickupDate || '').localeCompare(b.pickupDate || ''),
createdAtDesc: (a, b) => (b.createdAt || '').localeCompare(a.createdAt || ''),
createdAtAsc: (a, b) => (a.createdAt || '').localeCompare(b.createdAt || ''),
storeNameAsc: (a, b) => {
const aLabel = a.storeName || storeLabelMap.get(String(a.storeId)) || '';
const bLabel = b.storeName || storeLabelMap.get(String(b.storeId)) || '';
return aLabel.localeCompare(bLabel);
},
storeNameDesc: (a, b) => {
const aLabel = a.storeName || storeLabelMap.get(String(a.storeId)) || '';
const bLabel = b.storeName || storeLabelMap.get(String(b.storeId)) || '';
return bLabel.localeCompare(aLabel);
}
};
const sorter = sorters[sortBy] || sorters.pickupDateDesc;
return [...results].sort(sorter);
}, [entries, filters, parseDateValue, sortBy, storeLabelMap]);
const releaseEntryImageUrls = useCallback((entryList) => {
if (!Array.isArray(entryList)) {
return;
}
entryList.forEach((entry) => {
if (!Array.isArray(entry.images)) {
return;
}
entry.images.forEach((image) => {
if (image?.displayUrl) {
URL.revokeObjectURL(image.displayUrl);
}
});
});
}, []);
const hydrateEntryImages = useCallback(
async (entry) => {
if (!entry || !Array.isArray(entry.images) || entry.images.length === 0) {
return entry;
}
const hydratedImages = await Promise.all(
entry.images.map(async (image) => {
if (!image?.url) {
return image;
}
try {
const response = await authorizedFetch(image.url);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const blob = await response.blob();
return { ...image, displayUrl: URL.createObjectURL(blob) };
} catch (_error) {
return image;
}
})
);
return { ...entry, images: hydratedImages };
},
[authorizedFetch]
);
const loadEntries = useCallback(async () => {
setLoading(true);
setError('');
try {
const response = await authorizedFetch('/api/journal');
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
const normalized = Array.isArray(data) ? data : [];
const hydrated = await Promise.all(normalized.map((entry) => hydrateEntryImages(entry)));
hydrated.sort((a, b) => (b.pickupDate || '').localeCompare(a.pickupDate || ''));
setEntries((prev) => {
releaseEntryImageUrls(prev);
return hydrated;
});
} catch (err) {
setError(`Journal konnte nicht geladen werden: ${err.message}`);
} finally {
setLoading(false);
}
}, [authorizedFetch, hydrateEntryImages, releaseEntryImageUrls]);
const resetFormImmediate = useCallback(() => {
images.forEach((entry) => URL.revokeObjectURL(entry.previewUrl));
setImages([]);
setExistingImages([]);
setEditingEntryId(null);
setFormOpen(false);
setClosingForm(false);
setEditingStoreLabel('');
setForm({
storeId: '',
pickupDate: '',
note: '',
reminderEnabled: true,
reminderInterval: 'yearly',
reminderDaysBefore: 42
});
}, [images]);
const resetForm = useCallback(() => {
if (!formOpen) {
return;
}
setClosingForm(true);
setTimeout(() => resetFormImmediate(), 160);
}, [formOpen, resetFormImmediate]);
useEffect(() => {
loadEntries();
}, [loadEntries]);
useEffect(() => {
if (!formOpen) {
return;
}
const handleKeyDown = (event) => {
if (event.key === 'Escape') {
resetForm();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener('keydown', handleKeyDown);
};
}, [formOpen, resetForm]);
const handleFormChange = (patch) => {
setForm((prev) => ({ ...prev, ...patch }));
};
const handleImageChange = (event) => {
const files = Array.from(event.target.files || []);
const availableSlots = Math.max(0, 5 - existingImages.length - images.length);
if (availableSlots === 0) {
event.target.value = '';
return;
}
const nextImages = files.slice(0, availableSlots).map((file) => ({
file,
name: file.name,
previewUrl: URL.createObjectURL(file)
}));
setImages((prev) => [...prev, ...nextImages].slice(0, 5));
event.target.value = '';
};
const removeImage = (index) => {
setImages((prev) => {
const next = [...prev];
const removed = next.splice(index, 1);
removed.forEach((entry) => URL.revokeObjectURL(entry.previewUrl));
return next;
});
};
const readFileAsDataUrl = (file) =>
new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = () => reject(new Error('Bild konnte nicht gelesen werden'));
reader.readAsDataURL(file);
});
const handleSubmit = async (event) => {
event.preventDefault();
if (!form.storeId || !form.pickupDate) {
setError('Bitte Betrieb und Abholdatum ausfüllen.');
return;
}
setSaving(true);
setError('');
try {
const selectedStore = sortedStores.find((store) => String(store.id) === String(form.storeId));
const imagePayload = await Promise.all(
images.map(async (entry) => ({
name: entry.name,
dataUrl: await readFileAsDataUrl(entry.file)
}))
);
const payload = {
storeId: form.storeId,
storeName: selectedStore?.name || '',
pickupDate: form.pickupDate,
note: form.note.trim(),
reminder: {
enabled: form.reminderEnabled,
interval: form.reminderInterval,
daysBefore: Number(form.reminderDaysBefore)
},
images: imagePayload,
keepImageIds: existingImages.map((image) => image.id)
};
const response = await authorizedFetch(editingEntryId ? `/api/journal/${editingEntryId}` : '/api/journal', {
method: editingEntryId ? 'PUT' : 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!response.ok) {
const body = await response.json().catch(() => ({}));
throw new Error(body.error || `HTTP ${response.status}`);
}
const created = await response.json();
const hydrated = await hydrateEntryImages(created);
setEntries((prev) => {
const next = editingEntryId
? prev.map((entry) => (entry.id === editingEntryId ? hydrated : entry))
: [hydrated, ...prev];
next.sort((a, b) => (b.pickupDate || '').localeCompare(a.pickupDate || ''));
return next;
});
resetForm();
} catch (err) {
setError(`Eintrag konnte nicht gespeichert werden: ${err.message}`);
} finally {
setSaving(false);
}
};
const handleDeleteRequest = (entry) => {
setDeleteDialog({ open: true, entry });
};
const handleDeleteConfirm = async () => {
const entry = deleteDialog.entry;
if (!entry?.id) {
setDeleteDialog({ open: false, entry: null });
return;
}
setError('');
try {
const response = await authorizedFetch(`/api/journal/${entry.id}`, { method: 'DELETE' });
if (!response.ok) {
const body = await response.json().catch(() => ({}));
throw new Error(body.error || `HTTP ${response.status}`);
}
setEntries((prev) => {
const target = prev.find((item) => item.id === entry.id);
if (target) {
releaseEntryImageUrls([target]);
}
return prev.filter((item) => item.id !== entry.id);
});
if (editingEntryId === entry.id) {
resetForm();
}
} catch (err) {
setError(`Eintrag konnte nicht gelöscht werden: ${err.message}`);
} finally {
setDeleteDialog({ open: false, entry: null });
}
};
const handleEdit = (entry) => {
setError('');
images.forEach((image) => URL.revokeObjectURL(image.previewUrl));
setImages([]);
setEditingEntryId(entry.id);
setEditingStoreLabel(entry.storeName || storeLabelMap.get(String(entry.storeId)) || '');
setExistingImages(Array.isArray(entry.images) ? entry.images.map((image) => ({ ...image })) : []);
setForm({
storeId: entry.storeId ? String(entry.storeId) : '',
pickupDate: entry.pickupDate || '',
note: entry.note || '',
reminderEnabled: entry.reminder?.enabled ?? true,
reminderInterval: entry.reminder?.interval || 'yearly',
reminderDaysBefore: Number.isFinite(entry.reminder?.daysBefore) ? entry.reminder.daysBefore : 42
});
setFormOpen(true);
};
const handleCreate = () => {
setError('');
setEditingStoreLabel('');
resetForm();
setFormOpen(true);
};
const showHoverPreview = useCallback((event, image, size) => {
if (!image) {
return;
}
const src = image.displayUrl || image.url;
if (!src) {
return;
}
const rect = event.currentTarget.getBoundingClientRect();
const width = size?.width || 600;
const height = size?.height || 450;
const padding = 16;
let left = rect.right + 12;
let top = rect.top + rect.height / 2 - height / 2;
if (left + width > window.innerWidth - padding) {
left = rect.left - width - 12;
}
if (left < padding) {
left = padding;
}
if (top < padding) {
top = padding;
}
if (top + height > window.innerHeight - padding) {
top = window.innerHeight - height - padding;
}
setHoverPreview({
open: true,
src,
alt: image.originalName || 'Journalbild',
left,
top,
width,
height
});
}, []);
const hideHoverPreview = useCallback(() => {
setHoverPreview((prev) => (prev.open ? { ...prev, open: false } : prev));
}, []);
const removeExistingImage = (imageId) => {
setExistingImages((prev) => prev.filter((image) => image.id !== imageId));
};
return (
<div className="space-y-6">
<div className={formOpen ? 'journal-content journal-content--blur' : 'journal-content'}>
<div className="bg-white rounded-lg shadow p-6">
<div className="flex flex-wrap items-center justify-between gap-3 mb-4">
<h2 className="text-xl font-semibold text-gray-800">Journal-Einträge</h2>
<div className="flex items-center gap-2">
<button
type="button"
onClick={loadEntries}
className="text-sm px-3 py-2 border rounded-md hover:border-blue-400"
disabled={loading}
>
Aktualisieren
</button>
<button
type="button"
onClick={handleCreate}
className="text-sm px-3 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
>
Neuer Eintrag
</button>
</div>
</div>
{error ? (
<div className="status-banner error mb-4">
<p>{error}</p>
</div>
) : null}
{loading ? <p className="text-gray-500">Lade Einträge...</p> : null}
{!loading && entries.length === 0 ? (
<p className="text-gray-500">Noch keine Einträge vorhanden.</p>
) : null}
<div className="mt-3 flex flex-wrap items-center gap-2 border border-gray-200 bg-gray-50 rounded-md px-3 py-2">
<input
type="text"
value={filters.query}
onChange={(event) => setFilters((prev) => ({ ...prev, query: event.target.value }))}
placeholder="Suche..."
className="min-w-[140px] flex-1 border border-gray-300 rounded-md px-3 py-2 text-sm"
/>
<input
type="date"
value={filters.dateFrom}
onChange={(event) => setFilters((prev) => ({ ...prev, dateFrom: event.target.value }))}
className="min-w-[140px]"
/>
<input
type="date"
value={filters.dateTo}
onChange={(event) => setFilters((prev) => ({ ...prev, dateTo: event.target.value }))}
className="min-w-[140px]"
/>
<button
type="button"
onClick={() =>
setFilters({
query: '',
storeId: '',
reminderOnly: false,
withImagesOnly: false,
dateFrom: '',
dateTo: ''
})
}
className="text-sm px-3 py-2 border rounded-md hover:border-blue-400"
>
Reset
</button>
<select
value={sortBy}
onChange={(event) => setSortBy(event.target.value)}
className="min-w-[160px] ml-auto"
>
<option value="pickupDateDesc">Abhol-Datum </option>
<option value="pickupDateAsc">Abhol-Datum </option>
<option value="createdAtDesc">Erstellt </option>
<option value="createdAtAsc">Erstellt </option>
<option value="storeNameAsc">Betrieb AZ</option>
<option value="storeNameDesc">Betrieb ZA</option>
</select>
</div>
<div className="space-y-4">
{filteredEntries.map((entry) => {
const reminder = entry.reminder || {};
const reminderLabel = reminder.enabled
? `${intervalLabels[reminder.interval] || 'Jährlich'}, ${reminder.daysBefore} Tage vorher`
: 'Keine Erinnerung';
const storeLabel =
entry.storeName ||
storeLabelMap.get(String(entry.storeId)) ||
`Store ${entry.storeId}`;
const mainImage = Array.isArray(entry.images) && entry.images.length > 0 ? entry.images[0] : null;
return (
<div key={entry.id} className="journal-entry-row border rounded-md">
<div className="flex">
<div className="w-24 sm:w-28 md:w-32 bg-gray-100 flex-shrink-0">
{mainImage ? (
<div className="journal-image-hover">
<div className="h-full w-full overflow-hidden">
<img
src={mainImage.displayUrl || mainImage.url}
alt={mainImage.originalName || 'Journalbild'}
className="h-full w-full object-cover"
onMouseEnter={(event) =>
showHoverPreview(event, mainImage, { width: 720, height: 540 })
}
onMouseLeave={hideHoverPreview}
/>
</div>
</div>
) : (
<div className="h-full w-full flex items-center justify-center text-xs text-gray-400">
Kein Bild
</div>
)}
</div>
<div className="flex-1 p-4">
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-sm text-gray-500">{entry.pickupDate}</p>
<h3 className="text-lg font-semibold text-gray-800">
{storeLabel}
</h3>
</div>
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => handleEdit(entry)}
className="text-sm text-blue-600 hover:text-blue-700"
>
Bearbeiten
</button>
<button
type="button"
onClick={() => handleDeleteRequest(entry)}
className="text-sm text-red-600 hover:text-red-700"
>
Löschen
</button>
</div>
</div>
{entry.note ? <p className="mt-2 text-sm text-gray-700">{entry.note}</p> : null}
<p className="mt-2 text-xs text-gray-500">{reminderLabel}</p>
{Array.isArray(entry.images) && entry.images.length > 1 ? (
<div className="mt-3 grid grid-cols-3 gap-2">
{entry.images.slice(1).map((image) => (
<div
key={image.id}
className="journal-image-hover journal-image-hover--grid border rounded-md"
>
<div className="overflow-hidden rounded-md">
<img
src={image.displayUrl || image.url}
alt={image.originalName || 'Journalbild'}
className="h-24 w-full object-cover"
onMouseEnter={(event) =>
showHoverPreview(event, image, { width: 600, height: 450 })
}
onMouseLeave={hideHoverPreview}
/>
</div>
</div>
))}
</div>
) : null}
</div>
</div>
</div>
);
})}
{!loading && entries.length > 0 && filteredEntries.length === 0 ? (
<div className="text-sm text-gray-500">Keine Einträge für die aktuellen Filter.</div>
) : null}
</div>
</div>
</div>
{formOpen ? (
<>
<div
className="fixed inset-0 z-40 bg-slate-900/60 backdrop-blur-sm"
onMouseDown={resetForm}
/>
<div
className={`fixed inset-0 z-50 flex items-center justify-center p-4 ${closingForm ? 'journal-modal-wrap--closing' : 'journal-modal-wrap--open'}`}
onMouseDown={(event) => {
if (event.target === event.currentTarget) {
resetForm();
}
}}
>
<div
className={`journal-modal ${closingForm ? 'journal-modal--closing' : 'journal-modal--open'}`}
onMouseDown={(event) => event.stopPropagation()}
>
<div className="flex items-center justify-between border-b px-6 py-4">
<div>
<h2 className="text-lg font-semibold text-gray-800">
{editingEntryId ? 'Eintrag bearbeiten' : 'Neuer Eintrag'}
</h2>
<p className="text-sm text-gray-500">Abholung dokumentieren</p>
</div>
<button
type="button"
onClick={resetForm}
className="text-sm text-gray-600 hover:text-gray-800"
>
Schließen
</button>
</div>
<div className="px-6 py-6">
{error ? (
<div className="status-banner error mb-4">
<p>{error}</p>
</div>
) : null}
<form className="space-y-4" onSubmit={handleSubmit}>
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-700" htmlFor="journal-store">
Betrieb auswählen
</label>
<select
id="journal-store"
value={form.storeId}
onChange={(event) => handleFormChange({ storeId: event.target.value })}
className="w-full"
>
<option value="">Bitte wählen</option>
{storeOptions.map((store) => (
<option key={store.id} value={store.id}>
{store.name || `Store ${store.id}`}
</option>
))}
</select>
</div>
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-700" htmlFor="journal-date">
Abholdatum
</label>
<input
id="journal-date"
type="date"
value={form.pickupDate}
onChange={(event) => handleFormChange({ pickupDate: event.target.value })}
className="w-full"
/>
</div>
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-700" htmlFor="journal-note">
Kommentar
</label>
<textarea
id="journal-note"
value={form.note}
onChange={(event) => handleFormChange({ note: event.target.value })}
className="w-full border border-gray-300 rounded-md p-3 text-sm"
rows={4}
placeholder="Freitext zur Abholung..."
/>
</div>
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-700" htmlFor="journal-images">
Bilder hochladen
</label>
<div className="flex flex-wrap items-center gap-3">
<label
htmlFor="journal-images"
className="inline-flex items-center px-3 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 bg-white hover:border-blue-400 cursor-pointer"
>
Dateien auswählen
</label>
<span className="text-xs text-gray-500">
{images.length > 0 ? `${images.length} Bild(er) ausgewählt` : 'Maximal 5 Bilder'}
</span>
<input
id="journal-images"
type="file"
accept="image/*"
multiple
onChange={handleImageChange}
className="hidden"
/>
</div>
{existingImages.length > 0 ? (
<div className="grid grid-cols-3 gap-2">
{existingImages.map((image) => (
<div
key={image.id}
className="journal-image-hover journal-image-hover--grid border rounded-md p-1"
>
<div className="overflow-hidden rounded">
<img
src={image.displayUrl || image.url}
alt={image.originalName || 'Journalbild'}
className="h-20 w-full object-cover"
onMouseEnter={(event) =>
showHoverPreview(event, image, { width: 720, height: 540 })
}
onMouseLeave={hideHoverPreview}
/>
</div>
<button
type="button"
onClick={() => removeExistingImage(image.id)}
className="absolute top-1 right-1 bg-white/90 text-xs px-2 py-1 rounded shadow"
>
Entfernen
</button>
</div>
))}
</div>
) : null}
{images.length > 0 ? (
<div className="grid grid-cols-3 gap-2">
{images.map((image, index) => (
<div
key={`${image.name}-${index}`}
className="journal-image-hover journal-image-hover--grid border rounded-md p-1"
>
<div className="overflow-hidden rounded">
<img
src={image.previewUrl}
alt={image.name}
className="h-20 w-full object-cover"
onMouseEnter={(event) =>
showHoverPreview(event, image, { width: 720, height: 540 })
}
onMouseLeave={hideHoverPreview}
/>
</div>
<button
type="button"
onClick={() => removeImage(index)}
className="absolute top-1 right-1 bg-white/90 text-xs px-2 py-1 rounded shadow"
>
Entfernen
</button>
</div>
))}
</div>
) : null}
</div>
<div className="border rounded-md p-4 space-y-3 bg-gray-50">
<label className="flex items-center gap-2 text-sm font-medium text-gray-700">
<input
type="checkbox"
checked={form.reminderEnabled}
onChange={(event) => handleFormChange({ reminderEnabled: event.target.checked })}
/>
Telegram-Erinnerung aktivieren
</label>
{form.reminderEnabled ? (
<>
<div className="grid gap-3 md:grid-cols-2">
<div className="space-y-1">
<label className="block text-sm text-gray-600">Intervall</label>
<select
value={form.reminderInterval}
onChange={(event) => handleFormChange({ reminderInterval: event.target.value })}
className="w-full"
>
{Object.entries(intervalLabels).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
</div>
<div className="space-y-1">
<label className="block text-sm text-gray-600">Tage vorher</label>
<input
type="number"
min="0"
value={form.reminderDaysBefore}
onChange={(event) =>
handleFormChange({ reminderDaysBefore: event.target.value })
}
className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-200"
/>
</div>
</div>
<p className="text-xs text-gray-500">
Erinnerung wird standardmäßig jährlich eingeplant.
</p>
</>
) : null}
</div>
<div className="grid gap-3 md:grid-cols-2">
<button
type="submit"
className="w-full bg-blue-600 text-white py-2 rounded-md hover:bg-blue-700 disabled:opacity-60"
disabled={saving}
>
{saving ? 'Speichere...' : editingEntryId ? 'Änderungen speichern' : 'Eintrag speichern'}
</button>
<button
type="button"
className="w-full border border-gray-300 text-gray-700 py-2 rounded-md hover:border-blue-400"
onClick={resetForm}
>
Abbrechen
</button>
</div>
</form>
</div>
</div>
</div>
</>
) : null}
<ConfirmationDialog
open={deleteDialog.open}
title="Eintrag löschen"
message={`Soll der Eintrag${deleteDialog.entry?.storeName ? ` für "${deleteDialog.entry.storeName}"` : ''} wirklich gelöscht werden?`}
confirmLabel="Löschen"
cancelLabel="Abbrechen"
confirmTone="danger"
onConfirm={handleDeleteConfirm}
onCancel={() => setDeleteDialog({ open: false, entry: null })}
/>
{hoverPreview.open ? (
<div
className="journal-hover-preview journal-hover-preview--open"
style={{
left: `${hoverPreview.left}px`,
top: `${hoverPreview.top}px`,
width: `${hoverPreview.width}px`,
height: `${hoverPreview.height}px`
}}
>
<img src={hoverPreview.src} alt={hoverPreview.alt} />
</div>
) : null}
</div>
);
};
export default JournalPage;

View File

@@ -6,7 +6,8 @@ const NavigationTabs = ({ isAdmin, onProtectedNavigate }) => {
const tabs = [
{ to: '/', label: 'Slots buchen' },
{ to: '/store-watch', label: 'Betriebs-Monitoring' }
{ to: '/store-watch', label: 'Betriebs-Monitoring' },
{ to: '/journal', label: 'Abhol-Journal' }
];
if (isAdmin) {
tabs.push({ to: '/debug', label: 'Debug' });