Merge branch 'XinonMobile/improvement' into 'master'
improved some bugs See merge request fronk/thetool!2026
This commit is contained in:
@@ -16,6 +16,66 @@ class ShippingNoteHandler extends MobileAppBaseHandler {
|
||||
const OFFICE_LAT = 46.99552810791587;
|
||||
const OFFICE_LNG = 15.7751923956463;
|
||||
|
||||
public function initializeAction() {
|
||||
$db = $this->db();
|
||||
$userId = $this->user->id;
|
||||
|
||||
$userCar = null;
|
||||
$sql = "SELECT id, number_plate, brand, model
|
||||
FROM TimerecordingCar
|
||||
WHERE user_id = {$userId}
|
||||
AND (retired IS NULL OR retired = 0)
|
||||
LIMIT 1";
|
||||
$result = $db->query($sql);
|
||||
if ($result && $row = $result->fetch_assoc()) {
|
||||
$carName = trim(($row['brand'] ?? '') . ' ' . ($row['model'] ?? ''));
|
||||
if (!$carName) $carName = $row['number_plate'];
|
||||
$userCar = [
|
||||
'id' => intval($row['id']),
|
||||
'name' => $carName,
|
||||
'plate' => $row['number_plate'],
|
||||
];
|
||||
}
|
||||
|
||||
$allCars = [];
|
||||
$sql = "SELECT id, number_plate, brand, model
|
||||
FROM TimerecordingCar
|
||||
WHERE (retired IS NULL OR retired = 0)
|
||||
ORDER BY brand, model ASC";
|
||||
$result = $db->query($sql);
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$carName = trim(($row['brand'] ?? '') . ' ' . ($row['model'] ?? ''));
|
||||
if (!$carName) $carName = $row['number_plate'];
|
||||
$allCars[] = [
|
||||
'id' => intval($row['id']),
|
||||
'name' => $carName,
|
||||
'plate' => $row['number_plate'],
|
||||
];
|
||||
}
|
||||
|
||||
$hourTypes = [
|
||||
['id' => '', 'name' => 'Normal'],
|
||||
['id' => '50', 'name' => '+50%'],
|
||||
['id' => '100', 'name' => '+100%'],
|
||||
['id' => 'regie', 'name' => 'Regie'],
|
||||
];
|
||||
|
||||
$currentUser = [
|
||||
'id' => $this->user->id,
|
||||
'name' => $this->user->name,
|
||||
'firstname' => $this->user->firstname ?? '',
|
||||
'lastname' => $this->user->lastname ?? '',
|
||||
];
|
||||
|
||||
self::returnJson([
|
||||
'success' => true,
|
||||
'userCar' => $userCar,
|
||||
'allCars' => $allCars,
|
||||
'hourTypes' => $hourTypes,
|
||||
'currentUser' => $currentUser,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get customer by GPS location (nearest within radius)
|
||||
* GET /MobileApp/Lager/ShippingNote/getCustomerByLocation?lat=X&lng=Y
|
||||
|
||||
@@ -51,7 +51,9 @@ const App = {
|
||||
const deferredInstallPrompt = ref(null);
|
||||
const isIOS = ref(/iPad|iPhone|iPod/.test(navigator.userAgent));
|
||||
const isAndroid = ref(/Android/.test(navigator.userAgent));
|
||||
const canGoBack = computed(() => currentModule.value !== null);
|
||||
const canGoBack = computed(() => currentModule.value !== null || workorderDetailOpen.value);
|
||||
const workorderDetailOpen = ref(false);
|
||||
const workorderRef = ref(null);
|
||||
|
||||
const applyTheme = () => {
|
||||
const isDark = localStorage.theme === 'dark' ||
|
||||
@@ -157,6 +159,10 @@ const App = {
|
||||
};
|
||||
|
||||
const goBack = () => {
|
||||
if (workorderDetailOpen.value && workorderRef.value?.closeDetail) {
|
||||
workorderRef.value.closeDetail();
|
||||
return;
|
||||
}
|
||||
if (currentSubmodule.value) {
|
||||
navigate(currentModule.value, null);
|
||||
} else if (currentModule.value) {
|
||||
@@ -164,7 +170,19 @@ const App = {
|
||||
}
|
||||
};
|
||||
|
||||
const handleWorkorderDetailOpen = (workorderId) => {
|
||||
workorderDetailOpen.value = true;
|
||||
};
|
||||
|
||||
const handleWorkorderDetailClose = () => {
|
||||
workorderDetailOpen.value = false;
|
||||
};
|
||||
|
||||
const handlePopstate = (event) => {
|
||||
if (workorderDetailOpen.value && workorderRef.value?.closeDetail) {
|
||||
workorderRef.value.closeDetail();
|
||||
return;
|
||||
}
|
||||
if (event.state) {
|
||||
currentModule.value = event.state.module;
|
||||
currentSubmodule.value = event.state.submodule;
|
||||
@@ -301,6 +319,9 @@ const App = {
|
||||
showContinuePrompt,
|
||||
continueLastWorkflow,
|
||||
dismissContinuePrompt,
|
||||
workorderRef,
|
||||
handleWorkorderDetailOpen,
|
||||
handleWorkorderDetailClose,
|
||||
};
|
||||
},
|
||||
|
||||
@@ -500,9 +521,12 @@ const App = {
|
||||
|
||||
<WorkorderModule
|
||||
v-else-if="currentModule?.toLowerCase() === 'workorder'"
|
||||
ref="workorderRef"
|
||||
:user="user"
|
||||
@navigate="navigate"
|
||||
@toast="showToast"
|
||||
@detail-open="handleWorkorderDetailOpen"
|
||||
@detail-close="handleWorkorderDetailClose"
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -169,53 +169,27 @@ export default {
|
||||
return hoursEntries.value.map(e => e.userId).filter(id => id !== null);
|
||||
});
|
||||
|
||||
// Initialize
|
||||
onMounted(async () => {
|
||||
await Promise.all([
|
||||
loadUserCar(),
|
||||
loadAllCars(),
|
||||
loadHourTypes()
|
||||
]);
|
||||
await loadInitializationData();
|
||||
detectGPS();
|
||||
});
|
||||
|
||||
// Load user's assigned car
|
||||
const loadUserCar = async () => {
|
||||
const loadInitializationData = 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;
|
||||
const data = await shippingNoteApi.get('initialize');
|
||||
if (data.success) {
|
||||
if (data.userCar) {
|
||||
userCar.value = data.userCar;
|
||||
if (hoursEntries.value[0]) {
|
||||
hoursEntries.value[0].carId = data.userCar.id;
|
||||
hoursEntries.value[0].carName = data.userCar.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) {
|
||||
allCars.value = data.allCars || [];
|
||||
hourTypes.value = data.hourTypes || [];
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load hour types:', e);
|
||||
console.error('Failed to load initialization data:', e);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
export default {
|
||||
name: 'WorkorderModule',
|
||||
emits: ['navigate', 'toast'],
|
||||
emits: ['navigate', 'toast', 'detail-open', 'detail-close'],
|
||||
props: {
|
||||
user: Object,
|
||||
submodule: String
|
||||
@@ -63,10 +63,10 @@ export default {
|
||||
const problemType = ref('');
|
||||
const problemComment = ref('');
|
||||
|
||||
// Swipe state for list cards
|
||||
const swipeStartX = ref(0);
|
||||
const swipeCardId = ref(null);
|
||||
const swipeOffset = ref({}); // { [workorderId]: offsetX }
|
||||
const swipeOffset = ref({});
|
||||
const swipeTriggered = ref(false);
|
||||
|
||||
// =====================
|
||||
// COMPUTED
|
||||
@@ -136,12 +136,10 @@ export default {
|
||||
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;
|
||||
const allCompleted = checklist.value.every(c => c.completed);
|
||||
if (!allCompleted) return false;
|
||||
}
|
||||
|
||||
// Check cable data if required
|
||||
if (tenantConfig.value?.requireCableLength && !cableDataForm.value.cableLength?.trim()) {
|
||||
return false;
|
||||
}
|
||||
@@ -201,6 +199,7 @@ export default {
|
||||
selectedWorkorder.value = workorder;
|
||||
isDetailLoading.value = true;
|
||||
expandedCards.value = { customer: true, checklist: true, documentation: false, notes: false, journal: false, cableData: false };
|
||||
emit('detail-open', workorder.id);
|
||||
|
||||
try {
|
||||
// Fetch all workorder details in a single request
|
||||
@@ -233,6 +232,7 @@ export default {
|
||||
tenantConfig.value = null;
|
||||
checklist.value = [];
|
||||
isEditingNotes.value = false;
|
||||
emit('detail-close');
|
||||
};
|
||||
|
||||
const toggleCard = (cardId) => {
|
||||
@@ -535,10 +535,16 @@ export default {
|
||||
}
|
||||
};
|
||||
|
||||
// Swipe handlers for list cards
|
||||
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) => {
|
||||
@@ -546,9 +552,11 @@ export default {
|
||||
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) };
|
||||
if (diff > 10) {
|
||||
swipeTriggered.value = true;
|
||||
}
|
||||
} else {
|
||||
swipeOffset.value = { ...swipeOffset.value, [wo.id]: 0 };
|
||||
}
|
||||
@@ -558,18 +566,25 @@ export default {
|
||||
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) {
|
||||
swipeTriggered.value = true;
|
||||
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 handleCardClick = (wo) => {
|
||||
if (swipeTriggered.value) {
|
||||
swipeTriggered.value = false;
|
||||
return;
|
||||
}
|
||||
openDetail(wo);
|
||||
};
|
||||
|
||||
const getSwipeStyle = (woId) => {
|
||||
const offset = swipeOffset.value[woId] || 0;
|
||||
return {
|
||||
@@ -666,8 +681,10 @@ export default {
|
||||
handleTouchStart,
|
||||
handleTouchMove,
|
||||
handleTouchEnd,
|
||||
handleCardClick,
|
||||
getSwipeStyle,
|
||||
swipeOffset
|
||||
swipeOffset,
|
||||
scrollIntoViewOnFocus
|
||||
};
|
||||
},
|
||||
|
||||
@@ -767,13 +784,13 @@ export default {
|
||||
|
||||
<!-- Card content (slides on swipe) -->
|
||||
<div
|
||||
@click="openDetail(wo)"
|
||||
@click="handleCardClick(wo)"
|
||||
@touchstart="handleTouchStart($event, wo)"
|
||||
@touchmove="handleTouchMove($event, wo)"
|
||||
@touchend="handleTouchEnd($event, wo)"
|
||||
:style="getSwipeStyle(wo.id)"
|
||||
: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',
|
||||
'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)
|
||||
]"
|
||||
@@ -808,14 +825,7 @@ export default {
|
||||
|
||||
<!-- DETAIL VIEW -->
|
||||
<template v-else>
|
||||
<!-- Header -->
|
||||
<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">
|
||||
<button @click="closeDetail" class="flex items-center text-slate-600 dark:text-slate-300">
|
||||
<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="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Zurück
|
||||
</button>
|
||||
<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>
|
||||
@@ -839,7 +849,7 @@ export default {
|
||||
|
||||
<template v-else>
|
||||
<!-- Customer Card (Expanded by default) -->
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl overflow-hidden">
|
||||
<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"
|
||||
@@ -897,7 +907,7 @@ export default {
|
||||
</div>
|
||||
|
||||
<!-- Checklist Card -->
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl overflow-hidden">
|
||||
<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"
|
||||
@@ -963,7 +973,7 @@ export default {
|
||||
</div>
|
||||
|
||||
<!-- Documentation Card -->
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl overflow-hidden">
|
||||
<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"
|
||||
@@ -1020,7 +1030,7 @@ export default {
|
||||
</div>
|
||||
|
||||
<!-- Notes Card -->
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl overflow-hidden">
|
||||
<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"
|
||||
@@ -1044,6 +1054,7 @@ export default {
|
||||
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">
|
||||
@@ -1067,7 +1078,7 @@ export default {
|
||||
</div>
|
||||
|
||||
<!-- Journal Card -->
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl overflow-hidden">
|
||||
<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"
|
||||
@@ -1096,6 +1107,7 @@ export default {
|
||||
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">
|
||||
@@ -1117,7 +1129,7 @@ export default {
|
||||
</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">
|
||||
<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"
|
||||
@@ -1162,7 +1174,7 @@ export default {
|
||||
</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">
|
||||
<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"
|
||||
@@ -1230,15 +1242,6 @@ export default {
|
||||
</div>
|
||||
<!-- Upload Buttons -->
|
||||
<div class="space-y-2">
|
||||
<input
|
||||
ref="fileInputRef"
|
||||
type="file"
|
||||
accept="image/*,application/pdf"
|
||||
multiple
|
||||
class="hidden"
|
||||
@change="handleFileSelect"
|
||||
capture="environment"
|
||||
>
|
||||
<button
|
||||
@click="triggerFileInput"
|
||||
:disabled="isUploading"
|
||||
@@ -1300,6 +1303,7 @@ export default {
|
||||
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 -->
|
||||
@@ -1368,6 +1372,16 @@ export default {
|
||||
</div>
|
||||
</transition>
|
||||
</teleport>
|
||||
|
||||
<input
|
||||
ref="fileInputRef"
|
||||
type="file"
|
||||
accept="image/*,application/pdf"
|
||||
multiple
|
||||
class="hidden"
|
||||
@change="handleFileSelect"
|
||||
capture="environment"
|
||||
>
|
||||
</div>
|
||||
`
|
||||
};
|
||||
|
||||
@@ -336,3 +336,12 @@ textarea:focus-visible {
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
|
||||
.card-contrast {
|
||||
border: 1px solid rgba(148, 163, 184, 0.2);
|
||||
}
|
||||
|
||||
.dark .card-contrast {
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user