feat: enhance user profile and employee management with profile photo handling and validation updates

This commit is contained in:
Yoga Pangestu 2026-06-27 15:53:33 +07:00
parent 5cffe43a07
commit f6b5a78f4b
18 changed files with 177 additions and 20 deletions

View File

@ -7,6 +7,7 @@
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\Account\UpdateProfileRequest;
use App\Services\Account\ProfileService;
use App\Support\Media\MediaPresenter;
use Illuminate\Http\RedirectResponse;
use Inertia\Inertia;
use Inertia\Response;
@ -26,6 +27,9 @@ public function edit(): Response
return Inertia::render('admin/account/Profile', [
'genders' => Gender::selectOptions(),
'user' => $user,
'profilePhoto' => $user->profile
? MediaPresenter::first($user->profile, 'profile_photo')
: null,
]);
}

View File

@ -12,6 +12,7 @@
use App\Http\Requests\Admin\ToggleStatusRequest;
use App\Models\User;
use App\Services\Hr\EmployeeService;
use App\Support\Media\MediaPresenter;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
@ -85,6 +86,9 @@ public function edit(User $user): Response
'employmentStatuses' => EmploymentStatus::selectOptions(),
'roles' => $this->assignableRoleOptions(),
'employee' => $user,
'profilePhoto' => $user->profile
? MediaPresenter::first($user->profile, 'profile_photo')
: null,
]);
}

View File

@ -57,6 +57,7 @@ public function share(Request $request): array
'last_login_at' => $request->user()->last_login_at,
'roles' => $request->user()->getRoleNames(),
'permissions' => $request->user()->getAllPermissions()->pluck('name'),
'profile_photo_url' => $request->user()->profile?->profile_photo_url,
] : null,
'password_default' => config('auth.password_default'),
],

View File

@ -27,6 +27,9 @@ public function rules(): array
'gender' => ['nullable', Rule::enum(Gender::class)],
'birth_date' => ['nullable', 'date', 'before:today'],
'address' => ['nullable', 'string'],
'profile_photo' => ['nullable', 'image', 'mimes:jpg,jpeg,png,webp', 'max:2048'],
'remove_profile_photo_ids' => ['nullable', 'array'],
'remove_profile_photo_ids.*' => ['integer'],
];
}
@ -43,6 +46,7 @@ public function attributes(): array
'gender' => 'jenis kelamin',
'birth_date' => 'tanggal lahir',
'address' => 'alamat',
'profile_photo' => 'foto profil',
];
}
}

View File

@ -37,6 +37,9 @@ public function rules(): array
'birth_date' => ['nullable', 'date', 'before:today'],
'address' => ['nullable', 'string'],
'role' => ['required', Rule::in(Role::assignableValues())],
'profile_photo' => ['nullable', 'image', 'mimes:jpg,jpeg,png,webp', 'max:2048'],
'remove_profile_photo_ids' => ['nullable', 'array'],
'remove_profile_photo_ids.*' => ['integer'],
];
if (! $isOwner) {
@ -61,6 +64,7 @@ public function attributes(): array
'gender' => 'jenis kelamin',
'birth_date' => 'tanggal lahir',
'address' => 'alamat',
'profile_photo' => 'foto profil',
'role' => 'role',
'join_date' => 'tanggal bergabung',
'employment_status' => 'status kepegawaian',

View File

@ -3,8 +3,8 @@
namespace App\Models;
use App\Enums\OwnerVerificationStatus;
use App\Models\Concerns\HasPendingOwnerVerification;
use App\Models\Concerns\HasModuleMedia;
use App\Models\Concerns\HasPendingOwnerVerification;
use App\Models\Concerns\InteractsWithActivityLog;
use Illuminate\Database\Eloquent\Attributes\Appends;
use Illuminate\Database\Eloquent\Attributes\Guarded;

View File

@ -3,6 +3,7 @@
namespace App\Models;
use App\Enums\Gender;
use App\Models\Concerns\HasModuleMedia;
use App\Models\Concerns\InteractsWithActivityLog;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Attributes\Appends;
@ -12,12 +13,23 @@
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
use Spatie\MediaLibrary\HasMedia;
#[Guarded(['id'])]
#[Appends(['birth_date_formatted', 'birth_date_input', 'gender_label'])]
class UserProfile extends Model
#[Appends(['birth_date_formatted', 'birth_date_input', 'gender_label', 'profile_photo_url'])]
class UserProfile extends Model implements HasMedia
{
use HasFactory, InteractsWithActivityLog, SoftDeletes;
use HasFactory, HasModuleMedia, InteractsWithActivityLog, SoftDeletes;
public static function mediaModuleName(): string
{
return 'user-profile';
}
public function registerMediaCollections(): void
{
$this->addMediaCollection('profile_photo')->singleFile();
}
protected function casts(): array
{
@ -52,4 +64,11 @@ public function genderLabel(): Attribute
get: fn () => $this->gender?->label(),
);
}
public function profilePhotoUrl(): Attribute
{
return Attribute::make(
get: fn () => $this->getFirstMediaUrl('profile_photo') ?: null,
);
}
}

View File

@ -3,6 +3,8 @@
namespace App\Services\Account;
use App\Models\User;
use App\Models\UserProfile;
use App\Services\Media\MediaService;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Log;
@ -10,6 +12,10 @@
class ProfileService
{
public function __construct(
private readonly MediaService $mediaService,
) {}
/**
* @param array<string, mixed> $validated
*/
@ -22,7 +28,8 @@ public function update(array $validated, User $user): void
'username' => $validated['username'],
]);
$user->profile()->updateOrCreate(
/** @var UserProfile $profile */
$profile = $user->profile()->updateOrCreate(
['user_id' => $user->id],
[
'full_name' => $validated['full_name'],
@ -32,6 +39,11 @@ public function update(array $validated, User $user): void
'address' => $validated['address'] ?? null,
],
);
$newFiles = ! empty($validated['profile_photo']) ? [$validated['profile_photo']] : [];
$removeIds = $validated['remove_profile_photo_ids'] ?? [];
$this->mediaService->syncCollection($profile, 'profile_photo', $newFiles, $removeIds, 1);
});
} catch (ValidationException $e) {
throw $e;

View File

@ -6,6 +6,7 @@
use App\Models\Employee;
use App\Models\User;
use App\Models\UserProfile;
use App\Services\Media\MediaService;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\DB;
@ -15,6 +16,10 @@
class EmployeeService
{
public function __construct(
private readonly MediaService $mediaService,
) {}
/**
* @param array{search: string, sort: string, direction: 'asc'|'desc'} $tableQuery
*/
@ -66,7 +71,7 @@ public function create(array $validated): void
'password' => Hash::make(config('auth.password_default')),
]);
UserProfile::create([
$profile = UserProfile::create([
'user_id' => $user->id,
'full_name' => $validated['full_name'],
'phone_number' => $validated['phone_number'],
@ -75,6 +80,8 @@ public function create(array $validated): void
'address' => $validated['address'],
]);
$this->syncProfilePhoto($profile, $validated);
if ($validated['role'] !== Role::OWNER->value) {
Employee::create([
'user_id' => $user->id,
@ -113,7 +120,8 @@ public function update(User $user, array $validated): void
'username' => $validated['username'],
]);
$user->profile()->updateOrCreate(
/** @var UserProfile $profile */
$profile = $user->profile()->updateOrCreate(
['user_id' => $user->id],
[
'full_name' => $validated['full_name'],
@ -124,6 +132,8 @@ public function update(User $user, array $validated): void
],
);
$this->syncProfilePhoto($profile, $validated);
$hasEmployee = ! empty($validated['join_date']) && ! empty($validated['employment_status']) && ! empty($validated['base_salary']);
if ($hasEmployee) {
@ -203,6 +213,17 @@ public function delete(User $user): void
}
}
/**
* @param array<string, mixed> $validated
*/
private function syncProfilePhoto(UserProfile $profile, array $validated): void
{
$newFiles = ! empty($validated['profile_photo']) ? [$validated['profile_photo']] : [];
$removeIds = $validated['remove_profile_photo_ids'] ?? [];
$this->mediaService->syncCollection($profile, 'profile_photo', $newFiles, $removeIds, 1);
}
private function applySorting(Builder $query, string $sort, string $direction): void
{
$employeeSorts = [

View File

@ -11,7 +11,6 @@
use App\Models\Purchase;
use App\Models\RawMaterial;
use App\Models\User;
use App\Services\Manage\PurchaseService;
use App\Services\Master\ProductService;
use App\Services\Master\RawMaterialService;
use App\Services\System\PushNotificationService;

View File

@ -1,5 +1,5 @@
<script setup lang="ts">
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
@ -37,6 +37,7 @@ function logout() {
<DropdownMenuTrigger as-child>
<Button variant="ghost" class="relative size-8 rounded-full">
<Avatar class="size-8">
<AvatarImage v-if="user.profile_photo_url" :src="user.profile_photo_url" :alt="user.username" />
<AvatarFallback>{{ initials }}</AvatarFallback>
</Avatar>
</Button>

View File

@ -1,8 +1,10 @@
<script setup lang="ts">
import { Head, useForm } from '@inertiajs/vue3';
import { Save } from '@lucide/vue';
import { ref } from 'vue';
import { toast } from 'vue-sonner';
import { PhoneNumberInput } from '@/components/form/phone-number-input';
import MediaDropzone from '@/components/media/MediaDropzone.vue';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { DatePicker } from '@/components/ui/date-picker';
@ -25,11 +27,14 @@ import type {
ProfileFormData,
ProfileUser,
} from '@/types/account';
import { createMediaUploadState } from '@/types/media';
import type { MediaItem, MediaUploadState } from '@/types/media';
const props = defineProps<{
user: ProfileUser;
genders: EnumOption[];
profilePhoto?: MediaItem | null;
}>();
const form = useForm<ProfileFormData>({
@ -42,8 +47,36 @@ const form = useForm<ProfileFormData>({
address: props.user.profile?.address ?? '',
});
const profilePhotoState = ref<MediaUploadState>(
createMediaUploadState(props.profilePhoto ? [props.profilePhoto] : []),
);
function buildFormData(): FormData {
const formData = new FormData();
formData.append('email', form.email);
formData.append('username', form.username);
formData.append('full_name', form.full_name);
formData.append('phone_number', form.phone_number);
formData.append('gender', form.gender);
formData.append('birth_date', form.birth_date);
formData.append('address', form.address);
profilePhotoState.value.newFiles.forEach((file) => {
formData.append('profile_photo', file);
});
profilePhotoState.value.removeIds.forEach((id) => {
formData.append('remove_profile_photo_ids[]', String(id));
});
return formData;
}
function submit() {
form.put(update.url(), {
const payload = buildFormData();
form.transform(() => payload).put(update.url(), {
preserveScroll: true,
onError: (errors: any) => {
if (errors.system) {
@ -124,6 +157,10 @@ function submit() {
<FieldError :errors="formErrors(form, 'gender')" />
</Field>
<MediaDropzone id="profile_photo" v-model="profilePhotoState" label="Foto Profil"
description="Opsional. Format: JPG, JPEG, PNG, atau WebP. Maks. 2 MB."
:max-files="1" :errors="formErrors(form, 'profile_photo')" class="sm:col-span-3" />
<Field class="sm:col-span-3">
<FieldLabel for="address">
Alamat

View File

@ -2,6 +2,7 @@
import BackButton from '@/components/button/BackButton.vue';
import AdminLayout from '@/layouts/AdminLayout.vue';
import type { EmployeeListItem, EnumOption } from '@/types/employee';
import type { MediaItem } from '@/types/media';
import { Head } from '@inertiajs/vue3';
import { computed } from 'vue';
import EmployeeForm from './form/EmployeeForm.vue';
@ -12,6 +13,7 @@ const props = defineProps<{
genders: EnumOption[];
employmentStatuses: EnumOption[];
roles: EnumOption[];
profilePhoto?: MediaItem | null;
}>();
const initialData = computed(() => ({
@ -51,6 +53,7 @@ const initialData = computed(() => ({
method="put"
submit-label="Perbarui"
:initial-data="initialData"
:profile-photo="profilePhoto"
:employee-id="employee.id"
:employee-name="employee.profile?.full_name"
:genders="genders"

View File

@ -1,9 +1,11 @@
<script setup lang="ts">
import { useForm } from '@inertiajs/vue3';
import { Save } from '@lucide/vue';
import { ref } from 'vue';
import { toast } from 'vue-sonner';
import { PhoneNumberInput } from '@/components/form/phone-number-input';
import { RupiahInput } from '@/components/form/rupiah-input';
import MediaDropzone from '@/components/media/MediaDropzone.vue';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { DatePicker } from '@/components/ui/date-picker';
@ -28,6 +30,8 @@ import { Textarea } from '@/components/ui/textarea';
import { FIELD_LIMITS } from '@/lib/field-limits';
import { formErrors } from '@/lib/form';
import type { EmployeeFormData, EnumOption } from '@/types/employee';
import { createMediaUploadState } from '@/types/media';
import type { MediaItem, MediaUploadState } from '@/types/media';
const props = withDefaults(
defineProps<{
@ -35,6 +39,7 @@ const props = withDefaults(
employmentStatuses: EnumOption[];
roles: EnumOption[];
initialData?: Partial<EmployeeFormData>;
profilePhoto?: MediaItem | null;
submitUrl: string;
method?: 'post' | 'put';
submitLabel?: string;
@ -61,13 +66,38 @@ const form = useForm<EmployeeFormData>({
role: props.initialData?.role ?? '',
});
const profilePhotoState = ref<MediaUploadState>(
createMediaUploadState(props.profilePhoto ? [props.profilePhoto] : []),
);
function buildFormData(): FormData {
const formData = new FormData();
formData.append('email', form.email);
formData.append('username', form.username);
formData.append('full_name', form.full_name);
formData.append('phone_number', form.phone_number);
formData.append('gender', form.gender);
formData.append('birth_date', form.birth_date);
formData.append('address', form.address);
formData.append('join_date', form.join_date === '' ? '' : form.join_date);
formData.append('employment_status', form.employment_status === 'none' || form.employment_status === '' ? '' : form.employment_status);
formData.append('base_salary', form.base_salary === '' ? '' : form.base_salary);
formData.append('role', form.role);
profilePhotoState.value.newFiles.forEach((file) => {
formData.append('profile_photo', file);
});
profilePhotoState.value.removeIds.forEach((id) => {
formData.append('remove_profile_photo_ids[]', String(id));
});
return formData;
}
function submit() {
form.transform((data) => ({
...data,
join_date: data.join_date === '' ? null : data.join_date,
employment_status: data.employment_status === 'none' || data.employment_status === '' ? null : data.employment_status,
base_salary: data.base_salary === '' ? null : data.base_salary,
}));
const payload = buildFormData();
const options = {
onError: (errors: any) => {
@ -78,9 +108,9 @@ function submit() {
};
if (props.method === 'put') {
form.put(props.submitUrl, options);
form.transform(() => payload).put(props.submitUrl, options);
} else {
form.post(props.submitUrl, options);
form.transform(() => payload).post(props.submitUrl, options);
}
}
</script>
@ -164,6 +194,9 @@ function submit() {
</RadioGroup>
<FieldError :errors="formErrors(form, 'gender')" />
</Field>
<MediaDropzone id="profile_photo" v-model="profilePhotoState" label="Foto Profil"
description="Opsional. Format: JPG, JPEG, PNG, atau WebP. Maks. 2 MB." :max-files="1"
:errors="formErrors(form, 'profile_photo')" class="md:col-span-3" />
<Field class="md:col-span-3">
<FieldLabel for="address">Alamat</FieldLabel>
<Textarea id="address" v-model="form.address"

View File

@ -1,12 +1,27 @@
import type { ColumnDef } from '@tanstack/vue-table';
import { h } from 'vue';
import { DataTableColumnHeader } from '@/components/data-table';
import MediaThumbnailCell from '@/components/media/MediaThumbnailCell.vue';
import { Badge } from '@/components/ui/badge';
import type { EmployeeListItem } from '@/types/employee';
import type { MediaItem } from '@/types/media';
import DataTableActions from './data-table-actions.vue';
import EmployeeStatusToggle from './employee-status-toggle.vue';
export const columns: ColumnDef<EmployeeListItem>[] = [
{
id: 'photo',
enableSorting: false,
header: () => 'Foto',
cell: ({ row }) => {
const photoUrl = row.original.profile?.profile_photo_url;
const items: MediaItem[] = photoUrl
? [{ id: 0, url: photoUrl, thumb_url: photoUrl }]
: [];
return h(MediaThumbnailCell, { items, maxVisible: 1 });
},
},
{
accessorKey: 'profile.full_name',
enableSorting: true,

View File

@ -9,6 +9,7 @@ export type UserProfile = {
gender_label: string | null;
birth_date_input: string | null;
address: string | null;
profile_photo_url: string | null;
};
export type ProfileUser = {

View File

@ -11,6 +11,7 @@ export type EmployeeProfile = {
birth_date_formatted: string | null;
birth_date_input: string | null;
address: string | null;
profile_photo_url: string | null;
};
export type EmployeeRecord = {

View File

@ -9,8 +9,6 @@
use App\Models\OwnerVerificationRequest;
use App\Models\Product;
use App\Models\Purchase;
use App\Models\RawMaterialPrice;
use App\Models\Supplier;
use App\Models\User;
use App\Support\ActivityLog\ModelLabel;
use Database\Seeders\RolePermissionSeeder;