From 8b0326bac93aebf9bf6a5b982a6ec896e74fefa1 Mon Sep 17 00:00:00 2001 From: Meik Date: Sun, 4 Jan 2026 01:37:36 +0100 Subject: [PATCH] =?UTF-8?q?Journal=20f=C3=BCr=20abholungen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server.js | 246 ++++++++- services/journalStore.js | 83 +++ services/notificationService.js | 45 ++ services/pickupScheduler.js | 118 +++- src/App.js | 5 + src/components/JournalPage.css | 107 ++++ src/components/JournalPage.js | 897 +++++++++++++++++++++++++++++++ src/components/NavigationTabs.js | 3 +- 8 files changed, 1501 insertions(+), 3 deletions(-) create mode 100644 services/journalStore.js create mode 100644 src/components/JournalPage.css create mode 100644 src/components/JournalPage.js diff --git a/server.js b/server.js index f19d4d4..3c5f8a0 100644 --- a/server.js +++ b/server.js @@ -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); diff --git a/services/journalStore.js b/services/journalStore.js new file mode 100644 index 0000000..271eb1d --- /dev/null +++ b/services/journalStore.js @@ -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 +}; diff --git a/services/notificationService.js b/services/notificationService.js index fababbf..57597bb 100644 --- a/services/notificationService.js +++ b/services/notificationService.js @@ -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 }; diff --git a/services/pickupScheduler.js b/services/pickupScheduler.js index c093ea6..f5f8e6f 100644 --- a/services/pickupScheduler.js +++ b/services/pickupScheduler.js @@ -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, diff --git a/src/App.js b/src/App.js index 389effc..af2f732 100644 --- a/src/App.js +++ b/src/App.js @@ -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() { /> } /> + } + /> { + 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 ( +
+
+
+
+

Journal-Einträge

+
+ + +
+
+ {error ? ( +
+

{error}

+
+ ) : null} + {loading ?

Lade Einträge...

: null} + {!loading && entries.length === 0 ? ( +

Noch keine Einträge vorhanden.

+ ) : null} +
+ 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" + /> + setFilters((prev) => ({ ...prev, dateFrom: event.target.value }))} + className="min-w-[140px]" + /> + setFilters((prev) => ({ ...prev, dateTo: event.target.value }))} + className="min-w-[140px]" + /> + + +
+
+ {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 ( +
+
+
+ {mainImage ? ( +
+
+ {mainImage.originalName + showHoverPreview(event, mainImage, { width: 720, height: 540 }) + } + onMouseLeave={hideHoverPreview} + /> +
+
+ ) : ( +
+ Kein Bild +
+ )} +
+
+
+
+

{entry.pickupDate}

+

+ {storeLabel} +

+
+
+ + +
+
+ {entry.note ?

{entry.note}

: null} +

{reminderLabel}

+ {Array.isArray(entry.images) && entry.images.length > 1 ? ( +
+ {entry.images.slice(1).map((image) => ( +
+
+ {image.originalName + showHoverPreview(event, image, { width: 600, height: 450 }) + } + onMouseLeave={hideHoverPreview} + /> +
+
+ ))} +
+ ) : null} +
+
+
+ ); + })} + {!loading && entries.length > 0 && filteredEntries.length === 0 ? ( +
Keine Einträge für die aktuellen Filter.
+ ) : null} +
+
+
+ {formOpen ? ( + <> +
+
{ + if (event.target === event.currentTarget) { + resetForm(); + } + }} + > +
event.stopPropagation()} + > +
+
+

+ {editingEntryId ? 'Eintrag bearbeiten' : 'Neuer Eintrag'} +

+

Abholung dokumentieren

+
+ +
+
+ {error ? ( +
+

{error}

+
+ ) : null} +
+
+ + +
+
+ + handleFormChange({ pickupDate: event.target.value })} + className="w-full" + /> +
+
+ +