1159 lines
71 KiB
PHP
1159 lines
71 KiB
PHP
<!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>
|
||
|