1091 lines
57 KiB
JavaScript
1091 lines
57 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';
|
|
|
|
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: `
|
|
<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 -->
|
|
<teleport to="body">
|
|
<transition name="slide-up">
|
|
<div v-if="showCustomerSearch" class="fixed inset-0 z-50 flex flex-col bg-white dark:bg-slate-900">
|
|
<div class="flex items-center px-4 py-3 border-b border-slate-200 dark:border-slate-700">
|
|
<button @click="showCustomerSearch = false" class="p-2 -ml-2">
|
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-slate-500" 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>
|
|
<h2 class="ml-2 text-lg font-semibold text-slate-800 dark:text-white">Kunde suchen</h2>
|
|
</div>
|
|
<div class="p-4">
|
|
<input type="text" v-model="customerSearchQuery" autofocus class="w-full px-4 py-3 bg-slate-100 dark:bg-slate-800 rounded-xl text-base" placeholder="Name, Firma, Kundennummer..." />
|
|
</div>
|
|
<div class="flex-1 overflow-y-auto px-4">
|
|
<div v-if="customerSearchLoading" class="text-center py-8 text-slate-500">Suche...</div>
|
|
<div v-else-if="customerSearchResults.length === 0 && customerSearchQuery.length >= 1" class="text-center py-8 text-slate-500">Keine Kunden gefunden</div>
|
|
<div v-else class="space-y-2">
|
|
<button v-for="customer in customerSearchResults" :key="customer.id" @click="selectCustomer(customer)" class="w-full text-left p-3 bg-slate-50 dark:bg-slate-800 rounded-xl active:bg-slate-100 dark:active:bg-slate-700">
|
|
<div class="font-medium text-slate-800 dark:text-white">{{ customer.displayName }}</div>
|
|
<div v-if="customer.company && customer.company !== customer.displayName" class="text-sm text-primary">{{ customer.company }}</div>
|
|
<div class="text-sm text-slate-500 dark:text-slate-400">{{ customer.street }}, {{ customer.zip }} {{ customer.city }}</div>
|
|
<div v-if="customer.customerNumber" class="text-xs text-slate-400 dark:text-slate-500 mt-1">KNr: {{ customer.customerNumber }}</div>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</transition>
|
|
</teleport>
|
|
|
|
<!-- Article Search Modal -->
|
|
<teleport to="body">
|
|
<transition name="slide-up">
|
|
<div v-if="showArticleSearch" class="fixed inset-0 z-50 flex flex-col bg-white dark:bg-slate-900">
|
|
<div class="flex items-center px-4 py-3 border-b border-slate-200 dark:border-slate-700">
|
|
<button @click="showArticleSearch = false" class="p-2 -ml-2">
|
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-slate-500" 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>
|
|
<h2 class="ml-2 text-lg font-semibold text-slate-800 dark:text-white">Artikel suchen</h2>
|
|
</div>
|
|
<div class="p-4">
|
|
<input type="text" v-model="articleSearchQuery" autofocus class="w-full px-4 py-3 bg-slate-100 dark:bg-slate-800 rounded-xl text-base" placeholder="Artikelnummer oder Name..." />
|
|
</div>
|
|
<div class="flex-1 overflow-y-auto px-4">
|
|
<div v-if="articleSearchLoading" class="text-center py-8 text-slate-500">Suche...</div>
|
|
<div v-else-if="articleSearchResults.length === 0 && articleSearchQuery.length >= 1" class="text-center py-8 text-slate-500">Keine Artikel gefunden</div>
|
|
<div v-else class="space-y-2">
|
|
<button v-for="article in articleSearchResults" :key="article.id" @click="addArticle(article)" class="w-full text-left p-3 bg-slate-50 dark:bg-slate-800 rounded-xl active:bg-slate-100 dark:active:bg-slate-700">
|
|
<div class="font-medium text-slate-800 dark:text-white">{{ article.title }}</div>
|
|
<div class="text-sm text-slate-500 dark:text-slate-400">{{ article.articleNumber }}</div>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</transition>
|
|
</teleport>
|
|
|
|
<!-- Car Select Modal -->
|
|
<teleport to="body">
|
|
<transition name="slide-up">
|
|
<div v-if="showCarSelect" class="fixed inset-0 z-50 flex flex-col bg-white dark:bg-slate-900">
|
|
<div class="flex items-center px-4 py-3 border-b border-slate-200 dark:border-slate-700">
|
|
<button @click="showCarSelect = false" class="p-2 -ml-2">
|
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-slate-500" 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>
|
|
<h2 class="ml-2 text-lg font-semibold text-slate-800 dark:text-white">Fahrzeug wählen</h2>
|
|
</div>
|
|
<div class="flex-1 overflow-y-auto p-4 space-y-2">
|
|
<button v-for="car in allCars" :key="car.id" @click="selectCar(car)"
|
|
:class="['w-full text-left p-3 rounded-xl', hoursEntries[carSelectEntryIndex]?.carId === car.id ? 'bg-primary/10 border-2 border-primary' : 'bg-slate-50 dark:bg-slate-800']"
|
|
>
|
|
<div class="font-medium text-slate-800 dark:text-white">{{ car.name }}</div>
|
|
<div v-if="car.plate" class="text-sm text-slate-500 dark:text-slate-400">{{ car.plate }}</div>
|
|
</button>
|
|
<button @click="selectCar({id: null, name: ''})" class="w-full text-left p-3 bg-slate-50 dark:bg-slate-800 rounded-xl text-slate-500">
|
|
Kein Fahrzeug
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</transition>
|
|
</teleport>
|
|
|
|
<!-- Hour Type Select Modal -->
|
|
<teleport to="body">
|
|
<transition name="slide-up">
|
|
<div v-if="showHourTypeSelect" class="fixed inset-0 z-50 flex flex-col bg-white dark:bg-slate-900">
|
|
<div class="flex items-center px-4 py-3 border-b border-slate-200 dark:border-slate-700">
|
|
<button @click="showHourTypeSelect = false" class="p-2 -ml-2">
|
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-slate-500" 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>
|
|
<h2 class="ml-2 text-lg font-semibold text-slate-800 dark:text-white">Stundenart wählen</h2>
|
|
</div>
|
|
<div class="flex-1 overflow-y-auto p-4 space-y-2">
|
|
<button v-for="ht in hourTypes" :key="ht.id" @click="selectHourType(ht)"
|
|
:class="['w-full text-left p-4 rounded-xl', hoursEntries[hourTypeSelectEntryIndex]?.hourType === ht.id ? 'bg-primary/10 border-2 border-primary' : 'bg-slate-50 dark:bg-slate-800']"
|
|
>
|
|
<div class="font-medium text-slate-800 dark:text-white">{{ ht.name }}</div>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</transition>
|
|
</teleport>
|
|
</div>
|
|
`
|
|
};
|