Add user settings management features including Appearance, Password, and Profile controllers for handling user preferences. Implement corresponding requests for validation and create views for each setting. Enhance middleware to manage appearance settings across the application. Update routes for new settings functionality and integrate UI components for user navigation in the settings layout.

This commit is contained in:
Yoga Pangestu 2026-06-10 23:33:19 +07:00
parent ce460ed4fd
commit 01997dccc4
21 changed files with 730 additions and 5 deletions

View File

@ -0,0 +1,36 @@
<?php
namespace App\Http\Controllers\Admin\Setting;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\Setting\UpdateAppearanceRequest;
use Illuminate\Http\RedirectResponse;
use Inertia\Inertia;
use Inertia\Response;
use Symfony\Component\HttpFoundation\Cookie;
class AppearanceController extends Controller
{
public function edit(): Response
{
return Inertia::render('admin/setting/Appearance', [
'appearance' => request()->cookie('appearance', 'system'),
]);
}
public function update(UpdateAppearanceRequest $request): RedirectResponse
{
$appearance = $request->string('appearance')->toString();
Inertia::flash('success', 'Tampilan berhasil diperbarui.');
return redirect()
->route('admin.setting.appearance')
->withCookie(Cookie::create('appearance')
->withValue($appearance)
->withExpires(now()->addYear())
->withPath('/')
->withHttpOnly(false)
->withSameSite('lax'));
}
}

View File

@ -0,0 +1,29 @@
<?php
namespace App\Http\Controllers\Admin\Setting;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\Setting\UpdatePasswordRequest;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Hash;
use Inertia\Inertia;
use Inertia\Response;
class PasswordController extends Controller
{
public function edit(): Response
{
return Inertia::render('admin/setting/Password');
}
public function update(UpdatePasswordRequest $request): RedirectResponse
{
$user = $request->user();
$user->password = Hash::make($request->string('password')->toString());
$user->save();
Inertia::flash('success', 'Kata sandi berhasil diperbarui.');
return redirect()->route('admin.setting.password');
}
}

View File

@ -0,0 +1,37 @@
<?php
namespace App\Http\Controllers\Admin\Setting;
use App\Enums\Gender;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\Setting\UpdateProfileRequest;
use App\Services\Setting\ProfileService;
use Illuminate\Http\RedirectResponse;
use Inertia\Inertia;
use Inertia\Response;
class ProfileController extends Controller
{
public function __construct(
private readonly ProfileService $profileService,
) {}
public function edit(): Response
{
$user = auth()->user()->load('profile');
return Inertia::render('admin/setting/Profile', [
'genders' => Gender::selectOptions(),
'user' => $user,
]);
}
public function update(UpdateProfileRequest $request): RedirectResponse
{
$this->profileService->update($request->validated());
Inertia::flash('success', 'Profil berhasil diperbarui.');
return redirect()->route('admin.setting.profile');
}
}

View File

@ -0,0 +1,21 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\View;
use Symfony\Component\HttpFoundation\Response;
class HandleAppearance
{
/**
* @param Closure(Request): Response $next
*/
public function handle(Request $request, Closure $next): Response
{
View::share('appearance', $request->cookie('appearance', 'system'));
return $next($request);
}
}

View File

@ -55,6 +55,7 @@ public function share(Request $request): array
],
'sidebarOpen' => ! $request->hasCookie('sidebar_state')
|| $request->cookie('sidebar_state') === 'true',
'appearance' => $request->cookie('appearance', 'system'),
];
}
}

View File

@ -0,0 +1,21 @@
<?php
namespace App\Http\Requests\Admin\Setting;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateAppearanceRequest extends FormRequest
{
public function authorize(): bool
{
return auth()->check();
}
public function rules(): array
{
return [
'appearance' => ['required', Rule::in(['light', 'dark', 'system'])],
];
}
}

View File

@ -0,0 +1,22 @@
<?php
namespace App\Http\Requests\Admin\Setting;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rules\Password;
class UpdatePasswordRequest extends FormRequest
{
public function authorize(): bool
{
return auth()->check();
}
public function rules(): array
{
return [
'current_password' => ['required', 'current_password'],
'password' => ['required', 'confirmed', Password::defaults()],
];
}
}

View File

@ -0,0 +1,28 @@
<?php
namespace App\Http\Requests\Admin\Setting;
use App\Enums\Gender;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateProfileRequest extends FormRequest
{
public function authorize(): bool
{
return auth()->check();
}
public function rules(): array
{
return [
'email' => ['required', 'email', 'max:100', Rule::unique('users', 'email')->ignore($this->user()?->id)],
'username' => ['required', 'string', 'max:20', 'alpha_dash', Rule::unique('users', 'username')->ignore($this->user()?->id)],
'full_name' => ['required', 'string', 'max:200'],
'phone_number' => ['nullable', 'string', 'regex:/^08\d{8,11}$/'],
'gender' => ['nullable', Rule::enum(Gender::class)],
'birth_date' => ['nullable', 'date', 'before:today'],
'address' => ['nullable', 'string'],
];
}
}

View File

@ -0,0 +1,30 @@
<?php
namespace App\Services\Setting;
use Illuminate\Support\Facades\DB;
class ProfileService
{
public function update(array $validated): void
{
$user = auth()->user();
DB::transaction(function () use ($user, $validated): void {
$user->email = $validated['email'];
$user->username = $validated['username'];
$user->save();
$user->profile()->updateOrCreate(
['user_id' => $user->id],
[
'full_name' => $validated['full_name'],
'phone_number' => $validated['phone_number'] ?? null,
'gender' => $validated['gender'] ?? null,
'birth_date' => $validated['birth_date'] ?? null,
'address' => $validated['address'] ?? null,
],
);
});
}
}

View File

@ -1,5 +1,6 @@
<?php
use App\Http\Middleware\HandleAppearance;
use App\Http\Middleware\HandleInertiaRequests;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
@ -24,6 +25,7 @@
]);
$middleware->web(append: [
HandleAppearance::class,
HandleInertiaRequests::class,
AddLinkHeadersForPreloadedAssets::class,
]);

View File

@ -1,6 +1,6 @@
<script setup lang="ts">
import { router, usePage } from '@inertiajs/vue3';
import { LogOut } from '@lucide/vue';
import { LogOut, User } from '@lucide/vue';
import { computed } from 'vue';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
@ -9,9 +9,9 @@ import {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
DropdownMenuSeparator, DropdownMenuTrigger
} from '@/components/ui/dropdown-menu';
import Separator from './ui/separator/Separator.vue';
const page = usePage();
@ -49,6 +49,11 @@ function logout() {
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem @click="router.visit('/admin/setting/profile')">
<User />
Profil
</DropdownMenuItem>
<Separator />
<DropdownMenuItem variant="destructive" @click="logout">
<LogOut />
Keluar

View File

@ -0,0 +1,17 @@
import type { AppearanceMode } from '@/types/settings';
export function applyAppearance(mode: AppearanceMode): void {
const root = document.documentElement;
root.classList.remove('dark');
if (mode === 'dark' || (mode === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
root.classList.add('dark');
}
}
export function useAppearance() {
return {
applyAppearance,
};
}

View File

@ -0,0 +1,59 @@
<script setup lang="ts">
import { Link, usePage } from '@inertiajs/vue3';
import { KeyRound, Palette, User } from '@lucide/vue';
import { computed } from 'vue';
import AdminLayout from '@/layouts/AdminLayout.vue';
import { cn } from '@/lib/utils';
const page = usePage();
const currentPath = computed(() => page.url.split('?')[0]);
const navItems = [
{
href: '/admin/setting/profile',
label: 'Profil',
icon: User,
},
{
href: '/admin/setting/password',
label: 'Kata Sandi',
icon: KeyRound,
},
{
href: '/admin/setting/appearance',
label: 'Tampilan',
icon: Palette,
},
];
</script>
<template>
<AdminLayout title="Pengaturan">
<div class="space-y-6">
<div class="space-y-1">
<h2 class="text-2xl font-bold tracking-tight">
Pengaturan
</h2>
</div>
<div class="flex flex-col gap-6 lg:flex-row">
<nav class="flex shrink-0 flex-row gap-1 overflow-x-auto lg:w-56 lg:flex-col lg:overflow-visible">
<Link v-for="item in navItems" :key="item.href" :href="item.href" :class="cn(
'inline-flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium whitespace-nowrap transition-colors',
currentPath === item.href
? 'bg-accent text-accent-foreground'
: 'text-muted-foreground hover:bg-accent/50 hover:text-accent-foreground',
)">
<component :is="item.icon" class="size-4 shrink-0" />
{{ item.label }}
</Link>
</nav>
<div class="min-w-0 flex-1">
<slot />
</div>
</div>
</div>
</AdminLayout>
</template>

View File

@ -0,0 +1,121 @@
<script setup lang="ts">
import { Head, useForm } from '@inertiajs/vue3';
import { Monitor, Moon, Save, Sun } from '@lucide/vue';
import { toast } from 'vue-sonner';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import {
Field,
FieldError,
FieldGroup,
FieldLabel,
FieldSet,
} from '@/components/ui/field';
import { Label } from '@/components/ui/label';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { applyAppearance } from '@/composables/useAppearance';
import SettingsLayout from '@/layouts/SettingsLayout.vue';
import { cn } from '@/lib/utils';
import type { AppearanceFormData, AppearanceMode } from '@/types/settings';
const props = defineProps<{
appearance: AppearanceMode;
}>();
const form = useForm<AppearanceFormData>({
appearance: props.appearance,
});
const options: Array<{
value: AppearanceMode;
label: string;
description: string;
icon: typeof Sun;
}> = [
{
value: 'light',
label: 'Terang',
description: 'Tampilan terang untuk lingkungan yang cukup pencahayaan.',
icon: Sun,
},
{
value: 'dark',
label: 'Gelap',
description: 'Tampilan gelap yang nyaman di malam hari.',
icon: Moon,
},
{
value: 'system',
label: 'Sistem',
description: 'Mengikuti pengaturan tampilan perangkat Anda.',
icon: Monitor,
},
];
function onAppearanceChange(value: string) {
form.appearance = value as AppearanceMode;
applyAppearance(form.appearance);
}
function submit() {
form.put('/admin/setting/appearance', {
onError: () => {
toast.error('Gagal menyimpan tampilan.');
},
});
}
</script>
<template>
<Head title="Tampilan" />
<SettingsLayout>
<form @submit.prevent="submit">
<Card>
<CardContent>
<FieldSet>
<FieldGroup>
<Field>
<FieldLabel>Mode Tampilan</FieldLabel>
<RadioGroup :model-value="form.appearance" class="grid gap-3"
@update:model-value="onAppearanceChange">
<Label v-for="option in options" :key="option.value"
:for="`appearance-${option.value}`" :class="cn(
'flex cursor-pointer items-start gap-3 rounded-lg border p-4 transition-colors',
form.appearance === option.value
? 'border-primary bg-primary/5'
: 'hover:bg-accent/50',
)">
<RadioGroupItem :id="`appearance-${option.value}`" :value="option.value"
class="mt-0.5" />
<div class="flex flex-1 items-start gap-3">
<component :is="option.icon"
class="text-muted-foreground mt-0.5 size-5 shrink-0" />
<div class="space-y-1">
<p class="text-sm leading-none font-medium">
{{ option.label }}
</p>
<p class="text-muted-foreground text-sm">
{{ option.description }}
</p>
</div>
</div>
</Label>
</RadioGroup>
<FieldError :errors="form.errors.appearance ? [form.errors.appearance] : []" />
</Field>
</FieldGroup>
</FieldSet>
<div class="mt-6 flex justify-end">
<Button type="submit" :disabled="form.processing">
<Save class="size-4" />
Simpan Tampilan
</Button>
</div>
</CardContent>
</Card>
</form>
</SettingsLayout>
</template>

View File

@ -0,0 +1,89 @@
<script setup lang="ts">
import { Head, useForm } from '@inertiajs/vue3';
import { Save } from '@lucide/vue';
import { toast } from 'vue-sonner';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import {
Field,
FieldError,
FieldGroup,
FieldLabel,
FieldSet,
} from '@/components/ui/field';
import { Input } from '@/components/ui/input';
import SettingsLayout from '@/layouts/SettingsLayout.vue';
import type { PasswordFormData } from '@/types/settings';
const form = useForm<PasswordFormData>({
current_password: '',
password: '',
password_confirmation: '',
});
function submit() {
form.put('/admin/setting/password', {
onSuccess: () => {
form.reset();
},
onError: () => {
toast.error('Gagal memperbarui kata sandi. Periksa kembali formulir.');
},
});
}
</script>
<template>
<Head title="Kata Sandi" />
<SettingsLayout>
<form @submit.prevent="submit">
<div class="grid gap-6">
<Card>
<CardContent>
<FieldSet>
<FieldGroup class="grid gap-4 sm:grid-cols-3">
<Field>
<FieldLabel for="current_password" required>
Kata Sandi Saat Ini
</FieldLabel>
<Input id="current_password" v-model="form.current_password" type="password"
placeholder="********" autocomplete="current-password" />
<FieldError
:errors="form.errors.current_password ? [form.errors.current_password] : []" />
</Field>
<Field>
<FieldLabel for="password" required>
Kata Sandi Baru
</FieldLabel>
<Input id="password" v-model="form.password" type="password" placeholder="********"
autocomplete="new-password" />
<FieldError :errors="form.errors.password ? [form.errors.password] : []" />
</Field>
<Field>
<FieldLabel for="password_confirmation" required>
Konfirmasi Kata Sandi Baru
</FieldLabel>
<Input id="password_confirmation" v-model="form.password_confirmation"
type="password" placeholder="********" autocomplete="new-password" />
<FieldError
:errors="form.errors.password_confirmation ? [form.errors.password_confirmation] : []" />
</Field>
</FieldGroup>
</FieldSet>
</CardContent>
</Card>
<div class="flex items-center justify-end gap-2">
<Button type="submit" :disabled="form.processing">
<Save class="size-4" />
{{ form.processing ? 'Menyimpan...' : 'Simpan' }}
</Button>
</div>
</div>
</form>
</SettingsLayout>
</template>

View File

@ -0,0 +1,137 @@
<script setup lang="ts">
import { Head, useForm } from '@inertiajs/vue3';
import { Save } from '@lucide/vue';
import { toast } from 'vue-sonner';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { DatePicker } from '@/components/ui/date-picker';
import {
Field,
FieldError,
FieldGroup,
FieldLabel,
FieldSet,
} from '@/components/ui/field';
import { Input } from '@/components/ui/input';
import { PhoneNumberInput } from '@/components/ui/phone-number-input';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { Textarea } from '@/components/ui/textarea';
import SettingsLayout from '@/layouts/SettingsLayout.vue';
import type { EnumOption, ProfileFormData, ProfileUser } from '@/types/settings';
const props = defineProps<{
user: ProfileUser;
genders: EnumOption[];
}>();
const form = useForm<ProfileFormData>({
email: props.user.email ?? '',
username: props.user.username ?? '',
full_name: props.user.profile?.full_name ?? '',
phone_number: props.user.profile?.phone_number ?? '',
gender: props.user.profile?.gender ?? '',
birth_date: props.user.profile?.birth_date_input ?? '',
address: props.user.profile?.address ?? '',
});
function submit() {
form.put('/admin/setting/profile', {
onError: () => {
toast.error('Gagal menyimpan profil. Periksa kembali formulir.');
},
});
}
</script>
<template>
<Head title="Profil" />
<SettingsLayout>
<form @submit.prevent="submit">
<div class="grid gap-6">
<Card>
<CardContent>
<FieldSet>
<FieldGroup class="grid gap-4 sm:grid-cols-3">
<Field>
<FieldLabel for="email" required>
Email
</FieldLabel>
<Input id="email" v-model="form.email" type="email"
placeholder="nama@perusahaan.com" />
<FieldError :errors="form.errors.email ? [form.errors.email] : []" />
</Field>
<Field>
<FieldLabel for="username" required>
Username
</FieldLabel>
<Input id="username" v-model="form.username" type="text" placeholder="username" />
<FieldError :errors="form.errors.username ? [form.errors.username] : []" />
</Field>
<Field>
<FieldLabel for="full_name" required>
Nama Lengkap
</FieldLabel>
<Input id="full_name" v-model="form.full_name" type="text"
placeholder="Nama lengkap" />
<FieldError :errors="form.errors.full_name ? [form.errors.full_name] : []" />
</Field>
<Field>
<FieldLabel for="phone_number">
Nomor Telepon
</FieldLabel>
<PhoneNumberInput id="phone_number" v-model="form.phone_number" />
<FieldError :errors="form.errors.phone_number ? [form.errors.phone_number] : []" />
</Field>
<Field>
<FieldLabel for="birth_date">
Tanggal Lahir
</FieldLabel>
<DatePicker id="birth_date" v-model="form.birth_date" />
<FieldError :errors="form.errors.birth_date ? [form.errors.birth_date] : []" />
</Field>
<Field>
<FieldLabel>
Jenis Kelamin
</FieldLabel>
<RadioGroup v-model="form.gender" class="flex flex-wrap gap-4 pt-1">
<div v-for="gender in genders" :key="gender.value"
class="flex items-center gap-2">
<RadioGroupItem :id="`gender-${gender.value}`" :value="gender.value" />
<label :for="`gender-${gender.value}`" class="text-sm">
{{ gender.label }}
</label>
</div>
</RadioGroup>
<FieldError :errors="form.errors.gender ? [form.errors.gender] : []" />
</Field>
<Field class="sm:col-span-3">
<FieldLabel for="address">
Alamat
</FieldLabel>
<Textarea id="address" v-model="form.address" placeholder="Alamat lengkap"
rows="3" />
<FieldError :errors="form.errors.address ? [form.errors.address] : []" />
</Field>
</FieldGroup>
</FieldSet>
</CardContent>
</Card>
<div class="flex items-center justify-end gap-2">
<Button type="submit" :disabled="form.processing">
<Save class="size-4" />
{{ form.processing ? 'Menyimpan...' : 'Simpan' }}
</Button>
</div>
</div>
</form>
</SettingsLayout>
</template>

View File

@ -23,6 +23,7 @@ declare module '@inertiajs/core' {
error: string | null;
};
sidebarOpen: boolean;
appearance: 'light' | 'dark' | 'system';
[key: string]: unknown;
};
}

View File

@ -0,0 +1,41 @@
import type { EnumOption } from '@/types/employee';
export type AppearanceMode = 'light' | 'dark' | 'system';
export type UserProfile = {
full_name: string | null;
phone_number: string | null;
gender: string | null;
gender_label: string | null;
birth_date_input: string | null;
address: string | null;
};
export type ProfileUser = {
id: number;
email: string;
username: string;
profile: UserProfile | null;
};
export type ProfileFormData = {
email: string;
username: string;
full_name: string;
phone_number: string;
gender: string;
birth_date: string;
address: string;
};
export type PasswordFormData = {
current_password: string;
password: string;
password_confirmation: string;
};
export type AppearanceFormData = {
appearance: AppearanceMode;
};
export type { EnumOption };

View File

@ -1,6 +1,19 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" @class(['dark' => ($appearance ?? 'system') == 'dark'])>
<head>
<script>
(function() {
const appearance = @json($appearance ?? 'system');
if (appearance === 'dark' || (appearance === 'system' && window.matchMedia('(prefers-color-scheme: dark)')
.matches)) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
})();
</script>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="{{ config('app.name', 'Laravel') }} - Progressive Web App">
@ -18,7 +31,9 @@
<title>{{ config('app.name', 'Laravel') }}</title>
</x-inertia::head>
</head>
<body class="font-sans antialiased">
<x-inertia::app />
</body>
</html>

View File

@ -8,6 +8,9 @@
use App\Http\Controllers\Admin\Master\ProductController;
use App\Http\Controllers\Admin\Master\RawMaterialController;
use App\Http\Controllers\Admin\Master\SupplierController;
use App\Http\Controllers\Admin\Setting\AppearanceController;
use App\Http\Controllers\Admin\Setting\PasswordController;
use App\Http\Controllers\Admin\Setting\ProfileController;
use App\Http\Controllers\Auth\LoginController;
use App\Http\Controllers\Auth\LogoutController;
use Illuminate\Support\Facades\Route;
@ -143,6 +146,17 @@
});
Route::prefix('setting')->name('setting.')->group(function () {
Route::get('profile', [ProfileController::class, 'edit'])->name('profile');
Route::put('profile', [ProfileController::class, 'update'])->name('profile.update');
Route::get('password', [PasswordController::class, 'edit'])->name('password');
Route::put('password', [PasswordController::class, 'update'])->name('password.update');
Route::get('appearance', [AppearanceController::class, 'edit'])->name('appearance');
Route::put('appearance', [AppearanceController::class, 'update'])->name('appearance.update');
});
Route::prefix('hr')->name('hr.')->middleware('permission:'.Permission::EMPLOYEES_VIEW->value)->group(function () {
Route::prefix('employees')->name('employees.')
->middleware('permission:'.Permission::EMPLOYEES_VIEW->value)

View File

@ -7,7 +7,6 @@
*
* @see https://vite-pwa-org.netlify.app/frameworks/laravel.html
*/
$publicPath = getcwd();
$uri = urldecode(