/** * Workorder Module (Aufträge) * * Main module for workorder management in MobileApp. * Provides list view and full-screen detail view with collapsible cards. * Supports offline mode via WorkorderOfflineService. */ import workorderService from '/mobile/shared/workorderOfflineService.js'; import photoQueue from '/mobile/shared/photoQueue.js'; import { isOfflineModeEnabled } from '/mobile/shared/offlineSettings.js'; export default { name: 'WorkorderModule', emits: ['navigate', 'toast', 'detail-open', 'detail-close'], 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([]); const technicalData = ref(null); // Expanded cards state const expandedCards = ref({ customer: true, checklist: true, documentation: false, notes: false, journal: false, cableData: false, technical: true }); // 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); const showPdfViewer = ref(false); const pdfViewerUrl = ref(''); const pdfViewerTitle = ref(''); const showImageViewer = ref(false); const imageViewerUrl = ref(''); // 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(''); const swipeStartX = ref(0); const swipeCardId = ref(null); const swipeOffset = ref({}); const swipeTriggered = ref(false); // ===================== // 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) { const allCompleted = checklist.value.every(c => c.completed); if (!allCompleted) return false; } 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 data = await workorderService.getWorkorders({ pagination: { page: 1, per_page: 500 } }); if (data.success) { workorders.value = data.workorders; if (data.fromCache) { console.log('[WorkorderModule] Loaded from cache'); } } else if (data.offline) { emit('toast', 'Offline - Daten aus Cache', 'info'); } } 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, technical: true }; emit('detail-open', workorder.id); try { // Fetch all workorder details via offline service const data = await workorderService.getWorkorderDetail(workorder.id); if (data.success) { selectedWorkorder.value = data.workorder; cableDataForm.value = { cableLength: data.workorder.cableLength || '', cableType: data.workorder.cableType || '' }; documentation.value = data.documentation || { docs: [], journals: [] }; tenantConfig.value = data.tenantConfig; checklist.value = data.checklist || []; technicalData.value = data.technicalData || null; if (data.fromCache) { console.log('[WorkorderModule] Detail loaded from cache'); } } else if (data.offline) { emit('toast', 'Offline - keine gecachten Details', 'error'); closeDetail(); } 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 = []; technicalData.value = null; isEditingNotes.value = false; emit('detail-close'); }; 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 data = await workorderService.updateNotes(selectedWorkorder.value.id, tempNotes.value); if (data.success) { selectedWorkorder.value.additionalInfo = tempNotes.value; isEditingNotes.value = false; if (data.queued) { emit('toast', 'Notiz wird synchronisiert', 'info'); } else { emit('toast', 'Notiz gespeichert', 'success'); // Refresh detail to get updated journals const detailData = await workorderService.getWorkorderDetail(selectedWorkorder.value.id); if (detailData.success && detailData.documentation) { documentation.value.journals = detailData.documentation.journals || []; } } } } catch (error) { emit('toast', 'Fehler beim Speichern', 'error'); } }; // Journal const addJournalEntry = async () => { if (!newJournalText.value.trim()) return; try { const data = await workorderService.addJournal(selectedWorkorder.value.id, newJournalText.value); if (data.success) { if (data.queued) { // Add pending journal entry to local display documentation.value.journals.unshift({ id: 'pending-' + data.localId, text: newJournalText.value, create: Math.floor(Date.now() / 1000), _pending: true }); emit('toast', 'Eintrag wird synchronisiert', 'info'); } else if (data.journals) { documentation.value.journals = data.journals; emit('toast', 'Eintrag hinzugefügt', 'success'); } newJournalText.value = ''; } } catch (error) { emit('toast', 'Fehler beim Hinzufügen', 'error'); } }; // Cable data const saveCableData = async () => { try { const data = await workorderService.updateCableData( selectedWorkorder.value.id, cableDataForm.value.cableLength, cableDataForm.value.cableType ); if (data.success) { if (data.queued) { emit('toast', 'Daten werden synchronisiert', 'info'); } else { 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; // Check if offline mode is enabled and we're offline const offlineEnabled = isOfflineModeEnabled(); const isOffline = !navigator.onLine; if (offlineEnabled && isOffline) { // Queue files for later upload try { for (let i = 0; i < files.length; i++) { const result = await photoQueue.queue( selectedWorkorder.value.id, files[i], uploadDocType.value, '' ); if (result.success) { // Add pending doc to local display documentation.value.docs.push({ id: 'pending-' + result.localId, documentType: uploadDocType.value, fileName: files[i].name, _pending: true }); } } triggerHaptic('light'); emit('toast', 'Foto wird bei Verbindung hochgeladen', 'info'); showDocUploadSheet.value = false; } catch (error) { emit('toast', 'Fehler beim Speichern', 'error'); } finally { isUploading.value = false; if (fileInputRef.value) fileInputRef.value.value = ''; pendingChecklistUpload.value = null; } return; } // Online upload 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 detailData = await workorderService.getWorkorderDetail(selectedWorkorder.value.id); if (detailData.success) { documentation.value = detailData.documentation || { docs: [], journals: [] }; checklist.value = detailData.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) { // If upload fails and offline mode is enabled, queue for later if (offlineEnabled) { for (let i = 0; i < files.length; i++) { await photoQueue.queue(selectedWorkorder.value.id, files[i], uploadDocType.value, ''); } emit('toast', 'Foto wird bei Verbindung hochgeladen', 'info'); } else { 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 data = await workorderService.requestIntervention( selectedWorkorder.value.id, typeText, problemComment.value || typeText ); if (data.success) { if (data.queued) { emit('toast', 'Problem wird bei Verbindung gemeldet', 'info'); } else { 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 data = await workorderService.completeWorkorder(selectedWorkorder.value.id); if (data.success) { triggerHaptic('success'); emit('toast', 'Auftrag abgeschlossen', 'success'); showCompleteSheet.value = false; closeDetail(); fetchWorkorders(); } else if (data.blocked) { // Completion blocked when offline emit('toast', data.message || 'Bitte zuerst synchronisieren', 'warning'); } 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); } } }; const scrollIntoViewOnFocus = (e) => { setTimeout(() => { e.target.scrollIntoView({ behavior: 'smooth', block: 'center' }); }, 300); }; const handleTouchStart = (e, wo) => { swipeStartX.value = e.touches[0].clientX; swipeCardId.value = wo.id; swipeTriggered.value = false; }; const handleTouchMove = (e, wo) => { if (swipeCardId.value !== wo.id) return; const currentX = e.touches[0].clientX; const diff = swipeStartX.value - currentX; if (diff > 0) { swipeOffset.value = { ...swipeOffset.value, [wo.id]: Math.min(diff, 100) }; if (diff > 10) { swipeTriggered.value = true; } } 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 (offset > 60 && wo.customerAddress) { swipeTriggered.value = true; triggerHaptic('light'); const address = encodeURIComponent(wo.customerAddress); window.open(`https://maps.google.com/maps?q=${address}`, '_blank'); } swipeOffset.value = { ...swipeOffset.value, [wo.id]: 0 }; swipeCardId.value = null; }; const handleCardClick = (wo) => { if (swipeTriggered.value) { swipeTriggered.value = false; return; } openDetail(wo); }; 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 }; // Open PDF in viewer const openPdfViewer = (url, title) => { pdfViewerUrl.value = url; pdfViewerTitle.value = title || 'PDF'; showPdfViewer.value = true; }; // Open image in fullscreen viewer with zoom const openImageViewer = (url) => { imageViewerUrl.value = url; showImageViewer.value = true; }; const closeImageViewer = () => { showImageViewer.value = false; imageViewerUrl.value = ''; }; // Initialize onMounted(() => { fetchWorkorders(); }); return { // State isLoading, workorders, searchTerm, selectedFcp, showFcpFilter, fcpOptions, filteredWorkorders, selectedWorkorder, isDetailLoading, documentation, tenantConfig, checklist, technicalData, expandedCards, isEditingNotes, tempNotes, newJournalText, cableDataForm, showDocUploadSheet, showProblemSheet, showCompleteSheet, showPdfViewer, pdfViewerUrl, pdfViewerTitle, 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, openPdfViewer, openImageViewer, closeImageViewer, showImageViewer, imageViewerUrl, handleTouchStart, handleTouchMove, handleTouchEnd, handleCardClick, getSwipeStyle, swipeOffset, scrollIntoViewOnFocus }; }, 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.
Pinch zum Zoomen • Doppeltippen für 2x