admin felület különválasztva

This commit is contained in:
Roo
2026-06-24 11:29:45 +00:00
parent 71ef33bb85
commit 80a5d67f79
462 changed files with 87873 additions and 312 deletions

View File

@@ -0,0 +1,329 @@
<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>