Files
thetool/Layout/default/VueViews/WorkorderCompanyPWA.php

542 lines
33 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
// This file acts as the main layout for the Vue 3 PWA.
// It includes all necessary CDN links and the main Vue app structure.
?>
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>Workorders</title>
<!-- PWA Manifest and Theme Color -->
<link rel="manifest" href="/js/pages/WorkorderBase/manifest.json">
<meta name="theme-color" content="#005384">
<!-- Tailwind CSS -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- Vue 3 and Axios -->
<script src="https://cdn.jsdelivr.net/npm/vue@3.4.27/dist/vue.global.prod.js"></script>
<script src="https://cdn.jsdelivr.net/npm/axios@1.7.2/dist/axios.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/moment@2.30.1/moment.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/moment@2.30.1/locale/de.js"></script>
<!-- Global Config from PHP -->
<script>
window.TT_CONFIG = <?= json_encode($JSGlobals ?? []) ?>;
moment.locale('de');
tailwind.config = {
theme: {
extend: {
colors: {
'primary': '#005384', // Dark Blue
'secondary': '#fac41b', // Yellow/Gold
},
}
}
}
</script>
<style>
body {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.slide-enter-active, .slide-leave-active { transition: transform 0.35s cubic-bezier(0.4, 0, 0.2, 1); }
.slide-enter-from, .slide-leave-to { transform: translateX(100%); }
.list-container.panel-open {
transform: scale(0.95);
filter: blur(4px);
opacity: 0.7;
transition: transform 0.35s cubic-bezier(0.4, 0, 0.2, 1), filter 0.35s, opacity 0.35s;
}
.list-container {
transition: transform 0.35s cubic-bezier(0.4, 0, 0.2, 1), filter 0.35s, opacity 0.35s;
}
.overlay {
position: fixed;
inset: 0;
background-color: rgba(0,0,0,0.4);
transition: opacity 0.35s ease;
z-index: 15;
}
.overlay-enter-from, .overlay-leave-to { opacity: 0; }
.overlay-enter-to, .overlay-leave-from { opacity: 1; }
.fade-enter-active, .fade-leave-active { transition: opacity 0.2s ease-in-out; }
.fade-enter-from, .fade-leave-to { opacity: 0; }
</style>
</head>
<body class="bg-slate-100">
<div id="app" class="h-screen w-screen overflow-hidden antialiased"></div>
<script>
const { createApp, ref, reactive, computed, onMounted, watch, nextTick } = Vue;
const app = createApp({
setup() {
// --- STATE ---
const workorders = ref([]);
const selectedWorkorder = ref(null);
const isLoading = ref(true);
const isDetailsPanelOpen = ref(false);
const searchTerm = ref('');
const documentation = reactive({ docs: [], journals: [] });
const tenantConfig = ref(null);
const tempAdditionalInfo = ref('');
const isEditingInfo = ref(false);
const newJournalEntry = ref('');
const isUploading = ref(false);
const uploadModal = reactive({ show: false, files: null, documentType: '' });
const problemModal = reactive({ show: false, selectedInterventions: [], details: {} });
const fullscreenViewer = reactive({ show: false, item: null });
const missingTasksPopover = reactive({ show: false, tasks: [] });
const API_BASE_URL = window.TT_CONFIG.BASE_PATH || '/WorkorderCompany';
const api = axios.create({ baseURL: API_BASE_URL });
// --- COMPUTED ---
const filteredWorkorders = computed(() => {
let filtered = workorders.value.sort((a,b) => (a.deadlineDate || 0) - (b.deadlineDate || 0));
if (searchTerm.value.length > 2) {
const lowerSearch = searchTerm.value.toLowerCase();
return filtered.filter(wo =>
wo.id.toString().includes(lowerSearch) ||
(wo.customerName && wo.customerName.toLowerCase().includes(lowerSearch)) ||
(wo.street && wo.street.toLowerCase().includes(lowerSearch)) ||
(wo.city && wo.city.toLowerCase().includes(lowerSearch)) ||
(wo.oaid && wo.oaid.toLowerCase().includes(lowerSearch))
);
}
return filtered;
});
const googleMapsLink = computed(() => {
if (!selectedWorkorder.value) return '#';
const { street, hausnummer, plz, city } = selectedWorkorder.value;
const address = encodeURIComponent(`${street} ${hausnummer}, ${plz} ${city}`);
return `https://www.google.com/maps/search/?api=1&query=${address}`;
});
const checklist = computed(() => {
// Correctly use documentationTypes for the main checklist
if (!tenantConfig.value?.documentationTypes || !Array.isArray(tenantConfig.value.documentationTypes)) return [];
return tenantConfig.value.documentationTypes.map(reqType => {
const isCompleted = documentation.docs.some(doc => doc.documentType === reqType.value);
return { ...reqType, completed: isCompleted };
});
});
const isChecklistComplete = computed(() => {
if (checklist.value.length === 0) return true;
return checklist.value.every(item => item.completed);
});
const translatedDocs = computed(() => {
if (!documentation.docs.length || !tenantConfig.value?.documentationTypes) {
return documentation.docs;
}
const typeMap = new Map(tenantConfig.value.documentationTypes.map(t => [t.value, t.text]));
return documentation.docs.map(doc => ({
...doc,
translatedName: typeMap.get(doc.documentType) || doc.documentType,
}));
});
const filteredJournals = computed(() => {
return documentation.journals.filter(j => !j.text.toLowerCase().includes('wurde zugewiesen.'));
});
// --- METHODS ---
const getStatusInfo = (status) => {
const statuses = {
'new': { text: 'Neu', color: 'bg-blue-500' }, 'assigned': { text: 'Zugewiesen', color: 'bg-sky-500' },
'scheduled': { text: 'Geplant', color: 'bg-amber-500' }, 'correction_requested': { text: 'Korrektur', color: 'bg-red-500' },
'intervention_required': { text: 'Eingriff', color: 'bg-red-700' }, 'civil_engineering_required': { text: 'Tiefbau', color: 'bg-orange-500' },
'civil_engineering_completed': { text: 'Tiefbau OK', color: 'bg-green-500' }, 'problem_solved': { text: 'Problem gelöst', color: 'bg-teal-500' },
'documented': { text: 'Dokumentiert', color: 'bg-indigo-500' }, 'completed': { text: 'Abgeschlossen', color: 'bg-slate-500' },
'cancelled': { text: 'Storniert', color: 'bg-gray-600' }, 'default': { text: 'Unbekannt', color: 'bg-gray-400' }
};
return statuses[status] || statuses.default;
};
const formatDate = (timestamp, format = 'DD.MM.YYYY') => {
if (!timestamp) return '';
return moment.unix(timestamp).format(format);
};
const fetchWorkorders = async () => {
isLoading.value = true;
try {
const response = await api.post(`/get`, { pagination: { page: 1, per_page: 500 } });
workorders.value = response.data.rows;
} catch (error) { console.error("Failed to fetch workorders:", error); }
finally { isLoading.value = false; }
};
const fetchDetails = async (workorderId) => {
try {
const [docRes, configRes] = await Promise.all([
api.get(`/getDocumentation?workorderId=${workorderId}`),
api.get(`/getTenantConfig?workorderId=${workorderId}`)
]);
documentation.docs = docRes.data.docs.map(d => ({...d, isPdf: d.mimetype === 'application/pdf'}));
documentation.journals = docRes.data.journals;
if (configRes.data.success) {
tenantConfig.value = configRes.data;
}
} catch (e) { console.error("Could not load details", e); }
};
const openDetails = (workorder) => {
selectedWorkorder.value = workorder;
isDetailsPanelOpen.value = true;
fetchDetails(workorder.id);
};
const closeDetails = () => {
isDetailsPanelOpen.value = false;
setTimeout(() => {
selectedWorkorder.value = null;
documentation.docs = []; documentation.journals = [];
tenantConfig.value = null; isEditingInfo.value = false;
}, 350);
};
const startEditInfo = () => {
tempAdditionalInfo.value = selectedWorkorder.value.additionalInfo || '';
isEditingInfo.value = true;
};
const saveAdditionalInfo = async () => {
const newInfo = tempAdditionalInfo.value;
try {
await api.post('/updateAdditionalInfo', { workorderId: selectedWorkorder.value.id, additionalInfo: newInfo });
selectedWorkorder.value.additionalInfo = newInfo;
await fetchDetails(selectedWorkorder.value.id); // to refresh journal
} catch(e) { console.error("Failed to save info", e); }
finally { isEditingInfo.value = false; }
};
const addJournalEntry = async () => {
if (!newJournalEntry.value.trim()) return;
try {
const response = await api.post('/addJournal', { workorderId: selectedWorkorder.value.id, text: newJournalEntry.value });
documentation.journals = response.data.journals;
newJournalEntry.value = '';
await nextTick(() => {
const journalContainer = document.querySelector('.journal-container');
if(journalContainer) journalContainer.scrollTop = journalContainer.scrollHeight;
});
} catch(e) { console.error("Failed to add journal entry", e); }
};
const handleFileSelect = (event) => {
if (!event.target.files.length) return;
uploadModal.files = event.target.files;
uploadModal.documentType = tenantConfig.value?.documentationTypes?.[0]?.value || 'general';
uploadModal.show = true;
};
const executeUpload = async () => {
if (!uploadModal.files) return;
isUploading.value = true;
const formData = new FormData();
formData.append('workorderId', selectedWorkorder.value.id);
formData.append('documentType', uploadModal.documentType);
for (let i = 0; i < uploadModal.files.length; i++) {
formData.append('files[]', uploadModal.files[i]);
}
try {
await api.post(`/uploadDocumentation`, formData, { headers: { 'Content-Type': 'multipart/form-data' } });
await fetchDetails(selectedWorkorder.value.id);
} catch (error) { console.error('Upload failed:', error); }
finally {
isUploading.value = false;
uploadModal.show = false;
uploadModal.files = null;
}
};
const submitProblem = async () => {
if (problemModal.selectedInterventions.length === 0) return;
let journalParts = [];
// Sort to maintain a consistent order
const sortedInterventions = [...problemModal.selectedInterventions].sort((a, b) => a.value.localeCompare(b.value));
for (const type of sortedInterventions) {
let text = type.text;
if (text.includes('X')) { // Check for 'X' literally, not '[X]'
const detail = problemModal.details[type.value] || '';
if (!detail) {
alert(`Bitte geben Sie Details für "${type.text}" an.`);
return;
}
text = text.replace('X', detail);
}
journalParts.push(text);
}
const combinedText = journalParts.join('\n');
try {
await api.post('/requestIntervention', {
workorderId: selectedWorkorder.value.id,
journalText: combinedText
});
await fetchWorkorders();
closeDetails();
} catch(e) { console.error("Failed to report problem", e); }
finally { problemModal.show = false; problemModal.selectedInterventions = []; problemModal.details = {}; }
};
const handleCompleteClick = () => {
if (isChecklistComplete.value) {
if (confirm("Möchten Sie diesen Auftrag wirklich abschließen?")) {
completeWorkorder();
}
} else {
missingTasksPopover.tasks = checklist.value.filter(t => !t.completed).map(t => t.text);
missingTasksPopover.show = true;
setTimeout(() => missingTasksPopover.show = false, 4000);
}
};
const completeWorkorder = async () => {
try {
await api.post('/completeWorkorder', { workorderId: selectedWorkorder.value.id });
await fetchWorkorders();
closeDetails();
} catch(e) { console.error("Failed to complete workorder", e); }
};
onMounted(fetchWorkorders);
return {
isLoading, filteredWorkorders, searchTerm, isDetailsPanelOpen, selectedWorkorder, documentation, tenantConfig,
tempAdditionalInfo, isEditingInfo, newJournalEntry, uploadModal, problemModal, isUploading, isChecklistComplete,
checklist, fullscreenViewer, missingTasksPopover, translatedDocs, filteredJournals,
openDetails, closeDetails, getStatusInfo, formatDate, googleMapsLink, startEditInfo, saveAdditionalInfo,
handleFileSelect, executeUpload, addJournalEntry, submitProblem, handleCompleteClick
};
},
template: `
<!-- Main List View -->
<div class="relative h-full w-full">
<transition name="overlay">
<div v-if="isDetailsPanelOpen" @click="closeDetails" class="overlay"></div>
</transition>
<div :class="{'panel-open': isDetailsPanelOpen}" class="list-container flex flex-col h-full bg-slate-100">
<header class="bg-white shadow p-4 flex-shrink-0 z-10">
<div class="flex justify-between items-center">
<img src="/assets/images/xinon-full.png" alt="Logo" class="h-8 w-auto">
<button @click="fetchWorkorders" class="p-2 rounded-full hover:bg-slate-100">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-slate-600" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h5M20 20v-5h-5M4 4l16 16"/></svg>
</button>
</div>
<div class="mt-4"><input type="text" v-model="searchTerm" placeholder="Suche..." class="w-full p-3 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary transition"></div>
</header>
<main class="flex-grow overflow-y-auto p-2 pb-16">
<div v-if="isLoading" class="text-center p-10"><p class="text-slate-500">Lade Aufträge...</p></div>
<div v-else-if="filteredWorkorders.length === 0" class="text-center p-10"><p class="text-slate-500">Keine Aufträge.</p></div>
<div v-else class="space-y-3">
<div v-for="wo in filteredWorkorders" :key="wo.id" @click="openDetails(wo)" class="bg-white p-4 rounded-lg shadow-md cursor-pointer transition active:scale-[0.98]">
<div class="flex justify-between items-start">
<div class="flex-grow pr-2">
<p class="font-bold text-slate-800 truncate">{{ wo.customerName || 'N/A' }}</p>
<p class="text-sm text-slate-500 truncate">{{ wo.street }} {{ wo.hausnummer }}, {{ wo.plz }} {{ wo.city }}</p>
<p class="text-xs text-slate-400 mt-1">ID: {{ wo.id }}</p>
</div>
<div class="flex-shrink-0 ml-2 text-right space-y-1">
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium text-white" :class="getStatusInfo(wo.status).color">{{ getStatusInfo(wo.status).text }}</span>
<p class="text-sm font-semibold text-slate-600">{{ formatDate(wo.appointmentDate, 'DD.MM HH:mm') }}</p>
<p class="text-xs text-red-500">Frist: {{ formatDate(wo.deadlineDate) }}</p>
</div>
</div>
</div>
</div>
</main>
</div>
<!-- Details Panel -->
<transition name="slide">
<div v-if="isDetailsPanelOpen && selectedWorkorder" class="fixed inset-0 bg-slate-50 z-20 flex flex-col shadow-2xl">
<header class="bg-white p-4 flex justify-between items-center border-b border-slate-200 flex-shrink-0">
<h2 class="text-xl font-bold text-primary truncate pr-2">Auftrag #{{ selectedWorkorder.id }}</h2>
<button @click="closeDetails" class="p-2 rounded-full hover:bg-slate-200 flex-shrink-0"><svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /></svg></button>
</header>
<div class="overflow-y-auto p-4 flex-grow space-y-4">
<!-- Customer & Address -->
<div class="bg-white p-4 rounded-lg border border-slate-200 space-y-3 text-sm">
<div class="flex items-center text-base font-bold text-slate-800">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2 text-slate-500" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z" clip-rule="evenodd" /></svg>
<span>{{ selectedWorkorder.customerCompany || selectedWorkorder.customerName }}</span>
</div>
<a :href="googleMapsLink" target="_blank" class="flex items-center text-primary hover:underline">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" clip-rule="evenodd" /></svg>
<span>{{ selectedWorkorder.street }} {{ selectedWorkorder.hausnummer }}, {{ selectedWorkorder.plz }} {{ selectedWorkorder.city }}</span>
</a>
<div class="border-t pt-3 space-y-2">
<a :href="'mailto:' + selectedWorkorder.email" class="flex items-center text-primary hover:underline"><svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" viewBox="0 0 20 20" fill="currentColor"><path d="M2.003 5.884L10 9.882l7.997-3.998A2 2 0 0016 4H4a2 2 0 00-1.997 1.884z" /><path d="M18 8.118l-8 4-8-4V14a2 2 0 002 2h12a2 2 0 002-2V8.118z" /></svg><span>{{ selectedWorkorder.email }}</span></a>
<a :href="'tel:' + selectedWorkorder.phone" class="flex items-center text-primary hover:underline"><svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" viewBox="0 0 20 20" fill="currentColor"><path d="M2 3a1 1 0 011-1h2.153a1 1 0 01.986.836l.74 4.435a1 1 0 01-.54 1.06l-1.548.773a11.037 11.037 0 006.105 6.105l.774-1.548a1 1 0 011.059-.54l4.435.74a1 1 0 01.836.986V17a1 1 0 01-1 1h-2C7.82 18 2 12.18 2 5V3z" /></svg><span>{{ selectedWorkorder.phone }}</span></a>
</div>
</div>
<!-- Notiz -->
<div class="bg-white p-4 rounded-lg border border-slate-200">
<div class="flex justify-between items-center mb-2">
<h3 class="font-bold text-slate-700">Notiz</h3>
<button v-if="!isEditingInfo" @click="startEditInfo" class="flex items-center text-sm font-medium text-primary bg-slate-100 hover:bg-slate-200 px-3 py-1.5 rounded-md">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-1" viewBox="0 0 20 20" fill="currentColor"><path d="M17.414 2.586a2 2 0 00-2.828 0L7 10.172V13h2.828l7.586-7.586a2 2 0 000-2.828z" /><path fill-rule="evenodd" d="M2 6a2 2 0 012-2h4a1 1 0 010 2H4v10h10v-4a1 1 0 112 0v4a2 2 0 01-2 2H4a2 2 0 01-2-2V6z" clip-rule="evenodd" /></svg> Bearbeiten
</button>
</div>
<div v-if="isEditingInfo">
<textarea v-model="tempAdditionalInfo" class="w-full p-2 border rounded-md" rows="4"></textarea>
<div class="flex justify-end space-x-2 mt-2">
<button @click="isEditingInfo = false" class="px-3 py-1.5 bg-slate-200 rounded-md text-sm font-medium">Abbrechen</button>
<button @click="saveAdditionalInfo" class="px-3 py-1.5 bg-primary text-white rounded-md text-sm font-medium">Speichern</button>
</div>
</div>
<p v-else class="text-sm whitespace-pre-wrap">{{ selectedWorkorder.additionalInfo || 'Keine Notiz.' }}</p>
</div>
<!-- Checklist -->
<div class="bg-white p-4 rounded-lg border border-slate-200">
<h3 class="font-bold text-slate-700 mb-3">Checkliste</h3>
<ul v-if="checklist.length > 0" class="space-y-2">
<li v-for="item in checklist" :key="item.value" class="flex items-center text-sm">
<svg v-if="item.completed" xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2 text-green-500" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" /></svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2 text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10" /></svg>
<span :class="{'text-slate-500 line-through': item.completed}">{{ item.text }}</span>
</li>
</ul>
<p v-else class="text-sm text-slate-500">Keine Checklisten-Einträge vorhanden.</p>
</div>
<!-- Docs -->
<div class="bg-white p-4 rounded-lg border border-slate-200">
<h3 class="font-bold text-slate-700 mb-2">Dokumentation</h3>
<label for="file-upload" class="w-full inline-flex items-center justify-center px-4 py-2 border border-dashed border-slate-300 text-sm font-medium rounded-md text-slate-700 bg-slate-50 hover:bg-slate-100 cursor-pointer">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" /></svg>
<span>Foto/Dokument hinzufügen</span>
</label>
<input id="file-upload" type="file" class="hidden" @change="handleFileSelect" multiple accept="image/*,application/pdf">
<div v-if="translatedDocs.length > 0" class="grid grid-cols-3 sm:grid-cols-4 gap-2 mt-4">
<div v-for="doc in translatedDocs" :key="doc.id" @click="fullscreenViewer.show = true; fullscreenViewer.item = doc" class="relative aspect-square bg-slate-100 rounded-md overflow-hidden cursor-pointer group">
<template v-if="doc.isPdf">
<div class="h-full w-full flex items-center justify-center bg-red-50 p-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-red-500" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M4 2a2 2 0 00-2 2v12a2 2 0 002 2h12a2 2 0 002-2V4a2 2 0 00-2-2H4zm3 4a1 1 0 000 2h6a1 1 0 100-2H7zm0 4a1 1 0 100 2h6a1 1 0 100-2H7zm0 4a1 1 0 100 2h4a1 1 0 100-2H7z" clip-rule="evenodd" /></svg>
</div>
</template>
<template v-else>
<img :src="'/File/show?id=' + doc.fileId + '&size=small'" class="h-full w-full object-cover">
</template>
<div class="absolute inset-x-0 bottom-0 p-1 bg-black bg-opacity-50">
<p class="text-white text-xs truncate">{{ doc.translatedName }}</p>
</div>
<div class="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-40 transition flex items-center justify-center"><svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-white opacity-0 group-hover:opacity-100 transition" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /></svg></div>
</div>
</div>
</div>
<!-- Journal -->
<div class="bg-white p-4 rounded-lg border border-slate-200">
<h3 class="font-bold text-slate-700 mb-4">Journal</h3>
<div class="space-y-4 max-h-60 overflow-y-auto pr-2 journal-container">
<div v-if="filteredJournals.length === 0"><p class="text-sm text-slate-500">Keine Einträge.</p></div>
<div v-for="entry in filteredJournals" :key="entry.id" class="flex items-start">
<div class="flex-shrink-0 bg-secondary h-8 w-8 rounded-full flex items-center justify-center mr-3"><svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-primary" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd" /></svg></div>
<div class="flex-grow">
<p class="text-sm whitespace-pre-wrap">{{ entry.text }}</p>
<p class="text-xs text-slate-400 mt-1">{{ entry.createByName }} - {{ formatDate(entry.create, 'DD.MM.YY HH:mm') }}</p>
</div>
</div>
</div>
<div class="mt-4 pt-4 border-t">
<textarea v-model="newJournalEntry" placeholder="Neuer Eintrag..." class="w-full p-2 border rounded-md" rows="3"></textarea>
<button @click="addJournalEntry" class="mt-2 w-full px-4 py-2 bg-primary text-white font-semibold rounded-md text-sm">Senden</button>
</div>
</div>
</div>
<footer class="bg-white p-2 border-t border-slate-200 flex-shrink-0 grid grid-cols-2 gap-2 relative">
<button @click="problemModal.show = true" class="w-full px-4 py-3 bg-red-600 text-white font-bold rounded-md text-center">Problem melden</button>
<button @click="handleCompleteClick" class="w-full px-4 py-3 bg-green-600 text-white font-bold rounded-md text-center disabled:bg-slate-300">Abschließen</button>
<transition name="fade">
<div v-if="missingTasksPopover.show" class="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 w-72 bg-slate-800 text-white text-sm rounded-lg shadow-lg p-3">
<h4 class="font-bold mb-1">Fehlende Checklisten-Punkte:</h4>
<ul class="list-disc list-inside">
<li v-for="task in missingTasksPopover.tasks" :key="task">{{ task }}</li>
</ul>
<div class="absolute bottom-[-5px] left-1/2 -translate-x-1/2 w-0 h-0 border-x-8 border-x-transparent border-t-8 border-t-slate-800"></div>
</div>
</transition>
</footer>
</div>
</transition>
<!-- Upload Modal -->
<div v-if="uploadModal.show" class="fixed inset-0 bg-black bg-opacity-50 z-30 flex items-center justify-center p-4">
<div class="bg-white rounded-lg p-6 w-full max-w-sm">
<h3 class="font-bold text-lg mb-4">Dokumenttyp wählen</h3>
<select v-model="uploadModal.documentType" class="w-full p-2 border rounded-md mb-4">
<option v-for="type in tenantConfig.documentationTypes" :key="type.value" :value="type.value">{{ type.text }}</option>
<option v-if="!tenantConfig.documentationTypes || tenantConfig.documentationTypes.length === 0" value="general">Allgemein</option>
</select>
<div class="flex justify-end space-x-2">
<button @click="uploadModal.show = false" class="px-4 py-2 bg-slate-200 rounded-md">Abbrechen</button>
<button @click="executeUpload" :disabled="isUploading" class="px-4 py-2 bg-primary text-white rounded-md disabled:bg-slate-400">{{ isUploading ? 'Lade...' : 'Hochladen' }}</button>
</div>
</div>
</div>
<!-- Problem Modal -->
<div v-if="problemModal.show" class="fixed inset-0 bg-black bg-opacity-50 z-30 flex items-center justify-center p-4">
<div class="bg-white rounded-lg p-6 w-full max-w-sm flex flex-col">
<h3 class="font-bold text-lg mb-4">Problem melden</h3>
<div class="space-y-2 mb-4 overflow-y-auto" style="max-height: 30vh;">
<div v-for="type in tenantConfig.interventionTypes" :key="type.value">
<label class="flex items-center p-2 border rounded-md">
<input type="checkbox" :value="type" v-model="problemModal.selectedInterventions" class="h-4 w-4 rounded text-primary focus:ring-primary">
<span class="ml-2 text-sm">{{ type.text.replace('X', '...') }}</span>
</label>
<input v-if="problemModal.selectedInterventions.some(i => i.value === type.value) && type.text.includes('X')"
v-model="problemModal.details[type.value]"
type="text" class="w-full p-2 border rounded-md mt-1 text-sm" placeholder="Details für dieses Problem...">
</div>
</div>
<div class="flex justify-end space-x-2 mt-auto">
<button @click="problemModal.show = false; problemModal.selectedInterventions = []; problemModal.details = {}" class="px-4 py-2 bg-slate-200 rounded-md">Abbrechen</button>
<button @click="submitProblem" class="px-4 py-2 bg-red-600 text-white rounded-md">Senden</button>
</div>
</div>
</div>
<!-- Fullscreen Viewer -->
<div v-if="fullscreenViewer.show" @click="fullscreenViewer.show = false" class="fixed inset-0 bg-black bg-opacity-90 z-40 flex items-center justify-center p-2">
<button @click="fullscreenViewer.show = false" class="absolute top-2 right-2 p-2 bg-white/20 rounded-full text-white"><svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /></svg></button>
<template v-if="fullscreenViewer.item.isPdf">
<iframe :src="'/File/show?id=' + fullscreenViewer.item.fileId" class="w-full h-full border-0"></iframe>
</template>
<template v-else>
<img :src="'/File/show?id=' + fullscreenViewer.item.fileId" class="max-w-full max-h-full object-contain">
</template>
</div>
</div>
`
});
app.mount('#app');
</script>
<script src="/js/pages/WorkorderBase/WorkorderServiceWorker.js"></script>
</body>
</html>