330 lines
11 KiB
Vue
330 lines
11 KiB
Vue
<template>
|
||
<Teleport to="body">
|
||
<div
|
||
v-if="isOpen"
|
||
class="fixed inset-0 z-[9999] flex items-center justify-center bg-slate-900/60 backdrop-blur-sm"
|
||
@click.self="$emit('close')"
|
||
>
|
||
<div class="bg-white w-full max-w-lg rounded-2xl shadow-2xl overflow-hidden animate-fade-in-up">
|
||
<!-- Header -->
|
||
<div class="flex items-center justify-between bg-slate-700 px-6 py-4">
|
||
<h3 class="text-lg font-bold text-white flex items-center gap-2">
|
||
<span>{{ isFeeMode ? '🅿️' : '➕' }}</span>
|
||
{{ isFeeMode ? 'Parkolás / Útdíj rögzítése' : 'További költség rögzítése' }}
|
||
</h3>
|
||
<button
|
||
class="flex h-8 w-8 items-center justify-center rounded-full bg-white/10 text-white/70 hover:bg-white/20 hover:text-white transition-colors"
|
||
@click="$emit('close')"
|
||
>
|
||
✕
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Form Body -->
|
||
<form @submit.prevent="handleSubmit" class="p-6 space-y-5">
|
||
<!-- 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>
|
||
</div>
|
||
|
||
<!-- Date -->
|
||
<div>
|
||
<label class="block text-sm font-semibold text-slate-700 mb-1.5">Dátum</label>
|
||
<input
|
||
v-model="form.date"
|
||
type="date"
|
||
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"
|
||
/>
|
||
</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. 5000"
|
||
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>
|
||
|
||
<!-- Cascade Category: Main Category -->
|
||
<div v-if="!isFeeMode">
|
||
<label class="block text-sm font-semibold text-slate-700 mb-1.5">Főkategória</label>
|
||
<select
|
||
v-model="form.mainCategory"
|
||
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"
|
||
@change="onMainCategoryChange"
|
||
>
|
||
<option value="" disabled>Válassz kategóriát...</option>
|
||
<option
|
||
v-for="cat in mainCategories"
|
||
:key="cat.id"
|
||
:value="cat.id"
|
||
>
|
||
{{ cat.name }}
|
||
</option>
|
||
</select>
|
||
</div>
|
||
|
||
<!-- Cascade Category: Sub Category -->
|
||
<div v-if="!isFeeMode && subCategories.length > 0">
|
||
<label class="block text-sm font-semibold text-slate-700 mb-1.5">Alkategória</label>
|
||
<select
|
||
v-model="form.subCategory"
|
||
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>Válassz alkategóriát...</option>
|
||
<option
|
||
v-for="sub in subCategories"
|
||
:key="sub.id"
|
||
:value="sub.id"
|
||
>
|
||
{{ sub.name }}
|
||
</option>
|
||
</select>
|
||
</div>
|
||
|
||
<!-- Description / Notes -->
|
||
<div>
|
||
<label class="block text-sm font-semibold text-slate-700 mb-1.5">Megjegyzés</label>
|
||
<textarea
|
||
v-model="form.description"
|
||
rows="3"
|
||
placeholder="Rövid leírás a költségről..."
|
||
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 resize-none"
|
||
/>
|
||
</div>
|
||
|
||
<!-- Recurring Toggle -->
|
||
<div v-if="!isFeeMode" class="flex items-center gap-3">
|
||
<input
|
||
id="is-recurring"
|
||
v-model="form.isRecurring"
|
||
type="checkbox"
|
||
class="h-4 w-4 rounded border-slate-300 text-sf-accent focus:ring-sf-accent/20"
|
||
/>
|
||
<label for="is-recurring" class="text-sm text-slate-700">Rendszeres költség (havi ismétlődő)</label>
|
||
</div>
|
||
|
||
<!-- Error message -->
|
||
<div
|
||
v-if="errorMessage"
|
||
class="rounded-lg bg-red-50 border border-red-200 px-4 py-3 text-sm text-red-700"
|
||
>
|
||
{{ errorMessage }}
|
||
</div>
|
||
|
||
<!-- Submit button -->
|
||
<button
|
||
type="submit"
|
||
:disabled="costStore.isSubmitting"
|
||
class="w-full rounded-xl bg-sf-accent py-3 text-sm font-bold text-white shadow-md transition-all hover:bg-sf-blue hover:shadow-lg disabled:opacity-60 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||
>
|
||
<svg
|
||
v-if="costStore.isSubmitting"
|
||
class="h-4 w-4 animate-spin"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
viewBox="0 0 24 24"
|
||
>
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||
</svg>
|
||
<span v-else>✅ Költség rögzítése</span>
|
||
</button>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
</Teleport>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, reactive, computed, onMounted, watch } from 'vue'
|
||
import { useCostStore } from '../../stores/cost'
|
||
import api from '../../api/axios'
|
||
import type { Vehicle } from '../../stores/vehicle'
|
||
import { getLocalDateString } from '../../services/dateUtils'
|
||
|
||
const props = defineProps<{
|
||
isOpen: boolean
|
||
vehicle: Vehicle | null
|
||
/** If true, pre-fills as FEES category (parking/toll) */
|
||
isFeeMode?: boolean
|
||
}>()
|
||
|
||
const emit = defineEmits<{
|
||
close: []
|
||
saved: []
|
||
}>()
|
||
|
||
const costStore = useCostStore()
|
||
|
||
// ── Form State ──
|
||
const form = reactive({
|
||
date: getLocalDateString(),
|
||
amount: 0,
|
||
mainCategory: '',
|
||
subCategory: '',
|
||
description: '',
|
||
isRecurring: false,
|
||
})
|
||
|
||
const errorMessage = ref<string | null>(null)
|
||
|
||
// ── Category Cascade State ──
|
||
interface CategoryOption {
|
||
id: number
|
||
name: string
|
||
code?: string
|
||
parent_id: number | null
|
||
}
|
||
|
||
const mainCategories = ref<CategoryOption[]>([])
|
||
const subCategories = ref<CategoryOption[]>([])
|
||
|
||
const vehicleLabel = computed(() => {
|
||
if (!props.vehicle) return 'Nincs kiválasztva jármű'
|
||
const plate = props.vehicle.license_plate || ''
|
||
const brand = props.vehicle.brand || ''
|
||
const model = props.vehicle.model || ''
|
||
return `${plate} (${brand} ${model})`.trim()
|
||
})
|
||
|
||
/**
|
||
* Fetch hierarchical categories from the dictionaries API.
|
||
*/
|
||
async function fetchCategories() {
|
||
try {
|
||
const res = await api.get('/dictionaries/cost-categories')
|
||
const allCategories = res.data as CategoryOption[]
|
||
|
||
// Split into main (parent_id === null) and sub categories
|
||
mainCategories.value = allCategories.filter((c) => c.parent_id === null)
|
||
// Store all for sub-category filtering
|
||
window.__allCostCategories = allCategories
|
||
} catch (err) {
|
||
console.error('[ComplexExpenseModal] Failed to load categories:', err)
|
||
// Fallback: provide basic categories — IDs must match vehicle.cost_categories in DB
|
||
mainCategories.value = [
|
||
{ id: 1, name: 'Üzemanyag / Töltés', parent_id: null },
|
||
{ id: 2, name: 'Szerviz & Karbantartás', parent_id: null },
|
||
{ id: 3, name: 'Gumiabroncsok', parent_id: null },
|
||
{ id: 4, name: 'Biztosítás', parent_id: null },
|
||
{ id: 5, name: 'Adók', parent_id: null },
|
||
{ id: 6, name: 'Útdíj & Parkolás', parent_id: null },
|
||
{ id: 9, name: 'Ápolás & Kozmetika', parent_id: null },
|
||
{ id: 10, name: 'Egyéb', parent_id: null },
|
||
]
|
||
}
|
||
}
|
||
|
||
function onMainCategoryChange() {
|
||
// Reset sub-category when main changes
|
||
form.subCategory = ''
|
||
subCategories.value = []
|
||
|
||
if (!form.mainCategory) return
|
||
|
||
const allCats: CategoryOption[] = (window as any).__allCostCategories || []
|
||
subCategories.value = allCats.filter(
|
||
(c) => c.parent_id === Number(form.mainCategory)
|
||
)
|
||
}
|
||
|
||
async function handleSubmit() {
|
||
if (!props.vehicle) {
|
||
errorMessage.value = 'Nincs kiválasztva jármű.'
|
||
return
|
||
}
|
||
|
||
errorMessage.value = null
|
||
|
||
// Determine cost_type and category_id
|
||
let costType: string
|
||
let categoryId: number
|
||
|
||
if (props.isFeeMode) {
|
||
// Fee mode: pre-filled as FEES — resolve dynamically by code, not by hardcoded ID
|
||
costType = 'fee'
|
||
const allCats: CategoryOption[] = (window as any).__allCostCategories || []
|
||
const feesCat = allCats.find((c: CategoryOption) => c.code === 'FEES' && c.parent_id === null)
|
||
categoryId = feesCat?.id || 6 // Fallback to ID 6 if lookup fails
|
||
} else {
|
||
// Complex mode: use selected sub-category or main category
|
||
costType = 'other'
|
||
categoryId = Number(form.subCategory || form.mainCategory) || 10
|
||
}
|
||
|
||
const payload = {
|
||
asset_id: props.vehicle.id,
|
||
// organization_id is omitted — backend auto-resolves from asset
|
||
category_id: categoryId,
|
||
/** GROSS-FIRST (Masterbook 2.0.1): amount_gross is the primary field */
|
||
amount_gross: form.amount,
|
||
currency: 'HUF' as const,
|
||
date: new Date(form.date).toISOString(),
|
||
mileage_at_cost: null,
|
||
description: form.description || (props.isFeeMode ? 'Parkolás / Útdíj' : 'Egyéb költség'),
|
||
data: {
|
||
is_recurring: form.isRecurring,
|
||
category_id: categoryId,
|
||
main_category_id: form.mainCategory || null,
|
||
sub_category_id: form.subCategory || null,
|
||
},
|
||
}
|
||
|
||
const result = await costStore.addExpense(payload)
|
||
|
||
if (result) {
|
||
// Reset form
|
||
form.date = getLocalDateString()
|
||
form.amount = 0
|
||
form.mainCategory = ''
|
||
form.subCategory = ''
|
||
form.description = ''
|
||
form.isRecurring = false
|
||
emit('saved')
|
||
} else {
|
||
errorMessage.value = costStore.lastError || 'Ismeretlen hiba történt.'
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Reset the form date to today's local date every time the modal opens.
|
||
* This ensures the date-picker always shows the correct local date,
|
||
* even if the component is kept alive between opens.
|
||
*/
|
||
watch(() => props.isOpen, (open) => {
|
||
if (open) {
|
||
form.date = getLocalDateString()
|
||
errorMessage.value = null
|
||
}
|
||
})
|
||
|
||
onMounted(() => {
|
||
fetchCategories()
|
||
})
|
||
</script>
|
||
|
||
<style scoped>
|
||
@keyframes fadeInUp {
|
||
from {
|
||
opacity: 0;
|
||
transform: translateY(20px) scale(0.96);
|
||
}
|
||
to {
|
||
opacity: 1;
|
||
transform: translateY(0) scale(1);
|
||
}
|
||
}
|
||
.animate-fade-in-up {
|
||
animation: fadeInUp 0.25s ease-out forwards;
|
||
}
|
||
</style>
|