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