/** * 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', '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 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, technical: true }; emit('detail-open', workorder.id); 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; technicalData.value = data.technicalData || null; } 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 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); } } }; 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