Journal für abholungen
This commit is contained in:
246
server.js
246
server.js
@@ -19,6 +19,13 @@ const notificationService = require('./services/notificationService');
|
|||||||
const { readStoreWatch, writeStoreWatch, listWatcherProfiles } = require('./services/storeWatchStore');
|
const { readStoreWatch, writeStoreWatch, listWatcherProfiles } = require('./services/storeWatchStore');
|
||||||
const { readPreferences, writePreferences, sanitizeLocation } = require('./services/userPreferencesStore');
|
const { readPreferences, writePreferences, sanitizeLocation } = require('./services/userPreferencesStore');
|
||||||
const requestLogStore = require('./services/requestLogStore');
|
const requestLogStore = require('./services/requestLogStore');
|
||||||
|
const {
|
||||||
|
readJournal,
|
||||||
|
writeJournal,
|
||||||
|
saveJournalImage,
|
||||||
|
deleteJournalImage,
|
||||||
|
getProfileImageDir
|
||||||
|
} = require('./services/journalStore');
|
||||||
const { withSessionRetry } = require('./services/sessionRefresh');
|
const { withSessionRetry } = require('./services/sessionRefresh');
|
||||||
const {
|
const {
|
||||||
getStoreStatus: getCachedStoreStatusEntry,
|
getStoreStatus: getCachedStoreStatusEntry,
|
||||||
@@ -69,7 +76,7 @@ function haversineDistanceKm(lat1, lon1, lat2, lon2) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
app.use(cors());
|
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(express.static(path.join(__dirname, 'build')));
|
||||||
|
|
||||||
app.use((req, res, next) => {
|
app.use((req, res, next) => {
|
||||||
@@ -229,6 +236,45 @@ function getCachedStoreStatus(storeId) {
|
|||||||
return getCachedStoreStatusEntry(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 = []) {
|
function ingestStoreLocations(stores = []) {
|
||||||
let changed = false;
|
let changed = false;
|
||||||
stores.forEach((store) => {
|
stores.forEach((store) => {
|
||||||
@@ -990,6 +1036,204 @@ app.post('/api/user/preferences/location', requireAuth, (req, res) => {
|
|||||||
res.json({ location: updated.location });
|
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) => {
|
app.get('/api/config', requireAuth, (req, res) => {
|
||||||
const config = readConfig(req.session.profile.id);
|
const config = readConfig(req.session.profile.id);
|
||||||
res.json(config);
|
res.json(config);
|
||||||
|
|||||||
83
services/journalStore.js
Normal file
83
services/journalStore.js
Normal 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
|
||||||
|
};
|
||||||
@@ -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) {
|
async function sendNtfyNotification(adminNtfy, userNtfy, payload) {
|
||||||
if (!adminNtfy?.enabled || !userNtfy?.enabled || !userNtfy.topic) {
|
if (!adminNtfy?.enabled || !userNtfy?.enabled || !userNtfy.topic) {
|
||||||
return;
|
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 }) {
|
async function sendAdminBookingErrorNotification({ profileId, profileEmail, storeName, storeId, pickupDate, error }) {
|
||||||
const dateLabel = formatDateLabel(pickupDate);
|
const dateLabel = formatDateLabel(pickupDate);
|
||||||
const storeLabel = storeName || storeId || 'Unbekannter Store';
|
const storeLabel = storeName || storeId || 'Unbekannter Store';
|
||||||
@@ -257,5 +301,6 @@ module.exports = {
|
|||||||
sendTestNotification,
|
sendTestNotification,
|
||||||
sendDesiredWindowMissedNotification,
|
sendDesiredWindowMissedNotification,
|
||||||
sendDormantPickupWarning,
|
sendDormantPickupWarning,
|
||||||
|
sendJournalReminderNotification,
|
||||||
sendAdminBookingErrorNotification
|
sendAdminBookingErrorNotification
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,8 +5,9 @@ const { DEFAULT_SETTINGS } = require('./adminConfig');
|
|||||||
const notificationService = require('./notificationService');
|
const notificationService = require('./notificationService');
|
||||||
const { readConfig, writeConfig } = require('./configStore');
|
const { readConfig, writeConfig } = require('./configStore');
|
||||||
const { readStoreWatch, writeStoreWatch } = require('./storeWatchStore');
|
const { readStoreWatch, writeStoreWatch } = require('./storeWatchStore');
|
||||||
|
const { readJournal, writeJournal } = require('./journalStore');
|
||||||
const { setStoreStatus, persistStoreStatusCache } = require('./storeStatusCache');
|
const { setStoreStatus, persistStoreStatusCache } = require('./storeStatusCache');
|
||||||
const { sendDormantPickupWarning } = require('./notificationService');
|
const { sendDormantPickupWarning, sendJournalReminderNotification } = require('./notificationService');
|
||||||
const { ensureSession, withSessionRetry } = require('./sessionRefresh');
|
const { ensureSession, withSessionRetry } = require('./sessionRefresh');
|
||||||
|
|
||||||
function wait(ms) {
|
function wait(ms) {
|
||||||
@@ -32,6 +33,48 @@ function randomDelayMs(minSeconds = 10, maxSeconds = 120) {
|
|||||||
return Math.floor(Math.random() * (max - min + 1)) + min;
|
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) {
|
function resolveSettings(settings) {
|
||||||
if (!settings) {
|
if (!settings) {
|
||||||
return { ...DEFAULT_SETTINGS };
|
return { ...DEFAULT_SETTINGS };
|
||||||
@@ -555,6 +598,7 @@ function scheduleConfig(sessionId, config, settings) {
|
|||||||
sessionStore.clearJobs(sessionId);
|
sessionStore.clearJobs(sessionId);
|
||||||
scheduleDormantMembershipCheck(sessionId);
|
scheduleDormantMembershipCheck(sessionId);
|
||||||
const watchScheduled = scheduleStoreWatchers(sessionId, resolvedSettings);
|
const watchScheduled = scheduleStoreWatchers(sessionId, resolvedSettings);
|
||||||
|
scheduleJournalReminders(sessionId);
|
||||||
const entries = Array.isArray(config) ? config : [];
|
const entries = Array.isArray(config) ? config : [];
|
||||||
const activeEntries = entries.filter((entry) => entry.active);
|
const activeEntries = entries.filter((entry) => entry.active);
|
||||||
if (activeEntries.length === 0) {
|
if (activeEntries.length === 0) {
|
||||||
@@ -770,6 +814,78 @@ async function runDormantMembershipCheck(sessionId, options = {}) {
|
|||||||
await checkDormantMembers(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 = {
|
module.exports = {
|
||||||
scheduleConfig,
|
scheduleConfig,
|
||||||
runStoreWatchCheck,
|
runStoreWatchCheck,
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import StoreSyncOverlay from './components/StoreSyncOverlay';
|
|||||||
import RangePickerModal from './components/RangePickerModal';
|
import RangePickerModal from './components/RangePickerModal';
|
||||||
import StoreWatchPage from './components/StoreWatchPage';
|
import StoreWatchPage from './components/StoreWatchPage';
|
||||||
import DebugPage from './components/DebugPage';
|
import DebugPage from './components/DebugPage';
|
||||||
|
import JournalPage from './components/JournalPage';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [credentials, setCredentials] = useState({ email: '', password: '' });
|
const [credentials, setCredentials] = useState({ email: '', password: '' });
|
||||||
@@ -803,6 +804,10 @@ function App() {
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/journal"
|
||||||
|
element={<JournalPage authorizedFetch={authorizedFetch} stores={stores} />}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/debug"
|
path="/debug"
|
||||||
element={
|
element={
|
||||||
|
|||||||
107
src/components/JournalPage.css
Normal file
107
src/components/JournalPage.css
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
897
src/components/JournalPage.js
Normal file
897
src/components/JournalPage.js
Normal 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 A–Z</option>
|
||||||
|
<option value="storeNameDesc">Betrieb Z–A</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;
|
||||||
@@ -6,7 +6,8 @@ const NavigationTabs = ({ isAdmin, onProtectedNavigate }) => {
|
|||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{ to: '/', label: 'Slots buchen' },
|
{ to: '/', label: 'Slots buchen' },
|
||||||
{ to: '/store-watch', label: 'Betriebs-Monitoring' }
|
{ to: '/store-watch', label: 'Betriebs-Monitoring' },
|
||||||
|
{ to: '/journal', label: 'Abhol-Journal' }
|
||||||
];
|
];
|
||||||
if (isAdmin) {
|
if (isAdmin) {
|
||||||
tabs.push({ to: '/debug', label: 'Debug' });
|
tabs.push({ to: '/debug', label: 'Debug' });
|
||||||
|
|||||||
Reference in New Issue
Block a user