Schaden melden: Foto als Pflichtfeld fuer Mieter, Vorschau in Ticket-Liste

This commit is contained in:
Giuseppe Lombardo 2026-08-13 19:32:54 +00:00
parent d29f749b39
commit 6264bac99a
3 changed files with 85 additions and 2 deletions

View File

@ -16,6 +16,7 @@ const TICKET_SELECT = {
priority: true,
status: true,
createdAt: true,
imageUrls: true,
room: { select: { roomNumber: true } },
creator: { select: { id: true, fullName: true } },
craftsman: { select: { id: true, name: true, trade: true, phone: true } },

View File

@ -41,7 +41,7 @@ const TICKET_SELECT = {
};
ticketsRouter.post('/tickets', requireAuth, async (req: AuthedRequest, res: Response) => {
const { title, description, category, priority, roomId } = req.body || {};
const { title, description, category, priority, roomId, imageUrls } = req.body || {};
if (!title || typeof title !== 'string' || !title.trim()) {
return res.status(400).json({ error: 'Titel ist erforderlich' });
@ -57,6 +57,16 @@ ticketsRouter.post('/tickets', requireAuth, async (req: AuthedRequest, res: Resp
}
const isLandlord = req.user!.role === 'LANDLORD' || req.user!.role === 'ADMIN';
const sanitizedImageUrls = Array.isArray(imageUrls)
? imageUrls.filter((u) => typeof u === 'string' && u.trim()).map((u) => u.trim())
: [];
// Mieter müssen mindestens ein Foto als Beleg hochladen — Vermieter/Admin
// (die z. B. auf Zuruf eines Mieters melden) sind davon ausgenommen.
if (!isLandlord && sanitizedImageUrls.length === 0) {
return res.status(400).json({ error: 'Bitte mindestens ein Foto zum Schaden hochladen' });
}
let targetRoomId: string | null = roomId || null;
if (!isLandlord) {
@ -79,6 +89,7 @@ ticketsRouter.post('/tickets', requireAuth, async (req: AuthedRequest, res: Resp
category,
priority: priority || TicketPriority.MEDIUM,
status: TicketStatus.OPEN,
imageUrls: sanitizedImageUrls,
},
select: TICKET_SELECT,
});

View File

@ -792,6 +792,11 @@
<label for="ticketRoom">Bereich</label>
<select id="ticketRoom"></select>
</div>
<div class="field" style="flex:1 1 100%;">
<label for="ticketPhotos">Foto vom Schaden (bei Mietern Pflichtfeld)</label>
<input type="file" id="ticketPhotos" accept="image/*" multiple />
<div id="ticketPhotoPreview" style="display:flex; flex-wrap:wrap; gap:6px; margin-top:6px;"></div>
</div>
<button type="submit" class="btn-primary" style="width:auto" id="ticketSubmitBtn">Melden</button>
</form>
</div>
@ -1891,6 +1896,19 @@
if (t.craftsman) {
left.appendChild(el('div', 'meta-text', `Handwerker: ${t.craftsman.name} (${CRAFTSMAN_TRADE_LABEL[t.craftsman.trade] || t.craftsman.trade})${t.craftsman.phone ? ' · ' + t.craftsman.phone : ''}`));
}
if (t.imageUrls && t.imageUrls.length) {
const photoWrap = el('div');
photoWrap.style.cssText = 'display:flex; flex-wrap:wrap; gap:6px; margin-top:6px;';
t.imageUrls.forEach(url => {
const img = document.createElement('img');
img.src = url;
img.className = 'inventory-thumb';
img.style.cursor = 'pointer';
img.onclick = () => openDocumentPreview(t.title, url);
photoWrap.appendChild(img);
});
left.appendChild(photoWrap);
}
row.appendChild(left);
const right = el('div');
@ -2002,13 +2020,64 @@
prioSelect.value = 'MEDIUM';
}
const MAX_TICKET_PHOTOS = 4;
let ticketPhotoDataUrls = [];
function renderTicketPhotoPreview() {
const preview = document.getElementById('ticketPhotoPreview');
preview.innerHTML = '';
ticketPhotoDataUrls.forEach((dataUrl, idx) => {
const wrap = el('div');
wrap.style.position = 'relative';
const img = document.createElement('img');
img.src = dataUrl;
img.className = 'inventory-thumb';
wrap.appendChild(img);
const removeBtn = document.createElement('button');
removeBtn.type = 'button';
removeBtn.textContent = '×';
removeBtn.title = 'Entfernen';
removeBtn.style.cssText = 'position:absolute; top:-6px; right:-6px; width:18px; height:18px; border-radius:50%; border:1px solid var(--border); background:#fff; cursor:pointer; font-size:11px; line-height:1; padding:0;';
removeBtn.onclick = () => {
ticketPhotoDataUrls.splice(idx, 1);
renderTicketPhotoPreview();
};
wrap.appendChild(removeBtn);
preview.appendChild(wrap);
});
}
function initTicketForm() {
populateTicketSelects();
ticketPhotoDataUrls = [];
renderTicketPhotoPreview();
const photoInput = document.getElementById('ticketPhotos');
photoInput.onchange = async () => {
const files = Array.from(photoInput.files || []).slice(0, MAX_TICKET_PHOTOS - ticketPhotoDataUrls.length);
for (const file of files) {
try {
const dataUrl = await readFileAsCompressedDataUrl(file, 1600, 0.82);
ticketPhotoDataUrls.push(dataUrl);
} catch (err) {
console.error('[tickets] Foto konnte nicht gelesen werden:', err);
}
}
photoInput.value = '';
renderTicketPhotoPreview();
};
const form = document.getElementById('ticketForm');
const errorBox = document.getElementById('ticketFormError');
form.onsubmit = async (e) => {
e.preventDefault();
errorBox.style.display = 'none';
const isLandlord = currentUser.role === 'LANDLORD' || currentUser.role === 'ADMIN';
if (!isLandlord && ticketPhotoDataUrls.length === 0) {
errorBox.textContent = 'Bitte mindestens ein Foto zum Schaden hochladen';
errorBox.style.display = 'block';
return;
}
const submitBtn = document.getElementById('ticketSubmitBtn');
submitBtn.disabled = true;
submitBtn.textContent = 'Meldet…';
@ -2020,9 +2089,11 @@
const roomId = document.getElementById('ticketRoom').value || undefined;
await apiFetch('/tickets', {
method: 'POST',
body: JSON.stringify({ title, description, category, priority, roomId }),
body: JSON.stringify({ title, description, category, priority, roomId, imageUrls: ticketPhotoDataUrls }),
});
form.reset();
ticketPhotoDataUrls = [];
renderTicketPhotoPreview();
populateTicketSelects();
loadData();
} catch (err) {