Files
thetool/public/mobile/modules/lager/shippingnote/ShippingNoteForm.js
2026-01-18 18:31:23 +00:00

1076 lines
55 KiB
JavaScript

/**
* 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';
import SearchSelectModal from '/mobile/modules/lager/shippingnote/SearchSelectModal.js';
export default {
name: 'ShippingNoteForm',
emits: ['created', 'createAndSign', 'toast'],
props: {
user: Object
},
components: {
DatePicker,
EmployeeSelector,
SearchSelectModal
},
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);
});
onMounted(async () => {
await loadInitializationData();
detectGPS();
});
const loadInitializationData = async () => {
try {
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;
}
}
allCars.value = data.allCars || [];
hourTypes.value = data.hourTypes || [];
}
} catch (e) {
console.error('Failed to load initialization data:', 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 || null;
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: `
<div class="p-3 space-y-3">
<!-- Smart Customer Card -->
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-sm overflow-hidden">
<!-- Collapsed State (when customer found) -->
<div v-if="gpsState === 'customer_found' && !customerCardExpanded" class="p-4">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3 flex-1 min-w-0">
<div class="w-10 h-10 bg-green-100 dark:bg-green-900/50 rounded-full flex items-center justify-center flex-shrink-0">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-green-600 dark:text-green-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<span class="text-xs font-medium text-green-600 dark:text-green-400">Kunde erkannt</span>
<span v-if="formatDistance(gpsDistance)" class="text-xs text-slate-400">{{ formatDistance(gpsDistance) }}</span>
</div>
<p class="font-medium text-slate-800 dark:text-white truncate">{{ form.customerName }}</p>
<p class="text-sm text-slate-500 dark:text-slate-400 truncate">{{ form.deliveryAddressLine }}, {{ form.deliveryAddressPLZ }} {{ form.deliveryAddressCity }}</p>
</div>
</div>
<button @click="toggleCustomerCard" class="px-3 py-1.5 text-sm font-medium text-primary hover:bg-primary/5 rounded-lg transition flex-shrink-0">
Ändern
</button>
</div>
</div>
<!-- Expanded State / Manual / No Customer -->
<div v-else class="p-4 space-y-3">
<!-- GPS Status Row -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<!-- Mock Button (for screenshots) -->
<button @click="mockCustomerFound" class="px-2 py-1 bg-purple-100 dark:bg-purple-900/50 text-purple-600 dark:text-purple-400 rounded text-xs font-medium">
Demo
</button>
<!-- Detecting -->
<template v-if="gpsState === 'detecting'">
<div class="animate-pulse w-2 h-2 rounded-full bg-blue-500"></div>
<span class="text-sm text-blue-600 dark:text-blue-400">Standort wird ermittelt...</span>
</template>
<!-- Customer Found (expanded) -->
<template v-else-if="gpsState === 'customer_found'">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span class="text-sm text-green-600 dark:text-green-400">GPS Kunde erkannt</span>
</template>
<!-- No Customer -->
<template v-else-if="gpsState === 'no_customer'">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-amber-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<span class="text-sm text-amber-600 dark:text-amber-400">Kein Kunde in der Nähe</span>
</template>
<!-- Manual -->
<template v-else-if="gpsState === 'manual'">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
</svg>
<span class="text-sm text-slate-500 dark:text-slate-400">Manuell</span>
</template>
<!-- Error -->
<template v-else-if="gpsState === 'error'">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span class="text-sm text-red-600 dark:text-red-400">GPS nicht verfügbar</span>
</template>
</div>
<div class="flex items-center gap-2">
<button v-if="gpsState === 'customer_found'" @click="toggleCustomerCard" class="p-1 text-slate-400">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7" />
</svg>
</button>
<button @click="refreshGPS" class="p-1 text-slate-400 hover:text-primary" :disabled="gpsState === 'detecting'">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" :class="{'animate-spin': gpsState === 'detecting'}" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</button>
</div>
</div>
<!-- Customer Input -->
<div>
<label class="block text-xs text-slate-500 dark:text-slate-400 mb-1">Kunde *</label>
<button
@click="showCustomerSearch = true"
class="w-full px-3 py-2.5 bg-slate-50 dark:bg-slate-700 border border-slate-200 dark:border-slate-600 rounded-lg text-sm text-left flex items-center justify-between"
>
<span :class="form.customerName ? 'text-slate-800 dark:text-white' : 'text-slate-400'">
{{ form.customerName || 'Kunde wählen...' }}
</span>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</button>
</div>
<!-- Address Fields -->
<div>
<label class="block text-xs text-slate-500 dark:text-slate-400 mb-1">Straße *</label>
<input type="text" v-model="form.deliveryAddressLine" class="w-full px-3 py-2.5 bg-slate-50 dark:bg-slate-700 border border-slate-200 dark:border-slate-600 rounded-lg text-sm" />
</div>
<div class="grid grid-cols-3 gap-2">
<div>
<label class="block text-xs text-slate-500 dark:text-slate-400 mb-1">PLZ</label>
<input type="text" v-model="form.deliveryAddressPLZ" class="w-full px-3 py-2.5 bg-slate-50 dark:bg-slate-700 border border-slate-200 dark:border-slate-600 rounded-lg text-sm" />
</div>
<div class="col-span-2">
<label class="block text-xs text-slate-500 dark:text-slate-400 mb-1">Ort *</label>
<input type="text" v-model="form.deliveryAddressCity" class="w-full px-3 py-2.5 bg-slate-50 dark:bg-slate-700 border border-slate-200 dark:border-slate-600 rounded-lg text-sm" />
</div>
</div>
</div>
</div>
<!-- Work Description Card -->
<div class="bg-white dark:bg-slate-800 rounded-xl p-4 shadow-sm">
<h3 class="text-sm font-semibold text-slate-700 dark:text-slate-200 mb-2">Art der Arbeit *</h3>
<!-- Quick Work Type Chips -->
<div class="flex gap-2 mb-3">
<button
v-for="type in quickWorkTypes"
:key="type"
@click="selectWorkType(type)"
:class="[
'px-4 py-2 rounded-lg text-sm font-medium transition',
selectedWorkType === type
? 'bg-primary text-white'
: 'bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-300'
]"
>
{{ type }}
</button>
</div>
<textarea
v-model="form.note"
rows="2"
class="w-full px-3 py-2.5 bg-slate-50 dark:bg-slate-700 border border-slate-200 dark:border-slate-600 rounded-lg text-sm resize-none"
placeholder="Oder eigene Beschreibung..."
></textarea>
</div>
<!-- Arbeitszeit Section - Multi-Employee -->
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-sm overflow-hidden">
<div class="px-4 py-3 border-b border-slate-100 dark:border-slate-700 flex items-center justify-between">
<h3 class="text-sm font-semibold text-slate-700 dark:text-slate-200">Arbeitszeit</h3>
<button @click="openEmployeeSelector" class="flex items-center gap-1 px-2.5 py-1.5 bg-primary/10 text-primary rounded-lg text-xs font-medium hover:bg-primary/20 transition">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
MA
</button>
</div>
<!-- Hours Entries -->
<div class="divide-y divide-slate-100 dark:divide-slate-700">
<div v-for="(entry, idx) in hoursEntries" :key="entry.id" class="p-4 space-y-3">
<!-- Employee Header -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<div class="w-8 h-8 bg-primary/10 rounded-full flex items-center justify-center">
<span class="text-primary font-semibold text-xs">{{ entry.userName.charAt(0).toUpperCase() }}</span>
</div>
<span class="font-medium text-slate-800 dark:text-white text-sm">{{ entry.userName }}</span>
</div>
<button v-if="hoursEntries.length > 1" @click="removeHoursEntry(idx)" class="p-1 text-red-400 hover:text-red-600">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- Date Picker Button -->
<button @click="openDatePicker(idx)" class="w-full px-3 py-2.5 bg-slate-50 dark:bg-slate-700 border border-slate-200 dark:border-slate-600 rounded-lg text-sm flex items-center justify-between">
<div class="flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<span class="text-slate-800 dark:text-white">{{ formatDateDisplay(entry.date) }}</span>
</div>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
<!-- Hours Stepper -->
<div>
<div class="flex items-center gap-2">
<button @click="adjustHours(idx, -0.25)" class="w-12 h-12 flex items-center justify-center bg-slate-100 dark:bg-slate-700 rounded-xl text-lg font-bold text-slate-600 dark:text-slate-300 active:bg-slate-200 dark:active:bg-slate-600">-</button>
<div class="flex-1 text-center py-2.5 bg-slate-50 dark:bg-slate-700 border border-slate-200 dark:border-slate-600 rounded-xl">
<span class="text-2xl font-bold text-slate-800 dark:text-white">{{ formatHoursValue(entry.hourCount).h }}</span>
<span class="text-sm text-slate-500 dark:text-slate-400">h</span>
<span class="text-2xl font-bold text-slate-800 dark:text-white ml-1">{{ formatHoursValue(entry.hourCount).m }}</span>
<span class="text-sm text-slate-500 dark:text-slate-400">m</span>
</div>
<button @click="adjustHours(idx, 0.25)" class="w-12 h-12 flex items-center justify-center bg-slate-100 dark:bg-slate-700 rounded-xl text-lg font-bold text-slate-600 dark:text-slate-300 active:bg-slate-200 dark:active:bg-slate-600">+</button>
</div>
<div class="flex gap-2 mt-2">
<button v-for="h in [0.5, 1, 2, 4]" :key="h" @click="setHours(idx, h)"
:class="['flex-1 py-2 rounded-lg text-sm font-medium transition', entry.hourCount === h ? 'bg-primary text-white' : 'bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-300']"
>{{ h === 0.5 ? '½h' : h + 'h' }}</button>
</div>
</div>
<!-- Car & KM Row -->
<div class="grid grid-cols-2 gap-2">
<button @click="openCarSelect(idx)" class="px-3 py-2.5 bg-slate-50 dark:bg-slate-700 border border-slate-200 dark:border-slate-600 rounded-lg text-sm text-left flex items-center justify-between">
<div class="flex items-center gap-2 min-w-0 flex-1">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-slate-400 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7h8m-8 4h8m-6 4h4M3 21h18M3 17V7c0-1.1.9-2 2-2h14a2 2 0 012 2v10" />
</svg>
<span :class="entry.carName ? 'text-slate-800 dark:text-white truncate' : 'text-slate-400'" class="truncate">
{{ entry.carName || 'Fahrzeug' }}
</span>
</div>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-slate-400 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
<div class="relative">
<input type="number" v-model.number="entry.kilometerCount" class="w-full px-3 py-2.5 pr-8 bg-slate-50 dark:bg-slate-700 border border-slate-200 dark:border-slate-600 rounded-lg text-sm" min="0" placeholder="km" />
<span class="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-slate-400">km</span>
</div>
</div>
<!-- Hour Type - Compact -->
<button @click="openHourTypeSelect(idx)" class="inline-flex items-center gap-1 px-2.5 py-1.5 bg-slate-100 dark:bg-slate-700 rounded-lg text-xs text-slate-600 dark:text-slate-300">
<span>Stundenart:</span>
<span class="font-medium">{{ entry.hourTypeName || 'Normal' }}</span>
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
</div>
</div>
</div>
<!-- Positions Section -->
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-sm overflow-hidden">
<button @click="showPositionsSection = !showPositionsSection" class="w-full flex items-center justify-between px-4 py-3 text-left">
<div class="flex items-center gap-2">
<h3 class="text-sm font-semibold text-slate-700 dark:text-slate-200">Artikel</h3>
<span v-if="positions.length" class="text-xs bg-primary text-white px-2 py-0.5 rounded-full">{{ positions.length }}</span>
</div>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-slate-400 transition-transform" :class="{'rotate-180': showPositionsSection}" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
<div v-show="showPositionsSection" class="px-4 pb-4 space-y-3">
<div v-for="(pos, idx) in positions" :key="idx" class="p-3 bg-slate-50 dark:bg-slate-700 rounded-lg space-y-2">
<div class="flex items-start justify-between gap-2">
<div class="flex-1 min-w-0">
<div class="text-sm font-medium text-slate-700 dark:text-slate-200 truncate">{{ pos.articleName }}</div>
<div class="text-xs text-slate-500 dark:text-slate-400">{{ pos.articleNumber }}</div>
</div>
<button @click="removePosition(idx)" class="p-1 text-red-500 flex-shrink-0">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
<div class="flex items-center justify-between">
<div class="flex items-center gap-1">
<button @click="pos.amount = Math.max(1, pos.amount - 1)" class="w-8 h-8 flex items-center justify-center bg-white dark:bg-slate-600 rounded text-slate-600 dark:text-slate-300">-</button>
<span class="w-10 text-center font-medium text-slate-700 dark:text-white">{{ pos.amount }}</span>
<button @click="pos.amount++" class="w-8 h-8 flex items-center justify-center bg-white dark:bg-slate-600 rounded text-slate-600 dark:text-slate-300">+</button>
</div>
<button @click="toggleEnergieMaterial(pos)"
:class="['px-3 py-1.5 rounded-lg text-xs font-medium transition', pos.isEnergieMaterial ? 'bg-amber-100 dark:bg-amber-900/50 text-amber-700 dark:text-amber-300' : 'bg-slate-200 dark:bg-slate-600 text-slate-500 dark:text-slate-400']"
>
Beigestellt
</button>
</div>
</div>
<button @click="showArticleSearch = true" class="w-full py-3 border-2 border-dashed border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-500 dark:text-slate-400 flex items-center justify-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
Artikel hinzufügen
</button>
</div>
</div>
<!-- Submit Buttons -->
<div class="space-y-2 pb-6">
<button @click="submit(true)" :disabled="!isValid || loading"
:class="['w-full py-5 rounded-xl text-base font-semibold transition flex items-center justify-center gap-2', isValid && !loading ? 'bg-gradient-to-r from-primary to-indigo-600 text-white shadow-lg active:scale-[0.98]' : 'bg-slate-200 dark:bg-slate-700 text-slate-400 dark:text-slate-500']"
>
<svg v-if="loading" class="animate-spin h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
</svg>
{{ loading ? 'Wird erstellt...' : 'Speichern & Unterschreiben' }}
</button>
<button @click="submit(false)" :disabled="!isValid || loading" class="w-full py-3 text-primary text-sm font-medium">
Nur speichern (ohne Unterschrift)
</button>
</div>
<!-- Date Picker Component -->
<DatePicker
:show="showDatePicker"
:model-value="hoursEntries[activeEntryIndex]?.date"
@update:model-value="updateEntryDate"
@close="showDatePicker = false"
/>
<!-- Employee Selector Component -->
<EmployeeSelector
:show="showEmployeeSelector"
:exclude-ids="selectedEmployeeIds"
@select="addEmployee"
@close="showEmployeeSelector = false"
/>
<!-- Customer Search Modal -->
<SearchSelectModal
:show="showCustomerSearch"
title="Kunde suchen"
:searchable="true"
search-placeholder="Name, Firma, Kundennummer..."
v-model="customerSearchQuery"
:items="customerSearchResults"
:loading="customerSearchLoading"
empty-text="Keine Kunden gefunden"
empty-icon="users"
@select="selectCustomer"
@close="showCustomerSearch = false"
>
<template #item="{ item: customer }">
<div class="w-10 h-10 bg-primary/10 rounded-full flex items-center justify-center flex-shrink-0">
<span class="text-primary font-semibold text-sm">
{{ (customer.displayName || customer.company || '?').charAt(0).toUpperCase() }}
</span>
</div>
<div class="ml-3 flex-1 min-w-0">
<p class="font-medium text-slate-800 dark:text-white truncate">{{ customer.displayName }}</p>
<p v-if="customer.company && customer.company !== customer.displayName" class="text-sm text-primary truncate">{{ customer.company }}</p>
<p class="text-sm text-slate-500 dark:text-slate-400 truncate">{{ customer.street }}, {{ customer.zip }} {{ customer.city }}</p>
<p v-if="customer.customerNumber" class="text-xs text-slate-400 dark:text-slate-500">KNr: {{ customer.customerNumber }}</p>
</div>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-slate-400 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</template>
</SearchSelectModal>
<!-- Article Search Modal -->
<SearchSelectModal
:show="showArticleSearch"
title="Artikel suchen"
:searchable="true"
search-placeholder="Artikelnummer oder Name..."
v-model="articleSearchQuery"
:items="articleSearchResults"
:loading="articleSearchLoading"
empty-text="Keine Artikel gefunden"
empty-icon="box"
@select="addArticle"
@close="showArticleSearch = false"
>
<template #item="{ item: article }">
<div class="w-10 h-10 bg-amber-500/10 rounded-full flex items-center justify-center flex-shrink-0">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-amber-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
</svg>
</div>
<div class="ml-3 flex-1 min-w-0">
<p class="font-medium text-slate-800 dark:text-white truncate">{{ article.title }}</p>
<p class="text-sm text-slate-500 dark:text-slate-400">{{ article.articleNumber }}</p>
</div>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-green-500 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
</template>
</SearchSelectModal>
<!-- Car Select Modal -->
<SearchSelectModal
:show="showCarSelect"
title="Fahrzeug wählen"
:searchable="false"
:items="allCars"
empty-text="Keine Fahrzeuge verfügbar"
empty-icon="list"
:selected-id="hoursEntries[carSelectEntryIndex]?.carId"
:show-none-option="true"
none-option-text="Kein Fahrzeug"
@select="selectCar"
@close="showCarSelect = false"
>
<template #item="{ item: car, selected }">
<div class="w-10 h-10 bg-blue-500/10 rounded-full flex items-center justify-center flex-shrink-0">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-blue-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7h8m-8 4h8m-6 4h4M3 21h18M3 17V7c0-1.1.9-2 2-2h14a2 2 0 012 2v10" />
</svg>
</div>
<div class="ml-3 flex-1 min-w-0">
<p class="font-medium text-slate-800 dark:text-white truncate">{{ car.name }}</p>
<p v-if="car.plate" class="text-sm text-slate-500 dark:text-slate-400">{{ car.plate }}</p>
</div>
<svg v-if="selected" xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-primary flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
</template>
</SearchSelectModal>
<!-- Hour Type Select Modal -->
<SearchSelectModal
:show="showHourTypeSelect"
title="Stundenart wählen"
:searchable="false"
:items="hourTypes"
empty-text="Keine Stundenarten verfügbar"
empty-icon="list"
:selected-id="hoursEntries[hourTypeSelectEntryIndex]?.hourType"
@select="selectHourType"
@close="showHourTypeSelect = false"
>
<template #item="{ item: ht, selected }">
<div class="w-10 h-10 bg-green-500/10 rounded-full flex items-center justify-center flex-shrink-0">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div class="ml-3 flex-1 min-w-0">
<p class="font-medium text-slate-800 dark:text-white truncate">{{ ht.name }}</p>
</div>
<svg v-if="selected" xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-primary flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
</template>
</SearchSelectModal>
</div>
`
};