1602 lines
96 KiB
JavaScript
1602 lines
96 KiB
JavaScript
/**
|
||
* 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: `
|
||
<div class="h-full flex flex-col">
|
||
<!-- LIST VIEW -->
|
||
<template v-if="!selectedWorkorder">
|
||
<!-- Search & Filter Bar -->
|
||
<div class="p-3 space-y-2 flex-shrink-0">
|
||
<div class="flex gap-2">
|
||
<!-- Search Input -->
|
||
<div class="flex-1 relative">
|
||
<svg xmlns="http://www.w3.org/2000/svg" class="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-slate-400" 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>
|
||
<input
|
||
v-model="searchTerm"
|
||
type="text"
|
||
placeholder="Suchen..."
|
||
class="w-full pl-10 pr-4 py-2.5 bg-white dark:bg-slate-800 rounded-xl text-slate-800 dark:text-white placeholder-slate-400 border border-slate-200 dark:border-slate-700 focus:outline-none focus:ring-2 focus:ring-primary"
|
||
>
|
||
</div>
|
||
<!-- FCP Filter Button -->
|
||
<button
|
||
@click="showFcpFilter = !showFcpFilter"
|
||
class="px-4 py-2.5 bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 text-slate-700 dark:text-slate-300 flex items-center"
|
||
>
|
||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z" />
|
||
</svg>
|
||
<span class="hidden sm:inline">FCP</span>
|
||
</button>
|
||
</div>
|
||
|
||
<!-- FCP Dropdown -->
|
||
<div v-if="showFcpFilter" class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-2 max-h-48 overflow-y-auto">
|
||
<button
|
||
v-for="opt in fcpOptions"
|
||
:key="opt.value"
|
||
@click="selectedFcp = opt.value; showFcpFilter = false"
|
||
:class="[
|
||
'w-full text-left px-3 py-2 rounded-lg transition',
|
||
selectedFcp === opt.value
|
||
? 'bg-primary/10 text-primary font-medium'
|
||
: 'text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-700'
|
||
]"
|
||
>
|
||
{{ opt.text }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Workorder List -->
|
||
<div class="flex-1 overflow-y-auto px-3 pb-3">
|
||
<!-- Loading -->
|
||
<div v-if="isLoading" class="space-y-3">
|
||
<div v-for="i in 4" :key="i" class="bg-white dark:bg-slate-800 p-4 rounded-xl animate-pulse">
|
||
<div class="flex justify-between">
|
||
<div class="flex-1">
|
||
<div class="h-5 bg-slate-200 dark:bg-slate-700 rounded w-3/4 mb-2"></div>
|
||
<div class="h-4 bg-slate-200 dark:bg-slate-700 rounded w-full mb-2"></div>
|
||
<div class="h-3 bg-slate-200 dark:bg-slate-700 rounded w-1/2"></div>
|
||
</div>
|
||
<div class="ml-3 text-right">
|
||
<div class="h-5 w-20 bg-slate-200 dark:bg-slate-700 rounded-full mb-2"></div>
|
||
<div class="h-4 w-16 bg-slate-200 dark:bg-slate-700 rounded"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Empty State -->
|
||
<div v-else-if="filteredWorkorders.length === 0" class="text-center py-12">
|
||
<svg xmlns="http://www.w3.org/2000/svg" class="h-16 w-16 mx-auto text-slate-300 dark:text-slate-600 mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01" />
|
||
</svg>
|
||
<p class="text-slate-500 dark:text-slate-400">Keine Aufträge gefunden</p>
|
||
</div>
|
||
|
||
<!-- Workorder Cards -->
|
||
<div v-else class="space-y-3">
|
||
<div
|
||
v-for="wo in filteredWorkorders"
|
||
:key="wo.id"
|
||
class="relative overflow-hidden rounded-xl"
|
||
>
|
||
<!-- Background action (revealed on swipe) -->
|
||
<div class="absolute inset-y-0 right-0 w-24 bg-primary flex items-center justify-center rounded-r-xl">
|
||
<div class="text-white text-center">
|
||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 mx-auto" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
|
||
</svg>
|
||
<span class="text-xs font-medium mt-1">Navigation</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Card content (slides on swipe) -->
|
||
<div
|
||
@click="handleCardClick(wo)"
|
||
@touchstart="handleTouchStart($event, wo)"
|
||
@touchmove="handleTouchMove($event, wo)"
|
||
@touchend="handleTouchEnd($event, wo)"
|
||
:style="{ ...getSwipeStyle(wo.id), touchAction: 'pan-y' }"
|
||
:class="[
|
||
'relative w-full bg-white dark:bg-slate-800 p-4 rounded-xl shadow-sm text-left cursor-pointer card-contrast',
|
||
'border-l-4',
|
||
getStatusBorderColor(wo.status)
|
||
]"
|
||
>
|
||
<div class="flex justify-between items-start">
|
||
<div class="flex-1 min-w-0 pr-3">
|
||
<div class="flex items-center gap-2 mb-1">
|
||
<span v-if="wo.oaid" class="font-bold text-primary dark:text-sky-400">{{ wo.oaid }}</span>
|
||
<span v-else class="font-bold text-primary dark:text-sky-400">#{{ wo.id }}</span>
|
||
<span v-if="wo.fcpName" class="text-xs bg-slate-100 dark:bg-slate-700 px-1.5 py-0.5 rounded text-slate-600 dark:text-slate-400">{{ wo.fcpName }}</span>
|
||
</div>
|
||
<p class="font-medium text-slate-800 dark:text-white truncate">{{ wo.customerName || 'Unbekannt' }}</p>
|
||
<p class="text-sm text-slate-500 dark:text-slate-400 truncate">{{ wo.customerAddress }}</p>
|
||
</div>
|
||
<div class="text-right flex-shrink-0">
|
||
<span :class="[getStatusColor(wo.statusColor), 'inline-block px-2 py-0.5 rounded-full text-xs font-medium text-white']">
|
||
{{ wo.statusText }}
|
||
</span>
|
||
<p v-if="wo.appointmentFormatted" class="text-sm font-medium text-slate-600 dark:text-slate-300 mt-1">
|
||
{{ wo.appointmentFormatted }}
|
||
</p>
|
||
<p v-if="wo.deadlineFormatted" class="text-xs text-red-500 mt-0.5">
|
||
Frist: {{ wo.deadlineFormatted }}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- DETAIL VIEW -->
|
||
<template v-else>
|
||
<div class="flex items-center justify-between px-4 py-3 bg-white dark:bg-slate-800 border-b border-slate-100 dark:border-slate-700 flex-shrink-0">
|
||
<div class="flex items-center gap-2">
|
||
<span v-if="selectedWorkorder.oaid" class="font-bold text-primary dark:text-sky-400">{{ selectedWorkorder.oaid }}</span>
|
||
<span v-else class="font-bold text-primary dark:text-sky-400">#{{ selectedWorkorder.id }}</span>
|
||
<span v-if="selectedWorkorder.fcpName" class="text-xs bg-slate-100 dark:bg-slate-700 px-1.5 py-0.5 rounded text-slate-600 dark:text-slate-400">{{ selectedWorkorder.fcpName }}</span>
|
||
</div>
|
||
<span :class="[getStatusColor(selectedWorkorder.statusColor), 'px-2 py-0.5 rounded-full text-xs font-medium text-white']">
|
||
{{ selectedWorkorder.statusText }}
|
||
</span>
|
||
</div>
|
||
|
||
<!-- Detail Content -->
|
||
<div class="flex-1 overflow-y-auto p-3 pb-24 space-y-3">
|
||
<!-- Loading -->
|
||
<div v-if="isDetailLoading" class="space-y-3 animate-pulse">
|
||
<div class="bg-white dark:bg-slate-800 p-4 rounded-xl">
|
||
<div class="h-5 bg-slate-200 dark:bg-slate-700 rounded w-1/2 mb-3"></div>
|
||
<div class="h-4 bg-slate-200 dark:bg-slate-700 rounded w-full mb-2"></div>
|
||
<div class="h-4 bg-slate-200 dark:bg-slate-700 rounded w-3/4"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<template v-else>
|
||
<!-- Customer Card (Expanded by default) -->
|
||
<div class="bg-white dark:bg-slate-800 rounded-xl overflow-hidden card-contrast">
|
||
<button
|
||
@click="toggleCard('customer')"
|
||
class="w-full flex items-center justify-between p-4 text-left"
|
||
>
|
||
<div class="flex items-center">
|
||
<div class="w-8 h-8 bg-primary/10 rounded-lg flex items-center justify-center mr-3">
|
||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||
</svg>
|
||
</div>
|
||
<span class="font-semibold text-slate-800 dark:text-white">Kunde</span>
|
||
</div>
|
||
<svg xmlns="http://www.w3.org/2000/svg" :class="['h-5 w-5 text-slate-400 transition-transform', expandedCards.customer ? 'rotate-180' : '']" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||
</svg>
|
||
</button>
|
||
<div v-if="expandedCards.customer && selectedWorkorder.customer" class="px-4 pb-4 space-y-3">
|
||
<p class="font-medium text-slate-800 dark:text-white text-lg">{{ selectedWorkorder.customer.name }}</p>
|
||
<p class="text-slate-600 dark:text-slate-400">
|
||
{{ selectedWorkorder.customer.street }}, {{ selectedWorkorder.customer.zip }} {{ selectedWorkorder.customer.city }}
|
||
</p>
|
||
|
||
<!-- Prominent Action Buttons -->
|
||
<div class="flex gap-3 pt-2">
|
||
<button
|
||
@click="openNavigation"
|
||
class="flex-1 py-3 bg-primary text-white rounded-xl font-medium flex items-center justify-center gap-2 active:scale-95 transition"
|
||
>
|
||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
|
||
</svg>
|
||
Navigation
|
||
</button>
|
||
<button
|
||
v-if="selectedWorkorder.customer.phone"
|
||
@click="callCustomer"
|
||
class="flex-1 py-3 bg-green-500 text-white rounded-xl font-medium flex items-center justify-center gap-2 active:scale-95 transition"
|
||
>
|
||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" />
|
||
</svg>
|
||
Anrufen
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Email (smaller, secondary) -->
|
||
<div v-if="selectedWorkorder.customer.email" class="flex items-center pt-1">
|
||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-2 text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||
</svg>
|
||
<a :href="'mailto:' + selectedWorkorder.customer.email" class="text-sm text-primary dark:text-sky-400 truncate">{{ selectedWorkorder.customer.email }}</a>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Technical Data Card -->
|
||
<div v-if="technicalData && (technicalData.patchposition?.equipmentName || technicalData.rimoWorkorders?.length)"
|
||
class="bg-white dark:bg-slate-800 rounded-xl overflow-hidden card-contrast">
|
||
<button
|
||
@click="toggleCard('technical')"
|
||
class="w-full flex items-center justify-between p-4 text-left"
|
||
>
|
||
<div class="flex items-center">
|
||
<div class="w-8 h-8 bg-purple-500/10 rounded-lg flex items-center justify-center mr-3">
|
||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-purple-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z"/>
|
||
</svg>
|
||
</div>
|
||
<span class="font-semibold text-slate-800 dark:text-white">Technische Daten</span>
|
||
</div>
|
||
<svg xmlns="http://www.w3.org/2000/svg" :class="['h-5 w-5 text-slate-400 transition-transform', expandedCards.technical ? 'rotate-180' : '']" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
||
</svg>
|
||
</button>
|
||
<div v-if="expandedCards.technical" class="px-4 pb-4 space-y-3">
|
||
<!-- 1. Patchposition -->
|
||
<div v-if="technicalData.patchposition?.equipmentName" class="bg-slate-50 dark:bg-slate-700/50 rounded-xl p-3">
|
||
<div class="flex items-center gap-2 mb-2">
|
||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-purple-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/>
|
||
</svg>
|
||
<span class="text-xs font-semibold text-purple-600 dark:text-purple-400 uppercase tracking-wide">Patchposition</span>
|
||
</div>
|
||
<div class="flex flex-wrap gap-x-4 gap-y-1">
|
||
<div class="text-xs"><span class="text-slate-400">Equipment:</span> <span class="font-mono font-semibold text-slate-800 dark:text-white">{{ technicalData.patchposition.equipmentName }}</span></div>
|
||
<div v-if="technicalData.patchposition.equipmentPort" class="text-xs"><span class="text-slate-400">Port:</span> <span class="font-mono font-semibold text-slate-800 dark:text-white">{{ technicalData.patchposition.equipmentPort }}</span></div>
|
||
</div>
|
||
<div v-if="technicalData.patchposition.cluster || technicalData.patchposition.shelf || technicalData.patchposition.module" class="flex flex-wrap gap-x-4 gap-y-1 mt-2 pt-2 border-t border-slate-200 dark:border-slate-600">
|
||
<div v-if="technicalData.patchposition.cluster" class="text-xs"><span class="text-slate-400">Cluster:</span> <span class="font-medium text-slate-700 dark:text-slate-300">{{ technicalData.patchposition.cluster }}</span></div>
|
||
<div v-if="technicalData.patchposition.shelf" class="text-xs"><span class="text-slate-400">Shelf:</span> <span class="font-medium text-slate-700 dark:text-slate-300">{{ technicalData.patchposition.shelf }}</span></div>
|
||
<div v-if="technicalData.patchposition.module" class="text-xs"><span class="text-slate-400">Module:</span> <span class="font-medium text-slate-700 dark:text-slate-300">{{ technicalData.patchposition.module }}</span></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 2. Dropkabel -->
|
||
<div v-if="technicalData.dropcable?.entries?.length" class="bg-slate-50 dark:bg-slate-700/50 rounded-xl p-3">
|
||
<div class="flex items-center gap-2 mb-2">
|
||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-sky-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/>
|
||
</svg>
|
||
<span class="text-xs font-semibold text-sky-600 dark:text-sky-400 uppercase tracking-wide">Dropkabel</span>
|
||
<span class="ml-auto text-[10px] bg-sky-100 dark:bg-sky-900/40 text-sky-600 dark:text-sky-400 px-1.5 py-0.5 rounded-full font-medium">{{ technicalData.dropcable.entries.length }}</span>
|
||
</div>
|
||
<div class="divide-y divide-slate-200 dark:divide-slate-600">
|
||
<div v-for="(dk, idx) in technicalData.dropcable.entries" :key="idx" class="py-2 first:pt-0 last:pb-0">
|
||
<div class="flex items-center justify-between gap-2 mb-1">
|
||
<span class="font-mono text-xs font-semibold text-slate-800 dark:text-white">{{ dk.cable_id }}</span>
|
||
<span class="px-1.5 py-0.5 rounded text-[10px] font-medium"
|
||
:class="dk.status === 'Planfreigabe' || dk.status === 'Plan released' ? 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-400' : dk.status === 'Executed' || dk.status === 'Ausgeführt' ? 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-400' : 'bg-slate-200 text-slate-600 dark:bg-slate-600 dark:text-slate-300'">{{ dk.status || '-' }}</span>
|
||
</div>
|
||
<div class="flex flex-wrap gap-x-4 gap-y-1 text-xs">
|
||
<div v-if="dk.type" class="text-slate-500 dark:text-slate-400 truncate max-w-[200px]" :title="dk.type">{{ dk.type }}</div>
|
||
<div class="ml-auto flex gap-3">
|
||
<span><span class="text-slate-400">Plan:</span> <span class="font-medium text-slate-700 dark:text-slate-300">{{ dk.laenge_plan || '-' }}</span></span>
|
||
<span><span class="text-slate-400">Ist:</span> <span class="font-medium text-slate-700 dark:text-slate-300">{{ dk.laenge_ist || '-' }}</span></span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 3. Lageplan -->
|
||
<div v-if="technicalData.dropcable?.map_file" class="bg-slate-50 dark:bg-slate-700/50 rounded-xl p-3">
|
||
<div class="flex items-center gap-2 mb-2">
|
||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-emerald-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7"/>
|
||
</svg>
|
||
<span class="text-xs font-semibold text-emerald-600 dark:text-emerald-400 uppercase tracking-wide">Lageplan</span>
|
||
</div>
|
||
<button @click="openImageViewer(technicalData.dropcable.map_file.download_url)" class="w-full active:scale-[0.99] transition">
|
||
<div class="bg-white dark:bg-slate-800 rounded-lg p-1 border border-slate-200 dark:border-slate-600">
|
||
<img :src="technicalData.dropcable.map_file.download_url" class="w-full h-44 object-contain rounded" alt="Lageplan" @error="$event.target.closest('.bg-slate-50').style.display='none'">
|
||
</div>
|
||
<div class="flex items-center justify-center gap-1 mt-2 text-[11px] text-slate-400 dark:text-slate-500">
|
||
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" 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 0zM10 7v3m0 0v3m0-3h3m-3 0H7"/>
|
||
</svg>
|
||
Antippen zum Vergrößern
|
||
</div>
|
||
</button>
|
||
</div>
|
||
|
||
<!-- 4. AHA Blatt -->
|
||
<div v-if="technicalData.rimoWorkorders?.length" class="bg-slate-50 dark:bg-slate-700/50 rounded-xl p-3">
|
||
<div class="flex items-center gap-2 mb-2">
|
||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-amber-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
|
||
</svg>
|
||
<span class="text-xs font-semibold text-amber-600 dark:text-amber-400 uppercase tracking-wide">AHA Blatt</span>
|
||
</div>
|
||
<div class="divide-y divide-slate-200 dark:divide-slate-600">
|
||
<div v-for="wo in technicalData.rimoWorkorders" :key="wo.id" class="flex items-center justify-between gap-3 py-2 first:pt-0 last:pb-0">
|
||
<div class="min-w-0 flex-1">
|
||
<div class="text-xs font-medium text-slate-800 dark:text-white truncate">{{ wo.rimoName }}</div>
|
||
<div class="text-[11px] text-slate-500 dark:text-slate-400">{{ wo.rimoStatus }}</div>
|
||
</div>
|
||
<button @click="openPdfViewer(wo.downloadUrl, wo.rimoName)"
|
||
class="flex-shrink-0 flex items-center gap-1 px-2.5 py-1.5 bg-amber-500 hover:bg-amber-600 text-white rounded-lg text-[11px] font-semibold active:scale-95 transition">
|
||
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
|
||
</svg>
|
||
PDF
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Checklist Card -->
|
||
<div class="bg-white dark:bg-slate-800 rounded-xl overflow-hidden card-contrast">
|
||
<button
|
||
@click="toggleCard('checklist')"
|
||
class="w-full flex items-center justify-between p-4 text-left"
|
||
>
|
||
<div class="flex items-center">
|
||
<div class="w-8 h-8 bg-amber-500/10 rounded-lg flex items-center justify-center mr-3">
|
||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-amber-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
|
||
</svg>
|
||
</div>
|
||
<span class="font-semibold text-slate-800 dark:text-white">Checkliste</span>
|
||
<span :class="[
|
||
'ml-2 px-2 py-0.5 rounded-full text-xs',
|
||
checklistProgress.completed === checklistProgress.total && checklistProgress.total > 0
|
||
? 'bg-green-100 dark:bg-green-900/30 text-green-600 dark:text-green-400'
|
||
: 'bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-300'
|
||
]">
|
||
{{ checklistProgress.text }}
|
||
</span>
|
||
</div>
|
||
<svg xmlns="http://www.w3.org/2000/svg" :class="['h-5 w-5 text-slate-400 transition-transform', expandedCards.checklist ? 'rotate-180' : '']" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||
</svg>
|
||
</button>
|
||
<div v-if="expandedCards.checklist" class="px-4 pb-4">
|
||
<div v-if="checklist.length === 0" class="text-center py-4 text-slate-500 dark:text-slate-400">
|
||
Keine Checkliste vorhanden
|
||
</div>
|
||
<ul v-else class="space-y-2">
|
||
<li v-for="item in checklist" :key="item.type" class="flex items-center justify-between">
|
||
<div class="flex items-center flex-1 min-w-0">
|
||
<svg v-if="item.completed" xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-green-500 mr-2 flex-shrink-0" 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 text-slate-400 mr-2 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||
<circle cx="12" cy="12" r="10" stroke-width="2" />
|
||
</svg>
|
||
<span :class="[item.completed ? 'text-slate-500 dark:text-slate-400 line-through' : 'text-slate-800 dark:text-white', 'truncate']">
|
||
{{ item.text }}
|
||
</span>
|
||
</div>
|
||
<!-- Quick Camera Button for uncompleted items -->
|
||
<button
|
||
v-if="!item.completed"
|
||
@click="quickUploadForItem(item)"
|
||
class="ml-2 p-2 bg-primary/10 hover:bg-primary/20 rounded-lg text-primary active:scale-95 transition flex-shrink-0"
|
||
:disabled="isUploading"
|
||
>
|
||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z" />
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 13a3 3 0 11-6 0 3 3 0 016 0z" />
|
||
</svg>
|
||
</button>
|
||
<!-- Check mark for completed items -->
|
||
<div v-else class="ml-2 p-2 text-green-500 flex-shrink-0">
|
||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" />
|
||
</svg>
|
||
</div>
|
||
</li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Documentation Card -->
|
||
<div class="bg-white dark:bg-slate-800 rounded-xl overflow-hidden card-contrast">
|
||
<button
|
||
@click="toggleCard('documentation')"
|
||
class="w-full flex items-center justify-between p-4 text-left"
|
||
>
|
||
<div class="flex items-center">
|
||
<div class="w-8 h-8 bg-green-500/10 rounded-lg flex items-center justify-center mr-3">
|
||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||
</svg>
|
||
</div>
|
||
<span class="font-semibold text-slate-800 dark:text-white">Dokumentation</span>
|
||
<span class="ml-2 px-2 py-0.5 bg-slate-100 dark:bg-slate-700 rounded-full text-xs text-slate-600 dark:text-slate-300">
|
||
{{ documentation.docs.length }}
|
||
</span>
|
||
</div>
|
||
<div class="flex items-center">
|
||
<button
|
||
@click.stop="openDocUpload"
|
||
class="mr-2 p-1.5 bg-primary/10 rounded-lg text-primary"
|
||
>
|
||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||
</svg>
|
||
</button>
|
||
<svg xmlns="http://www.w3.org/2000/svg" :class="['h-5 w-5 text-slate-400 transition-transform', expandedCards.documentation ? 'rotate-180' : '']" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||
</svg>
|
||
</div>
|
||
</button>
|
||
<div v-if="expandedCards.documentation" class="px-4 pb-4">
|
||
<div v-if="documentation.docs.length === 0" class="text-center py-4 text-slate-500 dark:text-slate-400">
|
||
Keine Dokumente vorhanden
|
||
</div>
|
||
<div v-else class="space-y-2">
|
||
<a
|
||
v-for="doc in documentation.docs"
|
||
:key="doc.id"
|
||
:href="doc.previewUrl"
|
||
target="_blank"
|
||
class="flex items-center p-2 bg-slate-50 dark:bg-slate-700/50 rounded-lg"
|
||
>
|
||
<div class="w-10 h-10 bg-slate-200 dark:bg-slate-600 rounded-lg flex items-center justify-center mr-3">
|
||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-slate-500 dark:text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
|
||
</svg>
|
||
</div>
|
||
<div class="flex-1 min-w-0">
|
||
<p class="text-sm font-medium text-slate-800 dark:text-white truncate">{{ doc.fileName }}</p>
|
||
<p class="text-xs text-slate-500 dark:text-slate-400">{{ doc.createFormatted }} - {{ doc.userName }}</p>
|
||
</div>
|
||
</a>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Notes Card -->
|
||
<div class="bg-white dark:bg-slate-800 rounded-xl overflow-hidden card-contrast">
|
||
<button
|
||
@click="toggleCard('notes')"
|
||
class="w-full flex items-center justify-between p-4 text-left"
|
||
>
|
||
<div class="flex items-center">
|
||
<div class="w-8 h-8 bg-purple-500/10 rounded-lg flex items-center justify-center mr-3">
|
||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-purple-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||
</svg>
|
||
</div>
|
||
<span class="font-semibold text-slate-800 dark:text-white">Notizen</span>
|
||
</div>
|
||
<svg xmlns="http://www.w3.org/2000/svg" :class="['h-5 w-5 text-slate-400 transition-transform', expandedCards.notes ? 'rotate-180' : '']" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||
</svg>
|
||
</button>
|
||
<div v-if="expandedCards.notes" class="px-4 pb-4">
|
||
<div v-if="isEditingNotes">
|
||
<textarea
|
||
v-model="tempNotes"
|
||
rows="4"
|
||
class="w-full p-3 bg-slate-50 dark:bg-slate-700 rounded-lg text-slate-800 dark:text-white border-0 focus:ring-2 focus:ring-primary"
|
||
placeholder="Notiz eingeben..."
|
||
@focus="scrollIntoViewOnFocus"
|
||
></textarea>
|
||
<div class="flex justify-end gap-2 mt-2">
|
||
<button @click="cancelEditNotes" class="px-4 py-2 text-sm text-slate-600 dark:text-slate-300">
|
||
Abbrechen
|
||
</button>
|
||
<button @click="saveNotes" class="px-4 py-2 bg-primary text-white text-sm rounded-lg">
|
||
Speichern
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div v-else>
|
||
<p class="text-slate-700 dark:text-slate-300 whitespace-pre-wrap">{{ selectedWorkorder.additionalInfo || 'Keine Notiz vorhanden.' }}</p>
|
||
<button @click="startEditNotes" class="mt-3 text-sm text-primary dark:text-sky-400 flex items-center">
|
||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
|
||
</svg>
|
||
Bearbeiten
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Journal Card -->
|
||
<div class="bg-white dark:bg-slate-800 rounded-xl overflow-hidden card-contrast">
|
||
<button
|
||
@click="toggleCard('journal')"
|
||
class="w-full flex items-center justify-between p-4 text-left"
|
||
>
|
||
<div class="flex items-center">
|
||
<div class="w-8 h-8 bg-sky-500/10 rounded-lg flex items-center justify-center mr-3">
|
||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-sky-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||
</svg>
|
||
</div>
|
||
<span class="font-semibold text-slate-800 dark:text-white">Journal</span>
|
||
<span class="ml-2 px-2 py-0.5 bg-slate-100 dark:bg-slate-700 rounded-full text-xs text-slate-600 dark:text-slate-300">
|
||
{{ documentation.journals.length }}
|
||
</span>
|
||
</div>
|
||
<svg xmlns="http://www.w3.org/2000/svg" :class="['h-5 w-5 text-slate-400 transition-transform', expandedCards.journal ? 'rotate-180' : '']" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||
</svg>
|
||
</button>
|
||
<div v-if="expandedCards.journal" class="px-4 pb-4 space-y-3">
|
||
<!-- Add Entry -->
|
||
<div class="flex gap-2">
|
||
<input
|
||
v-model="newJournalText"
|
||
type="text"
|
||
placeholder="Neuer Eintrag..."
|
||
class="flex-1 px-3 py-2 bg-slate-50 dark:bg-slate-700 rounded-lg text-slate-800 dark:text-white border-0"
|
||
@keyup.enter="addJournalEntry"
|
||
@focus="scrollIntoViewOnFocus"
|
||
>
|
||
<button @click="addJournalEntry" class="px-4 py-2 bg-primary text-white rounded-lg">
|
||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
<!-- Entries -->
|
||
<div v-if="documentation.journals.length === 0" class="text-center py-2 text-slate-500 dark:text-slate-400 text-sm">
|
||
Keine Einträge
|
||
</div>
|
||
<div v-else class="space-y-2 max-h-48 overflow-y-auto">
|
||
<div v-for="entry in documentation.journals" :key="entry.id" class="p-2 bg-slate-50 dark:bg-slate-700/50 rounded-lg">
|
||
<p class="text-sm text-slate-800 dark:text-white whitespace-pre-wrap">{{ entry.text }}</p>
|
||
<p class="text-xs text-slate-500 dark:text-slate-400 mt-1">{{ entry.createFormatted }} - {{ entry.createByName }}</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Cable Data Card (only if required) -->
|
||
<div v-if="tenantConfig && (tenantConfig.requireCableLength || tenantConfig.requireCableType)" class="bg-white dark:bg-slate-800 rounded-xl overflow-hidden card-contrast">
|
||
<button
|
||
@click="toggleCard('cableData')"
|
||
class="w-full flex items-center justify-between p-4 text-left"
|
||
>
|
||
<div class="flex items-center">
|
||
<div class="w-8 h-8 bg-orange-500/10 rounded-lg flex items-center justify-center mr-3">
|
||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-orange-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z" />
|
||
</svg>
|
||
</div>
|
||
<span class="font-semibold text-slate-800 dark:text-white">Kabeldaten</span>
|
||
</div>
|
||
<svg xmlns="http://www.w3.org/2000/svg" :class="['h-5 w-5 text-slate-400 transition-transform', expandedCards.cableData ? 'rotate-180' : '']" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||
</svg>
|
||
</button>
|
||
<div v-if="expandedCards.cableData" class="px-4 pb-4 space-y-3">
|
||
<div v-if="tenantConfig.requireCableLength">
|
||
<label class="text-sm text-slate-600 dark:text-slate-400 mb-1 block">Kabellänge (m)</label>
|
||
<input
|
||
v-model="cableDataForm.cableLength"
|
||
type="text"
|
||
placeholder="z.B. 20"
|
||
class="w-full px-3 py-2 bg-slate-50 dark:bg-slate-700 rounded-lg text-slate-800 dark:text-white border-0"
|
||
>
|
||
</div>
|
||
<div v-if="tenantConfig.requireCableType">
|
||
<label class="text-sm text-slate-600 dark:text-slate-400 mb-1 block">Kabeltyp</label>
|
||
<input
|
||
v-model="cableDataForm.cableType"
|
||
type="text"
|
||
placeholder="z.B. LWL 4F"
|
||
class="w-full px-3 py-2 bg-slate-50 dark:bg-slate-700 rounded-lg text-slate-800 dark:text-white border-0"
|
||
>
|
||
</div>
|
||
<button @click="saveCableData" class="w-full py-2 bg-primary text-white rounded-lg text-sm font-medium">
|
||
Speichern
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
|
||
<!-- Bottom Action Bar -->
|
||
<div class="absolute bottom-0 left-0 right-0 p-3 bg-white dark:bg-slate-800 border-t border-slate-100 dark:border-slate-700 flex gap-3" style="padding-bottom: calc(0.75rem + env(safe-area-inset-bottom, 0px));">
|
||
<button
|
||
@click="openProblemSheet"
|
||
class="flex-1 py-3 bg-amber-500 text-white rounded-xl font-medium flex items-center justify-center active:scale-95 transition"
|
||
>
|
||
<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="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||
</svg>
|
||
Problem
|
||
</button>
|
||
<button
|
||
@click="handleComplete"
|
||
:disabled="!isChecklistComplete"
|
||
:class="[
|
||
'flex-1 py-3 rounded-xl font-medium flex items-center justify-center active:scale-95 transition',
|
||
isChecklistComplete
|
||
? 'bg-green-500 text-white'
|
||
: 'bg-slate-300 dark:bg-slate-600 text-slate-500 dark:text-slate-400 cursor-not-allowed'
|
||
]"
|
||
>
|
||
<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="M5 13l4 4L19 7" />
|
||
</svg>
|
||
<span v-if="checklist.length > 0">
|
||
Abschließen ({{ checklistProgress.text }})
|
||
</span>
|
||
<span v-else>Abschließen</span>
|
||
</button>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- BOTTOM SHEETS -->
|
||
|
||
<!-- Doc Upload Sheet -->
|
||
<teleport to="body">
|
||
<transition name="fade">
|
||
<div v-if="showDocUploadSheet" class="fixed inset-0 z-50">
|
||
<div class="absolute inset-0 bg-black/50" @click="showDocUploadSheet = false"></div>
|
||
<transition name="slide-up-sheet">
|
||
<div v-if="showDocUploadSheet" class="absolute bottom-0 left-0 right-0 bg-white dark:bg-slate-800 rounded-t-2xl shadow-xl max-h-[70vh] overflow-hidden">
|
||
<div class="flex justify-center pt-2 pb-1">
|
||
<div class="w-10 h-1 bg-slate-300 dark:bg-slate-600 rounded-full"></div>
|
||
</div>
|
||
<div class="px-4 pb-3 border-b border-slate-100 dark:border-slate-700">
|
||
<h3 class="text-lg font-semibold text-slate-800 dark:text-white text-center">Dokumentation hochladen</h3>
|
||
</div>
|
||
<div class="p-4 space-y-4">
|
||
<!-- Doc Type Selection -->
|
||
<div v-if="tenantConfig?.documentationTypes?.length">
|
||
<label class="text-sm text-slate-600 dark:text-slate-400 mb-2 block">Dokumenttyp</label>
|
||
<div class="space-y-2">
|
||
<button
|
||
v-for="type in tenantConfig.documentationTypes"
|
||
:key="type.value"
|
||
@click="uploadDocType = type.value"
|
||
:class="[
|
||
'w-full p-3 rounded-lg text-left transition',
|
||
uploadDocType === type.value
|
||
? 'bg-primary/10 border-2 border-primary text-primary'
|
||
: 'bg-slate-50 dark:bg-slate-700 text-slate-700 dark:text-slate-300'
|
||
]"
|
||
>
|
||
{{ type.text }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<!-- Upload Buttons -->
|
||
<div class="space-y-2">
|
||
<button
|
||
@click="triggerFileInput"
|
||
:disabled="isUploading"
|
||
class="w-full py-3 bg-primary text-white rounded-xl font-medium flex items-center justify-center disabled:opacity-50"
|
||
>
|
||
<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="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z" />
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 13a3 3 0 11-6 0 3 3 0 016 0z" />
|
||
</svg>
|
||
{{ isUploading ? 'Wird hochgeladen...' : 'Foto aufnehmen / auswählen' }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div class="h-6"></div>
|
||
</div>
|
||
</transition>
|
||
</div>
|
||
</transition>
|
||
</teleport>
|
||
|
||
<!-- Problem Sheet -->
|
||
<teleport to="body">
|
||
<transition name="fade">
|
||
<div v-if="showProblemSheet" class="fixed inset-0 z-50">
|
||
<div class="absolute inset-0 bg-black/50" @click="showProblemSheet = false"></div>
|
||
<transition name="slide-up-sheet">
|
||
<div v-if="showProblemSheet" class="absolute bottom-0 left-0 right-0 bg-white dark:bg-slate-800 rounded-t-2xl shadow-xl max-h-[80vh] overflow-hidden flex flex-col">
|
||
<div class="flex justify-center pt-2 pb-1 flex-shrink-0">
|
||
<div class="w-10 h-1 bg-slate-300 dark:bg-slate-600 rounded-full"></div>
|
||
</div>
|
||
<div class="px-4 pb-3 border-b border-slate-100 dark:border-slate-700 flex-shrink-0">
|
||
<h3 class="text-lg font-semibold text-slate-800 dark:text-white text-center">Problem melden</h3>
|
||
</div>
|
||
<div class="p-4 space-y-4 overflow-y-auto flex-1">
|
||
<!-- Problem Type Selection -->
|
||
<div v-if="tenantConfig?.interventionTypes?.length">
|
||
<label class="text-sm text-slate-600 dark:text-slate-400 mb-2 block">Grund</label>
|
||
<div class="space-y-2">
|
||
<button
|
||
v-for="type in tenantConfig.interventionTypes"
|
||
:key="type.value"
|
||
@click="problemType = type.value"
|
||
:class="[
|
||
'w-full p-3 rounded-lg text-left transition',
|
||
problemType === type.value
|
||
? 'bg-amber-500/10 border-2 border-amber-500 text-amber-600 dark:text-amber-400'
|
||
: 'bg-slate-50 dark:bg-slate-700 text-slate-700 dark:text-slate-300'
|
||
]"
|
||
>
|
||
{{ type.text }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<!-- Comment -->
|
||
<div>
|
||
<label class="text-sm text-slate-600 dark:text-slate-400 mb-2 block">Kommentar (optional)</label>
|
||
<textarea
|
||
v-model="problemComment"
|
||
rows="3"
|
||
placeholder="Weitere Details..."
|
||
class="w-full px-3 py-2 bg-slate-50 dark:bg-slate-700 rounded-lg text-slate-800 dark:text-white border-0"
|
||
@focus="scrollIntoViewOnFocus"
|
||
></textarea>
|
||
</div>
|
||
<!-- Submit -->
|
||
<button
|
||
@click="submitProblem"
|
||
class="w-full py-3 bg-amber-500 text-white rounded-xl font-medium"
|
||
>
|
||
Problem melden
|
||
</button>
|
||
</div>
|
||
<div class="h-6 flex-shrink-0"></div>
|
||
</div>
|
||
</transition>
|
||
</div>
|
||
</transition>
|
||
</teleport>
|
||
|
||
<!-- Complete Sheet (only shown when complete - for confirmation) -->
|
||
<teleport to="body">
|
||
<transition name="fade">
|
||
<div v-if="showCompleteSheet" class="fixed inset-0 z-50">
|
||
<div class="absolute inset-0 bg-black/50" @click="showCompleteSheet = false"></div>
|
||
<transition name="slide-up-sheet">
|
||
<div v-if="showCompleteSheet" class="absolute bottom-0 left-0 right-0 bg-white dark:bg-slate-800 rounded-t-2xl shadow-xl max-h-[70vh] overflow-hidden">
|
||
<div class="flex justify-center pt-2 pb-1">
|
||
<div class="w-10 h-1 bg-slate-300 dark:bg-slate-600 rounded-full"></div>
|
||
</div>
|
||
<div class="px-4 pb-3 border-b border-slate-100 dark:border-slate-700">
|
||
<h3 class="text-lg font-semibold text-slate-800 dark:text-white text-center">Auftrag abschließen</h3>
|
||
</div>
|
||
<div class="p-4 space-y-4">
|
||
<!-- Success message -->
|
||
<div class="p-4 bg-green-50 dark:bg-green-900/20 rounded-xl">
|
||
<div class="flex items-center justify-center mb-3">
|
||
<div class="w-12 h-12 bg-green-100 dark:bg-green-900/50 rounded-full flex items-center justify-center">
|
||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||
</svg>
|
||
</div>
|
||
</div>
|
||
<p class="text-center text-green-700 dark:text-green-400 font-medium">Alle Anforderungen erfüllt!</p>
|
||
<p class="text-center text-green-600 dark:text-green-500 text-sm mt-1">Der Auftrag kann abgeschlossen werden.</p>
|
||
</div>
|
||
<!-- Buttons -->
|
||
<div class="flex gap-3">
|
||
<button
|
||
@click="showCompleteSheet = false"
|
||
class="flex-1 py-3 bg-slate-100 dark:bg-slate-700 text-slate-700 dark:text-slate-300 rounded-xl font-medium"
|
||
>
|
||
Abbrechen
|
||
</button>
|
||
<button
|
||
@click="submitComplete"
|
||
class="flex-1 py-3 bg-green-500 text-white rounded-xl font-medium flex items-center justify-center gap-2"
|
||
>
|
||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||
</svg>
|
||
Abschließen
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div class="h-6"></div>
|
||
</div>
|
||
</transition>
|
||
</div>
|
||
</transition>
|
||
</teleport>
|
||
|
||
<!-- PDF Viewer Modal -->
|
||
<teleport to="body">
|
||
<transition name="fade">
|
||
<div v-if="showPdfViewer" class="fixed inset-0 z-50 flex flex-col bg-white dark:bg-slate-900">
|
||
<!-- Header -->
|
||
<div class="flex items-center justify-between px-4 py-3 bg-white dark:bg-slate-800 border-b border-slate-200 dark:border-slate-700 flex-shrink-0" style="padding-top: calc(0.75rem + env(safe-area-inset-top, 0px));">
|
||
<h3 class="text-lg font-semibold text-slate-800 dark:text-white truncate flex-1 mr-4">{{ pdfViewerTitle }}</h3>
|
||
<button @click="showPdfViewer = false" class="p-2 -mr-2 text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-200">
|
||
<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>
|
||
</div>
|
||
<!-- PDF Content -->
|
||
<div class="flex-1 overflow-hidden bg-slate-100 dark:bg-slate-800">
|
||
<iframe
|
||
:src="pdfViewerUrl + (pdfViewerUrl.includes('?') ? '&' : '?') + 'inline=1'"
|
||
class="w-full h-full border-0"
|
||
style="min-height: 100%;"
|
||
></iframe>
|
||
</div>
|
||
<!-- Footer with download option -->
|
||
<div class="flex-shrink-0 px-4 py-3 bg-white dark:bg-slate-800 border-t border-slate-200 dark:border-slate-700" style="padding-bottom: calc(0.75rem + env(safe-area-inset-bottom, 0px));">
|
||
<a :href="pdfViewerUrl" download class="w-full py-3 bg-primary text-white rounded-xl font-medium flex items-center justify-center gap-2">
|
||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" 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-4l-4 4m0 0l-4-4m4 4V4" />
|
||
</svg>
|
||
Download
|
||
</a>
|
||
</div>
|
||
</div>
|
||
</transition>
|
||
</teleport>
|
||
|
||
<!-- Image Viewer Modal with Zoom -->
|
||
<teleport to="body">
|
||
<transition name="fade">
|
||
<div v-if="showImageViewer" class="fixed inset-0 z-50 flex flex-col bg-black/95" @click.self="closeImageViewer">
|
||
<!-- Header -->
|
||
<div class="flex items-center justify-between px-4 py-3 flex-shrink-0" style="padding-top: calc(0.75rem + env(safe-area-inset-top, 0px));">
|
||
<h3 class="text-lg font-semibold text-white">Lageplan</h3>
|
||
<button @click="closeImageViewer" class="p-2 -mr-2 text-white/70 hover: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>
|
||
</div>
|
||
<!-- Image Content with pinch zoom -->
|
||
<div class="flex-1 overflow-auto flex items-center justify-center p-4">
|
||
<img
|
||
:src="imageViewerUrl"
|
||
class="max-w-none select-none"
|
||
style="touch-action: pinch-zoom; max-height: calc(100vh - 150px); width: auto;"
|
||
alt="Lageplan"
|
||
@dblclick="$event.target.style.transform = $event.target.style.transform === 'scale(2)' ? 'scale(1)' : 'scale(2)'"
|
||
>
|
||
</div>
|
||
<!-- Footer hint -->
|
||
<div class="flex-shrink-0 px-4 py-3 text-center" style="padding-bottom: calc(0.75rem + env(safe-area-inset-bottom, 0px));">
|
||
<p class="text-white/50 text-xs">Pinch zum Zoomen • Doppeltippen für 2x</p>
|
||
</div>
|
||
</div>
|
||
</transition>
|
||
</teleport>
|
||
|
||
<input
|
||
ref="fileInputRef"
|
||
type="file"
|
||
accept="image/*,application/pdf"
|
||
multiple
|
||
class="hidden"
|
||
@change="handleFileSelect"
|
||
capture="environment"
|
||
>
|
||
</div>
|
||
`
|
||
};
|