Files
thetool/Layout/default/VueViews/WorkorderCompanyPWA.php
2025-09-08 16:43:24 +02:00

668 lines
40 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 both the PWA's main entry point and its own simple API backend.
// --- API Handling ---
// We check if the request is an AJAX login attempt from the PWA.
// By posting to this same file with a specific action, we can handle login
// without needing a separate API controller, by reusing the existing login logic.
if ($_SERVER['REQUEST_METHOD'] === 'POST' && !empty($_POST['action']) && $_POST['action'] === 'pwa_login') {
header('Content-Type: application/json');
// We need to bootstrap the application to get access to session management and controllers.
// This path might need adjustment based on your project's directory structure.
require_once __DIR__ . '/../../../../#core/php/mf_bootstrap.php';
// A simplified login controller to handle the API-like request.
class PwaLoginController extends mfLoginController {
// Expose the protected performLogin method for our API call.
public function apiLogin($username, $password, $code2fa, $remember) {
return $this->performLogin($username, $password, $code2fa, $remember);
}
}
$loginController = new PwaLoginController();
$username = $_POST['Username'] ?? '';
$password = $_POST['Password'] ?? '';
$twoFactorCode = isset($_POST['TwofactorCode']) && is_numeric($_POST['TwofactorCode']) ? (int)$_POST['TwofactorCode'] : 'unset';
$remember = isset($_POST['Remember']) && $_POST['Remember'] === 'true';
$result = $loginController->apiLogin($username, $password, $twoFactorCode, $remember);
$response = ['status' => 'error', 'message' => 'Anmeldung fehlgeschlagen.'];
if ($result === true) {
$response = ['status' => 'success'];
} elseif ($result === '2fa') {
$response = ['status' => '2fa_required'];
} elseif ($result === 'false2fa') {
$response = ['status' => 'invalid_2fa', 'message' => 'Verifizierungscode falsch oder abgelaufen.'];
} else {
$response['message'] = 'Benutzername oder Passwort ist falsch.';
}
echo json_encode($response);
exit; // Stop execution to prevent rendering the full HTML page.
}
// --- HTML & Vue App Rendering ---
?>
<!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; }
main { overscroll-behavior-y: contain; }
#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-800">
<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;
console.error("Auth check failed:", error);
} finally {
isCheckingAuth.value = false;
}
};
const handleLogin = async () => {
loginForm.isLoading = true;
loginForm.error = '';
const formData = new FormData();
formData.append('action', 'pwa_login');
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 {
const response = await axios.post(window.location.pathname, 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) {
loginForm.error = 'Ein Netzwerkfehler ist aufgetreten.';
console.error('Login request failed:', error);
} finally {
loginForm.isLoading = false;
}
};
const handleLogout = async () => {
isSettingsOpen.value = false;
try {
await axios.get('https://thetool.xinon.at/Dashboard/logout', { withCredentials: true });
} catch (error) {
console.warn("Logout request might have failed, but logging out on client.", error);
} finally {
isAuthenticated.value = false;
loginForm.username = '';
loginForm.password = '';
loginForm.twoFactorCode = '';
loginForm.state = 'credentials';
loginForm.error = '';
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();
if(isSettingsOpen.value) isSettingsOpen.value = false;
if(showThemePicker.value) showThemePicker.value = false;
};
// --- COMPUTED & METHODS ---
const fcpOptions = computed(() => {
if (!workorders.value || workorders.value.length === 0) 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.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.'));
});
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);
if (error.response && (error.response.status === 401 || error.response.status === 403)) {
isAuthenticated.value = false;
}
}
finally { isLoading.value = false; }
};
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 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;
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;
const woInList = workorders.value.find(w => w.id === selectedWorkorder.value.id);
if(woInList) woInList.additionalInfo = newInfo;
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 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 = [];
const sortedInterventions = [...problemModal.selectedInterventions].sort((a, b) => a.value.localeCompare(b.value));
for (const type of sortedInterventions) {
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) {
alert(`Bitte geben Sie Details für "${type.text}" an.`);
return;
}
text = text.includes('X') ? text.replace('X', detail) : `${text}: ${detail}`;
}
journalParts.push(text);
}
try {
await api.post('/requestIntervention', { workorderId: selectedWorkorder.value.id, journalText: journalParts.join('\n') });
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); }
};
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-800 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-800">
<div class="w-full max-w-sm">
<img src="/assets/images/xinon-full.png" alt="Logo" class="h-10 w-auto mx-auto mb-8">
<div class="bg-white dark:bg-slate-700 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-600 border border-slate-300 dark:border-slate-500 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-600 border border-slate-300 dark:border-slate-500 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-600 border border-slate-300 dark:border-slate-500 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 justify-between 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-500">
<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-800 overflow-hidden transition-colors duration-300">
<header class="bg-white dark:bg-slate-700 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-600">
<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.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-600">
<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-600 dark:border-slate-500 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-600 dark:border-slate-500 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-700 p-4 rounded-lg shadow-md dark:shadow-lg 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.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-600 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">
<!-- All details panel content -->
</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 -->
</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-700 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-600 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-600 text-xl">×</button>
</div>
<div class="p-4 space-y-4">
<!-- Theme and App settings -->
<button @click="handleLogout" class="w-full text-left p-3 rounded-md hover:bg-slate-100 dark:hover:bg-slate-600 text-sm font-medium flex items-center">
<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="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 text-center text-xs text-slate-500 dark:text-slate-400 space-y-2">
<!-- Footer content -->
</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-700 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-600 text-white font-bold rounded-md hover:bg-slate-500">Dunkel</button>
<button @click="setTheme('system')" class="w-full px-4 py-3 bg-slate-200 dark:bg-slate-600 text-slate-800 dark:text-white font-bold rounded-md hover:bg-slate-300 dark:hover:bg-slate-500">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>
<!-- Other modals (Install, Fullscreen Viewer, FCP select, etc.) -->
</div>
`
});
app.mount('#app');
</script>
<script src="/js/pages/WorkorderBase/WorkorderServiceWorker.js"></script>
</body>
</html>