feat: implement media handling for user profiles and employee management, including photo uploads and display

This commit is contained in:
Yoga Pangestu 2026-08-12 21:57:28 +07:00
parent 5335e5ad58
commit 1b48f5c24f
9 changed files with 134 additions and 7 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<Employee>[] = [
{
id: 'photo',
header: () => <span>Foto</span>,
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 (
<Avatar className="h-10 w-10">
<AvatarImage
src={employee.photo_url ?? undefined}
alt={fullName}
/>
<AvatarFallback className="text-xs">
{initials || '-'}
</AvatarFallback>
</Avatar>
);
},
},
{
accessorKey: 'user_profile.full_name',
id: 'full_name',

View File

@ -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<string | null>(
user.photo_key ?? null,
);
const [photoUploading, setPhotoUploading] = useState(false);
return (
<>
@ -55,6 +62,27 @@ export default function Profile({ user }: Props) {
>
{({ processing, errors }) => (
<div className="grid gap-6">
<Card>
<CardHeader>
<CardTitle>Foto Profil</CardTitle>
</CardHeader>
<CardContent>
<input
type="hidden"
name="photo"
value={photoKey ?? ''}
/>
<FileUpload
value={photoKey}
onChange={setPhotoKey}
existingUrl={user.photo_url}
folder="profile"
onUploadingChange={setPhotoUploading}
/>
<InputError message={errors.photo} />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Akun</CardTitle>
@ -199,7 +227,7 @@ export default function Profile({ user }: Props) {
</Card>
<div className="flex items-center gap-4">
<Button type="submit" disabled={processing}>
<Button type="submit" disabled={processing || photoUploading}>
{processing ? 'Menyimpan...' : 'Simpan'}
</Button>
</div>