garázs fejlesztés

This commit is contained in:
Roo
2026-07-20 11:17:52 +00:00
parent 2e0abc62a7
commit 4594de6c11
80 changed files with 9135 additions and 8702 deletions

View File

@@ -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>

View File

@@ -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
}
})

View File

@@ -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>

View File

@@ -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 {

View File

@@ -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()
}

View File

@@ -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',

View File

@@ -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',