Nachrichten-Übersicht für Mieter + Vermieter-Ankündigungen hinzufügen
Neue Ankündigungen (z. B. Schornsteinfeger, Handwerkertermin), nur vom Vermieter anlegbar, für alle sichtbar. Zusätzlich eine gebündelte 'Für dich diese Woche'-Übersicht für Mieter mit fälligem Putzplan, fälliger Miete, anstehendem Mülltermin und Ankündigungen der nächsten 7 Tage.
This commit is contained in:
parent
ca1c1fdfb7
commit
b15ba77336
@ -11,6 +11,7 @@ import { expensesRouter } from './routes/expenses';
|
|||||||
import { inventoryRouter } from './routes/inventory';
|
import { inventoryRouter } from './routes/inventory';
|
||||||
import { handoverProtocolsRouter } from './routes/handoverProtocols';
|
import { handoverProtocolsRouter } from './routes/handoverProtocols';
|
||||||
import { accessCodesRouter } from './routes/accessCodes';
|
import { accessCodesRouter } from './routes/accessCodes';
|
||||||
|
import { announcementsRouter } from './routes/announcements';
|
||||||
import { storageRouter } from './routes/storage';
|
import { storageRouter } from './routes/storage';
|
||||||
import { documentsRouter } from './routes/documents';
|
import { documentsRouter } from './routes/documents';
|
||||||
import { contractsRouter } from './routes/contracts';
|
import { contractsRouter } from './routes/contracts';
|
||||||
@ -50,6 +51,7 @@ export function createApp() {
|
|||||||
app.use('/v1', inventoryRouter);
|
app.use('/v1', inventoryRouter);
|
||||||
app.use('/v1', handoverProtocolsRouter);
|
app.use('/v1', handoverProtocolsRouter);
|
||||||
app.use('/v1', accessCodesRouter);
|
app.use('/v1', accessCodesRouter);
|
||||||
|
app.use('/v1', announcementsRouter);
|
||||||
app.use('/v1', storageRouter);
|
app.use('/v1', storageRouter);
|
||||||
app.use('/v1', documentsRouter);
|
app.use('/v1', documentsRouter);
|
||||||
app.use('/v1', contractsRouter);
|
app.use('/v1', contractsRouter);
|
||||||
|
|||||||
71
backend/src/routes/announcements.ts
Normal file
71
backend/src/routes/announcements.ts
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
import { Router, Response } from 'express';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { AuthedRequest, requireAuth, requireRole } from '../middleware/auth';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ankündigungen des Vermieters (Schornsteinfeger, Handwerkertermin, etc.),
|
||||||
|
* für alle eingeloggten Nutzer sichtbar. Nur Vermieter/Admin legen sie an
|
||||||
|
* oder löschen sie.
|
||||||
|
*/
|
||||||
|
export const announcementsRouter = Router();
|
||||||
|
|
||||||
|
const ANNOUNCEMENT_SELECT = {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
message: true,
|
||||||
|
eventDate: true,
|
||||||
|
createdAt: true,
|
||||||
|
createdBy: { select: { id: true, fullName: true } },
|
||||||
|
};
|
||||||
|
|
||||||
|
announcementsRouter.post(
|
||||||
|
'/announcements',
|
||||||
|
requireAuth,
|
||||||
|
requireRole('LANDLORD', 'ADMIN'),
|
||||||
|
async (req: AuthedRequest, res: Response) => {
|
||||||
|
const { title, message, eventDate } = req.body || {};
|
||||||
|
if (!title || !String(title).trim()) {
|
||||||
|
return res.status(400).json({ error: 'Titel ist erforderlich' });
|
||||||
|
}
|
||||||
|
if (eventDate && Number.isNaN(Date.parse(eventDate))) {
|
||||||
|
return res.status(400).json({ error: 'Ungültiges Datum' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const announcement = await prisma.announcement.create({
|
||||||
|
data: {
|
||||||
|
title: String(title).trim().slice(0, 120),
|
||||||
|
message: typeof message === 'string' && message.trim() ? message.trim().slice(0, 500) : null,
|
||||||
|
eventDate: eventDate ? new Date(eventDate) : null,
|
||||||
|
createdById: req.user!.id,
|
||||||
|
},
|
||||||
|
select: ANNOUNCEMENT_SELECT,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(201).json({ announcement });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// GET /announcements — für alle sichtbar, neueste/nächste Termine zuerst.
|
||||||
|
announcementsRouter.get('/announcements', requireAuth, async (_req: AuthedRequest, res: Response) => {
|
||||||
|
const announcements = await prisma.announcement.findMany({
|
||||||
|
orderBy: [{ eventDate: 'asc' }, { createdAt: 'desc' }],
|
||||||
|
select: ANNOUNCEMENT_SELECT,
|
||||||
|
});
|
||||||
|
res.status(200).json({ announcements });
|
||||||
|
});
|
||||||
|
|
||||||
|
announcementsRouter.delete(
|
||||||
|
'/announcements/:id',
|
||||||
|
requireAuth,
|
||||||
|
requireRole('LANDLORD', 'ADMIN'),
|
||||||
|
async (req: AuthedRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
await prisma.announcement.delete({ where: { id: req.params.id } });
|
||||||
|
res.status(204).send();
|
||||||
|
} catch {
|
||||||
|
res.status(404).json({ error: 'Ankündigung nicht gefunden' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
@ -188,6 +188,7 @@ model User {
|
|||||||
handoverProtocols HandoverProtocol[]
|
handoverProtocols HandoverProtocol[]
|
||||||
accessCodeAssigned AccessCode[] @relation("AccessCodeUser")
|
accessCodeAssigned AccessCode[] @relation("AccessCodeUser")
|
||||||
accessCodesGiven AccessCode[] @relation("AccessCodeAssignedBy")
|
accessCodesGiven AccessCode[] @relation("AccessCodeAssignedBy")
|
||||||
|
announcementsMade Announcement[]
|
||||||
notifications Notification[]
|
notifications Notification[]
|
||||||
invitationsSent Invitation[] @relation("InvitationsSent")
|
invitationsSent Invitation[] @relation("InvitationsSent")
|
||||||
storageSlots StorageSlot[]
|
storageSlots StorageSlot[]
|
||||||
@ -411,6 +412,21 @@ model CleaningAbsence {
|
|||||||
@@map("cleaning_absences")
|
@@map("cleaning_absences")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ankündigungen des Vermieters (z. B. "Schornsteinfeger kommt am...",
|
||||||
|
// "Handwerker kommt am..."), für alle Mieter im Dashboard sichtbar.
|
||||||
|
model Announcement {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
title String
|
||||||
|
message String? @db.Text
|
||||||
|
eventDate DateTime? @map("event_date")
|
||||||
|
createdById String @map("created_by_id")
|
||||||
|
createdBy User @relation(fields: [createdById], references: [id])
|
||||||
|
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
|
@@map("announcements")
|
||||||
|
}
|
||||||
|
|
||||||
// Frei erweiterbare Liste der Putzplan-Bereiche. Ersetzt das frühere feste
|
// Frei erweiterbare Liste der Putzplan-Bereiche. Ersetzt das frühere feste
|
||||||
// Enum, damit der Vermieter im Dashboard eigene Bereiche anlegen kann, ohne
|
// Enum, damit der Vermieter im Dashboard eigene Bereiche anlegen kann, ohne
|
||||||
// dass ein Code-Deploy nötig ist.
|
// dass ein Code-Deploy nötig ist.
|
||||||
|
|||||||
@ -614,6 +614,34 @@
|
|||||||
<div class="page active" id="page-overview">
|
<div class="page active" id="page-overview">
|
||||||
<div class="stat-row" id="statRow"></div>
|
<div class="stat-row" id="statRow"></div>
|
||||||
|
|
||||||
|
<section id="notificationsSection" style="display:none">
|
||||||
|
<h2>Für dich diese Woche</h2>
|
||||||
|
<div class="ticket-list" id="notificationsList"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="announcementSection">
|
||||||
|
<h2>Ankündigungen</h2>
|
||||||
|
<div id="announcementFormCard" class="invite-card" style="margin-bottom:16px; display:none">
|
||||||
|
<div id="announcementFormError" style="display:none" class="form-error"></div>
|
||||||
|
<form id="announcementForm" class="invite-form-row" style="flex-wrap:wrap;">
|
||||||
|
<div class="field" style="flex:1 1 200px;">
|
||||||
|
<label for="announcementTitle">Titel</label>
|
||||||
|
<input type="text" id="announcementTitle" placeholder="z. B. Schornsteinfeger" required maxlength="120" />
|
||||||
|
</div>
|
||||||
|
<div class="field" style="flex:1 1 160px;">
|
||||||
|
<label for="announcementDate">Termin (optional)</label>
|
||||||
|
<input type="date" id="announcementDate" />
|
||||||
|
</div>
|
||||||
|
<div class="field" style="flex:2 1 240px;">
|
||||||
|
<label for="announcementMessage">Nachricht (optional)</label>
|
||||||
|
<input type="text" id="announcementMessage" maxlength="500" placeholder="z. B. bitte Zugang zum Keller freihalten" />
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn-primary" style="width:auto" id="announcementSubmitBtn">Ankündigen</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="ticket-list" id="announcementList"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section id="rentSection">
|
<section id="rentSection">
|
||||||
<h2 id="rentSectionTitle">Miet-Ampel je Zimmer</h2>
|
<h2 id="rentSectionTitle">Miet-Ampel je Zimmer</h2>
|
||||||
<div class="room-grid" id="roomGrid"></div>
|
<div class="room-grid" id="roomGrid"></div>
|
||||||
@ -1413,6 +1441,7 @@
|
|||||||
if (isLandlord) initCraftsmanForm();
|
if (isLandlord) initCraftsmanForm();
|
||||||
initRatingForm();
|
initRatingForm();
|
||||||
initChangePasswordForm();
|
initChangePasswordForm();
|
||||||
|
initAnnouncementForm();
|
||||||
|
|
||||||
loadData();
|
loadData();
|
||||||
loadCleaningTasks();
|
loadCleaningTasks();
|
||||||
@ -1429,6 +1458,7 @@
|
|||||||
loadRatings();
|
loadRatings();
|
||||||
loadContractDocuments();
|
loadContractDocuments();
|
||||||
loadContractArchive();
|
loadContractArchive();
|
||||||
|
loadAnnouncements();
|
||||||
|
|
||||||
setInterval(loadData, REFRESH_INTERVAL_MS);
|
setInterval(loadData, REFRESH_INTERVAL_MS);
|
||||||
setInterval(loadCleaningTasks, REFRESH_INTERVAL_MS);
|
setInterval(loadCleaningTasks, REFRESH_INTERVAL_MS);
|
||||||
@ -1445,6 +1475,7 @@
|
|||||||
setInterval(loadRatings, REFRESH_INTERVAL_MS);
|
setInterval(loadRatings, REFRESH_INTERVAL_MS);
|
||||||
setInterval(loadContractDocuments, REFRESH_INTERVAL_MS);
|
setInterval(loadContractDocuments, REFRESH_INTERVAL_MS);
|
||||||
setInterval(loadContractArchive, REFRESH_INTERVAL_MS);
|
setInterval(loadContractArchive, REFRESH_INTERVAL_MS);
|
||||||
|
setInterval(loadAnnouncements, REFRESH_INTERVAL_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
@ -1452,6 +1483,7 @@
|
|||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
let lastRooms = [];
|
let lastRooms = [];
|
||||||
|
let lastCockpitData = null;
|
||||||
|
|
||||||
function populateRoomSelect() {
|
function populateRoomSelect() {
|
||||||
const select = document.getElementById('inviteRoom');
|
const select = document.getElementById('inviteRoom');
|
||||||
@ -1558,10 +1590,12 @@
|
|||||||
const data = await apiFetch('/cockpit/summary');
|
const data = await apiFetch('/cockpit/summary');
|
||||||
lastRooms = data.rooms || [];
|
lastRooms = data.rooms || [];
|
||||||
lastMyRoom = data.myRoom || null;
|
lastMyRoom = data.myRoom || null;
|
||||||
|
lastCockpitData = data;
|
||||||
populateRoomSelect();
|
populateRoomSelect();
|
||||||
populateTicketRoomSelect();
|
populateTicketRoomSelect();
|
||||||
populateInventoryRoomSelect();
|
populateInventoryRoomSelect();
|
||||||
render(data);
|
render(data);
|
||||||
|
loadNotifications();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err.message === 'Nicht authentifiziert' || err.message === 'Token ungültig oder abgelaufen') {
|
if (err.message === 'Nicht authentifiziert' || err.message === 'Token ungültig oder abgelaufen') {
|
||||||
clearSession();
|
clearSession();
|
||||||
@ -3124,6 +3158,168 @@
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// ANKÜNDIGUNGEN
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
function initAnnouncementForm() {
|
||||||
|
const isLandlord = currentUser.role === 'LANDLORD' || currentUser.role === 'ADMIN';
|
||||||
|
document.getElementById('announcementFormCard').style.display = isLandlord ? 'block' : 'none';
|
||||||
|
if (!isLandlord) return;
|
||||||
|
|
||||||
|
const form = document.getElementById('announcementForm');
|
||||||
|
const errorBox = document.getElementById('announcementFormError');
|
||||||
|
form.onsubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
errorBox.style.display = 'none';
|
||||||
|
const submitBtn = document.getElementById('announcementSubmitBtn');
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
submitBtn.textContent = 'Kündigt an…';
|
||||||
|
try {
|
||||||
|
const title = document.getElementById('announcementTitle').value;
|
||||||
|
const eventDate = document.getElementById('announcementDate').value;
|
||||||
|
const message = document.getElementById('announcementMessage').value;
|
||||||
|
await apiFetch('/announcements', { method: 'POST', body: JSON.stringify({ title, eventDate: eventDate || null, message }) });
|
||||||
|
form.reset();
|
||||||
|
loadAnnouncements();
|
||||||
|
loadNotifications();
|
||||||
|
} catch (err) {
|
||||||
|
errorBox.textContent = err.message;
|
||||||
|
errorBox.style.display = 'block';
|
||||||
|
} finally {
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
submitBtn.textContent = 'Ankündigen';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAnnouncements() {
|
||||||
|
if (!currentUser) return;
|
||||||
|
try {
|
||||||
|
const data = await apiFetch('/announcements');
|
||||||
|
renderAnnouncements(data.announcements);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[announcements] Laden fehlgeschlagen:', err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAnnouncements(announcements) {
|
||||||
|
const isLandlord = currentUser.role === 'LANDLORD' || currentUser.role === 'ADMIN';
|
||||||
|
const list = document.getElementById('announcementList');
|
||||||
|
list.innerHTML = '';
|
||||||
|
if (!announcements.length) {
|
||||||
|
list.appendChild(el('div', 'empty-state', 'Keine Ankündigungen.'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
announcements.forEach(a => {
|
||||||
|
const row = el('div', 'ticket-row');
|
||||||
|
const left = el('div');
|
||||||
|
left.appendChild(el('div', 'title', a.title));
|
||||||
|
left.appendChild(el('div', 'meta-text',
|
||||||
|
`${a.eventDate ? new Date(a.eventDate).toLocaleDateString('de-DE') + ' · ' : ''}${a.message || ''}`));
|
||||||
|
row.appendChild(left);
|
||||||
|
if (isLandlord) {
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.className = 'btn-secondary';
|
||||||
|
btn.textContent = 'Löschen';
|
||||||
|
btn.onclick = () => deleteAnnouncement(a.id);
|
||||||
|
row.appendChild(btn);
|
||||||
|
}
|
||||||
|
list.appendChild(row);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteAnnouncement(id) {
|
||||||
|
try {
|
||||||
|
await apiFetch(`/announcements/${id}`, { method: 'DELETE' });
|
||||||
|
loadAnnouncements();
|
||||||
|
loadNotifications();
|
||||||
|
} catch (err) {
|
||||||
|
alert(err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// NACHRICHTEN-ÜBERSICHT ("Für dich diese Woche")
|
||||||
|
//
|
||||||
|
// Bündelt für den Mieter alles Terminliche an einer Stelle: eigene fällige
|
||||||
|
// Putzaufgaben, fällige Miete, anstehende Mülltermine sowie allgemeine
|
||||||
|
// Ankündigungen des Vermieters — jeweils nur, wenn sie in den nächsten 7
|
||||||
|
// Tagen liegen (bzw. bei der Miete: aktuell fällig/überfällig).
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function loadNotifications() {
|
||||||
|
if (!currentUser) return;
|
||||||
|
const isLandlord = currentUser.role === 'LANDLORD' || currentUser.role === 'ADMIN';
|
||||||
|
document.getElementById('notificationsSection').style.display = isLandlord ? 'none' : 'block';
|
||||||
|
if (isLandlord) return;
|
||||||
|
|
||||||
|
const items = [];
|
||||||
|
const today = stripTime(new Date());
|
||||||
|
const weekAhead = new Date(today);
|
||||||
|
weekAhead.setDate(weekAhead.getDate() + 7);
|
||||||
|
|
||||||
|
if (lastCockpitData && lastCockpitData.myRent && lastCockpitData.myRent.status !== 'GREEN') {
|
||||||
|
items.push({
|
||||||
|
date: today,
|
||||||
|
icon: '💶',
|
||||||
|
text: `Miete ${STATUS_LABEL[lastCockpitData.myRent.status].toLowerCase()}: ${formatEuro(lastCockpitData.myRent.amountDue)}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await apiFetch('/cleaning-tasks?status=PENDING');
|
||||||
|
data.tasks
|
||||||
|
.filter(t => t.assignedUser && t.assignedUser.id === currentUser.id)
|
||||||
|
.filter(t => { const d = stripTime(new Date(t.dueDate)); return d >= today && d <= weekAhead; })
|
||||||
|
.forEach(t => {
|
||||||
|
items.push({ date: new Date(t.dueDate), icon: '🧹', text: `Putzplan: ${t.area} fällig am ${new Date(t.dueDate).toLocaleDateString('de-DE')}` });
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[notifications] Putzplan laden fehlgeschlagen:', err.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const from = today.toISOString().slice(0, 10);
|
||||||
|
const to = weekAhead.toISOString().slice(0, 10);
|
||||||
|
const data = await apiFetch(`/trash-schedule?from=${from}&to=${to}`);
|
||||||
|
data.entries.forEach(e => {
|
||||||
|
items.push({ date: new Date(e.date), icon: '🗑️', text: `Müll rausstellen: ${TRASH_TYPE_LABEL[e.type] || e.type} am ${new Date(e.date).toLocaleDateString('de-DE')}` });
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[notifications] Müllkalender laden fehlgeschlagen:', err.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await apiFetch('/announcements');
|
||||||
|
data.announcements
|
||||||
|
.filter(a => !a.eventDate || (stripTime(new Date(a.eventDate)) >= today && stripTime(new Date(a.eventDate)) <= weekAhead))
|
||||||
|
.forEach(a => {
|
||||||
|
items.push({
|
||||||
|
date: a.eventDate ? new Date(a.eventDate) : new Date(a.createdAt),
|
||||||
|
icon: '📢',
|
||||||
|
text: `${a.title}${a.eventDate ? ' am ' + new Date(a.eventDate).toLocaleDateString('de-DE') : ''}${a.message ? ' · ' + a.message : ''}`,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[notifications] Ankündigungen laden fehlgeschlagen:', err.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
items.sort((a, b) => a.date - b.date);
|
||||||
|
|
||||||
|
const list = document.getElementById('notificationsList');
|
||||||
|
list.innerHTML = '';
|
||||||
|
if (!items.length) {
|
||||||
|
list.appendChild(el('div', 'empty-state', 'Für diese Woche gibt es nichts Neues für dich.'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
items.forEach(item => {
|
||||||
|
const row = el('div', 'ticket-row');
|
||||||
|
row.appendChild(el('div', 'title', `${item.icon} ${item.text}`));
|
||||||
|
list.appendChild(row);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function buildProfileCompletenessBadge(user) {
|
function buildProfileCompletenessBadge(user) {
|
||||||
const missing = [];
|
const missing = [];
|
||||||
if (!user.phoneNumber) missing.push('Telefonnummer');
|
if (!user.phoneNumber) missing.push('Telefonnummer');
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user