feat: implement services management with CRUD operations and UI integration
This commit is contained in:
parent
fc2054d045
commit
be621900ff
@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::connection('tenant')->create('services', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('name', 100);
|
||||
$table->decimal('price', 12, 2)->default(0);
|
||||
$table->unsignedSmallInteger('duration_minutes')->default(30);
|
||||
$table->boolean('is_active')->default(true);
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::connection('tenant')->dropIfExists('services');
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Barbershop\Http\Controllers;
|
||||
|
||||
use App\Domains\Barbershop\Http\Requests\ServiceRequest;
|
||||
use App\Domains\Barbershop\Http\Resources\ServiceResource;
|
||||
use App\Domains\Barbershop\Models\Service;
|
||||
use App\Domains\Barbershop\Services\ServiceService;
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class ServiceController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected ServiceService $serviceService
|
||||
) {}
|
||||
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$services = $this->serviceService->paginate($request)
|
||||
->through(fn (Service $service) => (new ServiceResource($service))->resolve());
|
||||
|
||||
return $this->paginated($services, 'Berhasil mengambil data layanan.');
|
||||
}
|
||||
|
||||
public function store(ServiceRequest $request): JsonResponse
|
||||
{
|
||||
$service = $this->serviceService->create($request->validated());
|
||||
|
||||
return $this->created(
|
||||
(new ServiceResource($service))->resolve(),
|
||||
'Layanan berhasil dibuat.'
|
||||
);
|
||||
}
|
||||
|
||||
public function show(string $id): JsonResponse
|
||||
{
|
||||
return $this->success(
|
||||
(new ServiceResource($this->serviceService->findOrFail($id)))->resolve(),
|
||||
'Berhasil mengambil data layanan.'
|
||||
);
|
||||
}
|
||||
|
||||
public function update(ServiceRequest $request, string $id): JsonResponse
|
||||
{
|
||||
$service = $this->serviceService->update(
|
||||
$this->serviceService->findOrFail($id),
|
||||
$request->validated()
|
||||
);
|
||||
|
||||
return $this->success(
|
||||
(new ServiceResource($service))->resolve(),
|
||||
'Layanan berhasil diperbarui.'
|
||||
);
|
||||
}
|
||||
|
||||
public function destroy(string $id): JsonResponse
|
||||
{
|
||||
$this->serviceService->delete($this->serviceService->findOrFail($id));
|
||||
|
||||
return $this->success(null, 'Layanan berhasil dihapus.');
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Barbershop\Http\Requests;
|
||||
|
||||
use App\Http\Requests\BaseRequest;
|
||||
|
||||
class ServiceRequest extends BaseRequest
|
||||
{
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => ['required', 'string', 'max:100'],
|
||||
'price' => ['required', 'numeric', 'min:0'],
|
||||
'duration_minutes' => ['sometimes', 'integer', 'min:1'],
|
||||
'is_active' => ['sometimes', 'boolean'],
|
||||
];
|
||||
}
|
||||
|
||||
public function attributes(): array
|
||||
{
|
||||
return [
|
||||
'name' => 'nama',
|
||||
'price' => 'harga',
|
||||
'duration_minutes' => 'durasi (menit)',
|
||||
'is_active' => 'status aktif',
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Barbershop\Http\Resources;
|
||||
|
||||
use App\Domains\Barbershop\Models\Service;
|
||||
use App\Helpers\Helper;
|
||||
use App\Http\Resources\BaseResource;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* @mixin Service
|
||||
*/
|
||||
class ServiceResource extends BaseResource
|
||||
{
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return array_merge(parent::toArray($request), [
|
||||
'name' => $this->name,
|
||||
'price' => (float) $this->price,
|
||||
'price_formatted' => Helper::formatCurrency((float) $this->price),
|
||||
'duration_minutes' => $this->duration_minutes,
|
||||
'is_active' => $this->is_active,
|
||||
]);
|
||||
}
|
||||
}
|
||||
47
api.profitra.id/app/Domains/Barbershop/Models/Service.php
Normal file
47
api.profitra.id/app/Domains/Barbershop/Models/Service.php
Normal file
@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Barbershop\Models;
|
||||
|
||||
use App\Traits\Filterable;
|
||||
use App\Traits\Sortable;
|
||||
use Illuminate\Database\Eloquent\Attributes\Guarded;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property string $name
|
||||
* @property string $price
|
||||
* @property int $duration_minutes
|
||||
* @property bool $is_active
|
||||
* @property Carbon $created_at
|
||||
* @property Carbon|null $updated_at
|
||||
*/
|
||||
#[Guarded(['id'])]
|
||||
class Service extends Model
|
||||
{
|
||||
use Filterable, Sortable;
|
||||
|
||||
protected $connection = 'tenant';
|
||||
|
||||
/** @var list<string> */
|
||||
protected $sortable = ['name', 'price', 'duration_minutes', 'is_active', 'created_at'];
|
||||
|
||||
/** @var list<string> */
|
||||
protected $filterable = ['name', 'is_active'];
|
||||
|
||||
/** @var array<string, mixed> */
|
||||
protected $attributes = [
|
||||
'duration_minutes' => 30,
|
||||
'is_active' => true,
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'price' => 'decimal:2',
|
||||
'duration_minutes' => 'integer',
|
||||
'is_active' => 'boolean',
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Barbershop\Services;
|
||||
|
||||
use App\Domains\Barbershop\Models\Service;
|
||||
use App\Exceptions\NotFoundException;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
|
||||
class ServiceService
|
||||
{
|
||||
public function paginate(Request $request): LengthAwarePaginator
|
||||
{
|
||||
$search = $request->input('search');
|
||||
|
||||
return Service::query()
|
||||
->filter($request->only(['name', 'is_active']))
|
||||
->when($search, function ($query) use ($search) {
|
||||
$query->where('name', 'like', "%{$search}%");
|
||||
})
|
||||
->sort($request->input('sort'))
|
||||
->paginate($request->integer('per_page', 25));
|
||||
}
|
||||
|
||||
public function create(array $data): Service
|
||||
{
|
||||
return Service::create($data);
|
||||
}
|
||||
|
||||
public function findOrFail(int|string $id): Service
|
||||
{
|
||||
$service = Service::find($id);
|
||||
|
||||
if (! $service) {
|
||||
throw new NotFoundException('Layanan tidak ditemukan.');
|
||||
}
|
||||
|
||||
return $service;
|
||||
}
|
||||
|
||||
public function update(Service $service, array $data): Service
|
||||
{
|
||||
$service->update($data);
|
||||
|
||||
return $service;
|
||||
}
|
||||
|
||||
public function delete(Service $service): void
|
||||
{
|
||||
$service->delete();
|
||||
}
|
||||
}
|
||||
12
api.profitra.id/app/Domains/Barbershop/routes.php
Normal file
12
api.profitra.id/app/Domains/Barbershop/routes.php
Normal file
@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
use App\Domains\Barbershop\Http\Controllers\ServiceController;
|
||||
use App\Enums\RoleName;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
// Tenant-owned data — scoped to the authenticated user's own tenant database.
|
||||
Route::middleware(['auth:sanctum', 'role:'.RoleName::User->value, 'tenant.db'])->group(function () {
|
||||
Route::apiResource('services', ServiceController::class)->parameters([
|
||||
'services' => 'id',
|
||||
]);
|
||||
});
|
||||
@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Enums\TenantStatus;
|
||||
use App\Exceptions\ForbiddenException;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\TenantConnection;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Points the `tenant` connection at the authenticated user's own tenant
|
||||
* database for the duration of the request. Must run after `auth:sanctum`.
|
||||
*/
|
||||
class ResolveTenantConnection
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
if (! $user?->tenant_id) {
|
||||
throw new ForbiddenException('Anda belum memiliki workspace.');
|
||||
}
|
||||
|
||||
$tenant = Tenant::find($user->tenant_id);
|
||||
|
||||
if (! $tenant || in_array($tenant->status, [TenantStatus::Suspended, TenantStatus::Cancelled], true)) {
|
||||
throw new ForbiddenException('Workspace tidak aktif.');
|
||||
}
|
||||
|
||||
TenantConnection::use($tenant->database_name);
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@ -2,6 +2,8 @@
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Support\TenantConnection;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
@ -26,7 +28,7 @@ private function createDatabase(string $databaseName): void
|
||||
|
||||
private function createSchema(string $databaseName, string $businessTypeSlug): void
|
||||
{
|
||||
$this->swapConnection($databaseName);
|
||||
TenantConnection::use($databaseName);
|
||||
|
||||
Schema::connection('tenant')->create('settings', function ($table) {
|
||||
$table->id();
|
||||
@ -44,14 +46,13 @@ private function createSchema(string $databaseName, string $businessTypeSlug): v
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::connection('tenant')->create('services', function ($table) {
|
||||
$table->id();
|
||||
$table->string('name', 100);
|
||||
$table->decimal('price', 12, 2)->default(0);
|
||||
$table->unsignedSmallInteger('duration_minutes')->default(30);
|
||||
$table->boolean('is_active')->default(true);
|
||||
$table->timestamps();
|
||||
});
|
||||
// `services` schema lives in app/Domains/Barbershop/Database/Migrations
|
||||
// so it stays the single source of truth shared with the API.
|
||||
Artisan::call('migrate', [
|
||||
'--path' => 'app/Domains/Barbershop/Database/Migrations',
|
||||
'--database' => 'tenant',
|
||||
'--force' => true,
|
||||
]);
|
||||
|
||||
Schema::connection('tenant')->create('appointments', function ($table) {
|
||||
$table->id();
|
||||
@ -67,12 +68,12 @@ private function createSchema(string $databaseName, string $businessTypeSlug): v
|
||||
});
|
||||
}
|
||||
|
||||
$this->restoreConnection();
|
||||
TenantConnection::restore();
|
||||
}
|
||||
|
||||
private function seedData(string $databaseName, string $businessTypeSlug): void
|
||||
{
|
||||
$this->swapConnection($databaseName);
|
||||
TenantConnection::use($databaseName);
|
||||
|
||||
$settings = [
|
||||
'business_name' => '',
|
||||
@ -98,19 +99,6 @@ private function seedData(string $databaseName, string $businessTypeSlug): void
|
||||
]);
|
||||
}
|
||||
|
||||
$this->restoreConnection();
|
||||
}
|
||||
|
||||
private function swapConnection(string $databaseName): void
|
||||
{
|
||||
config(['database.connections.tenant.database' => $databaseName]);
|
||||
DB::purge('tenant');
|
||||
DB::reconnect('tenant');
|
||||
}
|
||||
|
||||
private function restoreConnection(): void
|
||||
{
|
||||
config(['database.connections.tenant.database' => null]);
|
||||
DB::purge('tenant');
|
||||
TenantConnection::restore();
|
||||
}
|
||||
}
|
||||
|
||||
26
api.profitra.id/app/Support/TenantConnection.php
Normal file
26
api.profitra.id/app/Support/TenantConnection.php
Normal file
@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Points the `tenant` database connection at a specific tenant's database.
|
||||
* Used both during provisioning (app/Services/TenantProvisioner.php) and
|
||||
* per-request tenant resolution (app/Http/Middleware/ResolveTenantConnection.php).
|
||||
*/
|
||||
class TenantConnection
|
||||
{
|
||||
public static function use(string $databaseName): void
|
||||
{
|
||||
config(['database.connections.tenant.database' => $databaseName]);
|
||||
DB::purge('tenant');
|
||||
DB::reconnect('tenant');
|
||||
}
|
||||
|
||||
public static function restore(): void
|
||||
{
|
||||
config(['database.connections.tenant.database' => null]);
|
||||
DB::purge('tenant');
|
||||
}
|
||||
}
|
||||
@ -4,6 +4,7 @@
|
||||
use App\Http\Middleware\EnsureUserHasRole;
|
||||
use App\Http\Middleware\ForceJsonResponse;
|
||||
use App\Http\Middleware\RateLimiterMiddleware;
|
||||
use App\Http\Middleware\ResolveTenantConnection;
|
||||
use Illuminate\Foundation\Application;
|
||||
use Illuminate\Foundation\Configuration\Exceptions;
|
||||
use Illuminate\Foundation\Configuration\Middleware;
|
||||
@ -23,6 +24,7 @@
|
||||
'api.version' => ApiVersion::class,
|
||||
'rate.limit' => RateLimiterMiddleware::class,
|
||||
'role' => EnsureUserHasRole::class,
|
||||
'tenant.db' => ResolveTenantConnection::class,
|
||||
]);
|
||||
})
|
||||
->withExceptions(function (Exceptions $exceptions): void {
|
||||
|
||||
@ -62,4 +62,7 @@
|
||||
'tenants' => 'id',
|
||||
]);
|
||||
});
|
||||
|
||||
// Domain modules own their routes — app/Domains/{Domain}/routes.php.
|
||||
require app_path('Domains/Barbershop/routes.php');
|
||||
});
|
||||
|
||||
@ -1,5 +1,8 @@
|
||||
<script setup lang="ts"></script>
|
||||
<script setup lang="ts">
|
||||
import ToastContainer from '@shared/components/ToastContainer.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RouterView />
|
||||
<ToastContainer />
|
||||
</template>
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { Calendar, LayoutDashboard, Scissors, Users } from '@lucide/vue'
|
||||
import { Calendar, LayoutDashboard, Scissors, Tag, Users } from '@lucide/vue'
|
||||
import AdminHeader from '@shared/components/AdminHeader.vue'
|
||||
import AdminSidebar, { type NavGroup } from '@shared/components/AdminSidebar.vue'
|
||||
import { computed, ref } from 'vue'
|
||||
@ -10,6 +10,7 @@ const navGroups: NavGroup[] = [
|
||||
items: [
|
||||
{ label: 'Dashboard', to: '/dashboard', icon: LayoutDashboard },
|
||||
{ label: 'Jadwal', to: '/appointments', icon: Calendar, disabled: true },
|
||||
{ label: 'Layanan', to: '/services', icon: Tag },
|
||||
{ label: 'Barber', to: '/barbers', icon: Scissors, disabled: true },
|
||||
{ label: 'Pelanggan', to: '/customers', icon: Users, disabled: true },
|
||||
],
|
||||
|
||||
@ -21,6 +21,12 @@ const router = createRouter({
|
||||
component: () => import('../views/DashboardView.vue'),
|
||||
meta: { title: 'Dashboard' },
|
||||
},
|
||||
{
|
||||
path: 'services',
|
||||
name: 'services',
|
||||
component: () => import('../views/services/ServicesView.vue'),
|
||||
meta: { title: 'Layanan' },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
264
tenants/barbershop/src/views/services/ServicesView.vue
Normal file
264
tenants/barbershop/src/views/services/ServicesView.vue
Normal file
@ -0,0 +1,264 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue'
|
||||
import { ApiError, apiFetch } from '@/lib/api'
|
||||
import CurrencyInput from '@shared/components/CurrencyInput.vue'
|
||||
import DataTable, { type DataTableColumn } from '@shared/components/DataTable.vue'
|
||||
import DeleteConfirmModal from '@shared/components/DeleteConfirmModal.vue'
|
||||
import Modal from '@shared/components/Modal.vue'
|
||||
import PageHeader from '@shared/components/PageHeader.vue'
|
||||
import RowActions from '@shared/components/RowActions.vue'
|
||||
import SearchInput from '@shared/components/SearchInput.vue'
|
||||
import StatusBadge from '@shared/components/StatusBadge.vue'
|
||||
import StatusFilterField from '@shared/components/StatusFilterField.vue'
|
||||
import ToggleSwitch from '@shared/components/ToggleSwitch.vue'
|
||||
import { useDeleteConfirm } from '@shared/composables/useDeleteConfirm'
|
||||
import { useResourceList, type PaginatedResponse } from '@shared/composables/useResourceList'
|
||||
import { useToastStore } from '@shared/stores/toast'
|
||||
|
||||
interface Service {
|
||||
id: number
|
||||
name: string
|
||||
price: number
|
||||
price_formatted: string
|
||||
duration_minutes: number
|
||||
is_active: boolean
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
interface ItemResponse<T> {
|
||||
success: true
|
||||
message: string
|
||||
data: T
|
||||
}
|
||||
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'name', label: 'Nama', sortable: true },
|
||||
{ key: 'price', label: 'Harga', sortable: true },
|
||||
{ key: 'duration_minutes', label: 'Durasi', sortable: true },
|
||||
{ key: 'is_active', label: 'Status', sortable: true },
|
||||
{ key: 'actions', label: 'Aksi', align: 'right' },
|
||||
]
|
||||
|
||||
const toast = useToastStore()
|
||||
|
||||
function handleFetchError(error: unknown, fallback: string) {
|
||||
toast.error(error instanceof ApiError ? error.message : fallback)
|
||||
}
|
||||
|
||||
const {
|
||||
items: services,
|
||||
isLoading,
|
||||
search,
|
||||
filters,
|
||||
sort,
|
||||
page,
|
||||
perPage,
|
||||
lastPage,
|
||||
total,
|
||||
fetchList: fetchServices,
|
||||
handlePageChange,
|
||||
handlePerPageChange,
|
||||
handleSortChange,
|
||||
resetFilters,
|
||||
hasActiveFilters,
|
||||
} = useResourceList({
|
||||
fetchPage: (params) => apiFetch<PaginatedResponse<Service>>(`/v1/services?${params}`),
|
||||
onError: (error) => handleFetchError(error, 'Gagal memuat data layanan.'),
|
||||
initialFilters: { is_active: '' },
|
||||
})
|
||||
|
||||
const isFormOpen = ref(false)
|
||||
const isSubmitting = ref(false)
|
||||
const editingId = ref<number | null>(null)
|
||||
const form = reactive({ name: '', price: 0, duration_minutes: 30, is_active: true })
|
||||
const errors = reactive<{ name?: string; price?: string; duration_minutes?: string }>({})
|
||||
|
||||
function resetErrors() {
|
||||
errors.name = undefined
|
||||
errors.price = undefined
|
||||
errors.duration_minutes = undefined
|
||||
}
|
||||
|
||||
function openCreateForm() {
|
||||
editingId.value = null
|
||||
form.name = ''
|
||||
form.price = 0
|
||||
form.duration_minutes = 30
|
||||
form.is_active = true
|
||||
resetErrors()
|
||||
isFormOpen.value = true
|
||||
}
|
||||
|
||||
function openEditForm(service: Service) {
|
||||
editingId.value = service.id
|
||||
form.name = service.name
|
||||
form.price = service.price
|
||||
form.duration_minutes = service.duration_minutes
|
||||
form.is_active = service.is_active
|
||||
resetErrors()
|
||||
isFormOpen.value = true
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
resetErrors()
|
||||
isSubmitting.value = true
|
||||
|
||||
const payload = {
|
||||
name: form.name,
|
||||
price: form.price,
|
||||
duration_minutes: form.duration_minutes,
|
||||
is_active: form.is_active,
|
||||
}
|
||||
|
||||
try {
|
||||
const response = editingId.value
|
||||
? await apiFetch<ItemResponse<Service>>(`/v1/services/${editingId.value}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
: await apiFetch<ItemResponse<Service>>('/v1/services', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
|
||||
toast.success(response.message)
|
||||
isFormOpen.value = false
|
||||
fetchServices()
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.status === 422 && error.errors) {
|
||||
errors.name = error.errors.name
|
||||
errors.price = error.errors.price
|
||||
errors.duration_minutes = error.errors.duration_minutes
|
||||
} else if (error instanceof ApiError) {
|
||||
toast.error(error.message)
|
||||
} else {
|
||||
toast.error('Terjadi kesalahan tak terduga. Coba lagi.')
|
||||
}
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const togglingId = ref<number | null>(null)
|
||||
|
||||
async function toggleActive(service: Service, value: boolean) {
|
||||
togglingId.value = service.id
|
||||
|
||||
try {
|
||||
const response = await apiFetch<ItemResponse<Service>>(`/v1/services/${service.id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ name: service.name, price: service.price, is_active: value }),
|
||||
})
|
||||
service.is_active = response.data.is_active
|
||||
toast.success(response.message)
|
||||
} catch (error) {
|
||||
toast.error(error instanceof ApiError ? error.message : 'Gagal memperbarui status.')
|
||||
} finally {
|
||||
togglingId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const {
|
||||
target: deletingService,
|
||||
isDeleting,
|
||||
confirm: handleDelete,
|
||||
} = useDeleteConfirm<Service>({
|
||||
deleteFn: (item) => apiFetch<{ success: true; message: string }>(`/v1/services/${item.id}`, { method: 'DELETE' }),
|
||||
onSuccess: (message) => {
|
||||
toast.success(message)
|
||||
fetchServices()
|
||||
},
|
||||
onError: (error) => handleFetchError(error, 'Gagal menghapus layanan.'),
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<PageHeader title="Layanan" description="Kelola daftar layanan yang ditawarkan barbershop Anda."
|
||||
@action="openCreateForm" />
|
||||
|
||||
<DataTable class="mt-4" :columns="columns" :items="services" :loading="isLoading" :page="page"
|
||||
:last-page="lastPage" :per-page="perPage" :total="total" :sort="sort ?? undefined" empty-text="Belum ada layanan."
|
||||
:has-active-filters="hasActiveFilters" @update:page="handlePageChange" @update:per-page="handlePerPageChange"
|
||||
@update:sort="handleSortChange" @reset-filters="resetFilters">
|
||||
<template #search>
|
||||
<SearchInput v-model="search" placeholder="Cari nama layanan..." />
|
||||
</template>
|
||||
|
||||
<template #filters>
|
||||
<StatusFilterField v-model="filters.is_active" />
|
||||
</template>
|
||||
|
||||
<template #cell-price="{ item }">
|
||||
{{ (item as unknown as Service).price_formatted }}
|
||||
</template>
|
||||
|
||||
<template #cell-duration_minutes="{ item }">
|
||||
{{ (item as unknown as Service).duration_minutes }} menit
|
||||
</template>
|
||||
|
||||
<template #cell-is_active="{ item }">
|
||||
<div class="flex items-center gap-2">
|
||||
<StatusBadge :active="(item as unknown as Service).is_active" />
|
||||
<ToggleSwitch :model-value="(item as unknown as Service).is_active"
|
||||
:disabled="togglingId === (item as unknown as Service).id"
|
||||
@update:model-value="toggleActive(item as unknown as Service, $event)" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #cell-actions="{ item }">
|
||||
<RowActions @edit="openEditForm(item as unknown as Service)"
|
||||
@delete="deletingService = item as unknown as Service" />
|
||||
</template>
|
||||
</DataTable>
|
||||
|
||||
<Modal :open="isFormOpen" :title="editingId ? 'Ubah Layanan' : 'Tambah Layanan'" @close="isFormOpen = false">
|
||||
<form id="service-form" class="space-y-3" @submit.prevent="handleSubmit">
|
||||
<div>
|
||||
<label for="name" class="text-sm font-medium text-primary">Nama <span class="text-red-500">*</span></label>
|
||||
<input id="name" v-model="form.name" type="text" placeholder="mis. Potong Rambut" :aria-invalid="!!errors.name"
|
||||
@input="errors.name = undefined"
|
||||
class="mt-1.5 h-10 w-full rounded-lg border bg-surface px-3.5 text-sm text-primary placeholder:text-secondary focus:outline-none focus:ring-1"
|
||||
:class="errors.name ? 'border-danger focus:ring-danger' : 'border-border focus:ring-action'" />
|
||||
<p v-if="errors.name" class="mt-1.5 text-xs text-danger">{{ errors.name }}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="price" class="text-sm font-medium text-primary">Harga <span class="text-red-500">*</span></label>
|
||||
<CurrencyInput id="price" v-model="form.price" placeholder="0" :invalid="!!errors.price"
|
||||
@update:model-value="errors.price = undefined" />
|
||||
<p v-if="errors.price" class="mt-1.5 text-xs text-danger">{{ errors.price }}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="duration_minutes" class="text-sm font-medium text-primary">Durasi (menit) <span
|
||||
class="text-red-500">*</span></label>
|
||||
<input id="duration_minutes" v-model.number="form.duration_minutes" type="number" min="1" placeholder="30"
|
||||
:aria-invalid="!!errors.duration_minutes" @input="errors.duration_minutes = undefined"
|
||||
class="mt-1.5 h-10 w-full rounded-lg border bg-surface px-3.5 text-sm text-primary placeholder:text-secondary focus:outline-none focus:ring-1"
|
||||
:class="errors.duration_minutes ? 'border-danger focus:ring-danger' : 'border-border focus:ring-action'" />
|
||||
<p v-if="errors.duration_minutes" class="mt-1.5 text-xs text-danger">{{ errors.duration_minutes }}</p>
|
||||
</div>
|
||||
|
||||
<label class="flex items-center gap-2 pt-1">
|
||||
<ToggleSwitch v-model="form.is_active" />
|
||||
<span class="text-sm text-secondary">Aktif</span>
|
||||
</label>
|
||||
</form>
|
||||
|
||||
<template #footer>
|
||||
<button type="button" class="rounded-lg border border-border px-3.5 py-2 text-sm font-medium text-secondary"
|
||||
@click="isFormOpen = false">
|
||||
Batal
|
||||
</button>
|
||||
<button type="submit" form="service-form" :disabled="isSubmitting"
|
||||
class="rounded-lg bg-action px-3.5 py-2 text-sm font-semibold text-action-text disabled:opacity-60">
|
||||
{{ isSubmitting ? 'Menyimpan…' : 'Simpan' }}
|
||||
</button>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<DeleteConfirmModal :open="!!deletingService" title="Hapus Layanan" :item-name="deletingService?.name ?? ''"
|
||||
:loading="isDeleting" @close="deletingService = null" @confirm="handleDelete" />
|
||||
</div>
|
||||
</template>
|
||||
Loading…
Reference in New Issue
Block a user