Files
service-finder/frontend_app/src/components/dashboard/ComplexExpenseModal.vue
2026-06-24 11:29:45 +00:00

330 lines
11 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>