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