From 1b48f5c24faa55a5105706b8b07384bb5ff159c2 Mon Sep 17 00:00:00 2001 From: Yoga Pangestu Date: Wed, 12 Aug 2026 21:57:28 +0700 Subject: [PATCH] feat: implement media handling for user profiles and employee management, including photo uploads and display --- .ai/REFERENCE.md | 3 ++ .../Admin/HR/EmployeeController.php | 2 +- .../Settings/ProfileController.php | 14 +++++++++ app/Http/Middleware/HandleInertiaRequests.php | 2 +- .../Settings/ProfileUpdateRequest.php | 15 +++++++++ app/Models/User.php | 31 +++++++++++++++++-- app/Services/Admin/HR/EmployeeService.php | 15 ++++++++- .../js/pages/admin/hr/employee/columns.tsx | 29 +++++++++++++++++ resources/js/pages/settings/profile.tsx | 30 +++++++++++++++++- 9 files changed, 134 insertions(+), 7 deletions(-) diff --git a/.ai/REFERENCE.md b/.ai/REFERENCE.md index 725e192..bdcefcf 100644 --- a/.ai/REFERENCE.md +++ b/.ai/REFERENCE.md @@ -9,8 +9,11 @@ ## Auth & User ### `users` → User `id` `email`(unique) `username`(unique) `password` `is_active`(bool) `last_login_at`(datetime) `created_at` `updated_at` `deleted_at` - Casts: email_verified_at(datetime), is_active(bool), last_login_at(datetime), password(hashed), two_factor_confirmed_at(datetime) +- Implements: HasMedia (Spatie Media Library) +- Media Collections: photos (single photo, with thumb conversion) - Scopes: active() - Relations: userProfile(HasOne→UserProfile), employee(HasOne→Employee), attendances(HasMany→Attendance), cashAccounts(HasMany→CashAccount,created_by_id), cashTransactions(HasMany→CashTransaction,created_by_id), createdCuttings(HasMany→Cutting,created_by_id), submittedCuttings(HasMany→Cutting,submitted_by_id), createdExpenses(HasMany→Expense,created_by_id), createdOrders(HasMany→Order,created_by_id), marketingOrders(HasMany→Order,marketing_id), orderItems(HasMany→OrderItem), createdPurchases(HasMany→Purchase,created_by_id), createdRestocks(HasMany→Restock,created_by_id), stokOpnamesCreated(HasMany→StokOpname,created_by_id), stokOpnamesVerified(HasMany→StokOpname,verified_by_id), employeeAdvancesPaid(HasMany→EmployeeAdvance,paid_by_id), employeeAdvancesVerified(HasMany→EmployeeAdvance,verified_by_id), paidPayrolls(HasMany→Payroll,paid_by_id), payrollPeriodsClosed(HasMany→PayrollPeriod,closed_by_id), notifications(HasMany→AppNotification), rejections(HasMany→Rejection,rejected_by_id), pushSubscriptions(HasMany→PushSubscription,morph) +- Accessor: avatar → temporary S3 URL dari media 'photos' (atau null) ### `user_profiles` → UserProfile `id` `user_id`(FK→users,unique) `full_name`(200) `phone_number`(20,null) `gender`(enum,null) `birth_date`(date,null) `address`(text,null) `created_at` `updated_at` `deleted_at` diff --git a/app/Http/Controllers/Admin/HR/EmployeeController.php b/app/Http/Controllers/Admin/HR/EmployeeController.php index 3097ab0..dca788e 100644 --- a/app/Http/Controllers/Admin/HR/EmployeeController.php +++ b/app/Http/Controllers/Admin/HR/EmployeeController.php @@ -54,7 +54,7 @@ public function store(EmployeeRequest $request): RedirectResponse public function edit(User $user): Response { - $user->load(['userProfile', 'employee', 'roles']); + $user->load(['userProfile', 'employee', 'roles', 'media']); return Inertia::render('admin/hr/employee/edit', [ 'employee' => $user, diff --git a/app/Http/Controllers/Settings/ProfileController.php b/app/Http/Controllers/Settings/ProfileController.php index 570c371..59799e3 100644 --- a/app/Http/Controllers/Settings/ProfileController.php +++ b/app/Http/Controllers/Settings/ProfileController.php @@ -4,6 +4,8 @@ use App\Http\Controllers\Controller; use App\Http\Requests\Settings\ProfileUpdateRequest; +use App\Services\Concerns\RegistersMedia; +use App\Services\S3PresignedService; use Illuminate\Contracts\Auth\MustVerifyEmail; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; @@ -12,16 +14,26 @@ class ProfileController extends Controller { + use RegistersMedia; + public function edit(Request $request): Response { $user = $request->user(); $user->load('userProfile'); + $media = $user->getFirstMedia('photos'); + $photoKey = $media?->getCustomProperty('s3_key') ?? $media?->getPath(); + $photoUrl = $media + ? app(S3PresignedService::class)->getTemporaryUrl($media->getPath()) + : null; + return Inertia::render('settings/profile', [ 'user' => [ 'id' => $user->id, 'email' => $user->email, 'username' => $user->username, + 'photo_key' => $photoKey, + 'photo_url' => $photoUrl, 'userProfile' => $user->userProfile ? [ 'full_name' => $user->userProfile->full_name, 'phone_number' => $user->userProfile->phone_number, @@ -63,6 +75,8 @@ function () use ($request) { 'address' => $validated['address'] ?? null, ], ); + + $this->syncPhoto($user, ['photo_key' => $validated['photo'] ?? null], 'photos'); }, 'Profil berhasil diperbarui.', 'profile.edit', diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index bedabed..fbabe0a 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -42,7 +42,7 @@ public function share(Request $request): array 'address' => app(SystemSettings::class)->address ?? '', 'auth' => [ 'user' => $request->user() - ? tap($request->user()->load('userProfile', 'roles'), function ($user) { + ? tap($request->user()->load('userProfile', 'roles', 'media'), function ($user) { $user->setRelation('permissions', $user->getAllPermissions()); }) : null, diff --git a/app/Http/Requests/Settings/ProfileUpdateRequest.php b/app/Http/Requests/Settings/ProfileUpdateRequest.php index 219380f..56cdce3 100644 --- a/app/Http/Requests/Settings/ProfileUpdateRequest.php +++ b/app/Http/Requests/Settings/ProfileUpdateRequest.php @@ -42,6 +42,21 @@ public function rules(): array 'gender' => ['nullable', 'in:male,female'], 'birth_date' => ['nullable', 'date'], 'address' => ['nullable', 'string'], + 'photo' => ['nullable', 'string', 'max:500'], + ]; + } + + public function attributes(): array + { + return [ + 'email' => 'email', + 'username' => 'username', + 'full_name' => 'nama lengkap', + 'phone_number' => 'nomor telepon', + 'gender' => 'jenis kelamin', + 'birth_date' => 'tanggal lahir', + 'address' => 'alamat', + 'photo' => 'foto profil', ]; } } diff --git a/app/Models/User.php b/app/Models/User.php index 663176b..6e59298 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Services\S3PresignedService; use Illuminate\Database\Eloquent\Attributes\Appends; use Illuminate\Database\Eloquent\Attributes\Guarded; use Illuminate\Database\Eloquent\Attributes\Scope; @@ -15,13 +16,17 @@ use Illuminate\Notifications\Notifiable; use Laravel\Fortify\TwoFactorAuthenticatable; use NotificationChannels\WebPush\HasPushSubscriptions; +use Spatie\Image\Enums\Fit; +use Spatie\MediaLibrary\HasMedia; +use Spatie\MediaLibrary\InteractsWithMedia; +use Spatie\MediaLibrary\MediaCollections\Models\Media; use Spatie\Permission\Traits\HasRoles; -#[Appends(['full_name', 'name'])] +#[Appends(['avatar', 'full_name', 'name'])] #[Guarded(['id'])] -class User extends Authenticatable +class User extends Authenticatable implements HasMedia { - use HasFactory, HasPushSubscriptions, HasRoles, Notifiable, SoftDeletes, TwoFactorAuthenticatable; + use HasFactory, HasPushSubscriptions, HasRoles, InteractsWithMedia, Notifiable, SoftDeletes, TwoFactorAuthenticatable; protected function casts(): array { @@ -55,6 +60,15 @@ protected function fullName(): Attribute ); } + protected function avatar(): Attribute + { + return Attribute::make( + get: fn () => $this->getFirstMedia('photos') + ? app(S3PresignedService::class)->getTemporaryUrl($this->getFirstMedia('photos')->getPath()) + : null, + ); + } + #[Scope] protected function active(Builder $query): void { @@ -210,4 +224,15 @@ public function userProfile(): HasOne { return $this->hasOne(UserProfile::class); } + + public function registerMediaCollections(): void + { + $this->addMediaCollection('photos'); + } + + public function registerMediaConversions(?Media $media = null): void + { + $this->addMediaConversion('thumb') + ->fit(Fit::Contain, 150, 150); + } } diff --git a/app/Services/Admin/HR/EmployeeService.php b/app/Services/Admin/HR/EmployeeService.php index c22c0ca..6c7a879 100644 --- a/app/Services/Admin/HR/EmployeeService.php +++ b/app/Services/Admin/HR/EmployeeService.php @@ -6,6 +6,7 @@ use App\Enums\Role; use App\Models\Employee; use App\Models\User; +use App\Services\S3PresignedService; use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; @@ -16,13 +17,14 @@ class EmployeeService public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', array $filters = []): LengthAwarePaginator { - return User::query() + $paginator = User::query() ->select(['id', 'email', 'username', 'is_active']) ->where(fn ($q) => $q->whereHas('employee')->orWhereHas('roles', fn ($rq) => $rq->where('name', 'Owner'))) ->with([ 'userProfile' => fn ($q) => $q->select(['id', 'user_id', 'full_name', 'phone_number', 'gender']), 'employee' => fn ($q) => $q->select(['id', 'user_id', 'join_date', 'employment_status', 'base_salary']), 'roles' => fn ($q) => $q->select(['id', 'name']), + 'media', ]) ->when(! self::hasAnyRole([Role::DEVELOPER, Role::OWNER, Role::DIREKTUR, Role::ADMIN_TOKO]), function ($q) { $userRoles = auth()->user()->roles->pluck('name'); @@ -35,6 +37,17 @@ public function paginated(int $perPage = 25, string $search = '', string $sort = ->when($filters['gender'] ?? null, fn ($q, $gender) => $q->whereHas('userProfile', fn ($uq) => $uq->where('gender', $gender))) ->orderBy($sort, $direction) ->paginate($perPage); + + $paginator->getCollection()->transform(function (User $user) { + $media = $user->getFirstMedia('photos'); + $user->photo_url = $media + ? app(S3PresignedService::class)->getTemporaryUrl($media->getPath()) + : null; + + return $user; + }); + + return $paginator; } public function getAll(): Collection diff --git a/resources/js/pages/admin/hr/employee/columns.tsx b/resources/js/pages/admin/hr/employee/columns.tsx index d369517..d2cc0ac 100644 --- a/resources/js/pages/admin/hr/employee/columns.tsx +++ b/resources/js/pages/admin/hr/employee/columns.tsx @@ -1,5 +1,6 @@ import type { ColumnDef } from '@tanstack/react-table'; import { KeyRound, Pencil, Trash2 } from 'lucide-react'; +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { RowActions } from '@/components/data-display'; import { ToggleStatus } from '@/components/data-display'; import { formatCurrency } from '@/lib/utils'; @@ -9,6 +10,7 @@ export type Employee = { email: string; username: string; is_active: boolean; + photo_url: string | null; roles?: { name: string }[]; user_profile: { full_name: string; @@ -55,6 +57,33 @@ export function createEmployeeColumns( } = params; const columns: ColumnDef[] = [ + { + id: 'photo', + header: () => Foto, + meta: { className: 'w-[60px]' }, + cell: ({ row }) => { + const employee = row.original; + const fullName = employee.user_profile?.full_name ?? ''; + const initials = fullName + .split(' ') + .map((n) => n[0]) + .join('') + .toUpperCase() + .slice(0, 2); + + return ( + + + + {initials || '-'} + + + ); + }, + }, { accessorKey: 'user_profile.full_name', id: 'full_name', diff --git a/resources/js/pages/settings/profile.tsx b/resources/js/pages/settings/profile.tsx index efd73ab..6c5065c 100644 --- a/resources/js/pages/settings/profile.tsx +++ b/resources/js/pages/settings/profile.tsx @@ -3,6 +3,7 @@ import { useState } from 'react'; import { toast } from 'sonner'; import ProfileController from '@/actions/App/Http/Controllers/Settings/ProfileController'; import { DatePicker } from '@/components/inputs'; +import { FileUpload } from '@/components/inputs'; import { InputError } from '@/components/ui'; import { PhoneNumberInput } from '@/components/inputs'; import { Button } from '@/components/ui/button'; @@ -17,6 +18,8 @@ type UserData = { id: number; email: string; username: string; + photo_key: string | null; + photo_url: string | null; userProfile: { full_name: string; phone_number: string | null; @@ -38,6 +41,10 @@ export default function Profile({ user }: Props) { ? new Date(user.userProfile.birth_date) : undefined, ); + const [photoKey, setPhotoKey] = useState( + user.photo_key ?? null, + ); + const [photoUploading, setPhotoUploading] = useState(false); return ( <> @@ -55,6 +62,27 @@ export default function Profile({ user }: Props) { > {({ processing, errors }) => (
+ + + Foto Profil + + + + + + + + Akun @@ -199,7 +227,7 @@ export default function Profile({ user }: Props) {
-