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

246
server.js
View File

@@ -19,6 +19,13 @@ const notificationService = require('./services/notificationService');
const { readStoreWatch, writeStoreWatch, listWatcherProfiles } = require('./services/storeWatchStore');
const { readPreferences, writePreferences, sanitizeLocation } = require('./services/userPreferencesStore');
const requestLogStore = require('./services/requestLogStore');
const {
readJournal,
writeJournal,
saveJournalImage,
deleteJournalImage,
getProfileImageDir
} = require('./services/journalStore');
const { withSessionRetry } = require('./services/sessionRefresh');
const {
getStoreStatus: getCachedStoreStatusEntry,
@@ -69,7 +76,7 @@ function haversineDistanceKm(lat1, lon1, lat2, lon2) {
}
app.use(cors());
app.use(express.json({ limit: '1mb' }));
app.use(express.json({ limit: '15mb' }));
app.use(express.static(path.join(__dirname, 'build')));
app.use((req, res, next) => {
@@ -229,6 +236,45 @@ function getCachedStoreStatus(storeId) {
return getCachedStoreStatusEntry(storeId);
}
function normalizeJournalReminder(reminder = {}) {
return {
enabled: !!reminder.enabled,
interval: ['monthly', 'quarterly', 'yearly'].includes(reminder.interval)
? reminder.interval
: 'yearly',
daysBefore: Number.isFinite(reminder.daysBefore) ? Math.max(0, reminder.daysBefore) : 42
};
}
function parseDataUrl(dataUrl) {
if (typeof dataUrl !== 'string') {
return null;
}
const match = dataUrl.match(/^data:([^;]+);base64,(.+)$/);
if (!match) {
return null;
}
const mimeType = match[1];
const buffer = Buffer.from(match[2], 'base64');
return { mimeType, buffer };
}
function resolveImageExtension(mimeType) {
if (mimeType === 'image/jpeg') {
return '.jpg';
}
if (mimeType === 'image/png') {
return '.png';
}
if (mimeType === 'image/gif') {
return '.gif';
}
if (mimeType === 'image/webp') {
return '.webp';
}
return '';
}
function ingestStoreLocations(stores = []) {
let changed = false;
stores.forEach((store) => {
@@ -990,6 +1036,204 @@ app.post('/api/user/preferences/location', requireAuth, (req, res) => {
res.json({ location: updated.location });
});
app.get('/api/journal', requireAuth, (req, res) => {
const entries = readJournal(req.session.profile.id);
const normalized = entries.map((entry) => ({
...entry,
images: Array.isArray(entry.images)
? entry.images.map((image) => ({
...image,
url: `/api/journal/images/${image.id}`
}))
: []
}));
res.json(normalized);
});
app.post('/api/journal', requireAuth, (req, res) => {
const profileId = req.session.profile.id;
const { storeId, storeName, pickupDate, note, reminder, images } = req.body || {};
if (!storeId || !pickupDate) {
return res.status(400).json({ error: 'Store und Abholdatum sind erforderlich' });
}
const parsedDate = new Date(`${pickupDate}T00:00:00`);
if (Number.isNaN(parsedDate.getTime())) {
return res.status(400).json({ error: 'Ungültiges Abholdatum' });
}
const entryId = uuid();
const timestamp = new Date().toISOString();
const normalizedReminder = normalizeJournalReminder(reminder);
const maxImages = 5;
const maxImageBytes = 5 * 1024 * 1024;
const collectedImages = [];
if (Array.isArray(images)) {
images.slice(0, maxImages).forEach((image) => {
const payload = parseDataUrl(image?.dataUrl);
if (!payload) {
return;
}
if (payload.buffer.length > maxImageBytes) {
return;
}
const imageId = uuid();
const ext = resolveImageExtension(payload.mimeType) || path.extname(image?.name || '');
const filename = `${imageId}${ext || ''}`;
const saved = saveJournalImage(profileId, {
id: imageId,
filename,
buffer: payload.buffer
});
collectedImages.push({
id: imageId,
filename: saved.filename,
originalName: image?.name || saved.filename,
mimeType: payload.mimeType,
uploadedAt: timestamp
});
});
}
const entry = {
id: entryId,
storeId: String(storeId),
storeName: storeName || `Store ${storeId}`,
pickupDate,
note: note || '',
reminder: normalizedReminder,
images: collectedImages,
createdAt: timestamp,
updatedAt: timestamp,
lastReminderAt: null
};
const entries = readJournal(profileId);
entries.push(entry);
writeJournal(profileId, entries);
res.json({
...entry,
images: collectedImages.map((image) => ({
...image,
url: `/api/journal/images/${image.id}`
}))
});
});
app.put('/api/journal/:id', requireAuth, (req, res) => {
const profileId = req.session.profile.id;
const { storeId, storeName, pickupDate, note, reminder, images, keepImageIds } = req.body || {};
if (!storeId || !pickupDate) {
return res.status(400).json({ error: 'Store und Abholdatum sind erforderlich' });
}
const parsedDate = new Date(`${pickupDate}T00:00:00`);
if (Number.isNaN(parsedDate.getTime())) {
return res.status(400).json({ error: 'Ungültiges Abholdatum' });
}
const entries = readJournal(profileId);
const index = entries.findIndex((entry) => entry.id === req.params.id);
if (index === -1) {
return res.status(404).json({ error: 'Eintrag nicht gefunden' });
}
const existing = entries[index];
const normalizedReminder = normalizeJournalReminder(reminder);
const maxImages = 5;
const maxImageBytes = 5 * 1024 * 1024;
const keepIds = Array.isArray(keepImageIds) ? new Set(keepImageIds.map(String)) : new Set();
const keptImages = (existing.images || []).filter((image) => keepIds.has(String(image.id)));
const removedImages = (existing.images || []).filter((image) => !keepIds.has(String(image.id)));
removedImages.forEach((image) => deleteJournalImage(profileId, image.filename));
const availableSlots = Math.max(0, maxImages - keptImages.length);
const collectedImages = [...keptImages];
if (Array.isArray(images) && availableSlots > 0) {
images.slice(0, availableSlots).forEach((image) => {
const payload = parseDataUrl(image?.dataUrl);
if (!payload) {
return;
}
if (payload.buffer.length > maxImageBytes) {
return;
}
const imageId = uuid();
const ext = resolveImageExtension(payload.mimeType) || path.extname(image?.name || '');
const filename = `${imageId}${ext || ''}`;
const saved = saveJournalImage(profileId, {
id: imageId,
filename,
buffer: payload.buffer
});
collectedImages.push({
id: imageId,
filename: saved.filename,
originalName: image?.name || saved.filename,
mimeType: payload.mimeType,
uploadedAt: new Date().toISOString()
});
});
}
const pickupDateChanged = existing.pickupDate !== pickupDate;
const updated = {
...existing,
storeId: String(storeId),
storeName: storeName || existing.storeName || `Store ${storeId}`,
pickupDate,
note: note || '',
reminder: normalizedReminder,
images: collectedImages,
updatedAt: new Date().toISOString(),
lastReminderAt: pickupDateChanged ? null : existing.lastReminderAt
};
entries[index] = updated;
writeJournal(profileId, entries);
res.json({
...updated,
images: collectedImages.map((image) => ({
...image,
url: `/api/journal/images/${image.id}`
}))
});
});
app.delete('/api/journal/:id', requireAuth, (req, res) => {
const profileId = req.session.profile.id;
const entries = readJournal(profileId);
const filtered = entries.filter((entry) => entry.id !== req.params.id);
if (filtered.length === entries.length) {
return res.status(404).json({ error: 'Eintrag nicht gefunden' });
}
const removed = entries.find((entry) => entry.id === req.params.id);
if (removed?.images) {
removed.images.forEach((image) => deleteJournalImage(profileId, image.filename));
}
writeJournal(profileId, filtered);
res.json({ success: true });
});
app.get('/api/journal/images/:imageId', requireAuth, (req, res) => {
const profileId = req.session.profile.id;
const entries = readJournal(profileId);
const imageEntry = entries
.flatMap((entry) => (Array.isArray(entry.images) ? entry.images : []))
.find((image) => image.id === req.params.imageId);
if (!imageEntry?.filename) {
return res.status(404).json({ error: 'Bild nicht gefunden' });
}
const baseDir = getProfileImageDir(profileId);
const filePath = path.join(baseDir, imageEntry.filename);
if (!filePath || !path.resolve(filePath).startsWith(path.resolve(baseDir))) {
return res.status(404).json({ error: 'Bild nicht gefunden' });
}
res.sendFile(filePath);
});
app.get('/api/config', requireAuth, (req, res) => {
const config = readConfig(req.session.profile.id);
res.json(config);

83
services/journalStore.js Normal file
View File

@@ -0,0 +1,83 @@
const fs = require('fs');
const path = require('path');
const CONFIG_DIR = path.join(__dirname, '..', 'config');
const IMAGE_ROOT = path.join(CONFIG_DIR, 'journal-images');
function ensureDir(dirPath) {
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true });
}
}
function ensureBaseDirs() {
ensureDir(CONFIG_DIR);
ensureDir(IMAGE_ROOT);
}
function getJournalPath(profileId = 'shared') {
return path.join(CONFIG_DIR, `${profileId}-pickup-journal.json`);
}
function getProfileImageDir(profileId = 'shared') {
return path.join(IMAGE_ROOT, String(profileId));
}
function hydrateJournalFile(profileId) {
ensureBaseDirs();
const filePath = getJournalPath(profileId);
if (!fs.existsSync(filePath)) {
fs.writeFileSync(filePath, JSON.stringify([], null, 2));
}
return filePath;
}
function readJournal(profileId) {
const filePath = hydrateJournalFile(profileId);
try {
const raw = fs.readFileSync(filePath, 'utf8');
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : [];
} catch (error) {
console.error(`[JOURNAL] Konnte Journal für ${profileId} nicht lesen:`, error.message);
return [];
}
}
function writeJournal(profileId, entries = []) {
const filePath = hydrateJournalFile(profileId);
fs.writeFileSync(filePath, JSON.stringify(entries, null, 2));
return filePath;
}
function saveJournalImage(profileId, { id, filename, buffer }) {
ensureBaseDirs();
const profileDir = getProfileImageDir(profileId);
ensureDir(profileDir);
const safeName = filename.replace(/[^a-zA-Z0-9_.-]/g, '_');
const filePath = path.join(profileDir, safeName);
fs.writeFileSync(filePath, buffer);
return {
id,
filename: safeName,
filePath
};
}
function deleteJournalImage(profileId, filename) {
if (!filename) {
return;
}
const filePath = path.join(getProfileImageDir(profileId), filename);
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
}
}
module.exports = {
readJournal,
writeJournal,
saveJournalImage,
deleteJournalImage,
getProfileImageDir
};

View File

@@ -21,6 +21,22 @@ function formatDateLabel(dateInput) {
}
}
function formatDateOnly(dateInput) {
try {
const date = new Date(dateInput);
if (Number.isNaN(date.getTime())) {
return 'unbekanntes Datum';
}
return date.toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric'
});
} catch (_error) {
return 'unbekanntes Datum';
}
}
async function sendNtfyNotification(adminNtfy, userNtfy, payload) {
if (!adminNtfy?.enabled || !userNtfy?.enabled || !userNtfy.topic) {
return;
@@ -230,6 +246,34 @@ async function sendDormantPickupWarning({ profileId, storeName, storeId, reasonL
});
}
async function sendJournalReminderNotification({
profileId,
storeName,
pickupDate,
reminderDate,
note
}) {
if (!profileId) {
return;
}
const adminSettings = adminConfig.readSettings();
const userSettings = readNotificationSettings(profileId);
const title = `Erinnerung: Abholung bei ${storeName}`;
const reminderLabel = formatDateOnly(reminderDate);
const pickupLabel = formatDateOnly(pickupDate);
const noteLine = note ? `Notiz: ${note}` : null;
const messageLines = [
`Geplante Abholung: ${pickupLabel}`,
`Erinnerungstermin: ${reminderLabel}`,
noteLine
].filter(Boolean);
await sendTelegramNotification(adminSettings.notifications?.telegram, userSettings.notifications?.telegram, {
title,
message: messageLines.join('\n'),
priority: 'default'
});
}
async function sendAdminBookingErrorNotification({ profileId, profileEmail, storeName, storeId, pickupDate, error }) {
const dateLabel = formatDateLabel(pickupDate);
const storeLabel = storeName || storeId || 'Unbekannter Store';
@@ -257,5 +301,6 @@ module.exports = {
sendTestNotification,
sendDesiredWindowMissedNotification,
sendDormantPickupWarning,
sendJournalReminderNotification,
sendAdminBookingErrorNotification
};

View File

@@ -5,8 +5,9 @@ const { DEFAULT_SETTINGS } = require('./adminConfig');
const notificationService = require('./notificationService');
const { readConfig, writeConfig } = require('./configStore');
const { readStoreWatch, writeStoreWatch } = require('./storeWatchStore');
const { readJournal, writeJournal } = require('./journalStore');
const { setStoreStatus, persistStoreStatusCache } = require('./storeStatusCache');
const { sendDormantPickupWarning } = require('./notificationService');
const { sendDormantPickupWarning, sendJournalReminderNotification } = require('./notificationService');
const { ensureSession, withSessionRetry } = require('./sessionRefresh');
function wait(ms) {
@@ -32,6 +33,48 @@ function randomDelayMs(minSeconds = 10, maxSeconds = 120) {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
function startOfDay(date) {
return new Date(date.getFullYear(), date.getMonth(), date.getDate());
}
function isSameDay(a, b) {
return (
a &&
b &&
a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth() &&
a.getDate() === b.getDate()
);
}
function addMonths(date, months) {
const copy = new Date(date.getTime());
copy.setMonth(copy.getMonth() + months);
return copy;
}
function getIntervalMonths(interval) {
if (interval === 'monthly') {
return 1;
}
if (interval === 'quarterly') {
return 3;
}
return 12;
}
function getNextOccurrence(baseDate, intervalMonths, todayStart) {
if (!baseDate || Number.isNaN(baseDate.getTime())) {
return null;
}
let candidate = startOfDay(baseDate);
const guardYear = todayStart.getFullYear() + 200;
while (candidate < todayStart && candidate.getFullYear() < guardYear) {
candidate = startOfDay(addMonths(candidate, intervalMonths));
}
return candidate;
}
function resolveSettings(settings) {
if (!settings) {
return { ...DEFAULT_SETTINGS };
@@ -555,6 +598,7 @@ function scheduleConfig(sessionId, config, settings) {
sessionStore.clearJobs(sessionId);
scheduleDormantMembershipCheck(sessionId);
const watchScheduled = scheduleStoreWatchers(sessionId, resolvedSettings);
scheduleJournalReminders(sessionId);
const entries = Array.isArray(config) ? config : [];
const activeEntries = entries.filter((entry) => entry.active);
if (activeEntries.length === 0) {
@@ -770,6 +814,78 @@ async function runDormantMembershipCheck(sessionId, options = {}) {
await checkDormantMembers(sessionId, options);
}
async function checkJournalReminders(sessionId) {
const session = sessionStore.get(sessionId);
if (!session?.profile?.id) {
return;
}
const profileId = session.profile.id;
const entries = readJournal(profileId);
if (!Array.isArray(entries) || entries.length === 0) {
return;
}
const todayStart = startOfDay(new Date());
const todayLabel = todayStart.toISOString().slice(0, 10);
let updated = false;
for (const entry of entries) {
if (!entry?.reminder?.enabled || !entry.pickupDate) {
continue;
}
const intervalMonths = getIntervalMonths(entry.reminder.interval);
const baseDate = new Date(`${entry.pickupDate}T00:00:00`);
const occurrence = getNextOccurrence(baseDate, intervalMonths, todayStart);
if (!occurrence) {
continue;
}
const daysBefore = Number.isFinite(entry.reminder.daysBefore)
? Math.max(0, entry.reminder.daysBefore)
: 42;
const reminderDate = new Date(occurrence.getTime());
reminderDate.setDate(reminderDate.getDate() - daysBefore);
if (!isSameDay(reminderDate, todayStart)) {
continue;
}
if (entry.lastReminderAt === todayLabel) {
continue;
}
await sendJournalReminderNotification({
profileId,
storeName: entry.storeName || `Store ${entry.storeId || ''}`,
pickupDate: occurrence,
reminderDate,
note: entry.note || ''
});
entry.lastReminderAt = todayLabel;
entry.updatedAt = new Date().toISOString();
updated = true;
}
if (updated) {
writeJournal(profileId, entries);
}
}
function scheduleJournalReminders(sessionId) {
const cronExpression = '0 8 * * *';
const job = cron.schedule(
cronExpression,
() => {
checkJournalReminders(sessionId).catch((error) => {
console.error('[JOURNAL] Erinnerung fehlgeschlagen:', error.message);
});
},
{ timezone: 'Europe/Berlin' }
);
sessionStore.attachJob(sessionId, job);
setTimeout(() => {
checkJournalReminders(sessionId).catch((error) => {
console.error('[JOURNAL] Initiale Erinnerung fehlgeschlagen:', error.message);
});
}, randomDelayMs(30, 120));
}
module.exports = {
scheduleConfig,
runStoreWatchCheck,

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' });