feat: implement services management with CRUD operations and UI integration

This commit is contained in:
Yoga Pangestu 2026-09-05 12:19:42 +07:00
parent fc2054d045
commit be621900ff
16 changed files with 610 additions and 27 deletions

View File

@ -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');
}
};

View File

@ -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.');
}
}

View File

@ -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',
];
}
}

View File

@ -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,
]);
}
}

View 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',
];
}
}

View File

@ -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();
}
}

View 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',
]);
});

View File

@ -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);
}
}

View File

@ -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();
}
}

View 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');
}
}

View File

@ -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 {

View File

@ -62,4 +62,7 @@
'tenants' => 'id',
]);
});
// Domain modules own their routes — app/Domains/{Domain}/routes.php.
require app_path('Domains/Barbershop/routes.php');
});

View File

@ -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>

View File

@ -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 },
],

View File

@ -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' },
},
],
},
],

View 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>