feat: enhance user profile and employee management with profile photo handling and validation updates
This commit is contained in:
parent
5cffe43a07
commit
f6b5a78f4b
@ -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,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@ -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'),
|
||||
],
|
||||
|
||||
@ -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',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 = [
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user