Files
service-finder/frontend/src/views/admin/AdminServicesView.vue
2026-06-23 21:11:21 +00:00

350 lines
12 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="admin-services-view">
<!-- Page Header -->
<div class="flex items-center justify-between mb-6">
<div>
<h2 class="text-2xl font-bold text-white">{{ $t('admin.services.title') }}</h2>
<p class="text-gray-400 mt-1">{{ $t('admin.services.subtitle') }}</p>
</div>
<button
class="flex items-center gap-2 px-4 py-2.5 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors"
@click="openCreateModal"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
{{ $t('admin.services.create_button') }}
</button>
</div>
<!-- Toolbar -->
<div class="flex items-center gap-3 mb-6">
<button
class="px-3 py-1.5 bg-gray-700 hover:bg-gray-600 text-gray-300 text-sm rounded-lg transition-colors"
@click="loadServices"
>
{{ $t('admin.services.refresh') }}
</button>
</div>
<!-- Loading State -->
<div v-if="store.isLoading" class="text-center py-12 text-gray-400">
{{ $t('admin.services.loading') }}
</div>
<!-- Error State -->
<div v-else-if="store.error" class="text-center py-12 text-red-400">
{{ store.error }}
</div>
<!-- Empty State -->
<div
v-else-if="store.services.length === 0"
class="text-center py-12 text-gray-500"
>
<span class="text-5xl block mb-4">🛠</span>
<p class="text-lg">{{ $t('admin.services.empty') }}</p>
<p class="text-sm mt-1">{{ $t('admin.services.empty_hint') }}</p>
</div>
<!-- Service Cards Grid -->
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div
v-for="svc in store.services"
:key="svc.id"
class="relative bg-gray-800/60 rounded-xl border border-gray-700 p-6 hover:border-blue-500/30 transition-all group"
:class="{ 'opacity-70': !svc.is_active }"
>
<!-- Inactive Badge -->
<div
v-if="!svc.is_active"
class="absolute top-3 right-3 px-2 py-0.5 bg-yellow-900/60 text-yellow-300 border border-yellow-700 rounded-full text-xs font-medium"
>
{{ $t('admin.services.badge_inactive') }}
</div>
<!-- Service Name & Description -->
<div class="mb-4">
<h3 class="text-lg font-bold text-white">{{ svc.name }}</h3>
<!-- Service Code as small secondary badge -->
<span class="inline-block mt-1 px-2 py-0.5 bg-gray-700/50 text-gray-500 border border-gray-600/50 rounded text-xs font-mono">
{{ svc.service_code }}
</span>
<p v-if="svc.description" class="text-sm text-gray-400 mt-2 line-clamp-2">
{{ svc.description }}
</p>
</div>
<!-- Credit Cost -->
<div class="border-t border-gray-700/50 pt-3 mb-4">
<div class="flex justify-between text-sm">
<span class="text-gray-400">{{ $t('admin.services.credit_cost_label') }}</span>
<span class="text-gray-200 font-medium">{{ svc.credit_cost }} {{ $t('admin.services.credit_unit') }}</span>
</div>
</div>
<!-- Actions -->
<div class="flex items-center gap-2 pt-3 border-t border-gray-700/50">
<button
class="flex-1 px-3 py-2 text-sm font-medium text-blue-300 bg-blue-900/30 hover:bg-blue-900/50 border border-blue-700/50 rounded-lg transition-colors"
@click="openEditModal(svc)"
>
{{ $t('admin.services.edit_button') }}
</button>
<button
class="flex-1 px-3 py-2 text-sm font-medium rounded-lg transition-colors"
:class="svc.is_active
? 'text-red-300 bg-red-900/30 hover:bg-red-900/50 border border-red-700/50'
: 'text-green-300 bg-green-900/30 hover:bg-green-900/50 border border-green-700/50'"
@click="toggleActive(svc)"
>
{{ svc.is_active ? $t('admin.services.deactivate_button') : $t('admin.services.activate_button') }}
</button>
</div>
</div>
</div>
<!-- Edit / Create Modal -->
<div
v-if="showModal"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
@click.self="closeModal"
>
<div class="bg-gray-800 rounded-xl border border-gray-700 w-full max-w-lg mx-4 shadow-2xl">
<!-- Modal Header -->
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-700">
<h3 class="text-lg font-bold text-white">
{{ editingService ? $t('admin.services.modal_edit_title') : $t('admin.services.modal_create_title') }}
</h3>
<button
class="text-gray-400 hover:text-white transition-colors"
@click="closeModal"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- Modal Body -->
<div class="px-6 py-4 space-y-4">
<!-- Service Code (only for create) -->
<div v-if="!editingService">
<label class="block text-sm font-medium text-gray-300 mb-1">
{{ $t('admin.services.field_code') }}
</label>
<input
v-model="form.service_code"
type="text"
class="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none"
:placeholder="$t('admin.services.field_code_placeholder')"
/>
<p v-if="formErrors.service_code" class="text-red-400 text-xs mt-1">{{ formErrors.service_code }}</p>
</div>
<!-- Name -->
<div>
<label class="block text-sm font-medium text-gray-300 mb-1">
{{ $t('admin.services.field_name') }}
</label>
<input
v-model="form.name"
type="text"
class="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none"
:placeholder="$t('admin.services.field_name_placeholder')"
/>
<p v-if="formErrors.name" class="text-red-400 text-xs mt-1">{{ formErrors.name }}</p>
</div>
<!-- Description -->
<div>
<label class="block text-sm font-medium text-gray-300 mb-1">
{{ $t('admin.services.field_description') }}
</label>
<textarea
v-model="form.description"
rows="3"
class="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none resize-none"
:placeholder="$t('admin.services.field_description_placeholder')"
></textarea>
</div>
<!-- Credit Cost (no spinners) -->
<div>
<label class="block text-sm font-medium text-gray-300 mb-1">
{{ $t('admin.services.field_credit_cost') }}
</label>
<input
v-model.number="form.credit_cost"
type="number"
min="0"
class="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none
[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
/>
</div>
</div>
<!-- Modal Footer -->
<div class="flex items-center justify-end gap-3 px-6 py-4 border-t border-gray-700">
<button
class="px-4 py-2 text-sm font-medium text-gray-300 bg-gray-700 hover:bg-gray-600 rounded-lg transition-colors"
@click="closeModal"
>
{{ $t('common.cancel') }}
</button>
<button
class="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="store.isLoading"
@click="saveService"
>
<span v-if="store.isLoading">{{ $t('common.saving') }}</span>
<span v-else>{{ editingService ? $t('admin.services.save_changes') : $t('admin.services.create') }}</span>
</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { useAdminServicesStore } from '../../stores/adminServices'
import type { ServiceCatalogItem } from '../../stores/adminServices'
const store = useAdminServicesStore()
// ── State ───────────────────────────────────────────────────────────────────
const showModal = ref(false)
const editingService = ref<ServiceCatalogItem | null>(null)
const form = reactive({
service_code: '',
name: '',
description: '',
credit_cost: 0,
})
const formErrors = reactive({
service_code: '',
name: '',
})
// ── Lifecycle ───────────────────────────────────────────────────────────────
onMounted(() => {
loadServices()
})
// ── Methods ─────────────────────────────────────────────────────────────────
async function loadServices() {
await store.fetchServices()
}
function resetForm() {
form.service_code = ''
form.name = ''
form.description = ''
form.credit_cost = 0
formErrors.service_code = ''
formErrors.name = ''
}
function validateForm(): boolean {
let valid = true
formErrors.service_code = ''
formErrors.name = ''
if (!editingService) {
if (!form.service_code || form.service_code.trim().length < 3) {
formErrors.service_code = 'Service code must be at least 3 characters'
valid = false
}
}
if (!form.name || form.name.trim().length < 2) {
formErrors.name = 'Name must be at least 2 characters'
valid = false
}
return valid
}
// ── Modal Handlers ──────────────────────────────────────────────────────────
function openCreateModal() {
editingService.value = null
resetForm()
showModal.value = true
}
function openEditModal(svc: ServiceCatalogItem) {
editingService.value = svc
form.service_code = svc.service_code
form.name = svc.name
form.description = svc.description || ''
form.credit_cost = svc.credit_cost
formErrors.service_code = ''
formErrors.name = ''
showModal.value = true
}
function closeModal() {
showModal.value = false
editingService.value = null
resetForm()
}
async function saveService() {
if (!validateForm()) return
try {
if (editingService.value) {
await store.updateService(editingService.value.id, {
name: form.name.trim(),
description: form.description.trim() || null,
credit_cost: form.credit_cost,
})
} else {
await store.createService({
service_code: form.service_code.trim().toUpperCase(),
name: form.name.trim(),
description: form.description.trim() || null,
credit_cost: form.credit_cost,
is_active: true,
})
}
closeModal()
await loadServices()
} catch (err: any) {
// Error is already set in the store
}
}
async function toggleActive(svc: ServiceCatalogItem) {
try {
await store.updateService(svc.id, { is_active: !svc.is_active })
await loadServices()
} catch (err: any) {
// Error is already set in the store
}
}
</script>
<style scoped>
.admin-services-view {
font-family: 'Inter', sans-serif;
}
input[type='number']::-webkit-inner-spin-button,
input[type='number']::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
input[type='number'] {
-moz-appearance: textfield;
}
</style>