diff --git a/public/mobile/components/LoginScreen.js b/public/mobile/components/LoginScreen.js
index 47acf087f..8e1fd7075 100644
--- a/public/mobile/components/LoginScreen.js
+++ b/public/mobile/components/LoginScreen.js
@@ -1,13 +1,3 @@
-/**
- * LoginScreen Component
- *
- * Displays the login form for the PWA with 2FA support.
- * Features:
- * - Username/password authentication
- * - 2FA verification with OTP auto-detection (Web OTP API for Android, autocomplete for iOS)
- * - Remember me option
- */
-
import { login, verify2FA, resend2FA } from '/mobile/shared/auth.js';
export default {
@@ -23,32 +13,24 @@ export default {
setup(props, { emit }) {
const { ref, onMounted, onUnmounted, nextTick } = Vue;
- // Login form state
const username = ref('');
const password = ref('');
const rememberMe = ref(true);
const showPassword = ref(false);
-
- // 2FA state
const show2FA = ref(false);
const otpCode = ref('');
const otpDigits = ref(['', '', '', '', '']);
const deliveryMethod = ref('');
const maskedTarget = ref('');
const resendCooldown = ref(0);
-
- // General state
const error = ref('');
const success = ref('');
const loading = ref(false);
- const showThemePicker = ref(!localStorage.getItem('theme'));
-
- // OTP input refs
+ const showThemePicker = ref(false);
let otpInputRefs = [];
let otpAbortController = null;
let resendTimer = null;
- // Handle login form submission
const handleSubmit = async () => {
if (!username.value || !password.value) {
error.value = 'Bitte Benutzername und Passwort eingeben';
@@ -59,7 +41,6 @@ export default {
error.value = '';
try {
- // Call login API directly
const result = await login({
username: username.value,
password: password.value,
@@ -168,20 +149,20 @@ export default {
}
};
- // Go back to login form
const backToLogin = () => {
show2FA.value = false;
otpDigits.value = ['', '', '', '', ''];
+ otpInputRefs = [];
error.value = '';
success.value = '';
abortWebOTP();
};
- // Reset after session expired
const resetTo2FA = () => {
show2FA.value = false;
password.value = '';
otpDigits.value = ['', '', '', '', ''];
+ otpInputRefs = [];
error.value = 'Sitzung abgelaufen. Bitte erneut anmelden.';
};
@@ -197,11 +178,12 @@ export default {
}, 1000);
};
- // OTP input handlers
const focusOtpInput = (index) => {
- const inputs = document.querySelectorAll('.otp-input');
- if (inputs[index]) {
- inputs[index].focus();
+ if (otpInputRefs.length === 0) {
+ otpInputRefs = Array.from(document.querySelectorAll('.otp-input'));
+ }
+ if (otpInputRefs[index]) {
+ otpInputRefs[index].focus();
}
};
diff --git a/public/mobile/components/MainMenu.js b/public/mobile/components/MainMenu.js
index 6efe3d0d8..823e5b83a 100644
--- a/public/mobile/components/MainMenu.js
+++ b/public/mobile/components/MainMenu.js
@@ -15,6 +15,20 @@ export default {
setup(props, { emit }) {
// Available modules
const modules = [
+ {
+ id: 'Workorder',
+ name: 'Aufträge',
+ icon: 'clipboard-check',
+ color: 'bg-sky-500',
+ iconColor: 'text-sky-500'
+ },
+ {
+ id: 'Lieferschein',
+ name: 'Lieferschein',
+ icon: 'document',
+ color: 'bg-purple-500',
+ iconColor: 'text-purple-500'
+ },
{
id: 'Lager',
name: 'Lager',
@@ -22,7 +36,6 @@ export default {
color: 'bg-blue-500',
iconColor: 'text-blue-500'
}
- // Future modules can be added here
];
const openModule = (moduleId) => {
@@ -49,7 +62,13 @@ export default {
]"
>
diff --git a/public/mobile/modules/lager/inventur/Scanner.js b/public/mobile/modules/lager/inventur/Scanner.js
index 0697fbc67..0ef5909e7 100644
--- a/public/mobile/modules/lager/inventur/Scanner.js
+++ b/public/mobile/modules/lager/inventur/Scanner.js
@@ -1,19 +1,6 @@
-/**
- * Scanner Component (Inventur)
- *
- * The main scanning interface for stocktakes.
- * Uses the new API path: /MobileApp/Lager/Inventur/{action}
- */
+import { createModuleApi, debounce } from '/mobile/shared/api.js';
-// Inventur-specific API
-const inventurApi = {
- get: (endpoint) => fetch(`/MobileApp/Lager/Inventur/${endpoint}`).then(r => r.json()),
- post: (endpoint, data) => fetch(`/MobileApp/Lager/Inventur/${endpoint}`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(data)
- }).then(r => r.json())
-};
+const inventurApi = createModuleApi('Lager/Inventur');
export default {
name: 'Scanner',
@@ -26,44 +13,29 @@ export default {
setup(props, { emit }) {
const { ref, onMounted, onUnmounted, nextTick, computed } = Vue;
- // State
const currentTab = ref('scan');
const isLoading = ref(false);
-
- // Scanner
const scanner = ref(null);
const isScannerActive = ref(false);
const scannerError = ref('');
-
- // Article
const scannedArticle = ref(null);
const quantity = ref('1');
const rack = ref('');
const shelf = ref('');
-
- // Search
const searchQuery = ref('');
const searchResults = ref([]);
const categories = ref([]);
const selectedCategory = ref(0);
const isSearching = ref(false);
-
- // History
const recentScans = ref([]);
const isLoadingHistory = ref(false);
-
- // Warning
const alreadyScannedWarning = ref(null);
-
- // Keypad
const showKeypad = ref(false);
- // Computed
const canSubmit = computed(() => {
return scannedArticle.value && parseFloat(quantity.value) > 0 && !isLoading.value;
});
- // Scanner functions
const startScanner = async () => {
scannerError.value = '';
try {
@@ -92,7 +64,6 @@ export default {
await lookupArticle(decodedText);
};
- // Article lookup
const lookupArticle = async (code) => {
isLoading.value = true;
alreadyScannedWarning.value = null;
@@ -121,7 +92,6 @@ export default {
}
};
- // Submit
const submitScan = async (overwrite = false) => {
if (!canSubmit.value) return;
isLoading.value = true;
@@ -140,6 +110,7 @@ export default {
const result = await inventurApi.post('submitScan', payload);
if (result.success) {
+ navigator.vibrate?.([100]);
emit('toast', result.message, 'success');
scannedArticle.value = null;
quantity.value = '1';
@@ -157,13 +128,12 @@ export default {
}
};
- // Search
const loadCategories = async () => {
const result = await inventurApi.get('getCategories');
if (result.success) categories.value = result.categories;
};
- const searchArticles = async () => {
+ const doSearch = async () => {
if (searchQuery.value.length < 2 && !selectedCategory.value) {
searchResults.value = [];
return;
@@ -180,6 +150,8 @@ export default {
}
};
+ const searchArticles = debounce(doSearch, 300);
+
const selectSearchResult = async (article) => {
await stopScanner();
scannedArticle.value = article;
@@ -194,7 +166,6 @@ export default {
}
};
- // History
const loadHistory = async () => {
isLoadingHistory.value = true;
try {
@@ -205,7 +176,6 @@ export default {
}
};
- // Keypad
const appendDigit = (digit) => {
if (digit === '.' && quantity.value.includes('.')) return;
if (quantity.value === '0' && digit !== '.') {
@@ -221,7 +191,6 @@ export default {
const clearQuantity = () => { quantity.value = '0'; };
- // Navigation
const handleClose = async () => {
await stopScanner();
emit('close');
@@ -265,7 +234,6 @@ export default {
template: `
-
{{ stocktake.title || 'Inventur #' + stocktake.stocktakeNumber }}
-
-
-
-
@@ -296,9 +260,7 @@ export default {
QR-Code scannen oder Artikel suchen
-
-
-
{{ scannedArticle.title }}
Art.-Nr.: {{ scannedArticle.articleNumber }}
Kategorie: {{ scannedArticle.categoryName }}
-
-
- {{ quantity }}
+
+
+
+
+
+
+
+
+
+ {{ quantity }}
+
+
-
@@ -344,19 +313,17 @@ export default {
-
-
-
-
-
-
@@ -412,7 +378,6 @@ export default {
-
diff --git a/public/mobile/modules/lager/inventur/StocktakeList.js b/public/mobile/modules/lager/inventur/StocktakeList.js
index 4fd749571..31f0de4c2 100644
--- a/public/mobile/modules/lager/inventur/StocktakeList.js
+++ b/public/mobile/modules/lager/inventur/StocktakeList.js
@@ -1,21 +1,6 @@
-/**
- * StocktakeList Component (Inventur)
- *
- * Displays a list of active stocktakes.
- * Uses the new API path: /MobileApp/Lager/Inventur/{action}
- */
+import { createModuleApi } from '/mobile/shared/api.js';
-import { api } from '/mobile/shared/auth.js';
-
-// Override API base for Inventur
-const inventurApi = {
- get: (endpoint) => fetch(`/MobileApp/Lager/Inventur/${endpoint}`).then(r => r.json()),
- post: (endpoint, data) => fetch(`/MobileApp/Lager/Inventur/${endpoint}`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(data)
- }).then(r => r.json())
-};
+const inventurApi = createModuleApi('Lager/Inventur');
export default {
name: 'StocktakeList',
diff --git a/public/mobile/modules/lager/movement/MovementForm.js b/public/mobile/modules/lager/movement/MovementForm.js
index d10b8fe1e..383df6415 100644
--- a/public/mobile/modules/lager/movement/MovementForm.js
+++ b/public/mobile/modules/lager/movement/MovementForm.js
@@ -1,20 +1,8 @@
-/**
- * MovementForm Component (WarehouseMovement)
- *
- * The main interface for stock movements (IN/OUT/ADJUSTMENT).
- * API: /MobileApp/Lager/Movement/{action}
- */
+import { createModuleApi, debounce } from '/mobile/shared/api.js';
+
+const movementApi = createModuleApi('Lager/Movement');
-const movementApi = {
- get: (endpoint) => fetch(`/MobileApp/Lager/Movement/${endpoint}`).then(r => r.json()),
- post: (endpoint, data) => fetch(`/MobileApp/Lager/Movement/${endpoint}`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(data)
- }).then(r => r.json())
-};
-// Custom BottomSheet Select Component
const BottomSheetSelect = {
name: 'BottomSheetSelect',
emits: ['update:modelValue'],
@@ -212,6 +200,14 @@ export default {
const recentMovements = ref([]);
const isLoadingHistory = ref(false);
+ // ==================== ORDER RECEIVING ====================
+ const pendingOrders = ref([]);
+ const isLoadingOrders = ref(false);
+ const selectedOrder = ref(null);
+ const orderPositions = ref([]);
+ const deliveryNotePhoto = ref(null);
+ const isSubmittingOrder = ref(false);
+
// ==================== KEYPAD ====================
const showKeypad = ref(false);
const showNote = ref(false);
@@ -569,6 +565,9 @@ export default {
const result = await movementApi.post('submitMovement', payload);
if (result.success) {
+ // Haptic feedback on success
+ navigator.vibrate?.([100]);
+
// Store for undo
lastMovement.value = result.movement;
showUndo.value = true;
@@ -667,6 +666,8 @@ export default {
isLoading.value = false;
if (errorCount === 0) {
+ // Haptic feedback on success
+ navigator.vibrate?.([100, 50, 100]);
emit('toast', `${successCount} Bewegungen erfolgreich`, 'success');
clearCart();
} else {
@@ -709,6 +710,8 @@ export default {
const result = await movementApi.post('submitMovement', payload);
if (result.success) {
+ // Haptic feedback on success
+ navigator.vibrate?.([100]);
emit('toast', result.message, 'success');
// Reset form
scannedArticle.value = null;
@@ -727,8 +730,7 @@ export default {
}
};
- // Search
- const searchArticles = async () => {
+ const doSearch = async () => {
if (searchQuery.value.length < 2) {
searchResults.value = [];
return;
@@ -742,6 +744,8 @@ export default {
}
};
+ const searchArticles = debounce(doSearch, 300);
+
const selectSearchResult = async (article) => {
await stopScanner();
scannedArticle.value = article;
@@ -767,6 +771,97 @@ export default {
}
};
+ // ==================== ORDER RECEIVING FUNCTIONS ====================
+ const loadPendingOrders = async () => {
+ isLoadingOrders.value = true;
+ try {
+ const result = await movementApi.get('getPendingOrders');
+ if (result.success) {
+ pendingOrders.value = result.orders;
+ }
+ } catch (e) {
+ emit('toast', 'Fehler beim Laden der Bestellungen', 'error');
+ } finally {
+ isLoadingOrders.value = false;
+ }
+ };
+
+ const selectOrderForReceiving = async (order) => {
+ isLoadingOrders.value = true;
+ try {
+ const result = await movementApi.get(`getOrderForReceiving?orderId=${order.id}`);
+ if (result.success) {
+ selectedOrder.value = result.order;
+ orderPositions.value = result.positions;
+ deliveryNotePhoto.value = null;
+ }
+ } catch (e) {
+ emit('toast', 'Fehler beim Laden der Bestellung', 'error');
+ } finally {
+ isLoadingOrders.value = false;
+ }
+ };
+
+ const cancelOrderReceiving = () => {
+ selectedOrder.value = null;
+ orderPositions.value = [];
+ deliveryNotePhoto.value = null;
+ };
+
+ const submitOrderReceiving = async () => {
+ if (!selectedOrder.value || !selectedLocation.value) return;
+
+ // Collect positions with quantity > 0
+ const positionsToSubmit = orderPositions.value
+ .filter(p => p.receivingQty > 0)
+ .map(p => ({
+ articleId: p.articleId,
+ quantity: p.receivingQty
+ }));
+
+ if (positionsToSubmit.length === 0) {
+ emit('toast', 'Bitte mindestens eine Menge eingeben', 'error');
+ return;
+ }
+
+ isSubmittingOrder.value = true;
+ try {
+ const result = await movementApi.post('submitOrderReceiving', {
+ orderId: selectedOrder.value.id,
+ locationId: selectedLocation.value,
+ positions: positionsToSubmit,
+ deliveryNoteFileId: deliveryNotePhoto.value,
+ note: null
+ });
+
+ if (result.success) {
+ navigator.vibrate?.([100, 50, 100]);
+ emit('toast', result.message, 'success');
+ // Reset and reload orders
+ cancelOrderReceiving();
+ await loadPendingOrders();
+ } else {
+ emit('toast', result.message || 'Fehler beim Speichern', 'error');
+ }
+ } catch (e) {
+ emit('toast', 'Netzwerkfehler', 'error');
+ } finally {
+ isSubmittingOrder.value = false;
+ }
+ };
+
+ const setAllReceivingQty = () => {
+ orderPositions.value.forEach(p => {
+ p.receivingQty = p.remainingQty;
+ });
+ };
+
+ const clearAllReceivingQty = () => {
+ orderPositions.value.forEach(p => {
+ p.receivingQty = 0;
+ });
+ };
+
// Keypad
const appendDigit = (digit) => {
if (digit === '.' && quantity.value.includes('.')) return;
@@ -794,6 +889,9 @@ export default {
} else if (tab === 'history') {
await stopScanner();
await loadHistory();
+ } else if (tab === 'orders') {
+ await stopScanner();
+ await loadPendingOrders();
}
};
@@ -849,6 +947,11 @@ export default {
searchQuery, searchResults, isSearching,
// History
recentMovements, isLoadingHistory,
+ // Order Receiving
+ pendingOrders, isLoadingOrders, selectedOrder, orderPositions,
+ deliveryNotePhoto, isSubmittingOrder,
+ loadPendingOrders, selectOrderForReceiving, cancelOrderReceiving,
+ submitOrderReceiving, setAllReceivingQty, clearAllReceivingQty,
// UI
showKeypad, showNote, canSubmit, typeColor,
// Functions
@@ -923,29 +1026,30 @@ export default {
-
+
-
+
-
0" class="bg-white/30 px-1.5 rounded-full">{{ cartTotal }}
-
-
-
-
-
-
-
- Schnell
-
@@ -976,6 +1068,10 @@ export default {
Scannen
Suche
+
+ Bestellung
+ {{ pendingOrders.length }}
+
Verlauf
@@ -1016,17 +1112,30 @@ export default {
-
-
-
Menge
-
- {{ selectedType === 'OUT' ? '-' : selectedType === 'IN' ? '+' : '' }}{{ quantity }}
-
+
+
+
+
+ 1
+ 5
+ 10
+ 20
+
+
+
+
-
+
+
+ {{ selectedType === 'OUT' ? '-' : selectedType === 'IN' ? '+' : '' }}{{ quantity }}
+
+
+
+
+
@@ -1065,19 +1174,13 @@ export default {
>
-
-
-
- Abbrechen
-
+
+
+ Abbrechen
+
@@ -1163,6 +1272,149 @@ export default {
+
+
+
+
+
+
+
+
+
+
+
+
+
Keine offenen Bestellungen
+
Alle Lieferungen wurden empfangen
+
+
+
+
{{ pendingOrders.length }} Bestellung(en) warten auf Wareneingang:
+
+
+
+
+ {{ order.orderNumber }}
+
+ {{ order.statusLabel }}
+
+
+
{{ order.distributorName }}
+
+ {{ order.positionCount }} Position(en) · {{ order.totalItems }} Artikel gesamt
+
+
+
+
{{ order.create }}
+
+ {{ order.daysSinceSent }} Tag(e)
+
+
+
+
+
+
+
+
Wareneingang erfassen
+
+
+
+
+
+
+
+
+
+
+
+
{{ selectedOrder.orderNumber }}
+
{{ selectedOrder.distributorName }}
+
+
+
+
+
+
+
+
Bestellt: {{ selectedOrder.create }}
+
+
+
+
+
+ Alle übernehmen
+
+
+ Alle löschen
+
+
+
+
+
+
+
+
+
{{ pos.articleTitle }}
+
{{ pos.articleNumber }}
+
+
+
Bestellt: {{ pos.orderedQty }}
+
Erhalten: {{ pos.deliveredQty }}
+
+
+
+
Empfangen:
+
+ -
+
+ +
+
+
/ {{ pos.remainingQty }} {{ pos.unit }}
+
+
+
+
+
+
+
+ {{ isSubmittingOrder ? 'Wird gespeichert...' : 'Wareneingang buchen' }}
+
+
+
+
diff --git a/public/mobile/modules/lager/shippingnote/DatePicker.js b/public/mobile/modules/lager/shippingnote/DatePicker.js
new file mode 100644
index 000000000..325f50704
--- /dev/null
+++ b/public/mobile/modules/lager/shippingnote/DatePicker.js
@@ -0,0 +1,282 @@
+/**
+ * DatePicker Component
+ *
+ * Beautiful mobile date picker with bottom sheet modal.
+ * Features quick buttons (Heute, Gestern) and calendar grid.
+ */
+
+export default {
+ name: 'DatePicker',
+ emits: ['update:modelValue', 'close'],
+ props: {
+ modelValue: {
+ type: String,
+ default: null
+ },
+ show: {
+ type: Boolean,
+ default: false
+ }
+ },
+
+ setup(props, { emit }) {
+ const { ref, computed, watch } = Vue;
+
+ // Current calendar view month/year
+ const viewDate = ref(new Date());
+
+ // Initialize view date when opened
+ watch(() => props.show, (newVal) => {
+ if (newVal && props.modelValue) {
+ viewDate.value = new Date(props.modelValue);
+ } else if (newVal) {
+ viewDate.value = new Date();
+ }
+ });
+
+ // German weekday names (short)
+ const weekDays = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
+
+ // German month names
+ const monthNames = [
+ 'Januar', 'Februar', 'März', 'April', 'Mai', 'Juni',
+ 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember'
+ ];
+
+ const shortMonthNames = [
+ 'Jan', 'Feb', 'Mär', 'Apr', 'Mai', 'Jun',
+ 'Jul', 'Aug', 'Sep', 'Okt', 'Nov', 'Dez'
+ ];
+
+ const weekDayNames = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'];
+
+ // Format date for display
+ const formatDisplayDate = (dateStr) => {
+ if (!dateStr) return 'Datum wählen';
+ const date = new Date(dateStr);
+ const dayName = weekDayNames[date.getDay()];
+ const day = date.getDate();
+ const month = shortMonthNames[date.getMonth()];
+ const year = date.getFullYear();
+ return `${dayName}, ${day}. ${month} ${year}`;
+ };
+
+ // Current month/year display
+ const currentMonthYear = computed(() => {
+ const month = monthNames[viewDate.value.getMonth()];
+ const year = viewDate.value.getFullYear();
+ return `${month} ${year}`;
+ });
+
+ // Get calendar days for current view
+ const calendarDays = computed(() => {
+ const year = viewDate.value.getFullYear();
+ const month = viewDate.value.getMonth();
+
+ // First day of month
+ const firstDay = new Date(year, month, 1);
+ // Last day of month
+ const lastDay = new Date(year, month + 1, 0);
+
+ // Day of week for first day (0=Sun, convert to 0=Mon)
+ let startDay = firstDay.getDay() - 1;
+ if (startDay < 0) startDay = 6;
+
+ const days = [];
+
+ // Add empty slots for days before first of month
+ for (let i = 0; i < startDay; i++) {
+ days.push({ day: null, date: null });
+ }
+
+ // Add days of month
+ for (let d = 1; d <= lastDay.getDate(); d++) {
+ const date = new Date(year, month, d);
+ const dateStr = formatDateISO(date);
+ days.push({
+ day: d,
+ date: dateStr,
+ isToday: isToday(date),
+ isSelected: dateStr === props.modelValue
+ });
+ }
+
+ return days;
+ });
+
+ // Check if date is today
+ const isToday = (date) => {
+ const today = new Date();
+ return date.toDateString() === today.toDateString();
+ };
+
+ // Format date as ISO string (YYYY-MM-DD)
+ const formatDateISO = (date) => {
+ const year = date.getFullYear();
+ const month = String(date.getMonth() + 1).padStart(2, '0');
+ const day = String(date.getDate()).padStart(2, '0');
+ return `${year}-${month}-${day}`;
+ };
+
+ // Quick date helpers
+ const getToday = () => formatDateISO(new Date());
+ const getYesterday = () => {
+ const d = new Date();
+ d.setDate(d.getDate() - 1);
+ return formatDateISO(d);
+ };
+
+ // Navigation
+ const prevMonth = () => {
+ viewDate.value = new Date(viewDate.value.getFullYear(), viewDate.value.getMonth() - 1, 1);
+ };
+
+ const nextMonth = () => {
+ viewDate.value = new Date(viewDate.value.getFullYear(), viewDate.value.getMonth() + 1, 1);
+ };
+
+ // Selection
+ const selectDate = (dateStr) => {
+ if (!dateStr) return;
+ emit('update:modelValue', dateStr);
+ emit('close');
+ };
+
+ const selectToday = () => selectDate(getToday());
+ const selectYesterday = () => selectDate(getYesterday());
+
+ const close = () => {
+ emit('close');
+ };
+
+ return {
+ viewDate,
+ weekDays,
+ currentMonthYear,
+ calendarDays,
+ formatDisplayDate,
+ prevMonth,
+ nextMonth,
+ selectDate,
+ selectToday,
+ selectYesterday,
+ close,
+ getToday,
+ getYesterday
+ };
+ },
+
+ template: `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Datum wählen
+
+
+
+
+
+
+ Heute
+
+
+ Gestern
+
+
+
+
+
+
+
+
+
+
+
+ {{ currentMonthYear }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ item.day }}
+
+
+
+
+
+
+
+
+
+
+
+ `
+};
diff --git a/public/mobile/modules/lager/shippingnote/EmployeeSelector.js b/public/mobile/modules/lager/shippingnote/EmployeeSelector.js
new file mode 100644
index 000000000..df440c853
--- /dev/null
+++ b/public/mobile/modules/lager/shippingnote/EmployeeSelector.js
@@ -0,0 +1,189 @@
+/**
+ * EmployeeSelector Component
+ *
+ * Bottom sheet modal for searching and selecting employees.
+ * Supports lazy word search (e.g., "fab her" matches "Fabian Herbst").
+ */
+
+export default {
+ name: 'EmployeeSelector',
+ emits: ['select', 'close'],
+ props: {
+ show: {
+ type: Boolean,
+ default: false
+ },
+ excludeIds: {
+ type: Array,
+ default: () => []
+ }
+ },
+
+ setup(props, { emit }) {
+ const { ref, computed, watch } = Vue;
+
+ const searchQuery = ref('');
+ const employees = ref([]);
+ const isLoading = ref(false);
+ const searchTimeout = ref(null);
+
+ // Filter out already selected employees
+ const filteredEmployees = computed(() => {
+ return employees.value.filter(emp => !props.excludeIds.includes(emp.id));
+ });
+
+ // Search employees when query changes
+ watch(searchQuery, (newVal) => {
+ if (searchTimeout.value) {
+ clearTimeout(searchTimeout.value);
+ }
+
+ // Debounce search
+ searchTimeout.value = setTimeout(() => {
+ searchEmployees(newVal);
+ }, 300);
+ });
+
+ // Load employees when modal opens
+ watch(() => props.show, (newVal) => {
+ if (newVal) {
+ searchQuery.value = '';
+ searchEmployees('');
+ }
+ });
+
+ const searchEmployees = async (query) => {
+ isLoading.value = true;
+ try {
+ const params = new URLSearchParams();
+ if (query) params.append('query', query);
+
+ const response = await fetch(`/MobileApp/Lager/ShippingNote/searchEmployees?${params}`, {
+ credentials: 'include'
+ });
+ const data = await response.json();
+
+ if (data.success) {
+ employees.value = data.employees;
+ }
+ } catch (error) {
+ console.error('Error searching employees:', error);
+ } finally {
+ isLoading.value = false;
+ }
+ };
+
+ const selectEmployee = (employee) => {
+ emit('select', employee);
+ emit('close');
+ };
+
+ const close = () => {
+ emit('close');
+ };
+
+ return {
+ searchQuery,
+ filteredEmployees,
+ isLoading,
+ selectEmployee,
+ close
+ };
+ },
+
+ template: `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Mitarbeiter auswählen
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Keine Mitarbeiter gefunden
+
+
+
+
+
+
+
+
+ {{ emp.name.charAt(0).toUpperCase() }}
+
+
+
+
+
+
+ {{ emp.name }}
+
+
+ {{ emp.email }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `
+};
diff --git a/public/mobile/modules/lager/shippingnote/ShippingNoteForm.js b/public/mobile/modules/lager/shippingnote/ShippingNoteForm.js
new file mode 100644
index 000000000..de39a3b91
--- /dev/null
+++ b/public/mobile/modules/lager/shippingnote/ShippingNoteForm.js
@@ -0,0 +1,1090 @@
+/**
+ * ShippingNote Form Component - Redesigned
+ *
+ * Features:
+ * - GPS-based customer auto-detection with smart collapsed card
+ * - Multi-employee support with individual hours
+ * - Custom date picker
+ * - Reorganized field layout by importance
+ */
+
+import { shippingNoteApi } from '/mobile/modules/lager/shippingnote/ShippingNoteModule.js';
+import DatePicker from '/mobile/modules/lager/shippingnote/DatePicker.js';
+import EmployeeSelector from '/mobile/modules/lager/shippingnote/EmployeeSelector.js';
+
+export default {
+ name: 'ShippingNoteForm',
+ emits: ['created', 'createAndSign', 'toast'],
+ props: {
+ user: Object
+ },
+ components: {
+ DatePicker,
+ EmployeeSelector
+ },
+
+ setup(props, { emit }) {
+ const { ref, reactive, computed, onMounted, watch } = Vue;
+
+ // GPS detection state
+ const gpsState = ref('detecting'); // detecting | customer_found | no_customer | manual | error
+ const gpsPosition = ref(null);
+ const detectedCustomer = ref(null);
+ const gpsAddress = ref(null);
+ const gpsDistance = ref(null);
+
+ // Customer card state - collapsed when customer found
+ const customerCardExpanded = ref(false);
+
+ // Form data
+ const form = reactive({
+ customerId: null,
+ customerName: '',
+ billingAddressId: null,
+ deliveryAddressName: '',
+ deliveryAddressLine: '',
+ deliveryAddressPLZ: '',
+ deliveryAddressCity: '',
+ deliveryAddressEMail: '',
+ note: '',
+ type: 'V'
+ });
+
+ // Hours entries - supports multiple employees
+ const hoursEntries = ref([createNewHoursEntry()]);
+
+ // Active entry index for date picker
+ const activeEntryIndex = ref(0);
+ const showDatePicker = ref(false);
+ const showEmployeeSelector = ref(false);
+
+ // Positions
+ const positions = ref([]);
+
+ // UI state
+ const loading = ref(false);
+ const showCustomerSearch = ref(false);
+ const customerSearchQuery = ref('');
+ const customerSearchResults = ref([]);
+ const customerSearchLoading = ref(false);
+
+ const showArticleSearch = ref(false);
+ const articleSearchQuery = ref('');
+ const articleSearchResults = ref([]);
+ const articleSearchLoading = ref(false);
+
+ const showCarSelect = ref(false);
+ const carSelectEntryIndex = ref(0);
+ const allCars = ref([]);
+
+ const showHourTypeSelect = ref(false);
+ const hourTypeSelectEntryIndex = ref(0);
+ const hourTypes = ref([]);
+
+ const showPositionsSection = ref(false);
+
+ // User's car
+ const userCar = ref(null);
+
+ // Quick work type chips
+ const quickWorkTypes = ['Spleißen', 'Jetten', 'Inbetriebnahme'];
+ const selectedWorkType = ref(null);
+
+ // Select quick work type
+ const selectWorkType = (type) => {
+ if (selectedWorkType.value === type) {
+ // Deselect if already selected
+ selectedWorkType.value = null;
+ form.note = '';
+ } else {
+ selectedWorkType.value = type;
+ form.note = type;
+ }
+ navigator.vibrate?.([20]);
+ };
+
+ // Watch for manual note changes to clear chip selection
+ watch(() => form.note, (newVal) => {
+ if (newVal && !quickWorkTypes.includes(newVal)) {
+ selectedWorkType.value = null;
+ }
+ });
+
+ // Create new hours entry - default to 1h (most common)
+ function createNewHoursEntry(userId = null, userName = '') {
+ return {
+ id: Date.now() + Math.random(),
+ userId: userId || props.user?.id || null,
+ userName: userName || props.user?.name || '',
+ date: new Date().toISOString().split('T')[0],
+ hourCount: 1,
+ hourType: '',
+ hourTypeName: 'Normal',
+ carId: null,
+ carName: '',
+ kilometerCount: 0,
+ comment: '',
+ expanded: true
+ };
+ }
+
+ // Validation
+ const isValid = computed(() => {
+ return form.customerName.trim() !== '' &&
+ form.deliveryAddressLine.trim() !== '' &&
+ form.deliveryAddressCity.trim() !== '' &&
+ form.note.trim() !== '';
+ });
+
+ // Format distance for display
+ const formatDistance = (distance) => {
+ if (distance === null || distance === undefined || isNaN(distance)) {
+ return '';
+ }
+ return Math.round(distance) + 'm';
+ };
+
+ // Format date for display - German style
+ const formatDateDisplay = (dateStr) => {
+ if (!dateStr) return 'Datum wählen';
+ const date = new Date(dateStr);
+ const weekDays = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'];
+ const months = ['Jan', 'Feb', 'Mär', 'Apr', 'Mai', 'Jun', 'Jul', 'Aug', 'Sep', 'Okt', 'Nov', 'Dez'];
+ const dayName = weekDays[date.getDay()];
+ const day = date.getDate();
+ const month = months[date.getMonth()];
+ const year = date.getFullYear();
+ return `${dayName}, ${day}. ${month} ${year}`;
+ };
+
+ // Format hours display as "1h 15m"
+ const formatHoursValue = (hours) => {
+ const h = Math.floor(hours);
+ const m = Math.round((hours - h) * 60);
+ return { h, m };
+ };
+
+ // Get selected employee IDs (to exclude from selector)
+ const selectedEmployeeIds = computed(() => {
+ return hoursEntries.value.map(e => e.userId).filter(id => id !== null);
+ });
+
+ // Initialize
+ onMounted(async () => {
+ await Promise.all([
+ loadUserCar(),
+ loadAllCars(),
+ loadHourTypes()
+ ]);
+ detectGPS();
+ });
+
+ // Load user's assigned car
+ const loadUserCar = async () => {
+ try {
+ const data = await shippingNoteApi.get(`getUserCar?userId=${props.user?.id}`);
+ if (data.success && data.car) {
+ userCar.value = data.car;
+ if (hoursEntries.value[0]) {
+ hoursEntries.value[0].carId = data.car.id;
+ hoursEntries.value[0].carName = data.car.name;
+ }
+ }
+ } catch (e) {
+ console.error('Failed to load user car:', e);
+ }
+ };
+
+ // Load all available cars
+ const loadAllCars = async () => {
+ try {
+ const data = await shippingNoteApi.get('getAllCars');
+ if (data.success) {
+ allCars.value = data.cars || [];
+ }
+ } catch (e) {
+ console.error('Failed to load cars:', e);
+ }
+ };
+
+ // Load hour types
+ const loadHourTypes = async () => {
+ try {
+ const data = await shippingNoteApi.get('getHourTypes');
+ if (data.success) {
+ hourTypes.value = data.hourTypes || [];
+ }
+ } catch (e) {
+ console.error('Failed to load hour types:', e);
+ }
+ };
+
+ // GPS Detection
+ const detectGPS = () => {
+ gpsState.value = 'detecting';
+
+ if (!navigator.geolocation) {
+ gpsState.value = 'error';
+ return;
+ }
+
+ navigator.geolocation.getCurrentPosition(
+ async (position) => {
+ gpsPosition.value = {
+ lat: position.coords.latitude,
+ lng: position.coords.longitude
+ };
+ await findNearbyCustomer();
+ await calculateDistance();
+ },
+ (error) => {
+ console.error('GPS error:', error);
+ gpsState.value = 'error';
+ },
+ { enableHighAccuracy: true, timeout: 15000, maximumAge: 0 }
+ );
+ };
+
+ // Find nearby customer
+ const findNearbyCustomer = async () => {
+ if (!gpsPosition.value) return;
+
+ try {
+ const data = await shippingNoteApi.get(
+ `getCustomerByLocation?lat=${gpsPosition.value.lat}&lng=${gpsPosition.value.lng}`
+ );
+
+ if (data.success && data.customer) {
+ detectedCustomer.value = data.customer;
+ gpsDistance.value = data.customer.distance;
+ gpsState.value = 'customer_found';
+ fillFromCustomer(data.customer);
+ customerCardExpanded.value = false; // Collapse card when customer found
+ navigator.vibrate?.([100, 50, 100]);
+ } else {
+ gpsState.value = 'no_customer';
+ customerCardExpanded.value = true; // Expand card when no customer
+ await reverseGeocode();
+ }
+ } catch (e) {
+ console.error('Customer detection failed:', e);
+ gpsState.value = 'no_customer';
+ customerCardExpanded.value = true;
+ await reverseGeocode();
+ }
+ };
+
+ // Reverse geocode GPS position
+ const reverseGeocode = async () => {
+ if (!gpsPosition.value) return;
+
+ try {
+ const data = await shippingNoteApi.get(
+ `reverseGeocode?lat=${gpsPosition.value.lat}&lng=${gpsPosition.value.lng}`
+ );
+
+ if (data.success && data.address) {
+ gpsAddress.value = data.address;
+ form.deliveryAddressLine = data.address.street || '';
+ form.deliveryAddressPLZ = data.address.zip || '';
+ form.deliveryAddressCity = data.address.city || '';
+ }
+ } catch (e) {
+ console.error('Reverse geocode failed:', e);
+ }
+ };
+
+ // Calculate distance from office
+ const calculateDistance = async () => {
+ if (!gpsPosition.value) return;
+
+ try {
+ const data = await shippingNoteApi.get(
+ `calculateDistance?lat=${gpsPosition.value.lat}&lng=${gpsPosition.value.lng}`
+ );
+
+ if (data.success && data.distanceRoundTrip) {
+ if (hoursEntries.value[0]) {
+ hoursEntries.value[0].kilometerCount = Math.round(data.distanceRoundTrip);
+ }
+ }
+ } catch (e) {
+ console.error('Distance calculation failed:', e);
+ }
+ };
+
+ // Fill form from customer
+ const fillFromCustomer = (customer) => {
+ form.customerId = customer.id;
+ form.customerName = customer.displayName || customer.company || customer.name || '';
+ form.billingAddressId = customer.id;
+ form.deliveryAddressName = customer.displayName || customer.company || customer.name || '';
+ form.deliveryAddressLine = customer.street || '';
+ form.deliveryAddressPLZ = customer.zip || '';
+ form.deliveryAddressCity = customer.city || '';
+ form.deliveryAddressEMail = customer.email || '';
+ };
+
+ // Customer search
+ const searchCustomers = async () => {
+ if (customerSearchQuery.value.length < 1) {
+ customerSearchResults.value = [];
+ return;
+ }
+
+ customerSearchLoading.value = true;
+ try {
+ const data = await shippingNoteApi.get(
+ `searchCustomers?query=${encodeURIComponent(customerSearchQuery.value)}`
+ );
+ customerSearchResults.value = data.customers || [];
+ } catch (e) {
+ console.error('Customer search failed:', e);
+ } finally {
+ customerSearchLoading.value = false;
+ }
+ };
+
+ // Select customer from search
+ const selectCustomer = async (customer) => {
+ fillFromCustomer(customer);
+ gpsState.value = 'manual';
+ customerCardExpanded.value = false;
+ showCustomerSearch.value = false;
+ customerSearchQuery.value = '';
+ customerSearchResults.value = [];
+
+ // Calculate distance if customer has GPS coordinates
+ if (customer.gpsLat && customer.gpsLong) {
+ try {
+ const data = await shippingNoteApi.get(
+ `calculateDistance?lat=${customer.gpsLat}&lng=${customer.gpsLong}`
+ );
+ if (data.success && data.distanceRoundTrip) {
+ if (hoursEntries.value[0]) {
+ hoursEntries.value[0].kilometerCount = Math.round(data.distanceRoundTrip);
+ }
+ }
+ } catch (e) {
+ console.error('Distance calculation failed:', e);
+ }
+ }
+ };
+
+ // Article search
+ const searchArticles = async () => {
+ if (articleSearchQuery.value.length < 1) {
+ articleSearchResults.value = [];
+ return;
+ }
+
+ articleSearchLoading.value = true;
+ try {
+ const data = await shippingNoteApi.get(
+ `searchArticles?query=${encodeURIComponent(articleSearchQuery.value)}`
+ );
+ articleSearchResults.value = data.articles || [];
+ } catch (e) {
+ console.error('Article search failed:', e);
+ } finally {
+ articleSearchLoading.value = false;
+ }
+ };
+
+ // Add article to positions
+ const addArticle = (article) => {
+ const existing = positions.value.find(p => p.articleId === article.id);
+ if (existing) {
+ existing.amount++;
+ } else {
+ positions.value.push({
+ articleId: article.id,
+ articleNumber: article.articleNumber,
+ articleName: article.title,
+ amount: 1,
+ isEnergieMaterial: false
+ });
+ }
+ showArticleSearch.value = false;
+ articleSearchQuery.value = '';
+ articleSearchResults.value = [];
+ navigator.vibrate?.([50]);
+ };
+
+ // Remove position
+ const removePosition = (index) => {
+ positions.value.splice(index, 1);
+ };
+
+ // Toggle Beigestelltes Material
+ const toggleEnergieMaterial = (pos) => {
+ pos.isEnergieMaterial = !pos.isEnergieMaterial;
+ navigator.vibrate?.([20]);
+ };
+
+ // Hours functions for specific entry
+ const setHours = (entryIndex, hours) => {
+ if (hoursEntries.value[entryIndex]) {
+ hoursEntries.value[entryIndex].hourCount = hours;
+ navigator.vibrate?.([30]);
+ }
+ };
+
+ const adjustHours = (entryIndex, delta) => {
+ if (hoursEntries.value[entryIndex]) {
+ const newVal = Math.max(0, hoursEntries.value[entryIndex].hourCount + delta);
+ hoursEntries.value[entryIndex].hourCount = Math.round(newVal * 4) / 4;
+ navigator.vibrate?.([20]);
+ }
+ };
+
+ // Date picker
+ const openDatePicker = (entryIndex) => {
+ activeEntryIndex.value = entryIndex;
+ showDatePicker.value = true;
+ };
+
+ const updateEntryDate = (dateStr) => {
+ if (hoursEntries.value[activeEntryIndex.value]) {
+ hoursEntries.value[activeEntryIndex.value].date = dateStr;
+ }
+ };
+
+ // Employee selector
+ const openEmployeeSelector = () => {
+ showEmployeeSelector.value = true;
+ };
+
+ const addEmployee = (employee) => {
+ const newEntry = createNewHoursEntry(employee.id, employee.name);
+ // Copy date and km from first entry
+ if (hoursEntries.value[0]) {
+ newEntry.date = hoursEntries.value[0].date;
+ newEntry.kilometerCount = hoursEntries.value[0].kilometerCount;
+ }
+ hoursEntries.value.push(newEntry);
+ navigator.vibrate?.([50]);
+ };
+
+ const removeHoursEntry = (entryIndex) => {
+ if (hoursEntries.value.length > 1) {
+ hoursEntries.value.splice(entryIndex, 1);
+ navigator.vibrate?.([30]);
+ }
+ };
+
+ // Select car for specific entry
+ const openCarSelect = (entryIndex) => {
+ carSelectEntryIndex.value = entryIndex;
+ showCarSelect.value = true;
+ };
+
+ const selectCar = (car) => {
+ if (hoursEntries.value[carSelectEntryIndex.value]) {
+ hoursEntries.value[carSelectEntryIndex.value].carId = car.id;
+ hoursEntries.value[carSelectEntryIndex.value].carName = car.name;
+ }
+ showCarSelect.value = false;
+ navigator.vibrate?.([30]);
+ };
+
+ // Select hour type for specific entry
+ const openHourTypeSelect = (entryIndex) => {
+ hourTypeSelectEntryIndex.value = entryIndex;
+ showHourTypeSelect.value = true;
+ };
+
+ const selectHourType = (hourType) => {
+ if (hoursEntries.value[hourTypeSelectEntryIndex.value]) {
+ hoursEntries.value[hourTypeSelectEntryIndex.value].hourType = hourType.id;
+ hoursEntries.value[hourTypeSelectEntryIndex.value].hourTypeName = hourType.name;
+ }
+ showHourTypeSelect.value = false;
+ navigator.vibrate?.([30]);
+ };
+
+ // Customer card expand/collapse
+ const toggleCustomerCard = () => {
+ customerCardExpanded.value = !customerCardExpanded.value;
+ };
+
+ // Switch to manual mode
+ const switchToManual = () => {
+ gpsState.value = 'manual';
+ showCustomerSearch.value = true;
+ };
+
+ // Refresh GPS
+ const refreshGPS = () => {
+ detectGPS();
+ };
+
+ // Mock customer found (for screenshots/demo)
+ const mockCustomerFound = () => {
+ detectedCustomer.value = {
+ id: 999,
+ displayName: 'Frau im Zentrum GmbH',
+ company: 'Frau im Zentrum GmbH',
+ street: 'Schmiedgasse 14',
+ zip: '8010',
+ city: 'Graz',
+ email: 'office@frauzentrum.at'
+ };
+ gpsDistance.value = 42;
+ gpsState.value = 'customer_found';
+ fillFromCustomer(detectedCustomer.value);
+ customerCardExpanded.value = false;
+ if (hoursEntries.value[0]) {
+ hoursEntries.value[0].kilometerCount = 84;
+ }
+ navigator.vibrate?.([100, 50, 100]);
+ };
+
+ // Submit form
+ const submit = async (andSign = false) => {
+ if (!isValid.value) {
+ emit('toast', 'Bitte alle Pflichtfelder ausfüllen', 'error');
+ return;
+ }
+
+ loading.value = true;
+
+ try {
+ const payload = {
+ billingAddressId: form.billingAddressId,
+ deliveryAddressName: form.deliveryAddressName,
+ deliveryAddressLine: form.deliveryAddressLine,
+ deliveryAddressPLZ: form.deliveryAddressPLZ,
+ deliveryAddressCity: form.deliveryAddressCity,
+ deliveryAddressEMail: form.deliveryAddressEMail,
+ note: form.note,
+ type: form.type,
+ positions: positions.value.map(p => ({
+ article: p.articleId,
+ amount: p.amount,
+ isEnergieMaterial: p.isEnergieMaterial
+ })),
+ hoursEntries: hoursEntries.value.filter(h => h.hourCount > 0).map(h => ({
+ userId: h.userId,
+ date: h.date,
+ hourCount: h.hourCount,
+ hourType: h.hourType,
+ carId: h.carId,
+ kilometerCount: h.kilometerCount,
+ comment: h.comment
+ }))
+ };
+
+ const data = await shippingNoteApi.post('create', payload);
+
+ if (data.success) {
+ if (andSign) {
+ emit('createAndSign', data.shippingNote);
+ } else {
+ emit('created', data.shippingNote);
+ }
+ resetForm();
+ } else {
+ emit('toast', data.error || 'Fehler beim Erstellen', 'error');
+ }
+ } catch (e) {
+ console.error('Submit failed:', e);
+ emit('toast', 'Netzwerkfehler', 'error');
+ } finally {
+ loading.value = false;
+ }
+ };
+
+ // Reset form
+ const resetForm = () => {
+ form.customerId = null;
+ form.customerName = '';
+ form.billingAddressId = null;
+ form.deliveryAddressName = '';
+ form.deliveryAddressLine = '';
+ form.deliveryAddressPLZ = '';
+ form.deliveryAddressCity = '';
+ form.deliveryAddressEMail = '';
+ form.note = '';
+ positions.value = [];
+ hoursEntries.value = [createNewHoursEntry()];
+ if (userCar.value) {
+ hoursEntries.value[0].carId = userCar.value.id;
+ hoursEntries.value[0].carName = userCar.value.name;
+ }
+ detectGPS();
+ };
+
+ // Debounced search watchers
+ let customerSearchTimeout = null;
+ watch(customerSearchQuery, () => {
+ clearTimeout(customerSearchTimeout);
+ customerSearchTimeout = setTimeout(searchCustomers, 300);
+ });
+
+ let articleSearchTimeout = null;
+ watch(articleSearchQuery, () => {
+ clearTimeout(articleSearchTimeout);
+ articleSearchTimeout = setTimeout(searchArticles, 300);
+ });
+
+ return {
+ gpsState, gpsPosition, detectedCustomer, gpsAddress, gpsDistance,
+ customerCardExpanded, form, hoursEntries, positions, loading,
+ showCustomerSearch, customerSearchQuery, customerSearchResults, customerSearchLoading,
+ showArticleSearch, articleSearchQuery, articleSearchResults, articleSearchLoading,
+ showCarSelect, carSelectEntryIndex, allCars,
+ showHourTypeSelect, hourTypeSelectEntryIndex, hourTypes,
+ showPositionsSection, showDatePicker, activeEntryIndex,
+ showEmployeeSelector, selectedEmployeeIds,
+ userCar, isValid,
+ quickWorkTypes, selectedWorkType, selectWorkType,
+ formatDistance, formatDateDisplay, formatHoursValue,
+ detectGPS, refreshGPS, mockCustomerFound, switchToManual, selectCustomer,
+ toggleCustomerCard, addArticle, removePosition, toggleEnergieMaterial,
+ setHours, adjustHours, openDatePicker, updateEntryDate,
+ openEmployeeSelector, addEmployee, removeHoursEntry,
+ openCarSelect, selectCar, openHourTypeSelect, selectHourType,
+ submit, resetForm
+ };
+ },
+
+ template: `
+
+
+
+
+
+
+
+
+
+
+ Kunde erkannt
+ {{ formatDistance(gpsDistance) }}
+
+
{{ form.customerName }}
+
{{ form.deliveryAddressLine }}, {{ form.deliveryAddressPLZ }} {{ form.deliveryAddressCity }}
+
+
+
+ Ändern
+
+
+
+
+
+
+
+
+
+
+
+ Demo
+
+
+
+
+ Standort wird ermittelt...
+
+
+
+
+
+
+ GPS Kunde erkannt
+
+
+
+
+
+
+ Kein Kunde in der Nähe
+
+
+
+
+
+
+ Manuell
+
+
+
+
+
+
+ GPS nicht verfügbar
+
+
+
+
+
+
+
+
+
+
+ {{ form.customerName || 'Kunde wählen...' }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Art der Arbeit *
+
+
+
+
+ {{ type }}
+
+
+
+
+
+
+
+
+
+
Arbeitszeit
+
+
+
+
+ MA
+
+
+
+
+
+
+
+
+
+
+ {{ entry.userName.charAt(0).toUpperCase() }}
+
+
{{ entry.userName }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ formatDateDisplay(entry.date) }}
+
+
+
+
+
+
+
+
+
+
-
+
+ {{ formatHoursValue(entry.hourCount).h }}
+ h
+ {{ formatHoursValue(entry.hourCount).m }}
+ m
+
+
+
+
+
+ {{ h === 0.5 ? '½h' : h + 'h' }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ entry.carName || 'Fahrzeug' }}
+
+
+
+
+
+
+
+
+ km
+
+
+
+
+
+ Stundenart:
+ {{ entry.hourTypeName || 'Normal' }}
+
+
+
+
+
+
+
+
+
+
+
+
+
Artikel
+ {{ positions.length }}
+
+
+
+
+
+
+
+
+
+
+
{{ pos.articleName }}
+
{{ pos.articleNumber }}
+
+
+
+
+
+
+
+
+
+ -
+ {{ pos.amount }}
+ +
+
+
+ Beigestellt
+
+
+
+
+
+
+
+
+ Artikel hinzufügen
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ loading ? 'Wird erstellt...' : 'Speichern & Unterschreiben' }}
+
+
+ Nur speichern (ohne Unterschrift)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Kunde suchen
+
+
+
+
+
+
Suche...
+
Keine Kunden gefunden
+
+
+ {{ customer.displayName }}
+ {{ customer.company }}
+ {{ customer.street }}, {{ customer.zip }} {{ customer.city }}
+ KNr: {{ customer.customerNumber }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Artikel suchen
+
+
+
+
+
+
Suche...
+
Keine Artikel gefunden
+
+
+ {{ article.title }}
+ {{ article.articleNumber }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Fahrzeug wählen
+
+
+
+ {{ car.name }}
+ {{ car.plate }}
+
+
+ Kein Fahrzeug
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Stundenart wählen
+
+
+
+
+
+
+ `
+};
diff --git a/public/mobile/modules/lager/shippingnote/ShippingNoteList.js b/public/mobile/modules/lager/shippingnote/ShippingNoteList.js
new file mode 100644
index 000000000..284e7ada8
--- /dev/null
+++ b/public/mobile/modules/lager/shippingnote/ShippingNoteList.js
@@ -0,0 +1,227 @@
+/**
+ * ShippingNoteList Component
+ *
+ * Lists unsigned shipping notes for the current user.
+ * Features:
+ * - Pull to refresh
+ * - Tap to open signature modal
+ * - Shows customer, date, note preview
+ */
+
+import { shippingNoteApi } from '/mobile/modules/lager/shippingnote/ShippingNoteModule.js';
+
+export default {
+ name: 'ShippingNoteList',
+ emits: ['sign', 'toast'],
+ props: {
+ user: Object
+ },
+
+ setup(props, { emit }) {
+ const { ref, onMounted } = Vue;
+
+ // Data
+ const shippingNotes = ref([]);
+ const loading = ref(true);
+ const refreshing = ref(false);
+ const error = ref(null);
+
+ // Load shipping notes
+ const loadShippingNotes = async (isRefresh = false) => {
+ if (isRefresh) {
+ refreshing.value = true;
+ } else {
+ loading.value = true;
+ }
+ error.value = null;
+
+ try {
+ const data = await shippingNoteApi.get('getMyShippingNotes');
+
+ if (data.success) {
+ shippingNotes.value = data.shippingNotes || [];
+ } else {
+ error.value = data.error || 'Fehler beim Laden';
+ }
+ } catch (e) {
+ console.error('Failed to load shipping notes:', e);
+ error.value = 'Netzwerkfehler';
+ } finally {
+ loading.value = false;
+ refreshing.value = false;
+ }
+ };
+
+ // Pull to refresh
+ let touchStartY = 0;
+ let isPulling = false;
+
+ const handleTouchStart = (e) => {
+ const scrollTop = e.currentTarget.scrollTop;
+ if (scrollTop === 0) {
+ touchStartY = e.touches[0].clientY;
+ isPulling = true;
+ }
+ };
+
+ const handleTouchMove = (e) => {
+ if (!isPulling) return;
+ const deltaY = e.touches[0].clientY - touchStartY;
+ if (deltaY > 80 && !refreshing.value) {
+ loadShippingNotes(true);
+ isPulling = false;
+ }
+ };
+
+ const handleTouchEnd = () => {
+ isPulling = false;
+ };
+
+ // Open signature for a shipping note
+ const openSignature = (shippingNote) => {
+ emit('sign', shippingNote);
+ navigator.vibrate?.([50]);
+ };
+
+ // Format date
+ const formatDate = (dateStr) => {
+ if (!dateStr) return '';
+ const date = new Date(dateStr);
+ return date.toLocaleDateString('de-AT', {
+ day: '2-digit',
+ month: '2-digit',
+ year: 'numeric'
+ });
+ };
+
+ // Initialize
+ onMounted(() => {
+ loadShippingNotes();
+ });
+
+ return {
+ shippingNotes,
+ loading,
+ refreshing,
+ error,
+ loadShippingNotes,
+ handleTouchStart,
+ handleTouchMove,
+ handleTouchEnd,
+ openSignature,
+ formatDate
+ };
+ },
+
+ template: `
+
+
+
+
+
+
+
+
+
+
+
Lade Lieferscheine...
+
+
+
+
+
+
{{ error }}
+
+ Erneut versuchen
+
+
+
+
+
+
+
Alles unterschrieben!
+
+ Keine offenen Lieferscheine zum Unterschreiben.
+
+
+
+
+
+
+ {{ shippingNotes.length }} {{ shippingNotes.length === 1 ? 'Lieferschein' : 'Lieferscheine' }} zum Unterschreiben
+
+
+
+
+
+
+
+
+ #{{ note.number || note.id }}
+
+
+ {{ formatDate(note.date) }}
+
+
+
+
+
+ {{ note.customerName || note.deliveryAddressName || 'Unbekannter Kunde' }}
+
+
+
+
+ {{ note.deliveryAddressLine }}, {{ note.deliveryAddressPLZ }} {{ note.deliveryAddressCity }}
+
+
+
+
+ {{ note.note }}
+
+
+
+
+
+
+
+
+
+
+
+ Ziehen zum Aktualisieren
+
+
+ `
+};
diff --git a/public/mobile/modules/lager/shippingnote/ShippingNoteModule.js b/public/mobile/modules/lager/shippingnote/ShippingNoteModule.js
new file mode 100644
index 000000000..b5667a8ce
--- /dev/null
+++ b/public/mobile/modules/lager/shippingnote/ShippingNoteModule.js
@@ -0,0 +1,164 @@
+import { createModuleApi } from '/mobile/shared/api.js';
+import ShippingNoteForm from '/mobile/modules/lager/shippingnote/ShippingNoteForm.js';
+import ShippingNoteList from '/mobile/modules/lager/shippingnote/ShippingNoteList.js';
+import SignaturePad from '/mobile/modules/lager/shippingnote/SignaturePad.js';
+
+const shippingNoteApi = createModuleApi('Lager/ShippingNote');
+
+export { shippingNoteApi };
+
+export default {
+ name: 'ShippingNoteModule',
+ emits: ['navigate', 'toast'],
+ props: {
+ user: Object,
+ submodule: String
+ },
+ components: {
+ ShippingNoteForm,
+ ShippingNoteList,
+ SignaturePad
+ },
+
+ setup(props, { emit }) {
+ const { ref, computed, watch, onMounted } = Vue;
+
+ // Current view: 'create' | 'list' | 'sign'
+ const currentTab = ref('create');
+
+ // Signature modal state
+ const showSignatureModal = ref(false);
+ const signatureShippingNoteId = ref(null);
+ const signatureShippingNote = ref(null);
+
+ // Last created shipping note (for immediate signing)
+ const lastCreatedId = ref(null);
+
+ // Open signature modal for a shipping note
+ const openSignature = (shippingNote) => {
+ signatureShippingNoteId.value = shippingNote.id;
+ signatureShippingNote.value = shippingNote;
+ showSignatureModal.value = true;
+ };
+
+ // Close signature modal
+ const closeSignature = () => {
+ showSignatureModal.value = false;
+ signatureShippingNoteId.value = null;
+ signatureShippingNote.value = null;
+ };
+
+ // Handle successful signature
+ const handleSignatureComplete = () => {
+ closeSignature();
+ emit('toast', 'Unterschrift gespeichert', 'success');
+ // Haptic feedback
+ navigator.vibrate?.([100, 50, 100]);
+ };
+
+ // Handle shipping note created
+ const handleCreated = (shippingNote) => {
+ lastCreatedId.value = shippingNote.id;
+ emit('toast', 'Lieferschein erstellt', 'success');
+ // Haptic feedback
+ navigator.vibrate?.([100]);
+ };
+
+ // Handle immediate sign after create
+ const handleCreateAndSign = (shippingNote) => {
+ handleCreated(shippingNote);
+ // Open signature modal immediately
+ openSignature(shippingNote);
+ };
+
+ // Show toast
+ const showToast = (message, type) => {
+ emit('toast', message, type);
+ };
+
+ // Switch tab
+ const switchTab = (tab) => {
+ currentTab.value = tab;
+ };
+
+ return {
+ currentTab,
+ showSignatureModal,
+ signatureShippingNoteId,
+ signatureShippingNote,
+ lastCreatedId,
+ openSignature,
+ closeSignature,
+ handleSignatureComplete,
+ handleCreated,
+ handleCreateAndSign,
+ showToast,
+ switchTab
+ };
+ },
+
+ template: `
+
+
+
+
+
+
+
+ Neu erstellen
+
+
+
+
+
+ Unterschreiben
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `
+};
diff --git a/public/mobile/modules/lager/shippingnote/SignaturePad.js b/public/mobile/modules/lager/shippingnote/SignaturePad.js
new file mode 100644
index 000000000..e1b5310df
--- /dev/null
+++ b/public/mobile/modules/lager/shippingnote/SignaturePad.js
@@ -0,0 +1,300 @@
+/**
+ * SignaturePad Component
+ *
+ * Full-screen signature capture for shipping notes.
+ * Features:
+ * - Canvas-based signature drawing
+ * - Customer name input
+ * - Clear/retry functionality
+ * - Base64 PNG export
+ */
+
+import { shippingNoteApi } from '/mobile/modules/lager/shippingnote/ShippingNoteModule.js';
+
+export default {
+ name: 'SignaturePad',
+ emits: ['close', 'signed', 'toast'],
+ props: {
+ shippingNoteId: [Number, String],
+ shippingNote: Object
+ },
+
+ setup(props, { emit }) {
+ const { ref, onMounted, onUnmounted, nextTick } = Vue;
+
+ // Refs
+ const canvasRef = ref(null);
+ const signatureName = ref('');
+ const loading = ref(false);
+ const hasSignature = ref(false);
+
+ // Canvas context
+ let ctx = null;
+ let isDrawing = false;
+ let lastX = 0;
+ let lastY = 0;
+
+ // Initialize canvas
+ onMounted(async () => {
+ await nextTick();
+ initCanvas();
+ window.addEventListener('resize', handleResize);
+ });
+
+ onUnmounted(() => {
+ window.removeEventListener('resize', handleResize);
+ });
+
+ const initCanvas = () => {
+ const canvas = canvasRef.value;
+ if (!canvas) return;
+
+ // Set canvas size to container
+ const container = canvas.parentElement;
+ canvas.width = container.clientWidth;
+ canvas.height = container.clientHeight;
+
+ ctx = canvas.getContext('2d');
+ ctx.strokeStyle = '#000000';
+ ctx.lineWidth = 3;
+ ctx.lineCap = 'round';
+ ctx.lineJoin = 'round';
+
+ // Fill with white background
+ ctx.fillStyle = '#ffffff';
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
+ };
+
+ const handleResize = () => {
+ // Save current signature
+ const canvas = canvasRef.value;
+ if (!canvas) return;
+
+ const imageData = canvas.toDataURL();
+
+ // Resize canvas
+ initCanvas();
+
+ // Restore signature
+ if (hasSignature.value) {
+ const img = new Image();
+ img.onload = () => {
+ ctx.drawImage(img, 0, 0);
+ };
+ img.src = imageData;
+ }
+ };
+
+ // Get position from touch/mouse event
+ const getPosition = (e) => {
+ const canvas = canvasRef.value;
+ const rect = canvas.getBoundingClientRect();
+
+ if (e.touches && e.touches.length > 0) {
+ return {
+ x: e.touches[0].clientX - rect.left,
+ y: e.touches[0].clientY - rect.top
+ };
+ }
+ return {
+ x: e.clientX - rect.left,
+ y: e.clientY - rect.top
+ };
+ };
+
+ // Start drawing
+ const startDrawing = (e) => {
+ e.preventDefault();
+ isDrawing = true;
+ const pos = getPosition(e);
+ lastX = pos.x;
+ lastY = pos.y;
+ };
+
+ // Draw
+ const draw = (e) => {
+ if (!isDrawing) return;
+ e.preventDefault();
+
+ const pos = getPosition(e);
+
+ ctx.beginPath();
+ ctx.moveTo(lastX, lastY);
+ ctx.lineTo(pos.x, pos.y);
+ ctx.stroke();
+
+ lastX = pos.x;
+ lastY = pos.y;
+ hasSignature.value = true;
+ };
+
+ // Stop drawing
+ const stopDrawing = () => {
+ isDrawing = false;
+ };
+
+ // Clear canvas
+ const clearCanvas = () => {
+ const canvas = canvasRef.value;
+ if (!canvas || !ctx) return;
+
+ ctx.fillStyle = '#ffffff';
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
+ hasSignature.value = false;
+ navigator.vibrate?.([30]);
+ };
+
+ // Submit signature
+ const submitSignature = async () => {
+ if (!hasSignature.value) {
+ emit('toast', 'Bitte unterschreiben', 'error');
+ return;
+ }
+
+ if (!signatureName.value.trim()) {
+ emit('toast', 'Bitte Namen eingeben', 'error');
+ return;
+ }
+
+ loading.value = true;
+
+ try {
+ const canvas = canvasRef.value;
+ const signatureData = canvas.toDataURL('image/png');
+
+ const data = await shippingNoteApi.post(`sign?id=${props.shippingNoteId}`, {
+ signature: signatureData,
+ signatureName: signatureName.value.trim()
+ });
+
+ if (data.success) {
+ emit('signed', data);
+ } else {
+ emit('toast', data.error || 'Fehler beim Speichern', 'error');
+ }
+ } catch (e) {
+ console.error('Signature submit failed:', e);
+ emit('toast', 'Netzwerkfehler', 'error');
+ } finally {
+ loading.value = false;
+ }
+ };
+
+ // Close handler
+ const handleClose = () => {
+ emit('close');
+ };
+
+ return {
+ canvasRef,
+ signatureName,
+ loading,
+ hasSignature,
+ startDrawing,
+ draw,
+ stopDrawing,
+ clearCanvas,
+ submitSignature,
+ handleClose
+ };
+ },
+
+ template: `
+
+
+
+
+
+
+
+
+
Unterschrift
+
+
+
+
+
+
+
+
+
+
Lieferschein Nr.
+
{{ shippingNote.number || shippingNote.id }}
+
+ {{ shippingNote.customerName }}
+
+
+
+
+
+
+
+
+
+
Hier unterschreiben
+
+
+
+
+
+
+
+
Mit Finger unterschreiben
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ loading ? 'Wird gespeichert...' : 'Unterschrift speichern' }}
+
+
+
+ `
+};
diff --git a/public/mobile/modules/workorder/WorkorderModule.js b/public/mobile/modules/workorder/WorkorderModule.js
new file mode 100644
index 000000000..cdd16a81b
--- /dev/null
+++ b/public/mobile/modules/workorder/WorkorderModule.js
@@ -0,0 +1,1373 @@
+/**
+ * Workorder Module (Aufträge)
+ *
+ * Main module for workorder management in MobileApp.
+ * Provides list view and full-screen detail view with collapsible cards.
+ */
+
+export default {
+ name: 'WorkorderModule',
+ emits: ['navigate', 'toast'],
+ props: {
+ user: Object,
+ submodule: String
+ },
+
+ setup(props, { emit }) {
+ const { ref, computed, watch, onMounted, nextTick } = Vue;
+
+ // =====================
+ // STATE
+ // =====================
+ const isLoading = ref(true);
+ const workorders = ref([]);
+ const searchTerm = ref('');
+ const selectedFcp = ref('all');
+ const showFcpFilter = ref(false);
+
+ // Detail view state
+ const selectedWorkorder = ref(null);
+ const isDetailLoading = ref(false);
+ const documentation = ref({ docs: [], journals: [] });
+ const tenantConfig = ref(null);
+ const checklist = ref([]);
+
+ // Expanded cards state
+ const expandedCards = ref({
+ customer: true,
+ checklist: true,
+ documentation: false,
+ notes: false,
+ journal: false,
+ cableData: false
+ });
+
+ // Edit states
+ const isEditingNotes = ref(false);
+ const tempNotes = ref('');
+ const newJournalText = ref('');
+ const cableDataForm = ref({ cableLength: '', cableType: '' });
+
+ // Bottom sheets
+ const showDocUploadSheet = ref(false);
+ const showProblemSheet = ref(false);
+ const showCompleteSheet = ref(false);
+
+ // Upload state
+ const uploadDocType = ref('');
+ const isUploading = ref(false);
+ const fileInputRef = ref(null);
+ const pendingChecklistUpload = ref(null); // For auto-advance camera
+
+ // Problem form
+ const problemType = ref('');
+ const problemComment = ref('');
+
+ // Swipe state for list cards
+ const swipeStartX = ref(0);
+ const swipeCardId = ref(null);
+ const swipeOffset = ref({}); // { [workorderId]: offsetX }
+
+ // =====================
+ // COMPUTED
+ // =====================
+ const fcpOptions = computed(() => {
+ if (!workorders.value.length) return [{ value: 'all', text: 'Alle FCPs' }];
+ const fcps = [...new Set(workorders.value.map(wo => wo.fcpName).filter(Boolean))].sort();
+ return [{ value: 'all', text: 'Alle FCPs' }, ...fcps.map(fcp => ({ value: fcp, text: fcp }))];
+ });
+
+ const filteredWorkorders = computed(() => {
+ let filtered = workorders.value;
+
+ // FCP filter
+ if (selectedFcp.value !== 'all') {
+ filtered = filtered.filter(wo => wo.fcpName === selectedFcp.value);
+ }
+
+ // Search filter
+ if (searchTerm.value.length > 2) {
+ const term = searchTerm.value.toLowerCase();
+ filtered = filtered.filter(wo =>
+ wo.id.toString().includes(term) ||
+ (wo.oaid && wo.oaid.toLowerCase().includes(term)) ||
+ (wo.fcpName && wo.fcpName.toLowerCase().includes(term)) ||
+ (wo.customerName && wo.customerName.toLowerCase().includes(term)) ||
+ (wo.customerAddress && wo.customerAddress.toLowerCase().includes(term))
+ );
+ }
+
+ // Sort by status priority
+ const getStatusRank = (status) => {
+ switch (status) {
+ case 'scheduled':
+ case 'civil_engineering_completed': return 0;
+ case 'assigned':
+ case 'new':
+ case 'problem_solved': return 1;
+ case 'in_progress': return 2;
+ case 'intervention_required':
+ case 'correction_requested': return 3;
+ case 'documented':
+ case 'completed': return 4;
+ default: return 99;
+ }
+ };
+
+ return filtered.sort((a, b) => {
+ const rankA = getStatusRank(a.status);
+ const rankB = getStatusRank(b.status);
+ if (rankA !== rankB) return rankA - rankB;
+ return (a.appointmentDate || Infinity) - (b.appointmentDate || Infinity);
+ });
+ });
+
+ const checklistProgress = computed(() => {
+ if (!checklist.value.length) return { completed: 0, total: 0, text: '0/0' };
+ const completed = checklist.value.filter(c => c.completed).length;
+ const total = checklist.value.length;
+ return { completed, total, text: `${completed}/${total}` };
+ });
+
+ const isChecklistComplete = computed(() => {
+ // Check required items are completed (if any are marked as required)
+ const requiredItems = checklist.value.filter(c => c.required);
+ if (requiredItems.length > 0) {
+ const allRequiredComplete = requiredItems.every(c => c.completed);
+ if (!allRequiredComplete) return false;
+ } else if (checklist.value.length > 0) {
+ // If no items are marked as required, check if at least some items are completed
+ const hasAnyCompleted = checklist.value.some(c => c.completed);
+ if (!hasAnyCompleted) return false;
+ }
+
+ // Check cable data if required
+ if (tenantConfig.value?.requireCableLength && !cableDataForm.value.cableLength?.trim()) {
+ return false;
+ }
+ if (tenantConfig.value?.requireCableType && !cableDataForm.value.cableType?.trim()) {
+ return false;
+ }
+
+ return true;
+ });
+
+ const incompleteItems = computed(() => {
+ const items = [];
+ checklist.value.filter(c => !c.completed).forEach(c => {
+ items.push(c.text);
+ });
+ if (tenantConfig.value?.requireCableLength && !cableDataForm.value.cableLength?.trim()) {
+ items.push('Kabellänge');
+ }
+ if (tenantConfig.value?.requireCableType && !cableDataForm.value.cableType?.trim()) {
+ items.push('Kabeltyp');
+ }
+ return items;
+ });
+
+ const googleMapsLink = computed(() => {
+ if (!selectedWorkorder.value?.customer) return '#';
+ const c = selectedWorkorder.value.customer;
+ const address = encodeURIComponent(`${c.street}, ${c.zip} ${c.city}`);
+ return `https://maps.google.com/maps?q=${address}`;
+ });
+
+ // =====================
+ // METHODS
+ // =====================
+ const fetchWorkorders = async () => {
+ isLoading.value = true;
+ try {
+ const response = await fetch('/MobileApp/Workorder/Workorder/get', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ credentials: 'include',
+ body: JSON.stringify({ pagination: { page: 1, per_page: 500 } })
+ });
+ const data = await response.json();
+ if (data.success) {
+ workorders.value = data.workorders;
+ }
+ } catch (error) {
+ console.error('Error fetching workorders:', error);
+ emit('toast', 'Fehler beim Laden der Aufträge', 'error');
+ } finally {
+ isLoading.value = false;
+ }
+ };
+
+ const openDetail = async (workorder) => {
+ selectedWorkorder.value = workorder;
+ isDetailLoading.value = true;
+ expandedCards.value = { customer: true, checklist: true, documentation: false, notes: false, journal: false, cableData: false };
+
+ try {
+ // Fetch all workorder details in a single request
+ const response = await fetch(`/MobileApp/Workorder/Workorder/getWorkorderDetail?id=${workorder.id}`, { credentials: 'include' });
+ const data = await response.json();
+
+ if (data.success) {
+ selectedWorkorder.value = data.workorder;
+ cableDataForm.value = {
+ cableLength: data.workorder.cableLength || '',
+ cableType: data.workorder.cableType || ''
+ };
+ documentation.value = { docs: data.docs, journals: data.journals };
+ tenantConfig.value = data.tenantConfig;
+ checklist.value = data.checklist;
+ } else {
+ emit('toast', data.message || 'Fehler beim Laden', 'error');
+ }
+ } catch (error) {
+ console.error('Error loading detail:', error);
+ emit('toast', 'Fehler beim Laden der Details', 'error');
+ } finally {
+ isDetailLoading.value = false;
+ }
+ };
+
+ const closeDetail = () => {
+ selectedWorkorder.value = null;
+ documentation.value = { docs: [], journals: [] };
+ tenantConfig.value = null;
+ checklist.value = [];
+ isEditingNotes.value = false;
+ };
+
+ const toggleCard = (cardId) => {
+ expandedCards.value[cardId] = !expandedCards.value[cardId];
+ };
+
+ // Notes editing
+ const startEditNotes = () => {
+ tempNotes.value = selectedWorkorder.value.additionalInfo || '';
+ isEditingNotes.value = true;
+ };
+
+ const cancelEditNotes = () => {
+ isEditingNotes.value = false;
+ tempNotes.value = '';
+ };
+
+ const saveNotes = async () => {
+ try {
+ const response = await fetch('/MobileApp/Workorder/Workorder/updateAdditionalInfo', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ credentials: 'include',
+ body: JSON.stringify({
+ workorderId: selectedWorkorder.value.id,
+ additionalInfo: tempNotes.value
+ })
+ });
+ const data = await response.json();
+ if (data.success) {
+ selectedWorkorder.value.additionalInfo = data.newInfo;
+ isEditingNotes.value = false;
+ emit('toast', 'Notiz gespeichert', 'success');
+ // Refresh journals
+ const docRes = await fetch(`/MobileApp/Workorder/Workorder/getDocumentation?workorderId=${selectedWorkorder.value.id}`, { credentials: 'include' });
+ const docData = await docRes.json();
+ if (docData.success) documentation.value.journals = docData.journals;
+ }
+ } catch (error) {
+ emit('toast', 'Fehler beim Speichern', 'error');
+ }
+ };
+
+ // Journal
+ const addJournalEntry = async () => {
+ if (!newJournalText.value.trim()) return;
+ try {
+ const response = await fetch('/MobileApp/Workorder/Workorder/addJournal', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ credentials: 'include',
+ body: JSON.stringify({
+ workorderId: selectedWorkorder.value.id,
+ text: newJournalText.value
+ })
+ });
+ const data = await response.json();
+ if (data.success) {
+ documentation.value.journals = data.journals;
+ newJournalText.value = '';
+ emit('toast', 'Eintrag hinzugefügt', 'success');
+ }
+ } catch (error) {
+ emit('toast', 'Fehler beim Hinzufügen', 'error');
+ }
+ };
+
+ // Cable data
+ const saveCableData = async () => {
+ try {
+ const response = await fetch('/MobileApp/Workorder/Workorder/updateWorkorderData', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ credentials: 'include',
+ body: JSON.stringify({
+ workorderId: selectedWorkorder.value.id,
+ cableLength: cableDataForm.value.cableLength,
+ cableType: cableDataForm.value.cableType
+ })
+ });
+ const data = await response.json();
+ if (data.success) {
+ emit('toast', 'Daten gespeichert', 'success');
+ }
+ } catch (error) {
+ emit('toast', 'Fehler beim Speichern', 'error');
+ }
+ };
+
+ // Documentation upload
+ const openDocUpload = () => {
+ if (tenantConfig.value?.documentationTypes?.length) {
+ uploadDocType.value = tenantConfig.value.documentationTypes[0].value;
+ }
+ showDocUploadSheet.value = true;
+ };
+
+ const triggerFileInput = () => {
+ if (fileInputRef.value) {
+ fileInputRef.value.click();
+ }
+ };
+
+ const handleFileSelect = async (event) => {
+ const files = event.target.files;
+ if (!files || !files.length) return;
+
+ isUploading.value = true;
+ const wasFromChecklist = pendingChecklistUpload.value !== null;
+ const formData = new FormData();
+ formData.append('workorderId', selectedWorkorder.value.id);
+ formData.append('documentType', uploadDocType.value);
+
+ for (let i = 0; i < files.length; i++) {
+ formData.append('files[]', files[i]);
+ }
+
+ try {
+ const response = await fetch('/MobileApp/Workorder/Workorder/uploadDocumentation', {
+ method: 'POST',
+ credentials: 'include',
+ body: formData
+ });
+ const data = await response.json();
+ if (data.success) {
+ triggerHaptic('light');
+ emit('toast', 'Dokument hochgeladen', 'success');
+ showDocUploadSheet.value = false;
+
+ // Refresh documentation and checklist
+ const [docRes, checkRes] = await Promise.all([
+ fetch(`/MobileApp/Workorder/Workorder/getDocumentation?workorderId=${selectedWorkorder.value.id}`, { credentials: 'include' }),
+ fetch(`/MobileApp/Workorder/Workorder/getChecklist?workorderId=${selectedWorkorder.value.id}`, { credentials: 'include' })
+ ]);
+ const docData = await docRes.json();
+ const checkData = await checkRes.json();
+ if (docData.success) documentation.value = { docs: docData.docs, journals: docData.journals };
+ if (checkData.success) checklist.value = checkData.checklist;
+
+ // Auto-advance: If upload was from checklist, open camera for next item
+ if (wasFromChecklist) {
+ pendingChecklistUpload.value = null;
+ await nextTick();
+ const nextItem = getNextUncompletedItem();
+ if (nextItem) {
+ // Short delay so user sees the check mark
+ setTimeout(() => {
+ quickUploadForItem(nextItem);
+ }, 500);
+ }
+ }
+ }
+ } catch (error) {
+ emit('toast', 'Upload fehlgeschlagen', 'error');
+ } finally {
+ isUploading.value = false;
+ if (fileInputRef.value) fileInputRef.value.value = '';
+ }
+ };
+
+ // Problem reporting
+ const openProblemSheet = () => {
+ problemType.value = tenantConfig.value?.interventionTypes?.[0]?.value || '';
+ problemComment.value = '';
+ showProblemSheet.value = true;
+ };
+
+ const submitProblem = async () => {
+ if (!problemType.value && !problemComment.value) {
+ emit('toast', 'Bitte Grund angeben', 'error');
+ return;
+ }
+
+ const typeText = tenantConfig.value?.interventionTypes?.find(t => t.value === problemType.value)?.text || problemType.value;
+
+ try {
+ const response = await fetch('/MobileApp/Workorder/Workorder/requestIntervention', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ credentials: 'include',
+ body: JSON.stringify({
+ workorderId: selectedWorkorder.value.id,
+ interventionType: typeText,
+ journalText: problemComment.value || typeText
+ })
+ });
+ const data = await response.json();
+ if (data.success) {
+ emit('toast', 'Problem gemeldet', 'success');
+ showProblemSheet.value = false;
+ closeDetail();
+ fetchWorkorders();
+ } else {
+ emit('toast', data.message || 'Fehler', 'error');
+ }
+ } catch (error) {
+ emit('toast', 'Fehler beim Melden', 'error');
+ }
+ };
+
+ // Complete workorder
+ const openCompleteSheet = () => {
+ showCompleteSheet.value = true;
+ };
+
+ const submitComplete = async () => {
+ try {
+ const response = await fetch('/MobileApp/Workorder/Workorder/completeWorkorder', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ credentials: 'include',
+ body: JSON.stringify({
+ workorderId: selectedWorkorder.value.id
+ })
+ });
+ const data = await response.json();
+ if (data.success) {
+ triggerHaptic('success');
+ emit('toast', 'Auftrag abgeschlossen', 'success');
+ showCompleteSheet.value = false;
+ closeDetail();
+ fetchWorkorders();
+ } else {
+ emit('toast', data.message || 'Fehler', 'error');
+ }
+ } catch (error) {
+ emit('toast', 'Fehler beim Abschließen', 'error');
+ }
+ };
+
+ const formatDate = (timestamp, format = 'date') => {
+ if (!timestamp) return '–';
+ const date = new Date(timestamp * 1000);
+ if (format === 'datetime') {
+ return date.toLocaleDateString('de-DE') + ' ' + date.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
+ }
+ return date.toLocaleDateString('de-DE');
+ };
+
+ const getStatusColor = (color) => {
+ const colors = {
+ primary: 'bg-blue-500',
+ info: 'bg-sky-500',
+ warning: 'bg-amber-500',
+ danger: 'bg-red-500',
+ success: 'bg-green-500',
+ secondary: 'bg-slate-500',
+ orange: 'bg-orange-500',
+ purple: 'bg-purple-500',
+ muted: 'bg-gray-400'
+ };
+ return colors[color] || 'bg-gray-500';
+ };
+
+ // Get left border color for status indicator on cards
+ const getStatusBorderColor = (status) => {
+ const borderColors = {
+ 'new': 'border-l-blue-500',
+ 'assigned': 'border-l-sky-500',
+ 'scheduled': 'border-l-amber-500',
+ 'in_progress': 'border-l-amber-500',
+ 'correction_requested': 'border-l-red-500',
+ 'intervention_required': 'border-l-red-500',
+ 'civil_engineering_required': 'border-l-orange-500',
+ 'civil_engineering_completed': 'border-l-green-500',
+ 'problem_solved': 'border-l-green-500',
+ 'documented': 'border-l-green-500',
+ 'completed': 'border-l-slate-500',
+ 'charged': 'border-l-purple-500',
+ 'cancelled': 'border-l-red-500',
+ 'archived': 'border-l-gray-400'
+ };
+ return borderColors[status] || 'border-l-gray-400';
+ };
+
+ // Quick camera upload for checklist item
+ const quickUploadForItem = (item) => {
+ uploadDocType.value = item.type;
+ pendingChecklistUpload.value = item.type;
+ nextTick(() => {
+ if (fileInputRef.value) {
+ fileInputRef.value.click();
+ }
+ });
+ };
+
+ // Get next uncompleted checklist item
+ const getNextUncompletedItem = () => {
+ return checklist.value.find(item => !item.completed);
+ };
+
+ // Haptic feedback
+ const triggerHaptic = (type = 'success') => {
+ if ('vibrate' in navigator) {
+ if (type === 'success') {
+ navigator.vibrate([50, 50, 100]);
+ } else {
+ navigator.vibrate(50);
+ }
+ }
+ };
+
+ // Swipe handlers for list cards
+ const handleTouchStart = (e, wo) => {
+ swipeStartX.value = e.touches[0].clientX;
+ swipeCardId.value = wo.id;
+ };
+
+ const handleTouchMove = (e, wo) => {
+ if (swipeCardId.value !== wo.id) return;
+ const currentX = e.touches[0].clientX;
+ const diff = swipeStartX.value - currentX;
+
+ // Only allow left swipe, max 100px
+ if (diff > 0) {
+ swipeOffset.value = { ...swipeOffset.value, [wo.id]: Math.min(diff, 100) };
+ } else {
+ swipeOffset.value = { ...swipeOffset.value, [wo.id]: 0 };
+ }
+ };
+
+ const handleTouchEnd = (e, wo) => {
+ if (swipeCardId.value !== wo.id) return;
+ const offset = swipeOffset.value[wo.id] || 0;
+
+ // If swiped more than 60px, trigger navigation
+ if (offset > 60 && wo.customerAddress) {
+ triggerHaptic('light');
+ const address = encodeURIComponent(wo.customerAddress);
+ window.open(`https://maps.google.com/maps?q=${address}`, '_blank');
+ }
+
+ // Reset with animation
+ swipeOffset.value = { ...swipeOffset.value, [wo.id]: 0 };
+ swipeCardId.value = null;
+ };
+
+ const getSwipeStyle = (woId) => {
+ const offset = swipeOffset.value[woId] || 0;
+ return {
+ transform: `translateX(-${offset}px)`,
+ transition: swipeCardId.value === woId ? 'none' : 'transform 0.3s ease-out'
+ };
+ };
+
+ // Open navigation directly
+ const openNavigation = () => {
+ if (selectedWorkorder.value?.customer) {
+ const c = selectedWorkorder.value.customer;
+ const address = encodeURIComponent(`${c.street}, ${c.zip} ${c.city}`);
+ window.open(`https://maps.google.com/maps?q=${address}`, '_blank');
+ }
+ };
+
+ // Call customer directly
+ const callCustomer = () => {
+ if (selectedWorkorder.value?.customer?.phone) {
+ window.location.href = `tel:${selectedWorkorder.value.customer.phone}`;
+ }
+ };
+
+ // Smart complete - show confirmation sheet
+ const handleComplete = () => {
+ if (isChecklistComplete.value) {
+ showCompleteSheet.value = true;
+ }
+ // Button is disabled when not complete, so this won't be called
+ };
+
+ // Initialize
+ onMounted(() => {
+ fetchWorkorders();
+ });
+
+ return {
+ // State
+ isLoading,
+ workorders,
+ searchTerm,
+ selectedFcp,
+ showFcpFilter,
+ fcpOptions,
+ filteredWorkorders,
+ selectedWorkorder,
+ isDetailLoading,
+ documentation,
+ tenantConfig,
+ checklist,
+ expandedCards,
+ isEditingNotes,
+ tempNotes,
+ newJournalText,
+ cableDataForm,
+ showDocUploadSheet,
+ showProblemSheet,
+ showCompleteSheet,
+ uploadDocType,
+ isUploading,
+ fileInputRef,
+ problemType,
+ problemComment,
+ checklistProgress,
+ isChecklistComplete,
+ incompleteItems,
+ googleMapsLink,
+
+ // Methods
+ fetchWorkorders,
+ openDetail,
+ closeDetail,
+ toggleCard,
+ startEditNotes,
+ cancelEditNotes,
+ saveNotes,
+ addJournalEntry,
+ saveCableData,
+ openDocUpload,
+ triggerFileInput,
+ handleFileSelect,
+ openProblemSheet,
+ submitProblem,
+ openCompleteSheet,
+ submitComplete,
+ formatDate,
+ getStatusColor,
+ getStatusBorderColor,
+ quickUploadForItem,
+ openNavigation,
+ callCustomer,
+ handleComplete,
+ handleTouchStart,
+ handleTouchMove,
+ handleTouchEnd,
+ getSwipeStyle,
+ swipeOffset
+ };
+ },
+
+ template: `
+
+
+
+
+
+
+
+
+
+
+ {{ opt.text }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Keine Aufträge gefunden
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ wo.oaid }}
+ #{{ wo.id }}
+ {{ wo.fcpName }}
+
+
{{ wo.customerName || 'Unbekannt' }}
+
{{ wo.customerAddress }}
+
+
+
+ {{ wo.statusText }}
+
+
+ {{ wo.appointmentFormatted }}
+
+
+ Frist: {{ wo.deadlineFormatted }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Zurück
+
+
+ {{ selectedWorkorder.oaid }}
+ #{{ selectedWorkorder.id }}
+ {{ selectedWorkorder.fcpName }}
+
+
+ {{ selectedWorkorder.statusText }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ selectedWorkorder.customer.name }}
+
+ {{ selectedWorkorder.customer.street }}, {{ selectedWorkorder.customer.zip }} {{ selectedWorkorder.customer.city }}
+
+
+
+
+
+
+
+
+
+ Navigation
+
+
+
+
+
+ Anrufen
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Checkliste
+
+ {{ checklistProgress.text }}
+
+
+
+
+
+
+
+
+ Keine Checkliste vorhanden
+
+
+ -
+
+
+
+
+
+
+
+
+ {{ item.text }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Dokumentation
+
+ {{ documentation.docs.length }}
+
+
+
+
+
+
+ Keine Dokumente vorhanden
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Abbrechen
+
+
+ Speichern
+
+
+
+
+
{{ selectedWorkorder.additionalInfo || 'Keine Notiz vorhanden.' }}
+
+
+
+
+ Bearbeiten
+
+
+
+
+
+
+
+
+
+
+
Journal
+
+ {{ documentation.journals.length }}
+
+
+
+
+
+
+
+
+
+
+
+ Keine Einträge
+
+
+
+
{{ entry.text }}
+
{{ entry.createFormatted }} - {{ entry.createByName }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Speichern
+
+
+
+
+
+
+
+
+
+
+
+
+ Problem
+
+
+
+
+
+
+ Abschließen ({{ checklistProgress.text }})
+
+ Abschließen
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Dokumentation hochladen
+
+
+
+
+
+
+
+ {{ type.text }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ isUploading ? 'Wird hochgeladen...' : 'Foto aufnehmen / auswählen' }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Problem melden
+
+
+
+
+
+
+
+ {{ type.text }}
+
+
+
+
+
+
+
+
+
+
+ Problem melden
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Auftrag abschließen
+
+
+
+
+
+
Alle Anforderungen erfüllt!
+
Der Auftrag kann abgeschlossen werden.
+
+
+
+
+ Abbrechen
+
+
+
+
+
+ Abschließen
+
+
+
+
+
+
+
+
+
+
+ `
+};
diff --git a/public/mobile/shared/api.js b/public/mobile/shared/api.js
new file mode 100644
index 000000000..b3481f9ff
--- /dev/null
+++ b/public/mobile/shared/api.js
@@ -0,0 +1,18 @@
+export function createModuleApi(modulePath) {
+ return {
+ get: (endpoint) => fetch(`/MobileApp/${modulePath}/${endpoint}`).then(r => r.json()),
+ post: (endpoint, data) => fetch(`/MobileApp/${modulePath}/${endpoint}`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(data)
+ }).then(r => r.json())
+ };
+}
+
+export const debounce = (fn, delay) => {
+ let timeout;
+ return (...args) => {
+ clearTimeout(timeout);
+ timeout = setTimeout(() => fn(...args), delay);
+ };
+};
diff --git a/public/mobile/shared/useNumericKeypad.js b/public/mobile/shared/useNumericKeypad.js
new file mode 100644
index 000000000..1c46ed74a
--- /dev/null
+++ b/public/mobile/shared/useNumericKeypad.js
@@ -0,0 +1,43 @@
+export function useNumericKeypad(initialValue = '1') {
+ const { ref } = Vue;
+ const quantity = ref(initialValue);
+
+ const appendDigit = (digit) => {
+ if (digit === '.' && quantity.value.includes('.')) return;
+ if (quantity.value === '0' && digit !== '.') {
+ quantity.value = digit;
+ } else {
+ quantity.value += digit;
+ }
+ };
+
+ const deleteDigit = () => {
+ quantity.value = quantity.value.length > 1 ? quantity.value.slice(0, -1) : '0';
+ };
+
+ const clearQuantity = () => {
+ quantity.value = '0';
+ };
+
+ const setQuantity = (value) => {
+ quantity.value = String(value);
+ };
+
+ const increment = (amount = 1) => {
+ quantity.value = String(Math.max(1, parseFloat(quantity.value) + amount));
+ };
+
+ const decrement = (amount = 1) => {
+ quantity.value = String(Math.max(1, parseFloat(quantity.value) - amount));
+ };
+
+ return {
+ quantity,
+ appendDigit,
+ deleteDigit,
+ clearQuantity,
+ setQuantity,
+ increment,
+ decrement
+ };
+}
diff --git a/scripts/geocode-addresses.php b/scripts/geocode-addresses.php
new file mode 100644
index 000000000..274959ed6
--- /dev/null
+++ b/scripts/geocode-addresses.php
@@ -0,0 +1,134 @@
+#!/usr/bin/php
+query($sql);
+
+if (!$result) {
+ die("ERROR: Database query failed.\n");
+}
+
+$total = $db->num_rows($result);
+echo "Found {$total} addresses to geocode" . ($dryRun ? " (DRY RUN)" : "") . "\n";
+
+if ($total === 0) {
+ echo "Nothing to do.\n";
+ exit(0);
+}
+
+$success = 0;
+$failed = 0;
+$skipped = 0;
+
+while ($row = $result->fetch_assoc()) {
+ $id = $row['id'];
+ $street = $row['street'];
+ $zip = $row['zip'];
+ $city = $row['city'];
+
+ // Build address string
+ $addressParts = [];
+ if ($street) $addressParts[] = $street;
+ if ($zip) $addressParts[] = $zip;
+ if ($city) $addressParts[] = $city;
+ $addressParts[] = 'Austria'; // Default country
+
+ $addressString = implode(', ', $addressParts);
+
+ if ($verbose) {
+ echo "Processing ID {$id}: {$addressString}... ";
+ }
+
+ if ($dryRun) {
+ if ($verbose) echo "SKIPPED (dry run)\n";
+ $skipped++;
+ continue;
+ }
+
+ // Call Google Geocoding API
+ $encodedAddress = urlencode($addressString);
+ $url = "https://maps.googleapis.com/maps/api/geocode/json?address={$encodedAddress}&key={$apiKey}®ion=at";
+
+ $response = @file_get_contents($url);
+ if (!$response) {
+ if ($verbose) echo "FAILED (API error)\n";
+ $failed++;
+ continue;
+ }
+
+ $data = json_decode($response, true);
+
+ if ($data['status'] !== 'OK' || empty($data['results'])) {
+ if ($verbose) {
+ echo "FAILED (no results, status: {$data['status']})\n";
+ }
+ $failed++;
+ continue;
+ }
+
+ $location = $data['results'][0]['geometry']['location'];
+ $lat = $location['lat'];
+ $lng = $location['lng'];
+
+ // Update database
+ $db->query("UPDATE Address SET gps_lat = {$lat}, gps_long = {$lng} WHERE id = {$id}");
+
+ if ($verbose) {
+ echo "OK ({$lat}, {$lng})\n";
+ }
+
+ $success++;
+
+ // Rate limiting - 100ms between requests
+ usleep(100000);
+}
+
+echo "\n";
+echo "Completed:\n";
+echo " Success: {$success}\n";
+echo " Failed: {$failed}\n";
+echo " Skipped: {$skipped}\n";
+echo " Total: {$total}\n";