Compare commits

...

3 Commits

3 changed files with 297 additions and 39 deletions
+114 -8
View File
@@ -1138,6 +1138,7 @@ def update_appointment_statuses():
item_id_str = appointment.get('Item')
conflict_detected = False
conflict_note = ''
item_name = item_id_str or 'Termin'
if item_id_str:
try:
item_doc = items_col.find_one(
@@ -1145,6 +1146,7 @@ def update_appointment_statuses():
{'Verfuegbar': 1, 'User': 1, 'Name': 1, 'Exemplare': 1}
)
if item_doc:
item_name = item_doc.get('Name', item_id_str)
total_exemplare = int(item_doc.get('Exemplare', 1))
# Count how many active (non-planned) borrows currently hold this item
active_borrows = ausleihungen.count_documents({
@@ -1191,6 +1193,28 @@ def update_appointment_statuses():
updated_count += 1
if new_status == 'active':
activated_count += 1
try:
_create_notification(
db,
audience='user',
notif_type='appointment_activated',
title='Reservierung ist jetzt aktiv',
message=(
f"Deine geplante Ausleihe für {item_name} startet jetzt."
),
target_user=str(appointment.get('User') or '').strip() or None,
reference={
'appointment_id': str(appointment.get('_id')),
'item_id': str(appointment.get('Item') or ''),
'event': 'activated',
},
unique_key=f"appointment:activated:{appointment.get('_id')}",
severity='info',
)
except Exception as notif_err:
app.logger.warning(
f"Failed to create activation notification for {appointment.get('_id')}: {notif_err}"
)
elif new_status == 'completed':
completed_count += 1
@@ -4040,6 +4064,9 @@ def get_items():
'Filter3': 1,
'Ort': 1,
'User': 1,
'BlockedNow': 1,
'Reservierbar': 1,
'HasDamage': 1,
'ItemType': 1,
}
@@ -4183,6 +4210,83 @@ def get_item_json(id):
return jsonify({'error': str(e)}), 500
@app.route('/get_bookings')
def get_bookings():
"""Return calendar bookings for the current user session."""
if 'username' not in session:
return jsonify({'ok': False, 'error': 'unauthorized'}), 401
client = None
try:
username = session.get('username')
start = request.args.get('start')
end = request.args.get('end')
bookings = au.get_ausleihungen(status=['planned', 'active', 'completed'], start=start, end=end)
client = MongoClient(MONGODB_HOST, MONGODB_PORT)
db = client[MONGODB_DB]
items_col = db['items']
result = []
for booking in bookings:
start_dt = booking.get('Start')
if not start_dt:
continue
end_dt = booking.get('End')
if not end_dt and isinstance(start_dt, datetime.datetime):
end_dt = start_dt + datetime.timedelta(minutes=45)
elif not end_dt:
end_dt = start_dt
item_id = str(booking.get('Item') or '')
item_doc = None
if item_id:
try:
item_doc = items_col.find_one({'_id': ObjectId(item_id)})
except Exception:
item_doc = None
item_name = item_id or 'Ausleihe'
item_borrower = ''
if item_doc:
item_name = item_doc.get('Name') or item_doc.get('Code_4') or item_name
borrower_info = item_doc.get('BorrowerInfo') or {}
borrower_name = borrower_info.get('User') if isinstance(borrower_info, dict) else ''
item_borrower = str(item_doc.get('User') or borrower_name or '')
status = booking.get('Status') or 'unknown'
if status == 'active':
status = 'current'
period = booking.get('Period')
title = item_name
if period:
title = f"{title} - {period}. Std"
result.append({
'id': str(booking.get('_id')),
'title': title,
'start': start_dt.isoformat() if isinstance(start_dt, datetime.datetime) else str(start_dt),
'end': end_dt.isoformat() if isinstance(end_dt, datetime.datetime) else str(end_dt),
'status': status,
'itemId': item_id,
'userName': str(booking.get('User') or ''),
'notes': str(booking.get('Notes') or ''),
'period': period,
'isCurrentUser': str(booking.get('User') or '') == username,
'itemBorrower': item_borrower,
})
return jsonify({'ok': True, 'bookings': result})
except Exception as e:
return jsonify({'ok': False, 'error': str(e), 'bookings': []}), 500
finally:
if client:
client.close()
@app.route('/api/booking_conflicts')
def api_booking_conflicts():
"""
@@ -6577,17 +6681,19 @@ def plan_booking():
try:
# Extract form data
item_id = html.escape(request.form.get('item_id'))
start_date_str = html.escape(request.form.get('booking_date')) # Changed from start_date to booking_date
end_date_str = html.escape(request.form.get('booking_end_date')) # Changed from end_date to booking_end_date
period_start = html.escape(request.form.get('period_start'))
period_end = html.escape(request.form.get('period_end'))
notes = html.escape(request.form.get('notes', ''))
booking_type = html.escape(request.form.get('booking_type', 'single'))
item_id = (request.form.get('item_id') or '').strip()
start_date_str = (request.form.get('booking_date') or request.form.get('start_date') or '').strip()
end_date_str = (request.form.get('booking_end_date') or request.form.get('end_date') or '').strip()
period_start = (request.form.get('period_start') or '').strip()
period_end = (request.form.get('period_end') or '').strip()
notes = html.escape(request.form.get('notes', '') or '')
booking_type = (request.form.get('booking_type', 'single') or 'single').strip().lower()
# Validate inputs
if not all([item_id, start_date_str, end_date_str, period_start]):
if not all([item_id, start_date_str, period_start]):
return {"success": False, "error": "Missing required fields"}, 400
if not end_date_str:
end_date_str = start_date_str
# Parse dates
try:
+101 -10
View File
@@ -799,10 +799,8 @@
}
function loadItems(offset = 0, append = false) {
// Für Pages nach der ersten: Explizit vollständige Daten laden (light_mode=false)
// Erste Page: light_mode wird automatisch enablet
const lightModeParam = offset > 0 ? '&light_mode=false' : '';
return fetch(`{{ url_for('get_items') }}?offset=${offset}&limit=${MAIN_ITEMS_PAGE_SIZE}${lightModeParam}`)
// Keep list payload lightweight; full details are fetched on-demand in openItemQuick.
return fetch(`{{ url_for('get_items') }}?offset=${offset}&limit=${MAIN_ITEMS_PAGE_SIZE}&light_mode=true`)
.then(response => response.json())
.then(data => {
const itemsContainer = document.querySelector('#items-container');
@@ -1073,12 +1071,9 @@
// Stop event from bubbling up
e.stopPropagation();
// Get full item data with correct image paths
const modalItemData = {...item};
modalItemData.Images = item.Images ? item.Images.map(img => "{{ url_for('uploaded_file', filename='') }}" + img) : [];
openItemModal(modalItemData);
// Always load full, up-to-date item details for the modal.
openItemQuick(item._id);
});
}
} catch (err) {
@@ -1414,6 +1409,76 @@
}
function openItemModal(item) {
const escapeHtml = (value) => String(value ?? '')
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
const formatHistoryDate = (value) => {
if (!value) return '-';
const dt = new Date(value);
if (Number.isNaN(dt.getTime())) {
return escapeHtml(value);
}
return dt.toLocaleString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' });
};
const damageReports = Array.isArray(item.DamageReports) ? item.DamageReports : [];
const damageRepairs = Array.isArray(item.DamageRepairs) ? item.DamageRepairs : [];
const damageHistoryEntries = [];
damageReports.forEach((report) => {
damageHistoryEntries.push({
type: 'report',
rawDate: report?.reported_at || null,
dateLabel: formatHistoryDate(report?.reported_at),
actor: escapeHtml(report?.reported_by || '-'),
description: escapeHtml(report?.description || 'Schaden gemeldet'),
meta: '',
});
});
damageRepairs.forEach((repair) => {
const resolvedReports = Array.isArray(repair?.resolved_reports) ? repair.resolved_reports : [];
damageHistoryEntries.push({
type: 'repair',
rawDate: repair?.repaired_at || null,
dateLabel: formatHistoryDate(repair?.repaired_at),
actor: escapeHtml(repair?.repaired_by || '-'),
description: 'Als repariert markiert',
meta: `${resolvedReports.length} Meldung(en) abgeschlossen`,
});
});
damageHistoryEntries.sort((a, b) => {
const ta = a.rawDate ? new Date(a.rawDate).getTime() : 0;
const tb = b.rawDate ? new Date(b.rawDate).getTime() : 0;
return (Number.isNaN(tb) ? 0 : tb) - (Number.isNaN(ta) ? 0 : ta);
});
const damageHistoryHtml = damageHistoryEntries.length
? damageHistoryEntries.map((entry) => {
const badgeStyle = entry.type === 'repair'
? 'background:#dcfce7;color:#166534;'
: 'background:#fee2e2;color:#991b1b;';
const badgeText = entry.type === 'repair' ? 'Repariert' : 'Schaden';
const metaLine = entry.meta ? `<div style="font-size:0.84rem;color:#4b5563;">${escapeHtml(entry.meta)}</div>` : '';
return `
<div style="border:1px solid #dbe3ee;border-radius:8px;padding:10px;background:#fff;display:grid;gap:6px;">
<div style="display:flex;flex-wrap:wrap;gap:8px;align-items:center;">
<span style="display:inline-block;padding:2px 8px;border-radius:999px;font-size:0.75rem;font-weight:700;${badgeStyle}">${badgeText}</span>
<span style="font-size:0.84rem;color:#475569;">${entry.dateLabel}</span>
</div>
<div style="font-size:0.9rem;color:#0f172a;"><strong>Von:</strong> ${entry.actor}</div>
<div style="font-size:0.92rem;color:#1f2937;">${entry.description}</div>
${metaLine}
</div>
`;
}).join('')
: '<div style="font-size:0.92rem;color:#64748b;">Keine Beschädigungs-Historie vorhanden.</div>';
// Get modal elements
const modal = document.getElementById('item-modal');
const modalContent = document.getElementById('modal-content-wrapper');
@@ -1571,6 +1636,19 @@
<div class="detail-value">${item.Beschreibung || '-'}</div>
</div>
<div class="detail-group full-width" style="margin-top:12px;">
<div class="detail-label" style="font-weight:600; color:#374151;">Beschädigungs-Historie</div>
<div class="detail-value">
<button id="toggle-damage-history" class="calendar-toggle-btn" style="margin-bottom:12px; padding:10px 16px; border-radius:6px; background:#f3f4f6; border:1px solid #d1d5db; font-weight:500; cursor:pointer; display:inline-flex; align-items:center; gap:8px; transition:all 0.2s ease;">
<span>🛠️</span>
<span id="toggle-damage-history-text">Historie anzeigen</span>
</button>
<div id="damage-history-panel" style="display:none; margin-top:8px; border:1px solid #e7edf5; border-radius:10px; padding:12px; background:#f8fafc;">
<div style="display:grid; gap:10px;">${damageHistoryHtml}</div>
</div>
</div>
</div>
<div class="detail-group full-width" style="margin-top:14px; padding:12px; border:1px solid #e5e7eb; border-radius:10px; background:#fbfbfd; box-shadow: 0 1px 1px rgba(0,0,0,0.03);">
<div class="detail-label" style="font-weight:600; color:#374151;">Verfügbarkeit prüfen</div>
<div class="detail-value">
@@ -1687,6 +1765,9 @@
const detailsPanel = document.getElementById('calendar-day-details');
const detailsDate = document.getElementById('cal-details-date');
const detailsList = document.getElementById('cal-details-list');
const damageToggleBtn = document.getElementById('toggle-damage-history');
const damageHistoryPanel = document.getElementById('damage-history-panel');
const damageToggleText = document.getElementById('toggle-damage-history-text');
let bookings = [];
let currentDate = new Date();
@@ -1847,6 +1928,16 @@
renderCalendar();
});
damageToggleBtn?.addEventListener('click', () => {
const shouldOpen = damageHistoryPanel.style.display === 'none';
damageHistoryPanel.style.display = shouldOpen ? 'block' : 'none';
if (damageToggleText) {
damageToggleText.textContent = shouldOpen ? 'Historie verbergen' : 'Historie anzeigen';
}
damageToggleBtn.style.background = shouldOpen ? '#e0e7ff' : '#f3f4f6';
damageToggleBtn.style.borderColor = shouldOpen ? '#818cf8' : '#d1d5db';
});
// Availability checker (user)
const availDate = document.getElementById('avail-date');
const availStart = document.getElementById('avail-start');
+82 -21
View File
@@ -3415,10 +3415,8 @@ document.addEventListener('DOMContentLoaded', ()=>{
}
function loadItems(offset = 0, append = false) {
// Für Pages nach der ersten: Explizit vollständige Daten laden (light_mode=false)
// Erste Page: light_mode wird automatisch enablet
const lightModeParam = offset > 0 ? '&light_mode=false' : '';
return fetch(`{{ url_for('get_items') }}?offset=${offset}&limit=${MAIN_ADMIN_ITEMS_PAGE_SIZE}${lightModeParam}`)
// Keep list payload lightweight; full details are fetched on-demand in openItemQuick.
return fetch(`{{ url_for('get_items') }}?offset=${offset}&limit=${MAIN_ADMIN_ITEMS_PAGE_SIZE}&light_mode=true`)
.then(response => response.json())
.then(data => {
const itemsContainer = document.querySelector('#items-container');
@@ -3592,6 +3590,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
}
}
const damageCount = Array.isArray(item.DamageReports) ? item.DamageReports.length : 0;
const hasDamage = Boolean(item.HasDamage) || damageCount > 0;
const groupedCount = Number(item.GroupedDisplayCount || 1);
const availableGroupedCount = Number(item.AvailableGroupedCount ?? (item.Verfuegbar ? 1 : 0));
const isGroupedItem = groupedCount > 1;
@@ -3608,7 +3607,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
<p class="item-col-filter3"><strong>Thema:</strong> ${filter3Display}${filter3More}</p>
<p class="item-col-code"><strong>Barcode:</strong> ${item.Code_4 || '-'}</p>
<p class="item-col-count"><strong>Anzahl:</strong> ${groupedCount}</p>
${damageCount > 0 ? `<div class="damage-badge">Schäden gemeldet: ${damageCount}</div>` : ''}
${hasDamage ? `<div class="damage-badge">${damageCount > 0 ? `Schäden gemeldet: ${damageCount}` : 'Schäden gemeldet'}</div>` : ''}
<div class="image-container">
${imagesHtml}
</div>
@@ -3680,11 +3679,8 @@ document.addEventListener('DOMContentLoaded', ()=>{
}
e.stopPropagation();
const modalItemData = {...item};
modalItemData.Images = item.Images ? item.Images.map(img => "{{ url_for('uploaded_file', filename='') }}" + img) : [];
openItemModal(modalItemData);
openItemQuick(item._id);
});
}
} catch (err) {
@@ -4303,14 +4299,58 @@ document.addEventListener('DOMContentLoaded', ()=>{
}
const damageReports = Array.isArray(item.DamageReports) ? item.DamageReports : [];
const damageInfoHtml = damageReports.length > 0
? `<ul class="damage-list">${damageReports.map(report => {
const desc = escapeHtml(report?.description || '-');
const by = escapeHtml(report?.reported_by || 'Unbekannt');
const at = escapeHtml(formatDamageTimestamp(report?.reported_at));
return `<li><strong>${at}</strong> durch ${by}<br>${desc}</li>`;
}).join('')}</ul>`
: 'Keine Schäden erfasst.';
const damageRepairs = Array.isArray(item.DamageRepairs) ? item.DamageRepairs : [];
const damageHistoryEntries = [];
damageReports.forEach(report => {
damageHistoryEntries.push({
type: 'report',
timestamp: report?.reported_at || null,
dateLabel: escapeHtml(formatDamageTimestamp(report?.reported_at)),
actor: escapeHtml(report?.reported_by || 'Unbekannt'),
description: escapeHtml(report?.description || 'Schaden gemeldet'),
meta: '',
});
});
damageRepairs.forEach(repair => {
const resolvedReports = Array.isArray(repair?.resolved_reports) ? repair.resolved_reports : [];
damageHistoryEntries.push({
type: 'repair',
timestamp: repair?.repaired_at || null,
dateLabel: escapeHtml(formatDamageTimestamp(repair?.repaired_at)),
actor: escapeHtml(repair?.repaired_by || 'Unbekannt'),
description: 'Als repariert markiert',
meta: `${resolvedReports.length} Meldung(en) abgeschlossen`,
});
});
damageHistoryEntries.sort((a, b) => {
const ta = a.timestamp ? new Date(a.timestamp).getTime() : 0;
const tb = b.timestamp ? new Date(b.timestamp).getTime() : 0;
return (Number.isNaN(tb) ? 0 : tb) - (Number.isNaN(ta) ? 0 : ta);
});
const damageHistoryHtml = damageHistoryEntries.length > 0
? damageHistoryEntries.map(entry => {
const badgeStyle = entry.type === 'repair'
? 'background:#dcfce7;color:#166534;'
: 'background:#fee2e2;color:#991b1b;';
const badgeText = entry.type === 'repair' ? 'Repariert' : 'Schaden';
const metaLine = entry.meta ? `<div style="font-size:0.84rem;color:#4b5563;">${escapeHtml(entry.meta)}</div>` : '';
return `
<div style="border:1px solid #dbe3ee;border-radius:8px;padding:10px;background:#fff;display:grid;gap:6px;">
<div style="display:flex;flex-wrap:wrap;gap:8px;align-items:center;">
<span style="display:inline-block;padding:2px 8px;border-radius:999px;font-size:0.75rem;font-weight:700;${badgeStyle}">${badgeText}</span>
<span style="font-size:0.84rem;color:#475569;">${entry.dateLabel}</span>
</div>
<div style="font-size:0.9rem;color:#0f172a;"><strong>Von:</strong> ${entry.actor}</div>
<div style="font-size:0.92rem;color:#1f2937;">${entry.description}</div>
${metaLine}
</div>
`;
}).join('')
: '<div style="font-size:0.92rem;color:#64748b;">Keine Beschädigungs-Historie vorhanden.</div>';
modalContent.innerHTML = `
<h2>${item.Name}</h2>
@@ -4385,9 +4425,17 @@ document.addEventListener('DOMContentLoaded', ()=>{
<div class="detail-value">${item.Beschreibung || '-'}</div>
</div>
<div class="detail-group full-width">
<div class="detail-label">Schäden:</div>
<div class="detail-value">${damageInfoHtml}</div>
<div class="detail-group full-width" style="margin-top:12px;">
<div class="detail-label" style="font-weight:600; color:#374151;">Beschädigungs-Historie</div>
<div class="detail-value">
<button id="toggle-damage-history" class="calendar-toggle-btn" style="margin-bottom:12px; padding:10px 16px; border-radius:6px; background:#f3f4f6; border:1px solid #d1d5db; font-weight:500; cursor:pointer; display:inline-flex; align-items:center; gap:8px; transition:all 0.2s ease;">
<span>🛠️</span>
<span id="toggle-damage-history-text">Historie anzeigen</span>
</button>
<div id="damage-history-panel" style="display:none; margin-top:8px; border:1px solid #e7edf5; border-radius:10px; padding:12px; background:#f8fafc;">
<div style="display:grid; gap:10px;">${damageHistoryHtml}</div>
</div>
</div>
</div>
<div class="detail-group full-width" style="margin-top:12px; padding:10px; border:1px solid #e3e3e3; border-radius:8px;">
@@ -4486,6 +4534,9 @@ document.addEventListener('DOMContentLoaded', ()=>{
const detailsPanel = document.getElementById('calendar-day-details');
const detailsDate = document.getElementById('cal-details-date');
const detailsList = document.getElementById('cal-details-list');
const damageToggleBtn = document.getElementById('toggle-damage-history');
const damageHistoryPanel = document.getElementById('damage-history-panel');
const damageToggleText = document.getElementById('toggle-damage-history-text');
let bookings = [];
let currentDate = new Date();
@@ -4645,6 +4696,16 @@ document.addEventListener('DOMContentLoaded', ()=>{
renderCalendar();
});
damageToggleBtn?.addEventListener('click', () => {
const shouldOpen = damageHistoryPanel.style.display === 'none';
damageHistoryPanel.style.display = shouldOpen ? 'block' : 'none';
if (damageToggleText) {
damageToggleText.textContent = shouldOpen ? 'Historie verbergen' : 'Historie anzeigen';
}
damageToggleBtn.style.background = shouldOpen ? '#e0e7ff' : '#f3f4f6';
damageToggleBtn.style.borderColor = shouldOpen ? '#818cf8' : '#d1d5db';
});
// Availability checker
const availDate = document.getElementById('avail-date');