/** * Workorder Module (Aufträge) * * Main module for workorder management in MobileApp. * Provides list view and full-screen detail view with collapsible cards. */ export default { name: 'WorkorderModule', emits: ['navigate', 'toast'], props: { user: Object, submodule: String }, setup(props, { emit }) { const { ref, computed, watch, onMounted, nextTick } = Vue; // ===================== // STATE // ===================== const isLoading = ref(true); const workorders = ref([]); const searchTerm = ref(''); const selectedFcp = ref('all'); const showFcpFilter = ref(false); // Detail view state const selectedWorkorder = ref(null); const isDetailLoading = ref(false); const documentation = ref({ docs: [], journals: [] }); const tenantConfig = ref(null); const checklist = ref([]); // Expanded cards state const expandedCards = ref({ customer: true, checklist: true, documentation: false, notes: false, journal: false, cableData: false }); // Edit states const isEditingNotes = ref(false); const tempNotes = ref(''); const newJournalText = ref(''); const cableDataForm = ref({ cableLength: '', cableType: '' }); // Bottom sheets const showDocUploadSheet = ref(false); const showProblemSheet = ref(false); const showCompleteSheet = ref(false); // Upload state const uploadDocType = ref(''); const isUploading = ref(false); const fileInputRef = ref(null); const pendingChecklistUpload = ref(null); // For auto-advance camera // Problem form const problemType = ref(''); const problemComment = ref(''); // Swipe state for list cards const swipeStartX = ref(0); const swipeCardId = ref(null); const swipeOffset = ref({}); // { [workorderId]: offsetX } // ===================== // COMPUTED // ===================== const fcpOptions = computed(() => { if (!workorders.value.length) return [{ value: 'all', text: 'Alle FCPs' }]; const fcps = [...new Set(workorders.value.map(wo => wo.fcpName).filter(Boolean))].sort(); return [{ value: 'all', text: 'Alle FCPs' }, ...fcps.map(fcp => ({ value: fcp, text: fcp }))]; }); const filteredWorkorders = computed(() => { let filtered = workorders.value; // FCP filter if (selectedFcp.value !== 'all') { filtered = filtered.filter(wo => wo.fcpName === selectedFcp.value); } // Search filter if (searchTerm.value.length > 2) { const term = searchTerm.value.toLowerCase(); filtered = filtered.filter(wo => wo.id.toString().includes(term) || (wo.oaid && wo.oaid.toLowerCase().includes(term)) || (wo.fcpName && wo.fcpName.toLowerCase().includes(term)) || (wo.customerName && wo.customerName.toLowerCase().includes(term)) || (wo.customerAddress && wo.customerAddress.toLowerCase().includes(term)) ); } // Sort by status priority const getStatusRank = (status) => { switch (status) { case 'scheduled': case 'civil_engineering_completed': return 0; case 'assigned': case 'new': case 'problem_solved': return 1; case 'in_progress': return 2; case 'intervention_required': case 'correction_requested': return 3; case 'documented': case 'completed': return 4; default: return 99; } }; return filtered.sort((a, b) => { const rankA = getStatusRank(a.status); const rankB = getStatusRank(b.status); if (rankA !== rankB) return rankA - rankB; return (a.appointmentDate || Infinity) - (b.appointmentDate || Infinity); }); }); const checklistProgress = computed(() => { if (!checklist.value.length) return { completed: 0, total: 0, text: '0/0' }; const completed = checklist.value.filter(c => c.completed).length; const total = checklist.value.length; return { completed, total, text: `${completed}/${total}` }; }); const isChecklistComplete = computed(() => { // Check required items are completed (if any are marked as required) const requiredItems = checklist.value.filter(c => c.required); if (requiredItems.length > 0) { const allRequiredComplete = requiredItems.every(c => c.completed); if (!allRequiredComplete) return false; } else if (checklist.value.length > 0) { // If no items are marked as required, check if at least some items are completed const hasAnyCompleted = checklist.value.some(c => c.completed); if (!hasAnyCompleted) return false; } // Check cable data if required if (tenantConfig.value?.requireCableLength && !cableDataForm.value.cableLength?.trim()) { return false; } if (tenantConfig.value?.requireCableType && !cableDataForm.value.cableType?.trim()) { return false; } return true; }); const incompleteItems = computed(() => { const items = []; checklist.value.filter(c => !c.completed).forEach(c => { items.push(c.text); }); if (tenantConfig.value?.requireCableLength && !cableDataForm.value.cableLength?.trim()) { items.push('Kabellänge'); } if (tenantConfig.value?.requireCableType && !cableDataForm.value.cableType?.trim()) { items.push('Kabeltyp'); } return items; }); const googleMapsLink = computed(() => { if (!selectedWorkorder.value?.customer) return '#'; const c = selectedWorkorder.value.customer; const address = encodeURIComponent(`${c.street}, ${c.zip} ${c.city}`); return `https://maps.google.com/maps?q=${address}`; }); // ===================== // METHODS // ===================== const fetchWorkorders = async () => { isLoading.value = true; try { const response = await fetch('/MobileApp/Workorder/Workorder/get', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ pagination: { page: 1, per_page: 500 } }) }); const data = await response.json(); if (data.success) { workorders.value = data.workorders; } } catch (error) { console.error('Error fetching workorders:', error); emit('toast', 'Fehler beim Laden der Aufträge', 'error'); } finally { isLoading.value = false; } }; const openDetail = async (workorder) => { selectedWorkorder.value = workorder; isDetailLoading.value = true; expandedCards.value = { customer: true, checklist: true, documentation: false, notes: false, journal: false, cableData: false }; try { // Fetch all workorder details in a single request const response = await fetch(`/MobileApp/Workorder/Workorder/getWorkorderDetail?id=${workorder.id}`, { credentials: 'include' }); const data = await response.json(); if (data.success) { selectedWorkorder.value = data.workorder; cableDataForm.value = { cableLength: data.workorder.cableLength || '', cableType: data.workorder.cableType || '' }; documentation.value = { docs: data.docs, journals: data.journals }; tenantConfig.value = data.tenantConfig; checklist.value = data.checklist; } else { emit('toast', data.message || 'Fehler beim Laden', 'error'); } } catch (error) { console.error('Error loading detail:', error); emit('toast', 'Fehler beim Laden der Details', 'error'); } finally { isDetailLoading.value = false; } }; const closeDetail = () => { selectedWorkorder.value = null; documentation.value = { docs: [], journals: [] }; tenantConfig.value = null; checklist.value = []; isEditingNotes.value = false; }; const toggleCard = (cardId) => { expandedCards.value[cardId] = !expandedCards.value[cardId]; }; // Notes editing const startEditNotes = () => { tempNotes.value = selectedWorkorder.value.additionalInfo || ''; isEditingNotes.value = true; }; const cancelEditNotes = () => { isEditingNotes.value = false; tempNotes.value = ''; }; const saveNotes = async () => { try { const response = await fetch('/MobileApp/Workorder/Workorder/updateAdditionalInfo', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ workorderId: selectedWorkorder.value.id, additionalInfo: tempNotes.value }) }); const data = await response.json(); if (data.success) { selectedWorkorder.value.additionalInfo = data.newInfo; isEditingNotes.value = false; emit('toast', 'Notiz gespeichert', 'success'); // Refresh journals const docRes = await fetch(`/MobileApp/Workorder/Workorder/getDocumentation?workorderId=${selectedWorkorder.value.id}`, { credentials: 'include' }); const docData = await docRes.json(); if (docData.success) documentation.value.journals = docData.journals; } } catch (error) { emit('toast', 'Fehler beim Speichern', 'error'); } }; // Journal const addJournalEntry = async () => { if (!newJournalText.value.trim()) return; try { const response = await fetch('/MobileApp/Workorder/Workorder/addJournal', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ workorderId: selectedWorkorder.value.id, text: newJournalText.value }) }); const data = await response.json(); if (data.success) { documentation.value.journals = data.journals; newJournalText.value = ''; emit('toast', 'Eintrag hinzugefügt', 'success'); } } catch (error) { emit('toast', 'Fehler beim Hinzufügen', 'error'); } }; // Cable data const saveCableData = async () => { try { const response = await fetch('/MobileApp/Workorder/Workorder/updateWorkorderData', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ workorderId: selectedWorkorder.value.id, cableLength: cableDataForm.value.cableLength, cableType: cableDataForm.value.cableType }) }); const data = await response.json(); if (data.success) { emit('toast', 'Daten gespeichert', 'success'); } } catch (error) { emit('toast', 'Fehler beim Speichern', 'error'); } }; // Documentation upload const openDocUpload = () => { if (tenantConfig.value?.documentationTypes?.length) { uploadDocType.value = tenantConfig.value.documentationTypes[0].value; } showDocUploadSheet.value = true; }; const triggerFileInput = () => { if (fileInputRef.value) { fileInputRef.value.click(); } }; const handleFileSelect = async (event) => { const files = event.target.files; if (!files || !files.length) return; isUploading.value = true; const wasFromChecklist = pendingChecklistUpload.value !== null; const formData = new FormData(); formData.append('workorderId', selectedWorkorder.value.id); formData.append('documentType', uploadDocType.value); for (let i = 0; i < files.length; i++) { formData.append('files[]', files[i]); } try { const response = await fetch('/MobileApp/Workorder/Workorder/uploadDocumentation', { method: 'POST', credentials: 'include', body: formData }); const data = await response.json(); if (data.success) { triggerHaptic('light'); emit('toast', 'Dokument hochgeladen', 'success'); showDocUploadSheet.value = false; // Refresh documentation and checklist const [docRes, checkRes] = await Promise.all([ fetch(`/MobileApp/Workorder/Workorder/getDocumentation?workorderId=${selectedWorkorder.value.id}`, { credentials: 'include' }), fetch(`/MobileApp/Workorder/Workorder/getChecklist?workorderId=${selectedWorkorder.value.id}`, { credentials: 'include' }) ]); const docData = await docRes.json(); const checkData = await checkRes.json(); if (docData.success) documentation.value = { docs: docData.docs, journals: docData.journals }; if (checkData.success) checklist.value = checkData.checklist; // Auto-advance: If upload was from checklist, open camera for next item if (wasFromChecklist) { pendingChecklistUpload.value = null; await nextTick(); const nextItem = getNextUncompletedItem(); if (nextItem) { // Short delay so user sees the check mark setTimeout(() => { quickUploadForItem(nextItem); }, 500); } } } } catch (error) { emit('toast', 'Upload fehlgeschlagen', 'error'); } finally { isUploading.value = false; if (fileInputRef.value) fileInputRef.value.value = ''; } }; // Problem reporting const openProblemSheet = () => { problemType.value = tenantConfig.value?.interventionTypes?.[0]?.value || ''; problemComment.value = ''; showProblemSheet.value = true; }; const submitProblem = async () => { if (!problemType.value && !problemComment.value) { emit('toast', 'Bitte Grund angeben', 'error'); return; } const typeText = tenantConfig.value?.interventionTypes?.find(t => t.value === problemType.value)?.text || problemType.value; try { const response = await fetch('/MobileApp/Workorder/Workorder/requestIntervention', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ workorderId: selectedWorkorder.value.id, interventionType: typeText, journalText: problemComment.value || typeText }) }); const data = await response.json(); if (data.success) { emit('toast', 'Problem gemeldet', 'success'); showProblemSheet.value = false; closeDetail(); fetchWorkorders(); } else { emit('toast', data.message || 'Fehler', 'error'); } } catch (error) { emit('toast', 'Fehler beim Melden', 'error'); } }; // Complete workorder const openCompleteSheet = () => { showCompleteSheet.value = true; }; const submitComplete = async () => { try { const response = await fetch('/MobileApp/Workorder/Workorder/completeWorkorder', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ workorderId: selectedWorkorder.value.id }) }); const data = await response.json(); if (data.success) { triggerHaptic('success'); emit('toast', 'Auftrag abgeschlossen', 'success'); showCompleteSheet.value = false; closeDetail(); fetchWorkorders(); } else { emit('toast', data.message || 'Fehler', 'error'); } } catch (error) { emit('toast', 'Fehler beim Abschließen', 'error'); } }; const formatDate = (timestamp, format = 'date') => { if (!timestamp) return '–'; const date = new Date(timestamp * 1000); if (format === 'datetime') { return date.toLocaleDateString('de-DE') + ' ' + date.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }); } return date.toLocaleDateString('de-DE'); }; const getStatusColor = (color) => { const colors = { primary: 'bg-blue-500', info: 'bg-sky-500', warning: 'bg-amber-500', danger: 'bg-red-500', success: 'bg-green-500', secondary: 'bg-slate-500', orange: 'bg-orange-500', purple: 'bg-purple-500', muted: 'bg-gray-400' }; return colors[color] || 'bg-gray-500'; }; // Get left border color for status indicator on cards const getStatusBorderColor = (status) => { const borderColors = { 'new': 'border-l-blue-500', 'assigned': 'border-l-sky-500', 'scheduled': 'border-l-amber-500', 'in_progress': 'border-l-amber-500', 'correction_requested': 'border-l-red-500', 'intervention_required': 'border-l-red-500', 'civil_engineering_required': 'border-l-orange-500', 'civil_engineering_completed': 'border-l-green-500', 'problem_solved': 'border-l-green-500', 'documented': 'border-l-green-500', 'completed': 'border-l-slate-500', 'charged': 'border-l-purple-500', 'cancelled': 'border-l-red-500', 'archived': 'border-l-gray-400' }; return borderColors[status] || 'border-l-gray-400'; }; // Quick camera upload for checklist item const quickUploadForItem = (item) => { uploadDocType.value = item.type; pendingChecklistUpload.value = item.type; nextTick(() => { if (fileInputRef.value) { fileInputRef.value.click(); } }); }; // Get next uncompleted checklist item const getNextUncompletedItem = () => { return checklist.value.find(item => !item.completed); }; // Haptic feedback const triggerHaptic = (type = 'success') => { if ('vibrate' in navigator) { if (type === 'success') { navigator.vibrate([50, 50, 100]); } else { navigator.vibrate(50); } } }; // Swipe handlers for list cards const handleTouchStart = (e, wo) => { swipeStartX.value = e.touches[0].clientX; swipeCardId.value = wo.id; }; const handleTouchMove = (e, wo) => { if (swipeCardId.value !== wo.id) return; const currentX = e.touches[0].clientX; const diff = swipeStartX.value - currentX; // Only allow left swipe, max 100px if (diff > 0) { swipeOffset.value = { ...swipeOffset.value, [wo.id]: Math.min(diff, 100) }; } else { swipeOffset.value = { ...swipeOffset.value, [wo.id]: 0 }; } }; const handleTouchEnd = (e, wo) => { if (swipeCardId.value !== wo.id) return; const offset = swipeOffset.value[wo.id] || 0; // If swiped more than 60px, trigger navigation if (offset > 60 && wo.customerAddress) { triggerHaptic('light'); const address = encodeURIComponent(wo.customerAddress); window.open(`https://maps.google.com/maps?q=${address}`, '_blank'); } // Reset with animation swipeOffset.value = { ...swipeOffset.value, [wo.id]: 0 }; swipeCardId.value = null; }; const getSwipeStyle = (woId) => { const offset = swipeOffset.value[woId] || 0; return { transform: `translateX(-${offset}px)`, transition: swipeCardId.value === woId ? 'none' : 'transform 0.3s ease-out' }; }; // Open navigation directly const openNavigation = () => { if (selectedWorkorder.value?.customer) { const c = selectedWorkorder.value.customer; const address = encodeURIComponent(`${c.street}, ${c.zip} ${c.city}`); window.open(`https://maps.google.com/maps?q=${address}`, '_blank'); } }; // Call customer directly const callCustomer = () => { if (selectedWorkorder.value?.customer?.phone) { window.location.href = `tel:${selectedWorkorder.value.customer.phone}`; } }; // Smart complete - show confirmation sheet const handleComplete = () => { if (isChecklistComplete.value) { showCompleteSheet.value = true; } // Button is disabled when not complete, so this won't be called }; // Initialize onMounted(() => { fetchWorkorders(); }); return { // State isLoading, workorders, searchTerm, selectedFcp, showFcpFilter, fcpOptions, filteredWorkorders, selectedWorkorder, isDetailLoading, documentation, tenantConfig, checklist, expandedCards, isEditingNotes, tempNotes, newJournalText, cableDataForm, showDocUploadSheet, showProblemSheet, showCompleteSheet, uploadDocType, isUploading, fileInputRef, problemType, problemComment, checklistProgress, isChecklistComplete, incompleteItems, googleMapsLink, // Methods fetchWorkorders, openDetail, closeDetail, toggleCard, startEditNotes, cancelEditNotes, saveNotes, addJournalEntry, saveCableData, openDocUpload, triggerFileInput, handleFileSelect, openProblemSheet, submitProblem, openCompleteSheet, submitComplete, formatDate, getStatusColor, getStatusBorderColor, quickUploadForItem, openNavigation, callCustomer, handleComplete, handleTouchStart, handleTouchMove, handleTouchEnd, getSwipeStyle, swipeOffset }; }, template: `
Keine Aufträge gefunden
{{ wo.customerName || 'Unbekannt' }}
{{ wo.customerAddress }}
{{ wo.appointmentFormatted }}
Frist: {{ wo.deadlineFormatted }}
{{ selectedWorkorder.customer.name }}
{{ selectedWorkorder.customer.street }}, {{ selectedWorkorder.customer.zip }} {{ selectedWorkorder.customer.city }}
{{ selectedWorkorder.additionalInfo || 'Keine Notiz vorhanden.' }}
{{ entry.text }}
{{ entry.createFormatted }} - {{ entry.createByName }}
Alle Anforderungen erfüllt!
Der Auftrag kann abgeschlossen werden.