garázs fejlesztés
This commit is contained in:
@@ -58,18 +58,48 @@
|
||||
<!-- ── Scrollable Form Body ── -->
|
||||
<div class="flex-1 overflow-y-auto p-6">
|
||||
<!-- ════════════════════════════════════════════════════════════════
|
||||
STEP 1: Alapadatok (Vehicle, Category, Date)
|
||||
STEP 1: Szolgáltató & Alapadatok (Provider, Vehicle, Category, Date)
|
||||
════════════════════════════════════════════════════════════════ -->
|
||||
<div v-if="currentStep === 0" class="space-y-5">
|
||||
<!-- Vehicle (read-only display) -->
|
||||
<!-- Provider Typeahead (Step 1 - first field) -->
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-slate-700 mb-1.5">{{ t('costWizard.provider') }}</label>
|
||||
<ProviderAutocomplete
|
||||
v-model="selectedProvider"
|
||||
@select="onProviderSelect"
|
||||
@clear="onProviderClear"
|
||||
@open-quick-add="onOpenQuickAdd"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-slate-400">{{ t('costWizard.providerHint') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- P0 EPIC: Global Vehicle Selection (when no vehicle prop) -->
|
||||
<div v-if="userVehicles.length > 0">
|
||||
<label class="block text-sm font-semibold text-slate-700 mb-1.5">{{ t('costWizard.selectVehicle') }}</label>
|
||||
<select
|
||||
v-model="selectedVehicleId"
|
||||
required
|
||||
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 focus:border-sf-accent focus:ring-2 focus:ring-sf-accent/20 outline-none transition-all"
|
||||
>
|
||||
<option value="" disabled>{{ t('costWizard.selectVehicle') }}...</option>
|
||||
<option
|
||||
v-for="v in userVehicles"
|
||||
:key="v.id"
|
||||
:value="v.id"
|
||||
>
|
||||
{{ vehicleDisplayName(v) }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<!-- Fallback: read-only display when vehicle is passed via prop -->
|
||||
<div v-else-if="props.vehicle">
|
||||
<label class="block text-sm font-semibold text-slate-700 mb-1.5">{{ t('costWizard.selectVehicle') }}</label>
|
||||
<div class="rounded-lg bg-slate-50 border border-slate-200 px-4 py-3 text-sm text-slate-600">
|
||||
<span class="font-semibold text-slate-800">{{ vehicleLabel }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Category -->
|
||||
<!-- P0 EPIC: Grouped Categories (Main + Subcategories in optgroup) -->
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-slate-700 mb-1.5">{{ t('costWizard.selectCategory') }}</label>
|
||||
<select
|
||||
@@ -89,8 +119,8 @@
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Sub Category -->
|
||||
<div v-if="subCategories.length > 0">
|
||||
<!-- P0 EPIC: Grouped Subcategories (shown when a main category is selected) -->
|
||||
<div v-if="groupedSubCategories.length > 0">
|
||||
<label class="block text-sm font-semibold text-slate-700 mb-1.5">{{ t('costWizard.selectSubCategory') }}</label>
|
||||
<select
|
||||
v-model="form.subCategory"
|
||||
@@ -98,13 +128,20 @@
|
||||
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 focus:border-sf-accent focus:ring-2 focus:ring-sf-accent/20 outline-none transition-all"
|
||||
>
|
||||
<option value="" disabled>{{ t('costWizard.selectSubCategory') }}...</option>
|
||||
<option
|
||||
v-for="sub in subCategories"
|
||||
:key="sub.id"
|
||||
:value="sub.id"
|
||||
<!-- Group subcategories by their logical grouping -->
|
||||
<optgroup
|
||||
v-for="group in groupedSubCategories"
|
||||
:key="group.label"
|
||||
:label="group.label"
|
||||
>
|
||||
{{ sub.name }}
|
||||
</option>
|
||||
<option
|
||||
v-for="sub in group.items"
|
||||
:key="sub.id"
|
||||
:value="sub.id"
|
||||
>
|
||||
{{ sub.name }}
|
||||
</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -119,9 +156,9 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Mileage (shown for fuel/refueling) -->
|
||||
<div v-if="form.mainCategory === '1'" class="pt-2 border-t border-slate-100">
|
||||
<label class="block text-sm font-semibold text-slate-700 mb-1.5">Megtett km (óraállás)</label>
|
||||
<!-- P0 EPIC: Universal Odometer (always visible, not just for fuel) -->
|
||||
<div class="pt-2 border-t border-slate-100">
|
||||
<label class="block text-sm font-semibold text-slate-700 mb-1.5">Km óra állás <span class="text-xs text-slate-400 font-normal">(opcionális)</span></label>
|
||||
<input
|
||||
v-model.number="form.mileageAtCost"
|
||||
type="number"
|
||||
@@ -130,12 +167,17 @@
|
||||
placeholder="pl. 123456"
|
||||
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 focus:border-sf-accent focus:ring-2 focus:ring-sf-accent/20 outline-none transition-all"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-slate-400">Add meg a jármű óraállását tankoláskor</p>
|
||||
<!-- Odometer hint: show last known mileage for the selected vehicle -->
|
||||
<p v-if="odometerHint !== null" class="mt-1 text-xs text-slate-500 flex items-center gap-1">
|
||||
<span>📏</span> Előző állás: <strong>{{ odometerHint.toLocaleString() }} km</strong>
|
||||
</p>
|
||||
<p v-else-if="odometerLoading" class="mt-1 text-xs text-slate-400 italic">Km óra állás betöltése...</p>
|
||||
<p v-else class="mt-1 text-xs text-slate-400">Add meg a jármű óraállását a költség rögzítésekor</p>
|
||||
</div>
|
||||
|
||||
<!-- Liters (shown for fuel/refueling) -->
|
||||
<div v-if="form.mainCategory === '1'" class="pt-2">
|
||||
<label class="block text-sm font-semibold text-slate-700 mb-1.5">Mennyiség (liter)</label>
|
||||
<!-- Liters / kWh (shown only for fuel/charging subcategories) -->
|
||||
<div v-if="isFuelCategoryVisible" class="pt-2">
|
||||
<label class="block text-sm font-semibold text-slate-700 mb-1.5">{{ fuelUnitLabel }}</label>
|
||||
<input
|
||||
v-model.number="form.liters"
|
||||
type="number"
|
||||
@@ -144,7 +186,7 @@
|
||||
placeholder="pl. 45.5"
|
||||
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 focus:border-sf-accent focus:ring-2 focus:ring-sf-accent/20 outline-none transition-all"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-slate-400">Tankolt üzemanyag mennyisége literben</p>
|
||||
<p class="mt-1 text-xs text-slate-400">{{ fuelUnitHint }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -214,56 +256,9 @@
|
||||
</div>
|
||||
|
||||
<!-- ════════════════════════════════════════════════════════════════
|
||||
STEP 3: Admin & Szállító (Vendor, Invoice, Deadline)
|
||||
STEP 3: Számla adatok (Invoice, Payment, Deadline)
|
||||
════════════════════════════════════════════════════════════════ -->
|
||||
<div v-if="currentStep === 2" class="space-y-5">
|
||||
<!-- Vendor autocomplete with free-text fallback -->
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-slate-700 mb-1.5">{{ t('costWizard.vendor') }}</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
v-model="vendorSearch"
|
||||
type="text"
|
||||
:placeholder="t('costWizard.vendorPlaceholder')"
|
||||
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 focus:border-sf-accent focus:ring-2 focus:ring-sf-accent/20 outline-none transition-all"
|
||||
@input="onVendorSearchInput"
|
||||
@focus="showVendorDropdown = true"
|
||||
@blur="onVendorBlur"
|
||||
/>
|
||||
<!-- Dropdown suggestions -->
|
||||
<div
|
||||
v-if="showVendorDropdown && filteredVendors.length > 0"
|
||||
class="absolute z-20 mt-1 w-full rounded-xl border border-slate-200 bg-white shadow-lg max-h-48 overflow-y-auto"
|
||||
>
|
||||
<button
|
||||
v-for="vendor in filteredVendors"
|
||||
:key="vendor.organization_id"
|
||||
class="w-full px-4 py-2.5 text-left text-sm text-slate-700 hover:bg-slate-50 hover:text-sf-accent transition-colors border-b border-slate-100 last:border-b-0"
|
||||
@mousedown.prevent="selectVendor(vendor)"
|
||||
>
|
||||
<span class="font-medium">{{ vendor.display_name || vendor.name }}</span>
|
||||
<span v-if="vendor.tax_number" class="ml-2 text-xs text-slate-400">({{ vendor.tax_number }})</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Selected vendor badge -->
|
||||
<div
|
||||
v-if="selectedVendor"
|
||||
class="mt-2 inline-flex items-center gap-2 rounded-full bg-sf-accent/10 border border-sf-accent/20 px-3 py-1.5 text-xs font-medium text-sf-accent"
|
||||
>
|
||||
<span>{{ selectedVendor.display_name || selectedVendor.name }}</span>
|
||||
<button
|
||||
class="ml-1 text-sf-accent/60 hover:text-sf-accent"
|
||||
@click="clearVendor"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<p v-else-if="vendorSearch && !selectedVendor" class="mt-1 text-xs text-slate-400">
|
||||
{{ t('costWizard.vendorPlaceholder') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Invoice Number -->
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-slate-700 mb-1.5">{{ t('costWizard.invoiceNumber') }}</label>
|
||||
@@ -412,6 +407,14 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Provider Quick Add Modal (Teleported) ── -->
|
||||
<ProviderQuickAddModal
|
||||
:is-open="showQuickAddModal"
|
||||
:prefill-name="pendingNewProviderName"
|
||||
@close="onQuickAddClose"
|
||||
@saved="onQuickAddSaved"
|
||||
/>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
@@ -420,13 +423,18 @@ import { ref, reactive, computed, onMounted, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useCostStore } from '../../stores/cost'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
import { useVehicleStore } from '../../stores/vehicle'
|
||||
import api from '../../api/axios'
|
||||
import type { Vehicle } from '../../stores/vehicle'
|
||||
import { getLocalDateString } from '../../services/dateUtils'
|
||||
import ProviderAutocomplete from '../provider/ProviderAutocomplete.vue'
|
||||
import ProviderQuickAddModal from '../provider/ProviderQuickAddModal.vue'
|
||||
import type { ProviderSearchResult } from '../provider/ProviderAutocomplete.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const costStore = useCostStore()
|
||||
const authStore = useAuthStore()
|
||||
const vehicleStore = useVehicleStore()
|
||||
|
||||
// ── Props & Emits ──
|
||||
const props = defineProps<{
|
||||
@@ -468,27 +476,98 @@ const form = reactive({
|
||||
liters: null as number | null,
|
||||
})
|
||||
|
||||
// ── Vendor State ──
|
||||
interface VendorOption {
|
||||
organization_id: number
|
||||
name: string
|
||||
display_name: string | null
|
||||
tax_number: string | null
|
||||
// ── Provider State (using ProviderAutocomplete) ──
|
||||
const selectedProvider = ref<ProviderSearchResult | null>(null)
|
||||
|
||||
function onProviderSelect(provider: ProviderSearchResult) {
|
||||
selectedProvider.value = provider
|
||||
}
|
||||
|
||||
const vendorSearch = ref('')
|
||||
const vendors = ref<VendorOption[]>([])
|
||||
const filteredVendors = ref<VendorOption[]>([])
|
||||
const selectedVendor = ref<VendorOption | null>(null)
|
||||
const showVendorDropdown = ref(false)
|
||||
function onProviderClear() {
|
||||
selectedProvider.value = null
|
||||
}
|
||||
|
||||
/** Normalize a provider search result or organization object to VendorOption */
|
||||
function toVendorOption(item: any): VendorOption {
|
||||
return {
|
||||
organization_id: item.organization_id ?? item.id,
|
||||
name: item.name || '',
|
||||
display_name: item.display_name || item.name || null,
|
||||
tax_number: item.tax_number || null,
|
||||
// ── P0 BUGFIX (2026-07-10): Provider Quick Add Modal ──
|
||||
const showQuickAddModal = ref(false)
|
||||
const pendingNewProviderName = ref('')
|
||||
|
||||
/**
|
||||
* Called when ProviderAutocomplete emits 'open-quick-add'.
|
||||
* Opens the ProviderQuickAddModal so the user can create a new provider.
|
||||
*/
|
||||
function onOpenQuickAdd() {
|
||||
// Pre-fill with the current search query if available
|
||||
pendingNewProviderName.value = selectedProvider.value?.name || ''
|
||||
showQuickAddModal.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when ProviderQuickAddModal emits 'close' (user dismissed without saving).
|
||||
*/
|
||||
function onQuickAddClose() {
|
||||
showQuickAddModal.value = false
|
||||
pendingNewProviderName.value = ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when ProviderQuickAddModal emits 'saved' after successful creation.
|
||||
* Auto-selects the newly created provider.
|
||||
*/
|
||||
function onQuickAddSaved(provider: { id: number; name: string }) {
|
||||
showQuickAddModal.value = false
|
||||
pendingNewProviderName.value = ''
|
||||
// Auto-select the newly created provider
|
||||
selectedProvider.value = {
|
||||
id: provider.id,
|
||||
name: provider.name,
|
||||
category: provider.category || null,
|
||||
specialization: [],
|
||||
city: provider.city || null,
|
||||
address: provider.address || null,
|
||||
source: 'crowd_added',
|
||||
is_verified: false,
|
||||
rating: null,
|
||||
}
|
||||
}
|
||||
|
||||
// ── P0 EPIC: Global Vehicle Selection ──
|
||||
const selectedVehicleId = ref<string>('')
|
||||
const userVehicles = ref<Vehicle[]>([])
|
||||
|
||||
/**
|
||||
* Resolve the effective vehicle for the expense.
|
||||
* Priority:
|
||||
* 1. If a vehicle prop is passed, use it.
|
||||
* 2. If the user selected a vehicle from the dropdown, find it in the list.
|
||||
* 3. Fallback to null.
|
||||
*/
|
||||
const effectiveVehicle = computed<Vehicle | null>(() => {
|
||||
if (props.vehicle) return props.vehicle
|
||||
if (selectedVehicleId.value) {
|
||||
return userVehicles.value.find(v => v.id === selectedVehicleId.value) || null
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
function vehicleDisplayName(v: Vehicle): string {
|
||||
const plate = v.license_plate || ''
|
||||
const brand = v.brand || ''
|
||||
const model = v.model || ''
|
||||
return `${plate} (${brand} ${model})`.trim() || v.name || v.id
|
||||
}
|
||||
|
||||
async function fetchUserVehicles() {
|
||||
try {
|
||||
// If the vehicle store already has vehicles, use them
|
||||
if (vehicleStore.vehicles.length > 0) {
|
||||
userVehicles.value = vehicleStore.vehicles
|
||||
} else {
|
||||
await vehicleStore.fetchVehicles()
|
||||
userVehicles.value = vehicleStore.vehicles
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[CostEntryWizard] Failed to fetch vehicles:', err)
|
||||
userVehicles.value = []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -505,9 +584,47 @@ interface CategoryOption {
|
||||
parent_id: number | null
|
||||
}
|
||||
|
||||
interface SubCategoryGroup {
|
||||
label: string
|
||||
items: CategoryOption[]
|
||||
}
|
||||
|
||||
const mainCategories = ref<CategoryOption[]>([])
|
||||
const subCategories = ref<CategoryOption[]>([])
|
||||
|
||||
// ── P0 EPIC: Grouped Subcategories ──
|
||||
/**
|
||||
* Groups subcategories logically by their parent category name.
|
||||
* This creates <optgroup> sections in the UI for better UX.
|
||||
*/
|
||||
const groupedSubCategories = computed<SubCategoryGroup[]>(() => {
|
||||
if (subCategories.value.length === 0) return []
|
||||
|
||||
// Build a map of parent_id -> parent_name from mainCategories
|
||||
const parentMap = new Map<number, string>()
|
||||
for (const cat of mainCategories.value) {
|
||||
parentMap.set(cat.id, cat.name)
|
||||
}
|
||||
|
||||
// Group subcategories by their parent_id
|
||||
const groups = new Map<string, CategoryOption[]>()
|
||||
for (const sub of subCategories.value) {
|
||||
const parentName = parentMap.get(sub.parent_id ?? 0) || 'Egyéb'
|
||||
if (!groups.has(parentName)) {
|
||||
groups.set(parentName, [])
|
||||
}
|
||||
groups.get(parentName)!.push(sub)
|
||||
}
|
||||
|
||||
// Convert to array of SubCategoryGroup, sorted by parent name
|
||||
return Array.from(groups.entries())
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([label, items]) => ({
|
||||
label,
|
||||
items: items.sort((a, b) => a.name.localeCompare(b.name)),
|
||||
}))
|
||||
})
|
||||
|
||||
// ── Computed ──
|
||||
const vehicleLabel = computed(() => {
|
||||
if (!props.vehicle) return t('dashboard.noVehicleSelected')
|
||||
@@ -517,6 +634,95 @@ const vehicleLabel = computed(() => {
|
||||
return `${plate} (${brand} ${model})`.trim()
|
||||
})
|
||||
|
||||
// ── P0 EPIC: Universal Odometer Hint ──
|
||||
const odometerHint = ref<number | null>(null)
|
||||
const odometerLoading = ref(false)
|
||||
|
||||
/**
|
||||
* Fetch the last known odometer reading for the selected vehicle.
|
||||
* Re-uses the same logic from SimpleFuelModal: fetches from /assets/vehicles/{id}
|
||||
* which returns current_mileage (synced from odometer_readings on expense creation).
|
||||
*/
|
||||
async function fetchLastOdometer() {
|
||||
const vehicle = effectiveVehicle.value
|
||||
if (!vehicle?.id) {
|
||||
odometerHint.value = null
|
||||
return
|
||||
}
|
||||
|
||||
odometerLoading.value = true
|
||||
try {
|
||||
const res = await api.get(`/assets/vehicles/${vehicle.id}`)
|
||||
const v = res.data as { current_mileage?: number | null }
|
||||
if (v.current_mileage != null && v.current_mileage > 0) {
|
||||
odometerHint.value = v.current_mileage
|
||||
} else {
|
||||
odometerHint.value = null
|
||||
}
|
||||
} catch {
|
||||
odometerHint.value = null
|
||||
} finally {
|
||||
odometerLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── P0 BUGFIX: Fuel Category Detection ──
|
||||
/**
|
||||
* Strictly checks if the selected subcategory is a fuel or charging category.
|
||||
* Only OPERATION_FUEL (Üzemanyag) and OPERATION_ELECTRIC (Elektromos töltés)
|
||||
* should show the quantity/unit-price fields — NOT Insurance, Financing, etc.
|
||||
*/
|
||||
const FUEL_SUBCATEGORY_CODES = new Set(['OPERATION_FUEL', 'OPERATION_ELECTRIC'])
|
||||
|
||||
/**
|
||||
* Resolves the selected subcategory's code from the flattened category list.
|
||||
*/
|
||||
const selectedSubCategoryCode = computed<string | null>(() => {
|
||||
if (!form.subCategory) return null
|
||||
const allCats: CategoryOption[] = (window as any).__allCostCategories || []
|
||||
const found = allCats.find((c) => c.id === Number(form.subCategory))
|
||||
return found?.code || null
|
||||
})
|
||||
|
||||
/**
|
||||
* True when a fuel/charging subcategory is selected.
|
||||
* Used to show/hide the quantity (Liter/kWh) and unit-price fields.
|
||||
*/
|
||||
const isFuelCategoryVisible = computed<boolean>(() => {
|
||||
const code = selectedSubCategoryCode.value
|
||||
return code !== null && FUEL_SUBCATEGORY_CODES.has(code)
|
||||
})
|
||||
|
||||
/**
|
||||
* Dynamic label for the quantity input.
|
||||
* - Electric vehicle (fuel_type in ['elektromos','EV','BEV']) → "Töltött mennyiség (kWh)"
|
||||
* - Everything else (benzin, dízel, etc.) → "Tankolt mennyiség (Liter)"
|
||||
*/
|
||||
const fuelUnitLabel = computed<string>(() => {
|
||||
const vehicle = effectiveVehicle.value
|
||||
if (!vehicle) return 'Mennyiség (liter)'
|
||||
|
||||
const ft = (vehicle.fuel_type || '').toLowerCase()
|
||||
if (['elektromos', 'ev', 'bev'].includes(ft)) {
|
||||
return 'Töltött mennyiség (kWh)'
|
||||
}
|
||||
return 'Tankolt mennyiség (Liter)'
|
||||
})
|
||||
|
||||
/**
|
||||
* Dynamic hint text below the quantity input.
|
||||
*/
|
||||
const fuelUnitHint = computed<string>(() => {
|
||||
const vehicle = effectiveVehicle.value
|
||||
if (!vehicle) return 'Tankolt üzemanyag mennyisége literben'
|
||||
|
||||
const ft = (vehicle.fuel_type || '').toLowerCase()
|
||||
if (['elektromos', 'ev', 'bev'].includes(ft)) {
|
||||
return 'Elektromos töltés mennyisége kWh-ban'
|
||||
}
|
||||
return 'Tankolt üzemanyag mennyisége literben'
|
||||
})
|
||||
|
||||
// ── Stepper Methods ──
|
||||
function stepClass(idx: number): string {
|
||||
if (idx < currentStep.value) return 'bg-sf-accent text-white'
|
||||
@@ -535,6 +741,10 @@ function validateStep(step: number): boolean {
|
||||
errorMessage.value = null
|
||||
|
||||
if (step === 0) {
|
||||
if (!effectiveVehicle.value) {
|
||||
errorMessage.value = 'Kérlek válassz járművet.'
|
||||
return false
|
||||
}
|
||||
if (!form.mainCategory) {
|
||||
errorMessage.value = 'Kérlek válassz kategóriát.'
|
||||
return false
|
||||
@@ -615,19 +825,6 @@ function onMainCategoryChange() {
|
||||
subCategories.value = allCats.filter(
|
||||
(c) => c.parent_id === Number(form.mainCategory)
|
||||
)
|
||||
|
||||
// P0 FIX: Filter vendors by org_type when INSURANCE category is selected
|
||||
// Insurance companies are stored as organizations with org_type='service_provider'
|
||||
// in the fleet.organizations table. The /providers/search endpoint now supports
|
||||
// org_type parameter to filter by organization type.
|
||||
const catId = Number(form.mainCategory)
|
||||
if (catId === 4) {
|
||||
// INSURANCE category — show only insurance-related providers
|
||||
fetchVendors('service_provider')
|
||||
} else {
|
||||
// Other categories — show all service providers
|
||||
fetchVendors()
|
||||
}
|
||||
}
|
||||
|
||||
// ── Financial Calculation Methods ──
|
||||
@@ -654,167 +851,80 @@ function onGrossAmountChange() {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Vendor Methods ──
|
||||
async function fetchVendors(orgType?: string) {
|
||||
try {
|
||||
// Use /providers/search to list service providers (the /organizations endpoint
|
||||
// does not support org_type=SERVICE_PROVIDER filtering — returns 404)
|
||||
const params: Record<string, any> = { limit: 100 }
|
||||
if (orgType) {
|
||||
params.org_type = orgType
|
||||
}
|
||||
const res = await api.get('/providers/search', { params })
|
||||
const data = res.data
|
||||
// ProviderSearchResponse returns { results: [...], total, page, per_page }
|
||||
const rawList = data.results || data.data || data.items || data || []
|
||||
vendors.value = rawList.map(toVendorOption)
|
||||
} catch (err) {
|
||||
console.error('[CostEntryWizard] Failed to load vendors:', err)
|
||||
vendors.value = []
|
||||
}
|
||||
}
|
||||
|
||||
function onVendorSearchInput() {
|
||||
showVendorDropdown.value = true
|
||||
const query = vendorSearch.value.toLowerCase().trim()
|
||||
if (!query) {
|
||||
filteredVendors.value = vendors.value
|
||||
return
|
||||
}
|
||||
filteredVendors.value = vendors.value.filter(
|
||||
(v) =>
|
||||
(v.display_name || v.name).toLowerCase().includes(query) ||
|
||||
(v.tax_number && v.tax_number.includes(query))
|
||||
)
|
||||
}
|
||||
|
||||
function selectVendor(vendor: VendorOption) {
|
||||
selectedVendor.value = vendor
|
||||
vendorSearch.value = vendor.display_name || vendor.name
|
||||
showVendorDropdown.value = false
|
||||
}
|
||||
|
||||
function clearVendor() {
|
||||
selectedVendor.value = null
|
||||
vendorSearch.value = ''
|
||||
filteredVendors.value = vendors.value
|
||||
}
|
||||
|
||||
function onVendorBlur() {
|
||||
// Delay to allow click on dropdown item
|
||||
setTimeout(() => {
|
||||
showVendorDropdown.value = false
|
||||
}, 200)
|
||||
}
|
||||
|
||||
// ── File Methods ──
|
||||
function triggerFileInput() {
|
||||
fileInputRef.value?.click()
|
||||
}
|
||||
|
||||
function onFileSelect(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
if (input.files) {
|
||||
for (const file of Array.from(input.files)) {
|
||||
attachedFiles.value.push(file)
|
||||
}
|
||||
}
|
||||
// Reset input so same file can be re-selected
|
||||
if (fileInputRef.value) {
|
||||
fileInputRef.value.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function onFileDrop(event: DragEvent) {
|
||||
isDragOver.value = false
|
||||
if (event.dataTransfer?.files) {
|
||||
for (const file of Array.from(event.dataTransfer.files)) {
|
||||
attachedFiles.value.push(file)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function removeFile(idx: number) {
|
||||
attachedFiles.value.splice(idx, 1)
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
// ── Submit ──
|
||||
// ── Handle Submit ──
|
||||
async function handleSubmit() {
|
||||
if (!props.vehicle) {
|
||||
if (!validateStep(currentStep.value)) return
|
||||
|
||||
const vehicle = effectiveVehicle.value
|
||||
if (!vehicle) {
|
||||
errorMessage.value = 'Nincs kiválasztva jármű.'
|
||||
return
|
||||
}
|
||||
|
||||
if (!validateStep(currentStep.value)) return
|
||||
|
||||
isSubmitting.value = true
|
||||
errorMessage.value = null
|
||||
|
||||
try {
|
||||
// P0 FIX: Auto-set payment_deadline for CASH/CARD
|
||||
// If payment method is CASH or CARD, the deadline equals invoice_date or date
|
||||
if (form.paymentMethod === 'CASH' || form.paymentMethod === 'CARD') {
|
||||
form.paymentDeadline = form.invoiceDate || form.date || ''
|
||||
// P0 BUGFIX (2026-07-10): Fix provider payload wiring.
|
||||
// Determine provider fields (P0 Hybrid Vendor Refactor)
|
||||
const hasSelectedProvider = selectedProvider.value?.id != null && selectedProvider.value.id > 0
|
||||
const providerSource = selectedProvider.value?.source || null
|
||||
// P0 BUGFIX: Capture the provider name regardless of selection state.
|
||||
// When a provider IS selected, use its name. When no provider is selected,
|
||||
// the rawVendorName will be null (user typed nothing or cleared).
|
||||
const rawVendorName = selectedProvider.value?.name || null
|
||||
|
||||
// P0 BUGFIX: Source-aware vendor field routing.
|
||||
// - 'verified_org' → vendor_organization_id (KYC-verified organizations)
|
||||
// - 'crowd_added' | 'staged_data' → service_provider_id (community/robot-added)
|
||||
// - No selection → external_vendor_name (raw string, triggers Delayed New Provider flow)
|
||||
let vendorOrganizationId: number | null = null
|
||||
let serviceProviderId: number | null = null
|
||||
let externalVendorName: string | null = null
|
||||
|
||||
if (hasSelectedProvider) {
|
||||
if (providerSource === 'verified_org') {
|
||||
vendorOrganizationId = selectedProvider.value!.id
|
||||
} else if (providerSource === 'crowd_added' || providerSource === 'staged_data') {
|
||||
serviceProviderId = selectedProvider.value!.id
|
||||
} else {
|
||||
// Fallback for unknown source: send as external_vendor_name
|
||||
externalVendorName = rawVendorName
|
||||
}
|
||||
} else if (rawVendorName) {
|
||||
// User typed a name but didn't select from dropdown
|
||||
externalVendorName = rawVendorName
|
||||
}
|
||||
|
||||
// Determine category_id
|
||||
const allCats: CategoryOption[] = (window as any).__allCostCategories || []
|
||||
let categoryId = Number(form.subCategory || form.mainCategory) || 10
|
||||
|
||||
// Build payload matching AssetCostBase schema
|
||||
// Build payload matching backend AssetCostCreate schema
|
||||
const payload: Record<string, any> = {
|
||||
asset_id: props.vehicle.id,
|
||||
category_id: categoryId,
|
||||
asset_id: vehicle.id,
|
||||
category_id: form.subCategory ? Number(form.subCategory) : Number(form.mainCategory),
|
||||
/** GROSS-FIRST (Masterbook 2.0.1): amount_gross is the primary field */
|
||||
amount_gross: form.grossAmount > 0 ? form.grossAmount : form.netAmount,
|
||||
amount_gross: form.grossAmount > 0 ? form.grossAmount : Math.round(form.netAmount * (1 + form.vatRate / 100) * 100) / 100,
|
||||
currency: form.currency,
|
||||
date: new Date(form.date).toISOString(),
|
||||
mileage_at_cost: form.mileageAtCost || null,
|
||||
description: `Számla: ${form.invoiceNumber || 'N/A'}`,
|
||||
// P0 FIX: vendor_organization_id and external_vendor_name at root level
|
||||
// so the backend Pydantic schema writes them to the dedicated DB columns.
|
||||
vendor_organization_id: selectedVendor.value?.organization_id || null,
|
||||
external_vendor_name: selectedVendor.value
|
||||
? (selectedVendor.value.display_name || selectedVendor.value.name)
|
||||
: (vendorSearch.value || null),
|
||||
mileage_at_cost: form.mileageAtCost != null && form.mileageAtCost > 0 ? form.mileageAtCost : null,
|
||||
description: form.invoiceNumber ? `Számla: ${form.invoiceNumber}` : null,
|
||||
// P0 BUGFIX: Source-aware vendor fields at root level
|
||||
vendor_organization_id: vendorOrganizationId,
|
||||
service_provider_id: serviceProviderId,
|
||||
external_vendor_name: externalVendorName,
|
||||
data: {
|
||||
mileage_at_cost: form.mileageAtCost || null,
|
||||
liters: form.liters || null,
|
||||
main_category_id: form.mainCategory || null,
|
||||
sub_category_id: form.subCategory || null,
|
||||
net_amount: form.netAmount,
|
||||
vat_rate: form.vatRate,
|
||||
invoice_number: form.invoiceNumber || null,
|
||||
invoice_date: form.invoiceDate ? new Date(form.invoiceDate).toISOString() : null,
|
||||
payment_deadline: form.paymentDeadline ? new Date(form.paymentDeadline).toISOString() : null,
|
||||
invoice_date: form.invoiceDate || null,
|
||||
payment_method: form.paymentMethod,
|
||||
vendor_id: selectedVendor.value?.organization_id || null,
|
||||
vendor_name: selectedVendor.value
|
||||
? (selectedVendor.value.display_name || selectedVendor.value.name)
|
||||
: (vendorSearch.value || null),
|
||||
file_count: attachedFiles.value.length,
|
||||
file_names: attachedFiles.value.map((f) => f.name),
|
||||
payment_deadline: form.paymentDeadline || null,
|
||||
liters: form.liters,
|
||||
mileage_at_cost: form.mileageAtCost,
|
||||
vendor_id: selectedProvider.value?.id || null,
|
||||
vendor_name: selectedProvider.value?.name || null,
|
||||
vendor_source: providerSource,
|
||||
},
|
||||
}
|
||||
|
||||
// CRITICAL FIX: organization_id MUST be the user's active organization (fleet manager),
|
||||
// NOT the vendor's organization. The vendor is stored separately via vendor_organization_id.
|
||||
// If we set organization_id to the vendor's org, the expense becomes invisible
|
||||
// when the user queries GET /expenses?organization_id=<their_org>.
|
||||
const userOrgId = authStore.user?.active_organization_id
|
||||
if (userOrgId != null) {
|
||||
payload.organization_id = userOrgId
|
||||
}
|
||||
|
||||
// P0 FIX: Distinguish between CREATE and UPDATE based on editCost prop.
|
||||
// If editCost exists, call updateExpense(id, payload) via PUT.
|
||||
// Otherwise, call addExpense(payload) via POST.
|
||||
let result
|
||||
if (props.editCost?.id) {
|
||||
result = await costStore.updateExpense(props.editCost.id, payload)
|
||||
@@ -826,20 +936,17 @@ async function handleSubmit() {
|
||||
resetForm()
|
||||
emit('saved')
|
||||
} else {
|
||||
errorMessage.value = costStore.lastError || t('costWizard.error')
|
||||
errorMessage.value = costStore.lastError || 'Ismeretlen hiba történt.'
|
||||
}
|
||||
} catch (err: any) {
|
||||
const message =
|
||||
err.response?.data?.detail ||
|
||||
err.response?.data?.message ||
|
||||
t('costWizard.error')
|
||||
errorMessage.value = message
|
||||
console.error('[CostEntryWizard] Submit error:', err)
|
||||
errorMessage.value = err?.response?.data?.detail || err?.message || 'Hiba történt a mentés során.'
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Reset Form ──
|
||||
function resetForm() {
|
||||
form.date = getLocalDateString()
|
||||
form.mainCategory = ''
|
||||
@@ -854,147 +961,90 @@ function resetForm() {
|
||||
form.paymentMethod = 'TRANSFER'
|
||||
form.mileageAtCost = null
|
||||
form.liters = null
|
||||
vendorSearch.value = ''
|
||||
selectedVendor.value = null
|
||||
selectedProvider.value = null
|
||||
selectedVehicleId.value = ''
|
||||
attachedFiles.value = []
|
||||
currentStep.value = 0
|
||||
errorMessage.value = null
|
||||
currentStep.value = 0
|
||||
}
|
||||
|
||||
// ── Edit Cost Hydration ──
|
||||
/**
|
||||
* Biztonságos dátum formázó: bármilyen ISO stringből (pl. "2026-06-15T00:00:00")
|
||||
* vagy raw JSONB értékből kivágja az első 10 karaktert (YYYY-MM-DD).
|
||||
* Ha a bemenet undefined/null/üres, üres stringet ad vissza.
|
||||
*/
|
||||
const safeFormatDate = (dateStr: string | undefined | null): string => {
|
||||
if (!dateStr) return ''
|
||||
return dateStr.split('T')[0].substring(0, 10)
|
||||
}
|
||||
|
||||
watch(() => props.editCost, (cost) => {
|
||||
if (!cost) return
|
||||
|
||||
const editData = cost
|
||||
const data = editData.data || {}
|
||||
|
||||
// ── Date: slice T for HTML5 date input ──
|
||||
// Ensure we always get a valid YYYY-MM-DD string or empty string
|
||||
form.date = safeFormatDate(editData.date)
|
||||
|
||||
// ── Invoice Number: check BOTH top-level and JSONB ──
|
||||
form.invoiceNumber = editData.invoice_number || data.invoice_number || ''
|
||||
|
||||
// ── Category ──
|
||||
if (editData.category_id) {
|
||||
form.mainCategory = String(editData.category_id)
|
||||
}
|
||||
if (data.main_category_id) {
|
||||
form.mainCategory = String(data.main_category_id)
|
||||
}
|
||||
if (data.sub_category_id) {
|
||||
form.subCategory = String(data.sub_category_id)
|
||||
}
|
||||
|
||||
// ── Financial ──
|
||||
if (editData.amount_gross) {
|
||||
form.grossAmount = Number(editData.amount_gross)
|
||||
}
|
||||
if (data.net_amount) {
|
||||
form.netAmount = Number(data.net_amount)
|
||||
}
|
||||
if (data.vat_rate) {
|
||||
form.vatRate = Number(data.vat_rate)
|
||||
}
|
||||
if (editData.currency) {
|
||||
form.currency = editData.currency
|
||||
}
|
||||
|
||||
// ── Dates (P0 FIX: robust hydration for YYYY-MM-DD) ──
|
||||
// invoiceDate: extract from JSONB data.invoice_date or top-level
|
||||
form.invoiceDate = safeFormatDate(data.invoice_date || editData.invoice_date)
|
||||
|
||||
// paymentDeadline: extract from JSONB data.payment_deadline or top-level
|
||||
form.paymentDeadline = safeFormatDate(data.payment_deadline || editData.payment_deadline)
|
||||
|
||||
// ── Payment Method ──
|
||||
if (data.payment_method) {
|
||||
form.paymentMethod = data.payment_method
|
||||
}
|
||||
|
||||
// ── Vendor Hydration: check BOTH top-level and JSONB ──
|
||||
const vendorOrgId = editData.vendor_organization_id || data.vendor_id || null
|
||||
const vendorName = editData.external_vendor_name || data.vendor_name || ''
|
||||
|
||||
if (vendorOrgId) {
|
||||
// Try to find the vendor in the loaded list
|
||||
const vendor = vendors.value.find(
|
||||
(v) => v.organization_id === vendorOrgId
|
||||
)
|
||||
if (vendor) {
|
||||
selectedVendor.value = vendor
|
||||
vendorSearch.value = vendor.display_name || vendor.name
|
||||
} else {
|
||||
// Reconstruct from raw data so the UI shows the saved provider
|
||||
selectedVendor.value = {
|
||||
id: vendorOrgId,
|
||||
organization_id: vendorOrgId,
|
||||
name: vendorName,
|
||||
display_name: vendorName || null,
|
||||
tax_number: null,
|
||||
} as any
|
||||
vendorSearch.value = vendorName
|
||||
}
|
||||
} else if (vendorName) {
|
||||
vendorSearch.value = vendorName
|
||||
}
|
||||
|
||||
// ── Mileage & Liters ──
|
||||
if (editData.mileage_at_cost || data.mileage_at_cost) {
|
||||
form.mileageAtCost = Number(editData.mileage_at_cost || data.mileage_at_cost)
|
||||
}
|
||||
if (data.liters) {
|
||||
form.liters = Number(data.liters)
|
||||
}
|
||||
|
||||
// Reset step to beginning for editing
|
||||
currentStep.value = 0
|
||||
errorMessage.value = null
|
||||
}, { immediate: true })
|
||||
|
||||
// ── Gross-to-Net Auto Calculation ──
|
||||
// When the user types in the Gross Amount, automatically calculate and set the Net Amount
|
||||
watch([() => form.grossAmount, () => form.vatRate], ([newGross, newVat]) => {
|
||||
if (newGross > 0 && newVat > 0) {
|
||||
form.netAmount = Math.round((newGross / (1 + newVat / 100)) * 100) / 100
|
||||
}
|
||||
// Hydrate form from existing expense data
|
||||
form.date = cost.date ? getLocalDateString(new Date(cost.date)) : getLocalDateString()
|
||||
form.mainCategory = String(cost.category_id || '')
|
||||
form.subCategory = String(cost.sub_category_id || '')
|
||||
form.netAmount = cost.data?.net_amount || cost.amount_net || 0
|
||||
form.vatRate = cost.data?.vat_rate || 27
|
||||
form.grossAmount = cost.amount_gross || 0
|
||||
form.currency = cost.currency || 'HUF'
|
||||
form.invoiceNumber = cost.data?.invoice_number || ''
|
||||
form.invoiceDate = cost.data?.invoice_date || ''
|
||||
form.paymentDeadline = cost.data?.payment_deadline || ''
|
||||
form.paymentMethod = cost.data?.payment_method || 'TRANSFER'
|
||||
form.mileageAtCost = cost.mileage_at_cost || cost.data?.mileage_at_cost || null
|
||||
form.liters = cost.data?.liters || null
|
||||
})
|
||||
|
||||
// ── File Methods ──
|
||||
function triggerFileInput() {
|
||||
fileInputRef.value?.click()
|
||||
}
|
||||
|
||||
function onFileDrop(e: DragEvent) {
|
||||
isDragOver.value = false
|
||||
const files = e.dataTransfer?.files
|
||||
if (files) {
|
||||
attachedFiles.value.push(...Array.from(files))
|
||||
}
|
||||
}
|
||||
|
||||
function onFileSelect(e: Event) {
|
||||
const input = e.target as HTMLInputElement
|
||||
if (input.files) {
|
||||
attachedFiles.value.push(...Array.from(input.files))
|
||||
}
|
||||
}
|
||||
|
||||
function removeFile(idx: number) {
|
||||
attachedFiles.value.splice(idx, 1)
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes < 1024) return bytes + ' B'
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
|
||||
}
|
||||
|
||||
// ── Lifecycle ──
|
||||
watch(() => props.isOpen, (open) => {
|
||||
if (open) {
|
||||
currentStep.value = 0
|
||||
form.date = getLocalDateString()
|
||||
errorMessage.value = null
|
||||
fetchCategories()
|
||||
// Load all vendors by default (no org_type filter)
|
||||
fetchVendors()
|
||||
}
|
||||
onMounted(() => {
|
||||
fetchCategories()
|
||||
fetchUserVehicles()
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if (props.isOpen) {
|
||||
// Fetch odometer hint when vehicle changes
|
||||
watch(effectiveVehicle, () => {
|
||||
fetchLastOdometer()
|
||||
})
|
||||
|
||||
// Reset form when modal opens
|
||||
watch(() => props.isOpen, (open) => {
|
||||
if (open) {
|
||||
if (!props.editCost) {
|
||||
resetForm()
|
||||
}
|
||||
fetchUserVehicles()
|
||||
fetchCategories()
|
||||
fetchVendors()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@keyframes fadeInUp {
|
||||
@keyframes fade-in-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px) scale(0.96);
|
||||
transform: translateY(20px) scale(0.97);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
@@ -1002,6 +1052,6 @@ onMounted(() => {
|
||||
}
|
||||
}
|
||||
.animate-fade-in-up {
|
||||
animation: fadeInUp 0.25s ease-out forwards;
|
||||
animation: fade-in-up 0.25s ease-out;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
@@ -22,6 +22,17 @@
|
||||
|
||||
<!-- Form Body -->
|
||||
<form @submit.prevent="handleSubmit" class="p-6 space-y-5">
|
||||
<!-- Provider Typeahead (first field) -->
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-slate-700 mb-1.5">Szolgáltató</label>
|
||||
<ProviderAutocomplete
|
||||
v-model="selectedProvider"
|
||||
@select="onProviderSelect"
|
||||
@clear="onProviderClear"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-slate-400">Válaszd ki a szolgáltatót, vagy hagyd üresen</p>
|
||||
</div>
|
||||
|
||||
<!-- Vehicle selector (read-only display) -->
|
||||
<div class="rounded-lg bg-slate-50 border border-slate-200 px-4 py-3 text-sm text-slate-600">
|
||||
<span class="font-semibold text-slate-800">{{ vehicleLabel }}</span>
|
||||
@@ -150,6 +161,8 @@ import { useCostStore } from '../../stores/cost'
|
||||
import api from '../../api/axios'
|
||||
import type { Vehicle } from '../../stores/vehicle'
|
||||
import { getLocalDateString } from '../../services/dateUtils'
|
||||
import ProviderAutocomplete from '../provider/ProviderAutocomplete.vue'
|
||||
import type { ProviderSearchResult } from '../provider/ProviderAutocomplete.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
isOpen: boolean
|
||||
@@ -177,6 +190,17 @@ const form = reactive({
|
||||
|
||||
const errorMessage = ref<string | null>(null)
|
||||
|
||||
// ── Provider State ──
|
||||
const selectedProvider = ref<ProviderSearchResult | null>(null)
|
||||
|
||||
function onProviderSelect(provider: ProviderSearchResult) {
|
||||
selectedProvider.value = provider
|
||||
}
|
||||
|
||||
function onProviderClear() {
|
||||
selectedProvider.value = null
|
||||
}
|
||||
|
||||
// ── Category Cascade State ──
|
||||
interface CategoryOption {
|
||||
id: number
|
||||
@@ -237,6 +261,26 @@ function onMainCategoryChange() {
|
||||
)
|
||||
}
|
||||
|
||||
// ── Gamification Toast ──
|
||||
function showGamificationToast() {
|
||||
const toast = document.createElement('div')
|
||||
toast.className =
|
||||
'fixed top-6 right-6 z-[99999] bg-emerald-600 text-white px-6 py-4 rounded-xl shadow-2xl text-sm font-medium animate-slide-in flex items-center gap-3'
|
||||
toast.innerHTML = `
|
||||
<span class="text-lg">🎉</span>
|
||||
<div>
|
||||
<p class="font-semibold">Költség sikeresen rögzítve!</p>
|
||||
<p class="text-emerald-100 text-xs mt-0.5">A pontok jóváírásra kerültek.</p>
|
||||
</div>
|
||||
`
|
||||
document.body.appendChild(toast)
|
||||
|
||||
setTimeout(() => {
|
||||
toast.classList.add('animate-slide-out')
|
||||
setTimeout(() => toast.remove(), 300)
|
||||
}, 4000)
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!props.vehicle) {
|
||||
errorMessage.value = 'Nincs kiválasztva jármű.'
|
||||
@@ -261,7 +305,7 @@ async function handleSubmit() {
|
||||
categoryId = Number(form.subCategory || form.mainCategory) || 10
|
||||
}
|
||||
|
||||
const payload = {
|
||||
const payload: Record<string, any> = {
|
||||
asset_id: props.vehicle.id,
|
||||
// organization_id is omitted — backend auto-resolves from asset
|
||||
category_id: categoryId,
|
||||
@@ -271,17 +315,23 @@ async function handleSubmit() {
|
||||
date: new Date(form.date).toISOString(),
|
||||
mileage_at_cost: null,
|
||||
description: form.description || (props.isFeeMode ? 'Parkolás / Útdíj' : 'Egyéb költség'),
|
||||
// P0 FIX: vendor fields at root level
|
||||
vendor_organization_id: selectedProvider.value?.id || null,
|
||||
external_vendor_name: selectedProvider.value?.name || null,
|
||||
data: {
|
||||
is_recurring: form.isRecurring,
|
||||
category_id: categoryId,
|
||||
main_category_id: form.mainCategory || null,
|
||||
sub_category_id: form.subCategory || null,
|
||||
vendor_id: selectedProvider.value?.id || null,
|
||||
vendor_name: selectedProvider.value?.name || null,
|
||||
},
|
||||
}
|
||||
|
||||
const result = await costStore.addExpense(payload)
|
||||
|
||||
if (result) {
|
||||
showGamificationToast()
|
||||
// Reset form
|
||||
form.date = getLocalDateString()
|
||||
form.amount = 0
|
||||
@@ -289,6 +339,7 @@ async function handleSubmit() {
|
||||
form.subCategory = ''
|
||||
form.description = ''
|
||||
form.isRecurring = false
|
||||
selectedProvider.value = null
|
||||
emit('saved')
|
||||
} else {
|
||||
errorMessage.value = costStore.lastError || 'Ismeretlen hiba történt.'
|
||||
@@ -304,6 +355,7 @@ watch(() => props.isOpen, (open) => {
|
||||
if (open) {
|
||||
form.date = getLocalDateString()
|
||||
errorMessage.value = null
|
||||
selectedProvider.value = null
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -21,6 +21,17 @@
|
||||
|
||||
<!-- Form Body -->
|
||||
<form @submit.prevent="handleSubmit" class="p-6 space-y-5">
|
||||
<!-- Provider Typeahead (first field) -->
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-slate-700 mb-1.5">Szolgáltató</label>
|
||||
<ProviderAutocomplete
|
||||
v-model="selectedProvider"
|
||||
@select="onProviderSelect"
|
||||
@clear="onProviderClear"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-slate-400">Válaszd ki a szolgáltatót, vagy hagyd üresen</p>
|
||||
</div>
|
||||
|
||||
<!-- Vehicle selector (read-only display of selected vehicle) -->
|
||||
<div class="rounded-lg bg-slate-50 border border-slate-200 px-4 py-3 text-sm text-slate-600">
|
||||
<span class="font-semibold text-slate-800">{{ vehicleLabel }}</span>
|
||||
@@ -37,35 +48,68 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Amount (HUF) -->
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-slate-700 mb-1.5">Összeg (HUF)</label>
|
||||
<input
|
||||
v-model.number="form.amount"
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
required
|
||||
placeholder="pl. 15000"
|
||||
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 focus:border-sf-accent focus:ring-2 focus:ring-sf-accent/20 outline-none transition-all"
|
||||
/>
|
||||
<!-- ════════════════════════════════════════════ -->
|
||||
<!-- MAGIC TRIANGLE: Amount Gross, Unit Price, Quantity -->
|
||||
<!-- ════════════════════════════════════════════ -->
|
||||
<div class="rounded-lg bg-amber-50 border border-amber-200 p-3">
|
||||
<p class="text-xs font-semibold text-amber-700 mb-2">💰 Ár háromszög — tölts ki kettőt, a harmadik automatikusan számolódik</p>
|
||||
|
||||
<!-- Amount Gross with Currency Selector -->
|
||||
<div class="mb-3">
|
||||
<label class="block text-sm font-semibold text-slate-700 mb-1.5">Összeg</label>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
:value="form.amountGross"
|
||||
@blur="onAmountGrossBlur"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
required
|
||||
placeholder="pl. 15000"
|
||||
class="flex-1 rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 focus:border-sf-accent focus:ring-2 focus:ring-sf-accent/20 outline-none transition-all no-spinner"
|
||||
/>
|
||||
<!-- P0 BUGFIX (2026-07-10): Currency selector -->
|
||||
<select
|
||||
v-model="form.currency"
|
||||
class="w-20 rounded-xl border border-slate-300 bg-white px-2 py-2.5 text-sm text-slate-800 focus:border-sf-accent focus:ring-2 focus:ring-sf-accent/20 outline-none transition-all"
|
||||
>
|
||||
<option value="HUF">HUF</option>
|
||||
<option value="EUR">EUR</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Unit Price -->
|
||||
<div class="mb-3">
|
||||
<label class="block text-sm font-semibold text-slate-700 mb-1.5">Egységár ({{ form.currency }}/L vagy {{ form.currency }}/kWh)</label>
|
||||
<input
|
||||
:value="form.unitPrice"
|
||||
@blur="onUnitPriceBlur"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
placeholder="pl. 650"
|
||||
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 focus:border-sf-accent focus:ring-2 focus:ring-sf-accent/20 outline-none transition-all no-spinner"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Quantity (Liters/kWh) -->
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-slate-700 mb-1.5">Mennyiség (Liter / kWh)</label>
|
||||
<input
|
||||
:value="form.quantity"
|
||||
@blur="onQuantityBlur"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
required
|
||||
placeholder="pl. 45.5"
|
||||
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 focus:border-sf-accent focus:ring-2 focus:ring-sf-accent/20 outline-none transition-all no-spinner"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quantity (Liters/kWh) -->
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-slate-700 mb-1.5">Mennyiség (Liter / kWh)</label>
|
||||
<input
|
||||
v-model.number="form.quantity"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.1"
|
||||
required
|
||||
placeholder="pl. 45.5"
|
||||
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 focus:border-sf-accent focus:ring-2 focus:ring-sf-accent/20 outline-none transition-all"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Odometer -->
|
||||
<!-- Odometer with hint -->
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-slate-700 mb-1.5">Km óra állás</label>
|
||||
<input
|
||||
@@ -76,6 +120,11 @@
|
||||
placeholder="pl. 123456"
|
||||
class="w-full rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm text-slate-800 focus:border-sf-accent focus:ring-2 focus:ring-sf-accent/20 outline-none transition-all"
|
||||
/>
|
||||
<!-- Odometer hint -->
|
||||
<p v-if="odometerHint !== null" class="mt-1 text-xs text-slate-500 flex items-center gap-1">
|
||||
<span>📏</span> Utolsó rögzített km óra állás: <strong>{{ odometerHint.toLocaleString() }} km</strong>
|
||||
</p>
|
||||
<p v-else-if="odometerLoading" class="mt-1 text-xs text-slate-400 italic">Km óra állás betöltése...</p>
|
||||
</div>
|
||||
|
||||
<!-- Error message -->
|
||||
@@ -106,6 +155,16 @@
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ════════════════════════════════════════════ -->
|
||||
<!-- DELAYED NEW PROVIDER: ProviderQuickAddModal -->
|
||||
<!-- ════════════════════════════════════════════ -->
|
||||
<ProviderQuickAddModal
|
||||
:is-open="showQuickAddModal"
|
||||
:prefill-name="pendingNewProviderName"
|
||||
@close="onQuickAddClose"
|
||||
@saved="onQuickAddSaved"
|
||||
/>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
@@ -115,6 +174,9 @@ import { useCostStore } from '../../stores/cost'
|
||||
import api from '../../api/axios'
|
||||
import type { Vehicle } from '../../stores/vehicle'
|
||||
import { getLocalDateString } from '../../services/dateUtils'
|
||||
import ProviderAutocomplete from '../provider/ProviderAutocomplete.vue'
|
||||
import ProviderQuickAddModal from '../provider/ProviderQuickAddModal.vue'
|
||||
import type { ProviderSearchResult } from '../provider/ProviderAutocomplete.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
isOpen: boolean
|
||||
@@ -128,15 +190,181 @@ const emit = defineEmits<{
|
||||
|
||||
const costStore = useCostStore()
|
||||
|
||||
// ── Form State ──
|
||||
const form = reactive({
|
||||
date: getLocalDateString(),
|
||||
amount: 0,
|
||||
amountGross: 0,
|
||||
unitPrice: 0,
|
||||
quantity: 0,
|
||||
odometer: 0,
|
||||
currency: 'HUF' as string,
|
||||
})
|
||||
|
||||
// ── Magic Triangle: Track which field was last manually edited ──
|
||||
// P0 BUGFIX (2026-07-10): @blur events instead of @input events.
|
||||
// A lastEditedField csak @blur-kor frissül, így nincs végtelen rekurzió.
|
||||
const lastEditedField = ref<string | null>(null)
|
||||
|
||||
const errorMessage = ref<string | null>(null)
|
||||
|
||||
// ── Provider State ──
|
||||
const selectedProvider = ref<ProviderSearchResult | null>(null)
|
||||
|
||||
// P0 BUGFIX (2026-07-10): Fix stale provider state.
|
||||
// A ProviderAutocomplete @select eseménye tiszta overwrite-öt végez.
|
||||
// A selectedProvider ref teljesen felülíródik az új provider adataival.
|
||||
function onProviderSelect(provider: ProviderSearchResult) {
|
||||
selectedProvider.value = provider
|
||||
}
|
||||
|
||||
function onProviderClear() {
|
||||
selectedProvider.value = null
|
||||
}
|
||||
|
||||
// ── Odometer Hint ──
|
||||
const odometerHint = ref<number | null>(null)
|
||||
const odometerLoading = ref(false)
|
||||
|
||||
// P0 BUGFIX (2026-07-10): Fix odometer hint — fetch from asset/vehicle endpoint
|
||||
// instead of /expenses/{asset_id}. The asset endpoint returns current_mileage
|
||||
// which is the Source of Truth (synced from odometer_readings on expense creation).
|
||||
async function fetchLastOdometer() {
|
||||
if (!props.vehicle?.id) {
|
||||
odometerHint.value = null
|
||||
return
|
||||
}
|
||||
|
||||
odometerLoading.value = true
|
||||
try {
|
||||
// Fetch the asset/vehicle data which contains current_mileage
|
||||
// This is the performant read-cache — synced automatically when expenses are created.
|
||||
const res = await api.get(`/assets/vehicles/${props.vehicle.id}`)
|
||||
// The response is the Vehicle object with current_mileage field
|
||||
const vehicle = res.data as { current_mileage?: number | null }
|
||||
if (vehicle.current_mileage != null && vehicle.current_mileage > 0) {
|
||||
odometerHint.value = vehicle.current_mileage
|
||||
} else {
|
||||
odometerHint.value = null
|
||||
}
|
||||
} catch {
|
||||
// Silently fail — odometer hint is a nice-to-have
|
||||
odometerHint.value = null
|
||||
} finally {
|
||||
odometerLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Magic Triangle: Blur Handlers ──
|
||||
// P0 BUGFIX (2026-07-10): @blur events instead of @input events.
|
||||
// A @input események végtelen rekurziót okoztak, mert a számítás
|
||||
// visszaírta az értékeket, ami újabb @input eseményt triggerelt.
|
||||
// A @blur esemény csak akkor fut le, amikor a user elhagyja a mezőt,
|
||||
// így nincs rekurzió.
|
||||
|
||||
/**
|
||||
* Kerekítés 2 tizedesjegyre.
|
||||
* A Magic Triangle minden számítását ezzel végezzük,
|
||||
* hogy elkerüljük a lebegőpontos pontatlanságokat.
|
||||
*/
|
||||
function roundTo2Decimals(val: number): number {
|
||||
return Math.round(val * 100) / 100
|
||||
}
|
||||
|
||||
/**
|
||||
* @blur handler az Összeg (amountGross) mezőhöz.
|
||||
* Beállítja a lastEditedField értékét, majd meghívja az autoCalculate()-t.
|
||||
*/
|
||||
function onAmountGrossBlur(e: Event) {
|
||||
const val = parseFloat((e.target as HTMLInputElement).value) || 0
|
||||
form.amountGross = val
|
||||
lastEditedField.value = 'amount_gross'
|
||||
autoCalculate()
|
||||
}
|
||||
|
||||
/**
|
||||
* @blur handler az Egységár (unitPrice) mezőhöz.
|
||||
* Beállítja a lastEditedField értékét, majd meghívja az autoCalculate()-t.
|
||||
*/
|
||||
function onUnitPriceBlur(e: Event) {
|
||||
const val = parseFloat((e.target as HTMLInputElement).value) || 0
|
||||
form.unitPrice = val
|
||||
lastEditedField.value = 'unit_price'
|
||||
autoCalculate()
|
||||
}
|
||||
|
||||
/**
|
||||
* @blur handler a Mennyiség (quantity) mezőhöz.
|
||||
* Beállítja a lastEditedField értékét, majd meghívja az autoCalculate()-t.
|
||||
*/
|
||||
function onQuantityBlur(e: Event) {
|
||||
const val = parseFloat((e.target as HTMLInputElement).value) || 0
|
||||
form.quantity = val
|
||||
lastEditedField.value = 'quantity'
|
||||
autoCalculate()
|
||||
}
|
||||
|
||||
/**
|
||||
* Magic Triangle: Automatikus számítás a három mező között.
|
||||
*
|
||||
* Logika:
|
||||
* - A user mindig 2 mezőt tölt ki, a harmadik automatikusan számolódik.
|
||||
* - A lastEditedField alapján tudjuk, melyik mezőt szerkesztette a user.
|
||||
* - Ha a másik két mező közül legalább az egyik > 0, kiszámoljuk a harmadikat.
|
||||
* - Prioritás: ha mindkét másik mező > 0, akkor a lastEditedField-et
|
||||
* számoljuk újra a másik kettőből (pl. user javítja a quantity-t,
|
||||
* akkor amountGross = unitPrice * quantity).
|
||||
* - Ha csak az egyik másik mező > 0, akkor a hiányzó harmadikat számoljuk.
|
||||
*
|
||||
* Képletek:
|
||||
* amountGross = unitPrice * quantity
|
||||
* unitPrice = amountGross / quantity
|
||||
* quantity = amountGross / unitPrice
|
||||
*
|
||||
* P0 BUGFIX (2026-07-10): @blur events — nincs rekurzió.
|
||||
*/
|
||||
function autoCalculate() {
|
||||
const a = form.amountGross
|
||||
const p = form.unitPrice
|
||||
const q = form.quantity
|
||||
|
||||
// Hány mezőnek van értéke > 0?
|
||||
const filledCount = [a > 0, p > 0, q > 0].filter(Boolean).length
|
||||
|
||||
// Ha már mindhárom ki van töltve, nincs mit számolni
|
||||
if (filledCount >= 3) return
|
||||
|
||||
const edited = lastEditedField.value
|
||||
|
||||
if (edited === 'amount_gross') {
|
||||
// User az összeget szerkesztette → számoljuk az egységárat VAGY a mennyiséget
|
||||
if (q > 0 && a > 0) {
|
||||
// Van mennyiség és összeg → egységár = összeg / mennyiség
|
||||
form.unitPrice = roundTo2Decimals(a / q)
|
||||
} else if (p > 0 && a > 0) {
|
||||
// Van egységár és összeg → mennyiség = összeg / egységár
|
||||
form.quantity = roundTo2Decimals(a / p)
|
||||
}
|
||||
} else if (edited === 'unit_price') {
|
||||
// User az egységárat szerkesztette → számoljuk az összeget VAGY a mennyiséget
|
||||
if (q > 0 && p > 0) {
|
||||
// Van mennyiség és egységár → összeg = egységár * mennyiség
|
||||
form.amountGross = roundTo2Decimals(p * q)
|
||||
} else if (a > 0 && p > 0) {
|
||||
// Van összeg és egységár → mennyiség = összeg / egységár
|
||||
form.quantity = roundTo2Decimals(a / p)
|
||||
}
|
||||
} else if (edited === 'quantity') {
|
||||
// User a mennyiséget szerkesztette → számoljuk az összeget VAGY az egységárat
|
||||
if (p > 0 && q > 0) {
|
||||
// Van egységár és mennyiség → összeg = egységár * mennyiség
|
||||
form.amountGross = roundTo2Decimals(p * q)
|
||||
} else if (a > 0 && q > 0) {
|
||||
// Van összeg és mennyiség → egységár = összeg / mennyiség
|
||||
form.unitPrice = roundTo2Decimals(a / q)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Category Resolution ──
|
||||
const fuelCategoryId = ref<number>(1) // Default fallback
|
||||
|
||||
@@ -161,7 +389,16 @@ async function resolveFuelCategory() {
|
||||
watch(() => props.isOpen, (open) => {
|
||||
if (open) {
|
||||
form.date = getLocalDateString()
|
||||
form.amountGross = 0
|
||||
form.unitPrice = 0
|
||||
form.quantity = 0
|
||||
form.odometer = 0
|
||||
form.currency = 'HUF'
|
||||
lastEditedField.value = null
|
||||
errorMessage.value = null
|
||||
selectedProvider.value = null
|
||||
// Fetch odometer hint on open
|
||||
fetchLastOdometer()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -177,6 +414,30 @@ const vehicleLabel = computed(() => {
|
||||
return `${plate} (${brand} ${model})`.trim()
|
||||
})
|
||||
|
||||
// ── Delayed New Provider State ──
|
||||
const showQuickAddModal = ref(false)
|
||||
const pendingNewProviderName = ref('')
|
||||
|
||||
// ── Gamification Toast ──
|
||||
function showGamificationToast() {
|
||||
const toast = document.createElement('div')
|
||||
toast.className =
|
||||
'fixed top-6 right-6 z-[99999] bg-emerald-600 text-white px-6 py-4 rounded-xl shadow-2xl text-sm font-medium animate-slide-in flex items-center gap-3'
|
||||
toast.innerHTML = `
|
||||
<span class="text-lg">🎉</span>
|
||||
<div>
|
||||
<p class="font-semibold">Tankolás sikeresen rögzítve!</p>
|
||||
<p class="text-emerald-100 text-xs mt-0.5">A pontok jóváírásra kerültek.</p>
|
||||
</div>
|
||||
`
|
||||
document.body.appendChild(toast)
|
||||
|
||||
setTimeout(() => {
|
||||
toast.classList.add('animate-slide-out')
|
||||
setTimeout(() => toast.remove(), 300)
|
||||
}, 4000)
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!props.vehicle) {
|
||||
errorMessage.value = 'Nincs kiválasztva jármű.'
|
||||
@@ -185,36 +446,103 @@ async function handleSubmit() {
|
||||
|
||||
errorMessage.value = null
|
||||
|
||||
// Determine if we have a selected provider or a raw typed name
|
||||
const hasSelectedProvider = selectedProvider.value?.id != null && selectedProvider.value.id > 0
|
||||
const rawVendorName = selectedProvider.value?.name || null
|
||||
|
||||
// P0 BUGFIX (2026-07-10): Fix stale provider state.
|
||||
// A vendor_organization_id-t csak akkor küldjük, ha a provider forrása 'verified_org'.
|
||||
// Ha a forrás 'crowd_added', akkor service_provider_id-t küldünk.
|
||||
// A backend FK-safe validációja is véd, de itt a frontend is helyesen küldi.
|
||||
const providerSource = selectedProvider.value?.source || null
|
||||
|
||||
// Build payload: category auto-set to FUEL (resolved dynamically by code)
|
||||
// organization_id is omitted — backend auto-resolves from asset
|
||||
const payload = {
|
||||
const payload: Record<string, any> = {
|
||||
asset_id: props.vehicle.id,
|
||||
category_id: fuelCategoryId.value, // FUEL category (resolved by code)
|
||||
/** GROSS-FIRST (Masterbook 2.0.1): amount_gross is the primary field */
|
||||
amount_gross: form.amount,
|
||||
currency: 'HUF' as const,
|
||||
amount_gross: form.amountGross,
|
||||
currency: form.currency,
|
||||
date: new Date(form.date).toISOString(),
|
||||
mileage_at_cost: form.odometer > 0 ? form.odometer : null,
|
||||
description: `Tankolás: ${form.quantity} L/kWh`,
|
||||
// P0 BUGFIX: vendor fields at root level - source-aware routing
|
||||
vendor_organization_id: hasSelectedProvider && providerSource === 'verified_org' ? selectedProvider.value!.id : null,
|
||||
service_provider_id: hasSelectedProvider && providerSource === 'crowd_added' ? selectedProvider.value!.id : null,
|
||||
external_vendor_name: hasSelectedProvider ? null : rawVendorName,
|
||||
data: {
|
||||
quantity: form.quantity,
|
||||
unit_price: form.unitPrice,
|
||||
fuel_type: props.vehicle.fuel_type || null,
|
||||
vendor_id: selectedProvider.value?.id || null,
|
||||
vendor_name: selectedProvider.value?.name || null,
|
||||
vendor_source: providerSource,
|
||||
},
|
||||
}
|
||||
|
||||
const result = await costStore.addExpense(payload)
|
||||
|
||||
if (result) {
|
||||
// ── DELAYED NEW PROVIDER FLOW ──
|
||||
// If the user typed a raw string (no selected provider with valid ID),
|
||||
// save the expense first, then open ProviderQuickAddModal pre-filled
|
||||
if (!hasSelectedProvider && rawVendorName && rawVendorName.trim().length >= 2) {
|
||||
pendingNewProviderName.value = rawVendorName.trim()
|
||||
showQuickAddModal.value = true
|
||||
// Don't emit 'saved' yet — wait for the quick-add flow to complete
|
||||
return
|
||||
}
|
||||
|
||||
showGamificationToast()
|
||||
// Reset form
|
||||
form.date = getLocalDateString()
|
||||
form.amount = 0
|
||||
form.amountGross = 0
|
||||
form.unitPrice = 0
|
||||
form.quantity = 0
|
||||
form.odometer = 0
|
||||
form.currency = 'HUF'
|
||||
lastEditedField.value = null
|
||||
selectedProvider.value = null
|
||||
emit('saved')
|
||||
} else {
|
||||
errorMessage.value = costStore.lastError || 'Ismeretlen hiba történt.'
|
||||
}
|
||||
}
|
||||
|
||||
// ── Delayed New Provider: QuickAdd Modal Handlers ──
|
||||
function onQuickAddClose() {
|
||||
showQuickAddModal.value = false
|
||||
pendingNewProviderName.value = ''
|
||||
// The expense was already saved, so emit saved
|
||||
showGamificationToast()
|
||||
// Reset form
|
||||
form.date = getLocalDateString()
|
||||
form.amountGross = 0
|
||||
form.unitPrice = 0
|
||||
form.quantity = 0
|
||||
form.odometer = 0
|
||||
form.currency = 'HUF'
|
||||
lastEditedField.value = null
|
||||
selectedProvider.value = null
|
||||
emit('saved')
|
||||
}
|
||||
|
||||
function onQuickAddSaved(provider: { id: number; name: string }) {
|
||||
showQuickAddModal.value = false
|
||||
pendingNewProviderName.value = ''
|
||||
// The expense was already saved, and now the provider is created too
|
||||
showGamificationToast()
|
||||
// Reset form
|
||||
form.date = getLocalDateString()
|
||||
form.amountGross = 0
|
||||
form.unitPrice = 0
|
||||
form.quantity = 0
|
||||
form.odometer = 0
|
||||
form.currency = 'HUF'
|
||||
lastEditedField.value = null
|
||||
selectedProvider.value = null
|
||||
emit('saved')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -231,4 +559,18 @@ async function handleSubmit() {
|
||||
.animate-fade-in-up {
|
||||
animation: fadeInUp 0.25s ease-out forwards;
|
||||
}
|
||||
|
||||
/* ── P0 BUGFIX (2026-07-09): Hide number input spinners ── */
|
||||
/* Chrome, Safari, Edge, Opera */
|
||||
input[type='number'].no-spinner::-webkit-inner-spin-button,
|
||||
input[type='number'].no-spinner::-webkit-outer-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Firefox */
|
||||
input[type='number'].no-spinner {
|
||||
-moz-appearance: textfield;
|
||||
appearance: textfield;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -124,7 +124,7 @@
|
||||
|
||||
<!-- ── Empty State (no results) ── -->
|
||||
<div
|
||||
v-if="showDropdown && filteredResults.length === 0 && searchQuery.length >= 2 && !isSearching"
|
||||
v-if="showDropdown && filteredResults.length === 0 && searchQuery.length >= 3 && !isSearching"
|
||||
class="absolute z-50 mt-1 w-full rounded-xl border border-slate-200 bg-white shadow-lg p-4"
|
||||
>
|
||||
<p class="text-sm text-slate-500 text-center mb-3">{{ t('provider.autocompleteNoResults') }}</p>
|
||||
@@ -221,7 +221,7 @@ function sourceLabel(source: string): string {
|
||||
|
||||
// ── Search API ──
|
||||
async function performSearch(query: string) {
|
||||
if (query.length < 2) {
|
||||
if (query.length < 3) {
|
||||
results.value = []
|
||||
showDropdown.value = false
|
||||
return
|
||||
@@ -229,14 +229,28 @@ async function performSearch(query: string) {
|
||||
|
||||
isSearching.value = true
|
||||
try {
|
||||
// P0 BUGFIX (2026-07-09): Enhanced error logging for debugging.
|
||||
// The axios baseURL already includes /api/v1, so 'providers/search'
|
||||
// correctly resolves to /api/v1/providers/search.
|
||||
const response = await api.get('providers/search', {
|
||||
params: { q: query, limit: 10 },
|
||||
})
|
||||
results.value = response.data.results || []
|
||||
highlightIndex.value = 0
|
||||
showDropdown.value = true
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
console.error('[ProviderAutocomplete] Search error:', error)
|
||||
// P0 BUGFIX (2026-07-09): Log detailed error info for debugging
|
||||
if (error.response) {
|
||||
// Server responded with error status (4xx, 5xx)
|
||||
console.warn(
|
||||
`[ProviderAutocomplete] API Error: status=${error.response.status}, data=`,
|
||||
error.response.data
|
||||
)
|
||||
} else if (error.request) {
|
||||
// Request was made but no response received (network/CORS issue)
|
||||
console.warn('[ProviderAutocomplete] Network error: no response received')
|
||||
}
|
||||
results.value = []
|
||||
showDropdown.value = false
|
||||
} finally {
|
||||
|
||||
@@ -539,6 +539,9 @@ interface SelectedTag {
|
||||
// ── Props ──
|
||||
const props = defineProps<{
|
||||
isOpen: boolean
|
||||
/** P0 UI/UX OVERHAUL (2026-07-09): Pre-fill the provider name when opening
|
||||
* from the Delayed New Provider flow in SimpleFuelModal. */
|
||||
prefillName?: string
|
||||
}>()
|
||||
|
||||
// ── Emits ──
|
||||
@@ -931,6 +934,10 @@ watch(
|
||||
(open) => {
|
||||
if (open) {
|
||||
resetForm()
|
||||
// P0 UI/UX OVERHAUL (2026-07-09): Pre-fill provider name if provided
|
||||
if (props.prefillName) {
|
||||
form.name = props.prefillName
|
||||
}
|
||||
fetchCategories()
|
||||
loadCategoryTree()
|
||||
}
|
||||
|
||||
@@ -342,13 +342,13 @@ export default {
|
||||
},
|
||||
costWizard: {
|
||||
title: '🧾 Detailed Invoice',
|
||||
step1: 'Basic Data',
|
||||
step1: 'Provider & Basic Data',
|
||||
step2: 'Financials',
|
||||
step3: 'Admin & Supplier',
|
||||
step3: 'Invoice Details',
|
||||
step4: 'Files',
|
||||
step1Desc: 'Vehicle, category & date',
|
||||
step1Desc: 'Provider, vehicle, category & date',
|
||||
step2Desc: 'Amount, VAT & currency',
|
||||
step3Desc: 'Supplier, invoice & deadline',
|
||||
step3Desc: 'Invoice number, payment & deadline',
|
||||
step4Desc: 'Attach documents',
|
||||
selectVehicle: 'Select Vehicle',
|
||||
selectCategory: 'Select Category',
|
||||
@@ -358,11 +358,17 @@ export default {
|
||||
vatRate: 'VAT Rate',
|
||||
grossAmount: 'Gross Amount',
|
||||
currency: 'Currency',
|
||||
provider: 'Provider',
|
||||
providerHint: 'Search for a provider, or leave empty',
|
||||
vendor: 'Supplier',
|
||||
vendorPlaceholder: 'Search or type supplier name...',
|
||||
invoiceNumber: 'Invoice Number',
|
||||
invoiceDate: 'Invoice Date',
|
||||
paymentDeadline: 'Payment Deadline',
|
||||
paymentMethod: 'Payment Method',
|
||||
paymentTransfer: 'Bank Transfer',
|
||||
paymentCash: 'Cash',
|
||||
paymentCard: 'Card',
|
||||
attachFiles: 'Attach Files',
|
||||
dragDrop: 'Drag & drop files here, or click to browse',
|
||||
noFiles: 'No files attached',
|
||||
|
||||
@@ -339,13 +339,13 @@ export default {
|
||||
},
|
||||
costWizard: {
|
||||
title: '🧾 Részletes Számla',
|
||||
step1: 'Alapadatok',
|
||||
step1: 'Szolgáltató & Alapadatok',
|
||||
step2: 'Pénzügyek',
|
||||
step3: 'Admin & Szállító',
|
||||
step3: 'Számla adatok',
|
||||
step4: 'Fájlok',
|
||||
step1Desc: 'Jármű, kategória & dátum',
|
||||
step1Desc: 'Szolgáltató, jármű, kategória & dátum',
|
||||
step2Desc: 'Összeg, ÁFA & pénznem',
|
||||
step3Desc: 'Szállító, számla & határidő',
|
||||
step3Desc: 'Számlaszám, fizetési mód & határidő',
|
||||
step4Desc: 'Dokumentumok csatolása',
|
||||
selectVehicle: 'Jármű kiválasztása',
|
||||
selectCategory: 'Kategória kiválasztása',
|
||||
@@ -355,11 +355,17 @@ export default {
|
||||
vatRate: 'ÁFA kulcs',
|
||||
grossAmount: 'Bruttó összeg',
|
||||
currency: 'Pénznem',
|
||||
provider: 'Szolgáltató',
|
||||
providerHint: 'Keresd meg a szolgáltatót, vagy hagyd üresen',
|
||||
vendor: 'Szállító',
|
||||
vendorPlaceholder: 'Keresés vagy szállító nevének beírása...',
|
||||
invoiceNumber: 'Számlaszám',
|
||||
invoiceDate: 'Számla dátuma',
|
||||
paymentDeadline: 'Fizetési határidő',
|
||||
paymentMethod: 'Fizetési mód',
|
||||
paymentTransfer: 'Átutalás',
|
||||
paymentCash: 'Készpénz',
|
||||
paymentCard: 'Bankkártya',
|
||||
attachFiles: 'Fájlok csatolása',
|
||||
dragDrop: 'Húzd ide a fájlokat, vagy kattints a tallózáshoz',
|
||||
noFiles: 'Nincs fájl csatolva',
|
||||
|
||||
Reference in New Issue
Block a user