feat: implement media handling for user profiles and employee management, including photo uploads and display
This commit is contained in:
parent
5335e5ad58
commit
1b48f5c24f
@ -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`
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user