Files
thetool/Layout/default/VueViews/WorkorderCompanyPWA.php
2025-09-08 18:25:58 +02:00

1159 lines
71 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.
<!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, viewport-fit=cover">
<title>Workorders</title>
<link rel="shortcut icon" href="/assets/images/favicon.ico">
<link rel="manifest" href="/js/pages/WorkorderBase/manifest.json">
<meta name="theme-color" content="#005384">
<script src="https://cdn.tailwindcss.com"></script>
<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>
<script>
window.TT_CONFIG = <?= json_encode($JSGlobals ?? []) ?>;
moment.locale('de');
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
colors: {
'primary': '#005384',
'secondary': '#fac41b',
},
}
}
}
</script>
<style>
html, body { overscroll-behavior: none; }
body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; }
#app { position: fixed; inset: 0; }
.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; }
@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
.spin { animation: spin 1s linear infinite; }
</style>
</head>
<body class="transition-colors duration-300 bg-slate-100 dark:bg-slate-900">
<div id="app"></div>
<script>
const { createApp, ref, reactive, computed, onMounted, watch, nextTick } = Vue;
const app = createApp({
setup() {
// --- STATE ---
const isAuthenticated = ref(false);
const isCheckingAuth = ref(true);
const loginForm = reactive({
username: '', password: '', twoFactorCode: '', remember: false,
error: '', state: 'credentials', isLoading: false
});
const workorders = ref([]);
const selectedWorkorder = ref(null);
const isLoading = ref(true);
const isDetailsLoading = ref(false);
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 installModal = reactive({ show: false });
const isStandalone = ref(false);
const selectedFcp = ref('all');
const isFcpSelectOpen = ref(false);
const fcpSearchTerm = ref('');
const fcpInputRef = ref(null);
const isSettingsOpen = ref(false);
const theme = ref('system');
const showThemePicker = ref(false);
const API_BASE_URL = window.TT_CONFIG.BASE_PATH || '/WorkorderCompany';
const api = axios.create({ baseURL: API_BASE_URL });
// --- AUTHENTICATION ---
const checkAuthStatus = async () => {
isCheckingAuth.value = true;
try {
const response = await api.post(`/get`, { pagination: { page: 1, per_page: 1 } });
if (response.data && typeof response.data === 'object' && 'rows' in response.data) {
isAuthenticated.value = true;
fetchWorkorders();
} else {
isAuthenticated.value = false;
}
} catch (error) {
isAuthenticated.value = false;
} finally {
isCheckingAuth.value = false;
}
};
const handleLogin = async () => {
loginForm.isLoading = true;
loginForm.error = '';
const formData = new FormData();
formData.append('Username', loginForm.username);
formData.append('Password', loginForm.password);
formData.append('Remember', loginForm.remember);
if (loginForm.state === '2fa') {
formData.append('TwofactorCode', loginForm.twoFactorCode);
}
try {
// Use the new, dedicated login endpoint
const response = await axios.post(`${API_BASE_URL}/loginOverride`, formData);
const data = response.data;
if (data.status === 'success') {
isAuthenticated.value = true;
fetchWorkorders();
} else if (data.status === '2fa_required') {
loginForm.state = '2fa';
await nextTick(() => document.getElementById('TwofactorCode')?.focus());
} else {
loginForm.error = data.message || 'Anmeldung fehlgeschlagen.';
loginForm.state = 'credentials';
}
} catch (error) {
if(error.response && error.response.status === 403) {
loginForm.error = 'Sie sind bereits in einem anderen Tab angemeldet. Bitte laden Sie die Seite neu.';
} else {
loginForm.error = 'Ein Netzwerkfehler ist aufgetreten.';
}
} finally {
loginForm.isLoading = false;
}
};
const handleLogout = async () => {
isSettingsOpen.value = false;
try {
await axios.get('/Dashboard/logout');
} catch (error) {
console.warn("Logout request might have failed, but logging out on client.", error);
} finally {
isAuthenticated.value = false;
Object.assign(loginForm, {
username: '', password: '', twoFactorCode: '', remember: false,
error: '', state: 'credentials', isLoading: false
});
workorders.value = [];
}
};
// --- THEME ---
const applyTheme = () => {
const isDark = localStorage.theme === 'dark' || (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches);
document.documentElement.classList.toggle('dark', isDark);
document.querySelector('meta[name="theme-color"]').setAttribute('content', isDark ? '#334155' : '#005384');
};
const setTheme = (newTheme) => {
theme.value = newTheme;
if (newTheme === 'system') localStorage.removeItem('theme');
else localStorage.setItem('theme', newTheme);
applyTheme();
showThemePicker.value = false;
};
// --- COMPUTED & METHODS ---
const fcpOptions = computed(() => {
if (!workorders.value.length) return [{ value: 'all', text: 'Alle FCPs' }];
const fcps = [...new Set(workorders.value.map(wo => wo.rimo_fcp_name).filter(Boolean))].sort();
return [{ value: 'all', text: 'Alle FCPs' }, ...fcps.map(fcp => ({ value: fcp, text: fcp }))];
});
const filteredFcpOptions = computed(() => {
if (!fcpSearchTerm.value) return fcpOptions.value;
return fcpOptions.value.filter(option => option.text.toLowerCase().includes(fcpSearchTerm.value.toLowerCase()));
});
const selectedFcpText = computed(() => fcpOptions.value.find(opt => opt.value === selectedFcp.value)?.text || 'Alle FCPs');
const filteredWorkorders = computed(() => {
let filtered = workorders.value;
if (selectedFcp.value !== 'all') filtered = filtered.filter(wo => wo.rimo_fcp_name === selectedFcp.value);
if (searchTerm.value.length > 2) {
const lowerSearch = searchTerm.value.toLowerCase();
filtered = filtered.filter(wo =>
Object.values(wo).some(val => String(val).toLowerCase().includes(lowerSearch))
);
}
const getStatusRank = (status) => ({ 'scheduled': 0, 'civil_engineering_completed': 0, 'assigned': 1, 'new': 1, 'problem_solved': 1, 'intervention_required': 2, 'correction_requested': 2, 'civil_engineering_required': 2, 'documented': 3, 'completed': 3, 'cancelled': 4 }[status] ?? 99);
return filtered.sort((a, b) => {
const rankA = getStatusRank(a.status), rankB = getStatusRank(b.status);
if (rankA !== rankB) return rankA - rankB;
if (rankA === 0) return (a.appointmentDate || Infinity) - (b.appointmentDate || Infinity);
return (a.deadlineDate || Infinity) - (b.deadlineDate || Infinity);
});
});
const googleMapsLink = computed(() => {
if (!selectedWorkorder.value) return '#';
const { street, hausnummer, plz, city } = selectedWorkorder.value;
return `https://maps.google.com/?q=${encodeURIComponent(`${street} ${hausnummer}, ${plz} ${city}`)}`;
});
const checklist = computed(() => {
if (!tenantConfig.value?.documentationTypes) return [];
return tenantConfig.value.documentationTypes.map(reqType => ({
...reqType,
completed: documentation.docs.some(doc => doc.documentType === reqType.value)
}));
});
const isChecklistComplete = computed(() => checklist.value.length > 0 && 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(() => documentation.journals.filter(j => !j.text.toLowerCase().includes('wurde zugewiesen.')));
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) {
if (error.response && [401, 403].includes(error.response.status)) {
isAuthenticated.value = false;
}
}
finally { isLoading.value = false; }
};
const getStatusInfo = (status) => {
return {
'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' }
}[status] || { text: 'Unbekannt', color: 'bg-gray-400' };
};
const formatDate = (timestamp, format = 'DD.MM.YYYY') => timestamp ? moment.unix(timestamp).format(format) : '';
const fetchDetails = async (workorderId) => {
isDetailsLoading.value = true;
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); }
finally { isDetailsLoading.value = false; }
};
const openDetails = (workorder) => {
selectedWorkorder.value = workorder;
isDetailsPanelOpen.value = true;
fetchDetails(workorder.id);
};
const closeDetails = () => {
isDetailsPanelOpen.value = false;
setTimeout(() => {
selectedWorkorder.value = null;
Object.assign(documentation, { docs: [], journals: [] });
tenantConfig.value = null; isEditingInfo.value = false;
}, 350);
};
const startEditInfo = () => {
tempAdditionalInfo.value = selectedWorkorder.value.additionalInfo || '';
isEditingInfo.value = true;
};
const saveAdditionalInfo = async () => {
try {
await api.post('/updateAdditionalInfo', { workorderId: selectedWorkorder.value.id, additionalInfo: tempAdditionalInfo.value });
selectedWorkorder.value.additionalInfo = tempAdditionalInfo.value;
const woInList = workorders.value.find(w => w.id === selectedWorkorder.value.id);
if(woInList) woInList.additionalInfo = tempAdditionalInfo.value;
await fetchDetails(selectedWorkorder.value.id);
} 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 el = document.querySelector('.journal-container');
if(el) el.scrollTop = el.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 (const file of uploadModal.files) formData.append('files[]', file);
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;
try {
const journalParts = problemModal.selectedInterventions.map(type => {
let text = type.text;
const needsDetail = type.text.includes('X') || type.text.toLowerCase().includes('sonstiges');
if (needsDetail) {
const detail = problemModal.details[type.value] || '';
if (!detail) {
throw new Error(`Bitte geben Sie Details für "${type.text}" an.`);
}
text = text.includes('X') ? text.replace('X', detail) : `${text}: ${detail}`;
}
return text;
});
await api.post('/requestIntervention', { workorderId: selectedWorkorder.value.id, journalText: journalParts.join('\n') });
await fetchWorkorders();
closeDetails();
} catch(e) {
alert(e.message || "Problem konnte nicht gemeldet werden.");
} 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); }
};
const selectFcp = (fcpValue) => {
selectedFcp.value = fcpValue;
isFcpSelectOpen.value = false;
};
// --- LIFECYCLE & WATCHERS ---
onMounted(() => {
isStandalone.value = window.matchMedia('(display-mode: standalone)').matches;
if (!localStorage.getItem('theme')) showThemePicker.value = true;
theme.value = localStorage.getItem('theme') || 'system';
applyTheme();
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', applyTheme);
checkAuthStatus();
});
watch(isFcpSelectOpen, (isOpen) => {
if (isOpen) nextTick(() => fcpInputRef.value?.focus());
else fcpSearchTerm.value = '';
});
return {
isAuthenticated, isCheckingAuth, loginForm, handleLogin, handleLogout,
isLoading, isDetailsLoading, filteredWorkorders, searchTerm, isDetailsPanelOpen, selectedWorkorder, documentation, tenantConfig,
tempAdditionalInfo, isEditingInfo, newJournalEntry, isUploading, uploadModal, problemModal, isChecklistComplete, checklist, fullscreenViewer, missingTasksPopover, installModal, isStandalone,
selectedFcp, isFcpSelectOpen, fcpOptions, selectedFcpText, fcpSearchTerm, filteredFcpOptions, fcpInputRef,
isSettingsOpen, theme, showThemePicker,
fetchWorkorders, openDetails, closeDetails, getStatusInfo, formatDate, googleMapsLink, startEditInfo, saveAdditionalInfo,
handleFileSelect, executeUpload, addJournalEntry, submitProblem, handleCompleteClick, selectFcp, setTheme
};
},
template: `
<!-- Auth Loading Screen -->
<div v-if="isCheckingAuth"
class="h-full w-full flex flex-col justify-center items-center bg-slate-100 dark:bg-slate-900 text-slate-500 dark:text-slate-400">
<svg class="h-10 w-10 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<p class="mt-4">Authentifizierung wird geprüft...</p>
</div>
<!-- Login Screen -->
<div v-else-if="!isAuthenticated"
class="h-full w-full flex items-center justify-center p-4 bg-slate-100 dark:bg-slate-900">
<div class="w-full max-w-sm">
<img src="/assets/images/xinon-full-transparent.png" alt="Logo" class="h-10 w-auto mx-auto mb-8">
<div class="bg-white dark:bg-slate-800 p-8 rounded-2xl shadow-lg">
<h2 class="text-center text-2xl font-bold text-slate-800 dark:text-slate-100 mb-2">Anmelden</h2>
<p class="text-center text-sm text-slate-600 dark:text-slate-400 mb-6">Bitte loggen Sie sich ein.</p>
<form @submit.prevent="handleLogin">
<div v-if="loginForm.error"
class="bg-red-100 dark:bg-red-500/20 border-l-4 border-red-500 text-red-700 dark:text-red-300 p-4 mb-4 rounded-r-lg"
role="alert">
<p class="text-sm font-bold">{{ loginForm.error }}</p>
</div>
<div v-if="loginForm.state === 'credentials'" class="space-y-4">
<div>
<label for="username"
class="text-sm font-medium text-slate-700 dark:text-slate-300">Benutzername</label>
<input v-model="loginForm.username" id="username" type="text" required
class="mt-1 block w-full px-3 py-2 bg-white dark:bg-slate-700 border border-slate-300 dark:border-slate-600 rounded-md text-sm shadow-sm placeholder-slate-400 focus:outline-none focus:ring-primary focus:border-primary">
</div>
<div>
<label for="password"
class="text-sm font-medium text-slate-700 dark:text-slate-300">Passwort</label>
<input v-model="loginForm.password" id="password" type="password" required
class="mt-1 block w-full px-3 py-2 bg-white dark:bg-slate-700 border border-slate-300 dark:border-slate-600 rounded-md text-sm shadow-sm placeholder-slate-400 focus:outline-none focus:ring-primary focus:border-primary">
</div>
</div>
<div v-if="loginForm.state === '2fa'" class="space-y-4">
<div>
<label for="TwofactorCode" class="text-sm font-medium text-slate-700 dark:text-slate-300">2FA
Code</label>
<input v-model="loginForm.twoFactorCode" id="TwofactorCode" type="number" inputmode="numeric"
required
class="mt-1 block w-full px-3 py-2 bg-white dark:bg-slate-700 border border-slate-300 dark:border-slate-600 rounded-md text-sm shadow-sm placeholder-slate-400 focus:outline-none focus:ring-primary focus:border-primary">
</div>
</div>
<div class="flex items-center mt-6">
<label class="flex items-center text-sm text-slate-600 dark:text-slate-400">
<input v-model="loginForm.remember" type="checkbox"
class="rounded border-slate-300 text-primary shadow-sm focus:border-primary focus:ring focus:ring-offset-0 focus:ring-primary focus:ring-opacity-50">
<span class="ml-2">Angemeldet bleiben</span>
</label>
</div>
<div class="mt-6">
<button type="submit" :disabled="loginForm.isLoading"
class="w-full flex justify-center py-3 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-primary hover:bg-opacity-90 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary disabled:bg-slate-400 disabled:dark:bg-slate-600">
<span v-if="!loginForm.isLoading">Anmelden</span>
<svg v-else class="animate-spin h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none"
viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor"
stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</button>
</div>
</form>
</div>
</div>
</div>
<!-- Main App Screen -->
<div v-else class="relative h-full w-full">
<transition name="overlay">
<div v-if="isDetailsPanelOpen || installModal.show || isFcpSelectOpen || isSettingsOpen"
@click="closeDetails(); isFcpSelectOpen = false; isSettingsOpen = false;" class="overlay"></div>
</transition>
<div :class="{'panel-open': isDetailsPanelOpen}"
class="list-container flex flex-col h-full bg-slate-100 dark:bg-slate-900 overflow-hidden transition-colors duration-300">
<header class="bg-white dark:bg-slate-800 shadow dark:shadow-md p-4 flex-shrink-0 z-10">
<div class="grid grid-cols-3 items-center">
<div class="justify-self-start">
<button @click="fetchWorkorders"
class="p-2 rounded-full hover:bg-slate-100 dark:hover:bg-slate-700">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-slate-600 dark:text-slate-300"
:class="{'spin': isLoading}" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round"
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0011.667 0l3.181-3.183m-4.991-2.695h-4.992v.001M21.015 4.356v4.992m0 0h-4.992m4.992 0l-3.181-3.183a8.25 8.25 0 00-11.667 0L3.985 9.348"/>
</svg>
</button>
</div>
<div class="justify-self-center">
<img src="/assets/images/xinon-full-transparent.png" alt="Logo" class="h-8 w-auto">
</div>
<div class="justify-self-end">
<button @click="isSettingsOpen = true"
class="p-2 rounded-full hover:bg-slate-100 dark:hover:bg-slate-700">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-slate-600 dark:text-slate-300"
fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round"
d="M9.594 3.94c.09-.542.56-1.007 1.11-1.226.554-.22 1.197-.24 1.752-.07C13.06 2.83 13.5 3.326 13.5 3.94v.293c.312.08.614.195.912.338.298.144.58.32.843.533.264.214.504.46.717.734.213.273.392.58.52.922.128.341.19.71.19 1.093v.293c.338.298.636.623.896.98.26.357.46.754.593 1.178.133.424.2 1.86.2 3.371v.293c-.08.312-.195.614-.338.912a5.992 5.992 0 01-.533.843c-.214.264-.46.504-.734.717-.273.213-.58.392-.922.52-.341.128-.71.19-1.093.19h-.293c-.298.338-.623.636-.98.896-.357.26-.754.46-1.178.593-.424.133-1.86.2-3.371.2h-.293c-.312-.08-.614-.195-.912-.338a5.992 5.992 0 01-.843-.533c-.264-.214-.504-.46-.717-.734a6.01 6.01 0 01-.52-.922c-.128-.341-.19-.71-.19-1.093v-.293c-.338-.298-.636-.623-.896-.98-.26-.357-.46-.754-.593-1.178-.133-.424-.2-1.86-.2-3.371v-.293c.08-.312.195-.614.338-.912.144-.298.32-.58.533-.843.214-.264.46-.504.734-.717.273-.213.58-.392.922.52.341-.128.71-.19 1.093-.19V3.94zM12 6.375a3.625 3.625 0 100 7.25 3.625 3.625 0 000-7.25z"/>
</svg>
</button>
</div>
</div>
<div class="mt-4 grid grid-cols-2 gap-2">
<input type="text" v-model="searchTerm" placeholder="Suche..." inputmode="search"
class="w-full p-3 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary transition dark:bg-slate-700 dark:border-slate-600 dark:text-white dark:placeholder-slate-400">
<button @click="isFcpSelectOpen = true"
class="w-full p-3 border border-slate-300 rounded-lg bg-white dark:bg-slate-700 dark:border-slate-600 text-left flex justify-between items-center text-sm">
<span class="truncate pr-2 text-slate-800 dark:text-slate-200">{{ selectedFcpText }}</span>
<svg xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5 text-slate-400 dark:text-slate-300 flex-shrink-0" viewBox="0 0 20 20"
fill="currentColor">
<path fill-rule="evenodd"
d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
clip-rule="evenodd"/>
</svg>
</button>
</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 dark:text-slate-400">Lade
Aufträge...</p></div>
<div v-else-if="filteredWorkorders.length === 0" class="text-center p-10"><p
class="text-slate-500 dark:text-slate-400">Keine Aufträge gefunden.</p></div>
<div v-else class="space-y-3">
<div v-for="wo in filteredWorkorders" :key="wo.id" @click="openDetails(wo)"
class="bg-white dark:bg-slate-800 p-4 rounded-lg shadow-md dark:shadow-black/20 cursor-pointer transition active:scale-[0.98]">
<div class="flex justify-between items-start">
<div class="flex-grow pr-2 min-w-0">
<p class="font-bold text-slate-800 dark:text-slate-100 break-words">#{{ wo.id }} |
{{ wo.customerName || 'N/A' }}</p>
<p class="text-sm text-slate-500 dark:text-slate-400 break-words">{{ wo.street }}
{{ wo.hausnummer }}, {{ wo.plz }} {{ wo.city }}</p>
<div class="items-center text-xs text-slate-400 dark:text-slate-500 mt-1">
<span class="mr-2">OAID: {{ wo.oaid || 'N/A' }}</span><br>
<span class="truncate">FCP: {{ wo.rimo_fcp_name || 'N/A' }}</span>
</div>
</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 dark:text-slate-300">
{{ 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>
<transition name="slide">
<div v-if="isDetailsPanelOpen && selectedWorkorder"
class="fixed inset-0 bg-slate-50 dark:bg-slate-900 z-20 flex flex-col shadow-2xl">
<header
class="bg-white dark:bg-slate-800 p-4 flex justify-between items-center border-b border-slate-200 dark:border-slate-700 flex-shrink-0">
<div class="flex items-center min-w-0">
<img src="/assets/images/xinon-full-transparent.png" alt="Logo" class="h-6 w-auto mr-4">
<h2 class="text-xl font-bold text-primary truncate">Auftrag #{{ selectedWorkorder.id }}</h2>
</div>
<button @click="closeDetails"
class="p-2 rounded-full hover:bg-slate-200 dark:hover:bg-slate-700 flex-shrink-0">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-slate-600 dark:text-slate-300"
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">
<!-- Details panel content will be rendered here -->
</div>
<footer
class="bg-white dark:bg-slate-800 p-2 border-t border-slate-200 dark:border-slate-700 flex-shrink-0 grid grid-cols-2 gap-2 pt-2 px-2 pb-[calc(0.5rem+env(safe-area-inset-bottom))]">
<!-- Footer buttons will be rendered here -->
</footer>
</div>
</transition>
<transition name="fade">
<div v-if="isSettingsOpen" class="fixed inset-0 z-30 flex items-start justify-center p-4 pt-20"
@click.self="isSettingsOpen = false">
<div
class="bg-white dark:bg-slate-800 rounded-lg shadow-xl w-full max-w-sm flex flex-col text-slate-800 dark:text-slate-200">
<div class="p-4 border-b border-slate-200 dark:border-slate-700 flex justify-between items-center">
<h3 class="font-bold text-lg">Einstellungen</h3>
<button @click="isSettingsOpen = false"
class="flex items-center justify-center h-7 w-7 rounded-full hover:bg-slate-100 dark:hover:bg-slate-700 text-xl">
×
</button>
</div>
<div class="p-4 space-y-4">
<div>
<label
class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Farbschema</label>
<div class="grid grid-cols-3 gap-2">
<button @click="setTheme('light')"
class="px-3 py-2 text-sm font-semibold rounded-md transition-colors"
:class="theme === 'light' ? 'bg-primary text-white' : 'bg-slate-200 dark:bg-slate-700 hover:bg-slate-300 dark:hover:bg-slate-600'">
Hell
</button>
<button @click="setTheme('dark')"
class="px-3 py-2 text-sm font-semibold rounded-md transition-colors"
:class="theme === 'dark' ? 'bg-primary text-white' : 'bg-slate-200 dark:bg-slate-700 hover:bg-slate-300 dark:hover:bg-slate-600'">
Dunkel
</button>
<button @click="setTheme('system')"
class="px-3 py-2 text-sm font-semibold rounded-md transition-colors"
:class="theme === 'system' ? 'bg-primary text-white' : 'bg-slate-200 dark:bg-slate-700 hover:bg-slate-300 dark:hover:bg-slate-600'">
System
</button>
</div>
</div>
<div class="border-t border-slate-200 dark:border-slate-700"></div>
<button v-if="!isStandalone" @click="installModal.show = true; isSettingsOpen = false"
class="w-full text-left p-3 rounded-md hover:bg-slate-100 dark:hover:bg-slate-700 text-sm font-medium flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-3" fill="none" viewBox="0 0 24 24"
stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round"
d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3"/>
</svg>
App installieren
</button>
<button @click="handleLogout"
class="w-full text-left p-3 rounded-md hover:bg-red-50 dark:hover:bg-red-500/10 text-sm font-medium flex items-center text-red-600 dark:text-red-400">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-3" viewBox="0 0 20 20"
fill="currentColor">
<path fill-rule="evenodd"
d="M3 3a1 1 0 00-1 1v12a1 1 0 102 0V4a1 1 0 00-1-1zm10.293 9.293a1 1 0 001.414 1.414l3-3a1 1 0 000-1.414l-3-3a1 1 0 10-1.414 1.414L14.586 9H7a1 1 0 100 2h7.586l-1.293 1.293z"
clip-rule="evenodd"/>
</svg>
Logout
</button>
</div>
<footer
class="p-4 mt-2 border-t border-slate-200 dark:border-slate-700 text-center text-xs text-slate-500 dark:text-slate-400 space-y-2">
<img src="/assets/images/xinon-sm.png" class="h-8 w-auto mx-auto">
<p>Powered by XINON GmbH</p>
<a href="https://xinon.at/impressum/" target="_blank" class="hover:underline text-primary">© 2024
XINON GmbH - Impressum</a>
</footer>
</div>
</div>
</transition>
<transition name="fade">
<div v-if="showThemePicker"
class="fixed inset-0 bg-black bg-opacity-60 z-50 flex items-center justify-center p-4">
<div class="bg-white dark:bg-slate-800 rounded-lg p-6 w-full max-w-sm text-center shadow-2xl">
<h3 class="font-bold text-lg mb-2 dark:text-white">Willkommen!</h3>
<p class="text-sm text-slate-600 dark:text-slate-300 mb-6">Wählen Sie Ihr bevorzugtes Farbschema.</p>
<div class="flex flex-col space-y-3">
<button @click="setTheme('light')"
class="w-full px-4 py-3 bg-slate-200 text-slate-800 font-bold rounded-md hover:bg-slate-300">
Hell
</button>
<button @click="setTheme('dark')"
class="w-full px-4 py-3 bg-slate-700 text-white font-bold rounded-md hover:bg-slate-600">
Dunkel
</button>
<button @click="setTheme('system')"
class="w-full px-4 py-3 bg-slate-200 dark:bg-slate-700 text-slate-800 dark:text-white font-bold rounded-md hover:bg-slate-300 dark:hover:bg-slate-600">
Systemstandard
</button>
</div>
<p class="text-xs text-slate-500 dark:text-slate-400 mt-4">Sie können dies jederzeit in den
Einstellungen ändern.</p>
</div>
</div>
</transition>
<!--
This file contains the Vue template snippets for various modals and panels
used in the Workorder PWA. You can integrate these back into the main
template section of your Vue component.
-->
<!-- Details Panel (Slide-in) -->
<transition name="slide">
<div v-if="isDetailsPanelOpen && selectedWorkorder"
class="fixed inset-0 bg-slate-50 dark:bg-slate-900 z-20 flex flex-col shadow-2xl">
<header
class="bg-white dark:bg-slate-800 p-4 flex justify-between items-center border-b border-slate-200 dark:border-slate-700 flex-shrink-0">
<div class="flex items-center min-w-0">
<img src="/assets/images/xinon-full-transparent.png" alt="Logo" class="h-6 w-auto mr-4">
<h2 class="text-xl font-bold text-primary truncate">Auftrag #{{ selectedWorkorder.id }}</h2>
</div>
<button @click="closeDetails"
class="p-2 rounded-full hover:bg-slate-200 dark:hover:bg-slate-700 flex-shrink-0">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-slate-600 dark:text-slate-300"
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">
<div
class="bg-white dark:bg-slate-800 p-4 rounded-lg border border-slate-200 dark:border-slate-700 space-y-3 text-sm">
<div class="flex items-center text-base font-bold text-slate-800 dark:text-slate-100">
<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 class="dark:text-sky-400">{{ selectedWorkorder.street }} {{ selectedWorkorder.hausnummer }},
{{ selectedWorkorder.plz }} {{ selectedWorkorder.city }}</span>
</a>
<div
class="border-t border-slate-200 dark:border-slate-700 pt-3 mt-3 grid grid-cols-2 gap-2 text-sm">
<div>
<p class="text-xs text-slate-500 font-semibold">OAID</p>
<p class="text-slate-800 dark:text-slate-300">{{ selectedWorkorder.oaid || 'N/A' }}</p>
</div>
<div>
<p class="text-xs text-slate-500 font-semibold">FCP</p>
<p class="text-slate-800 dark:text-slate-300">{{ selectedWorkorder.rimo_fcp_name || 'N/A' }}</p>
</div>
</div>
<div class="border-t border-slate-200 dark:border-slate-700 pt-3 space-y-2">
<a :href="'mailto:' + selectedWorkorder.email"
class="flex items-center text-primary hover:underline dark:text-sky-400">
<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 dark:text-sky-400">
<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>
<div class="bg-white dark:bg-slate-800 p-4 rounded-lg border border-slate-200 dark:border-slate-700">
<div class="flex justify-between items-center mb-2">
<h3 class="font-bold text-slate-700 dark:text-slate-200">Notiz</h3>
<button v-if="!isEditingInfo" @click="startEditInfo"
class="flex items-center text-sm font-medium text-primary dark:text-sky-400 bg-slate-100 dark:bg-slate-700 hover:bg-slate-200 dark:hover:bg-slate-600 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 dark:bg-slate-700 dark:border-slate-600 dark:text-white"
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 dark:bg-slate-600 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 dark:text-slate-300">
{{ selectedWorkorder.additionalInfo || 'Keine Notiz.' }}</p>
</div>
<div class="bg-white dark:bg-slate-800 p-4 rounded-lg border border-slate-200 dark:border-slate-700">
<h3 class="font-bold text-slate-700 dark:text-slate-200 mb-3">Checkliste</h3>
<div v-if="isDetailsLoading" class="space-y-3 animate-pulse">
<div v-for="i in 4" :key="i" class="flex items-center">
<div class="h-5 w-5 rounded-full bg-slate-200 dark:bg-slate-700 mr-2"></div>
<div class="h-4 w-3/4 rounded bg-slate-200 dark:bg-slate-700"></div>
</div>
</div>
<div v-else>
<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 dark:text-slate-300">
<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 dark:text-slate-400">Keine Checklisten-Einträge
vorhanden.</p>
</div>
</div>
<div class="bg-white dark:bg-slate-800 p-4 rounded-lg border border-slate-200 dark:border-slate-700">
<h3 class="font-bold text-slate-700 dark:text-slate-200 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 dark:border-slate-600 text-sm font-medium rounded-md text-slate-700 dark:text-slate-300 bg-slate-50 dark:bg-slate-700 hover:bg-slate-100 dark:hover:bg-slate-600 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 dark:bg-slate-700 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 dark:bg-red-900/20 p-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-red-500 dark:text-red-400"
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>
<div class="bg-white dark:bg-slate-800 p-4 rounded-lg border border-slate-200 dark:border-slate-700">
<h3 class="font-bold text-slate-700 dark:text-slate-200 mb-4">Journal</h3>
<div v-if="isDetailsLoading" class="animate-pulse">
<div class="flex items-start">
<div class="flex-shrink-0 bg-slate-200 dark:bg-slate-700 h-8 w-8 rounded-full mr-3"></div>
<div class="flex-grow space-y-2">
<div class="h-4 w-full rounded bg-slate-200 dark:bg-slate-700"></div>
<div class="h-3 w-1/2 rounded bg-slate-200 dark:bg-slate-700"></div>
</div>
</div>
</div>
<div v-else 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 dark:text-slate-400">
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 dark:text-slate-300">{{ 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 border-slate-200 dark:border-slate-700">
<textarea v-model="newJournalEntry" placeholder="Neuer Eintrag..."
class="w-full p-2 border rounded-md dark:bg-slate-700 dark:border-slate-600 dark:text-white"
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 dark:bg-slate-800 p-2 border-t border-slate-200 dark:border-slate-700 flex-shrink-0 grid grid-cols-2 gap-2 pt-2 px-2 pb-[calc(0.5rem+env(safe-area-inset-bottom))]">
<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>
<div class="relative">
<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-red-700 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 space-y-1">
<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-red-700"></div>
</div>
</transition>
</div>
</footer>
</div>
</transition>
<!-- FCP Select Modal -->
<transition name="fade">
<div v-if="isFcpSelectOpen" class="fixed inset-0 z-30 flex items-start justify-center p-4 pt-20"
@click.self="isFcpSelectOpen = false">
<div class="bg-white dark:bg-slate-800 rounded-lg p-4 w-full max-w-sm flex flex-col max-h-[80vh]">
<div class="flex justify-between items-center mb-2 flex-shrink-0">
<h3 class="font-bold text-lg dark:text-white">FCP auswählen</h3>
<button @click="isFcpSelectOpen = false"
class="flex items-center justify-center h-7 w-7 rounded-full hover:bg-slate-100 dark:hover:bg-slate-700 text-xl dark:text-slate-300">
×
</button>
</div>
<div class="relative mb-2 flex-shrink-0">
<input type="text" v-model="fcpSearchTerm" ref="fcpInputRef" placeholder="FCP suchen..."
class="w-full p-2 pl-8 border border-slate-300 dark:border-slate-600 rounded-md dark:bg-slate-700 dark:text-white">
<svg class="absolute left-2 top-1/2 -translate-y-1/2 h-5 w-5 text-slate-400"
xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd"
d="M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z"
clip-rule="evenodd"/>
</svg>
</div>
<ul class="flex-grow overflow-y-auto -mr-2 pr-2">
<li v-for="option in filteredFcpOptions" :key="option.value" @click="selectFcp(option.value)"
class="flex justify-between items-center p-3 rounded-md hover:bg-slate-100 dark:hover:bg-slate-700 cursor-pointer text-sm font-medium dark:text-slate-200"
:class="{'bg-primary/10 text-primary dark:bg-primary/20 dark:text-sky-300': selectedFcp === option.value}">
<span>{{ option.text }}</span>
<svg v-if="selectedFcp === option.value" xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5 text-primary dark:text-sky-300" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clip-rule="evenodd"/>
</svg>
</li>
<li v-if="filteredFcpOptions.length === 0" class="text-sm text-slate-500 p-3">Kein FCP gefunden.
</li>
</ul>
</div>
</div>
</transition>
<!-- Upload Modal -->
<div v-if="uploadModal.show"
class="fixed inset-0 bg-black bg-opacity-50 z-30 flex items-start justify-center p-4 pt-20"
@click.self="uploadModal.show = false">
<div class="bg-white dark:bg-slate-800 rounded-lg p-4 w-full max-w-sm flex flex-col max-h-[80vh]"
@click.stop>
<div class="flex justify-between items-center mb-4 flex-shrink-0">
<h3 class="font-bold text-lg dark:text-white">Dokumenttyp wählen</h3>
<button @click="uploadModal.show = false"
class="flex items-center justify-center h-7 w-7 rounded-full hover:bg-slate-100 dark:hover:bg-slate-700 text-xl dark:text-slate-300">
×
</button>
</div>
<ul class="flex-grow overflow-y-auto -mr-2 pr-2 space-y-1 mb-4">
<li v-for="type in tenantConfig.documentationTypes" :key="type.value"
@click="uploadModal.documentType = type.value"
class="flex justify-between items-center p-3 rounded-md hover:bg-slate-100 dark:hover:bg-slate-700 cursor-pointer text-sm font-medium dark:text-slate-200"
:class="{'bg-primary/10 text-primary dark:bg-primary/20 dark:text-sky-300': uploadModal.documentType === type.value}">
<span>{{ type.text }}</span>
<svg v-if="uploadModal.documentType === type.value" xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5 text-primary dark:text-sky-300" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clip-rule="evenodd"/>
</svg>
</li>
<li v-if="!tenantConfig.documentationTypes || tenantConfig.documentationTypes.length === 0">
<p class="text-sm text-slate-500 dark:text-slate-400 p-3">Keine Dokumenttypen konfiguriert.</p>
</li>
</ul>
<div
class="flex justify-end space-x-2 mt-auto flex-shrink-0 border-t border-slate-200 dark:border-slate-700 pt-3">
<button @click="uploadModal.show = false"
class="px-4 py-2 bg-slate-200 dark:bg-slate-600 rounded-md text-sm font-medium">Abbrechen
</button>
<button @click="executeUpload" :disabled="isUploading"
class="px-4 py-2 bg-primary text-white rounded-md disabled:bg-slate-400 text-sm font-medium">
{{ 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 dark:bg-slate-800 rounded-lg p-6 w-full max-w-sm flex flex-col max-h-[80vh]">
<h3 class="font-bold text-lg mb-4 flex-shrink-0 dark:text-white">Problem melden</h3>
<div class="flex-grow overflow-y-auto pr-2 space-y-2 mb-4">
<div v-for="type in tenantConfig.interventionTypes" :key="type.value">
<label
class="flex items-center p-3 border border-slate-200 dark:border-slate-700 rounded-lg hover:bg-slate-50 dark:hover:bg-slate-700 transition cursor-pointer">
<input type="checkbox" :value="type" v-model="problemModal.selectedInterventions"
class="h-5 w-5 rounded text-primary focus:ring-primary focus:ring-2 focus:ring-offset-1">
<span
class="ml-3 text-sm font-medium dark:text-slate-200">{{ type.text.replace('X', '...') }}</span>
</label>
<input
v-if="problemModal.selectedInterventions.some(i => i.value === type.value) && (type.text.includes('X') || type.text.toLowerCase().includes('sonstiges'))"
v-model="problemModal.details[type.value]"
type="text"
class="w-full p-2 border border-slate-300 dark:border-slate-600 rounded-md mt-1 text-sm focus:ring-primary focus:border-primary dark:bg-slate-700 dark:text-white"
placeholder="Details hier eingeben...">
</div>
</div>
<div class="flex justify-end space-x-2 mt-auto flex-shrink-0">
<button
@click="problemModal.show = false; problemModal.selectedInterventions = []; problemModal.details = {}"
class="px-4 py-2 bg-slate-200 dark:bg-slate-600 rounded-md">Abbrechen
</button>
<button @click="submitProblem" class="px-4 py-2 bg-red-600 text-white rounded-md">Senden</button>
</div>
</div>
</div>
<!-- Install PWA Modal -->
<transition name="fade">
<div v-if="installModal.show"
class="fixed inset-0 bg-black bg-opacity-50 z-30 flex items-center justify-center p-4">
<div class="bg-white dark:bg-slate-800 rounded-lg p-6 w-full max-w-md max-h-[80vh] flex flex-col">
<div class="flex justify-between items-center mb-4 flex-shrink-0">
<h3 class="font-bold text-lg dark:text-white">App installieren</h3>
<button @click="installModal.show = false"
class="p-1 rounded-full hover:bg-slate-100 dark:hover:bg-slate-700 dark:text-slate-300">×
</button>
</div>
<div class="overflow-y-auto text-sm text-slate-700 dark:text-slate-300 space-y-6">
<div>
<h4 class="font-bold text-base mb-2 flex items-center">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z"></path>
</svg>
iPhone & iPad (mit Safari)
</h4>
<ol class="list-decimal list-inside space-y-1 pl-2">
<li>Öffnen Sie diese Webseite im <strong>Safari</strong>-Browser.</li>
<li>Tippen Sie auf das "Teilen"-Symbol (das Quadrat mit dem Pfeil nach oben).</li>
<li>Scrollen Sie nach unten und wählen Sie <strong>"Zum Home-Bildschirm"</strong>.</li>
<li>Bestätigen Sie mit "Hinzufügen". Die App erscheint nun auf Ihrem Startbildschirm.</li>
</ol>
</div>
<div>
<h4 class="font-bold text-base mb-2 flex items-center">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 5v14m-7-7h14"></path>
</svg>
Android (mit Chrome)
</h4>
<ol class="list-decimal list-inside space-y-1 pl-2">
<li>Öffnen Sie diese Webseite im <strong>Chrome</strong>-Browser.</li>
<li>Tippen Sie auf die drei Punkte oben rechts, um das Menü zu öffnen.</li>
<li>Wählen Sie <strong>"App installieren"</strong> oder <strong>"Zum Startbildschirm
hinzufügen"</strong>.
</li>
<li>Bestätigen Sie die Installation. Die App erscheint nun auf Ihrem Startbildschirm.</li>
</ol>
</div>
</div>
<div class="mt-6 text-right flex-shrink-0">
<button @click="installModal.show = false" class="px-4 py-2 bg-primary text-white rounded-md">
Verstanden
</button>
</div>
</div>
</div>
</transition>
<!-- 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>