From f8322566da9d6893855a20b19195adc826121680 Mon Sep 17 00:00:00 2001 From: Yoga Pangestu Date: Thu, 11 Jun 2026 19:32:33 +0700 Subject: [PATCH] Add media management features across various models and services. Implement media handling in Product, RawMaterial, Attendance, CashTransaction, EmployeeAdvance, and Expense models. Introduce ValidatesMediaUploads trait for consistent media validation in requests. Enhance services to support media synchronization and retrieval, improving user experience with image uploads and management. --- .../Admin/Master/ProductController.php | 8 +- .../Admin/Master/RawMaterialController.php | 8 +- .../Admin/Finance/DepositCashRequest.php | 4 + .../Admin/Finance/EmployeeAdvanceRequest.php | 4 + .../Requests/Admin/Finance/ExpenseRequest.php | 4 + .../Finance/UpdateCashTransactionRequest.php | 4 + .../Requests/Admin/Master/ProductRequest.php | 4 + .../Admin/Master/RawMaterialRequest.php | 4 + .../Concerns/ValidatesMediaUploads.php | 32 ++ app/Models/Attendance.php | 35 +- app/Models/CashTransaction.php | 15 +- app/Models/Concerns/HasModuleMedia.php | 22 + app/Models/EmployeeAdvance.php | 15 +- app/Models/Expense.php | 15 +- app/Models/ProductVariant.php | 15 +- app/Models/RawMaterialPrice.php | 15 +- app/Models/SystemConfiguration.php | 30 ++ app/Services/Finance/CashService.php | 45 +- .../Finance/EmployeeAdvanceService.php | 64 ++- app/Services/Finance/ExpenseService.php | 38 +- app/Services/Hr/AttendanceService.php | 58 ++- app/Services/Master/ProductService.php | 58 ++- app/Services/Master/RawMaterialService.php | 63 ++- app/Services/Media/MediaService.php | 133 ++++++ app/Services/System/Setting/SystemService.php | 42 +- app/Settings/SystemSettings.php | 4 - app/Support/Media/MediaPresenter.php | 44 ++ app/Support/Media/ModulePathGenerator.php | 33 ++ composer.json | 1 + composer.lock | 395 +++++++++++++++++- config/media-library.php | 363 ++++++++++++++++ .../2026_06_11_120438_create_media_table.php | 32 ++ ...500_create_system_configurations_table.php | 22 + ...ove_photo_paths_from_attendances_table.php | 23 + ...0502_remove_system_media_from_settings.php | 15 + .../finance/cash/CashTransactionFormModal.vue | 39 +- .../components/admin/finance/cash/columns.ts | 7 + .../EmployeeAdvanceFormModal.vue | 40 +- .../finance/employee-advances/columns.ts | 7 + .../finance/expenses/ExpenseFormModal.vue | 39 +- .../admin/finance/expenses/columns.ts | 7 + .../admin/master/products/ProductForm.vue | 73 +++- .../master/products/ProductGroupedTable.vue | 7 +- .../master/raw-materials/RawMaterialForm.vue | 140 +++---- .../raw-materials/RawMaterialGroupedTable.vue | 7 +- .../admin/setting/ImageUploadField.vue | 33 +- .../admin/setting/SystemSection.vue | 4 +- .../js/components/media/MediaImageUpload.vue | 156 +++++++ .../components/media/MediaPreviewDialog.vue | 31 ++ .../components/media/MediaThumbnailCell.vue | 41 ++ .../js/pages/admin/master/products/Edit.vue | 10 +- .../pages/admin/master/raw-materials/Edit.vue | 1 + resources/js/types/attendance.ts | 2 - resources/js/types/cash.ts | 4 + resources/js/types/employee-advance.ts | 4 + resources/js/types/expense.ts | 4 + resources/js/types/media.ts | 47 +++ resources/js/types/product.ts | 4 + resources/js/types/raw-material.ts | 4 + resources/js/types/setting.ts | 2 - 60 files changed, 2118 insertions(+), 262 deletions(-) create mode 100644 app/Http/Requests/Concerns/ValidatesMediaUploads.php create mode 100644 app/Models/Concerns/HasModuleMedia.php create mode 100644 app/Models/SystemConfiguration.php create mode 100644 app/Services/Media/MediaService.php create mode 100644 app/Support/Media/MediaPresenter.php create mode 100644 app/Support/Media/ModulePathGenerator.php create mode 100644 config/media-library.php create mode 100644 database/migrations/2026_06_11_120438_create_media_table.php create mode 100644 database/migrations/2026_06_11_120500_create_system_configurations_table.php create mode 100644 database/migrations/2026_06_11_120501_remove_photo_paths_from_attendances_table.php create mode 100644 database/settings/2026_06_11_120502_remove_system_media_from_settings.php create mode 100644 resources/js/components/media/MediaImageUpload.vue create mode 100644 resources/js/components/media/MediaPreviewDialog.vue create mode 100644 resources/js/components/media/MediaThumbnailCell.vue create mode 100644 resources/js/types/media.ts diff --git a/app/Http/Controllers/Admin/Master/ProductController.php b/app/Http/Controllers/Admin/Master/ProductController.php index 6eb5a8a..06f50df 100644 --- a/app/Http/Controllers/Admin/Master/ProductController.php +++ b/app/Http/Controllers/Admin/Master/ProductController.php @@ -7,7 +7,9 @@ use App\Http\Controllers\Controller; use App\Http\Requests\Admin\Master\ProductRequest; use App\Models\Product; +use App\Models\ProductVariant; use App\Services\Master\ProductService; +use App\Support\Media\MediaPresenter; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Inertia\Inertia; @@ -56,10 +58,14 @@ public function edit(Product $product): Response $product->load([ 'categories', 'variants' => fn ($query) => $query - ->with(['prices' => fn ($query) => $query->orderBy('type')]) + ->with(['prices' => fn ($query) => $query->orderBy('type'), 'media']) ->orderBy('created_at'), ]); + $product->variants->each(function (ProductVariant $variant): void { + $variant->setAttribute('images', MediaPresenter::collection($variant, 'images')); + }); + return Inertia::render('admin/master/products/Edit', [ 'categories' => $this->productService->categoryOptions(), 'priceTypes' => PriceType::selectOptions(), diff --git a/app/Http/Controllers/Admin/Master/RawMaterialController.php b/app/Http/Controllers/Admin/Master/RawMaterialController.php index 07ce9ae..ca7b9d8 100644 --- a/app/Http/Controllers/Admin/Master/RawMaterialController.php +++ b/app/Http/Controllers/Admin/Master/RawMaterialController.php @@ -7,7 +7,9 @@ use App\Http\Controllers\Controller; use App\Http\Requests\Admin\Master\RawMaterialRequest; use App\Models\RawMaterial; +use App\Models\RawMaterialPrice; use App\Services\Master\RawMaterialService; +use App\Support\Media\MediaPresenter; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Inertia\Inertia; @@ -53,9 +55,13 @@ public function store(RawMaterialRequest $request): RedirectResponse public function edit(RawMaterial $rawMaterial): Response { $rawMaterial->load([ - 'prices' => fn ($query) => $query->orderBy('created_at'), + 'prices' => fn ($query) => $query->orderBy('created_at')->with('media'), ]); + $rawMaterial->prices->each(function (RawMaterialPrice $price): void { + $price->setAttribute('images', MediaPresenter::collection($price, 'images')); + }); + return Inertia::render('admin/master/raw-materials/Edit', [ 'units' => RawMaterialUnit::selectOptions(), 'rawMaterial' => $rawMaterial, diff --git a/app/Http/Requests/Admin/Finance/DepositCashRequest.php b/app/Http/Requests/Admin/Finance/DepositCashRequest.php index 9623fe0..f70a09c 100644 --- a/app/Http/Requests/Admin/Finance/DepositCashRequest.php +++ b/app/Http/Requests/Admin/Finance/DepositCashRequest.php @@ -3,10 +3,13 @@ namespace App\Http\Requests\Admin\Finance; use App\Enums\Permission; +use App\Http\Requests\Concerns\ValidatesMediaUploads; use Illuminate\Foundation\Http\FormRequest; class DepositCashRequest extends FormRequest { + use ValidatesMediaUploads; + public function authorize(): bool { return $this->user()?->can(Permission::CASH_DEPOSIT->value) ?? false; @@ -17,6 +20,7 @@ public function rules(): array return [ 'amount' => ['required', 'integer', 'min:1'], 'description' => ['required', 'string', 'max:200'], + ...$this->photoRules(), ]; } } diff --git a/app/Http/Requests/Admin/Finance/EmployeeAdvanceRequest.php b/app/Http/Requests/Admin/Finance/EmployeeAdvanceRequest.php index 000166e..3bcbc97 100644 --- a/app/Http/Requests/Admin/Finance/EmployeeAdvanceRequest.php +++ b/app/Http/Requests/Admin/Finance/EmployeeAdvanceRequest.php @@ -4,11 +4,14 @@ use App\Enums\EmployeeAdvanceStatus; use App\Enums\Permission; +use App\Http\Requests\Concerns\ValidatesMediaUploads; use App\Models\EmployeeAdvance; use Illuminate\Foundation\Http\FormRequest; class EmployeeAdvanceRequest extends FormRequest { + use ValidatesMediaUploads; + public function authorize(): bool { $user = $this->user(); @@ -42,6 +45,7 @@ public function rules(): array 'amount' => ['required', 'integer', 'min:1'], 'description' => ['required', 'string', 'max:500'], 'due_date' => ['required', 'date'], + ...$this->photoRules(), ]; } } diff --git a/app/Http/Requests/Admin/Finance/ExpenseRequest.php b/app/Http/Requests/Admin/Finance/ExpenseRequest.php index 098dd30..02c4bf3 100644 --- a/app/Http/Requests/Admin/Finance/ExpenseRequest.php +++ b/app/Http/Requests/Admin/Finance/ExpenseRequest.php @@ -3,10 +3,13 @@ namespace App\Http\Requests\Admin\Finance; use App\Enums\Permission; +use App\Http\Requests\Concerns\ValidatesMediaUploads; use Illuminate\Foundation\Http\FormRequest; class ExpenseRequest extends FormRequest { + use ValidatesMediaUploads; + public function authorize(): bool { $permission = $this->isMethod('POST') @@ -24,6 +27,7 @@ public function rules(): array return [ 'amount' => ['required', 'integer', 'min:1'], 'description' => ['required', 'string', 'max:500'], + ...$this->photoRules(), ]; } } diff --git a/app/Http/Requests/Admin/Finance/UpdateCashTransactionRequest.php b/app/Http/Requests/Admin/Finance/UpdateCashTransactionRequest.php index 0a326a0..1432350 100644 --- a/app/Http/Requests/Admin/Finance/UpdateCashTransactionRequest.php +++ b/app/Http/Requests/Admin/Finance/UpdateCashTransactionRequest.php @@ -3,10 +3,13 @@ namespace App\Http\Requests\Admin\Finance; use App\Enums\Permission; +use App\Http\Requests\Concerns\ValidatesMediaUploads; use Illuminate\Foundation\Http\FormRequest; class UpdateCashTransactionRequest extends FormRequest { + use ValidatesMediaUploads; + public function authorize(): bool { $transaction = $this->route('cashTransaction'); @@ -20,6 +23,7 @@ public function rules(): array return [ 'amount' => ['required', 'integer', 'min:1'], 'description' => ['required', 'string', 'max:200'], + ...$this->photoRules(), ]; } } diff --git a/app/Http/Requests/Admin/Master/ProductRequest.php b/app/Http/Requests/Admin/Master/ProductRequest.php index 02892f7..a5c923c 100644 --- a/app/Http/Requests/Admin/Master/ProductRequest.php +++ b/app/Http/Requests/Admin/Master/ProductRequest.php @@ -4,11 +4,14 @@ use App\Enums\Permission; use App\Enums\PriceType; +use App\Http\Requests\Concerns\ValidatesMediaUploads; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; class ProductRequest extends FormRequest { + use ValidatesMediaUploads; + public function authorize(): bool { $permission = $this->isMethod('POST') @@ -41,6 +44,7 @@ public function rules(): array 'variants.*.prices' => ['required', 'array', 'min:1'], 'variants.*.prices.*.type' => ['required', Rule::enum(PriceType::class)], 'variants.*.prices.*.price' => ['required', 'numeric', 'gt:0'], + ...$this->variantImageRules(), ]; } } diff --git a/app/Http/Requests/Admin/Master/RawMaterialRequest.php b/app/Http/Requests/Admin/Master/RawMaterialRequest.php index 28ecad1..2c1bc10 100644 --- a/app/Http/Requests/Admin/Master/RawMaterialRequest.php +++ b/app/Http/Requests/Admin/Master/RawMaterialRequest.php @@ -4,11 +4,14 @@ use App\Enums\Permission; use App\Enums\RawMaterialUnit; +use App\Http\Requests\Concerns\ValidatesMediaUploads; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; class RawMaterialRequest extends FormRequest { + use ValidatesMediaUploads; + public function authorize(): bool { $permission = $this->isMethod('POST') @@ -37,6 +40,7 @@ public function rules(): array 'prices.*.variant' => ['required', 'string', 'max:200'], 'prices.*.price' => ['required', 'numeric', 'gt:0'], 'prices.*.stock' => ['required', 'numeric', 'min:0'], + ...$this->variantImageRules('prices'), ]; } } diff --git a/app/Http/Requests/Concerns/ValidatesMediaUploads.php b/app/Http/Requests/Concerns/ValidatesMediaUploads.php new file mode 100644 index 0000000..5de428d --- /dev/null +++ b/app/Http/Requests/Concerns/ValidatesMediaUploads.php @@ -0,0 +1,32 @@ + + */ + protected function photoRules(string $prefix = 'photos', int $max = 1): array + { + return [ + $prefix => ['nullable', 'array', "max:{$max}"], + "{$prefix}.*" => ['image', 'max:5120'], + 'remove_media_ids' => ['nullable', 'array'], + 'remove_media_ids.*' => ['integer'], + ]; + } + + /** + * @return array + */ + protected function variantImageRules(string $variantsKey = 'variants', int $max = 5): array + { + return [ + "{$variantsKey}.*.images" => ['nullable', 'array', "max:{$max}"], + "{$variantsKey}.*.images.*" => ['image', 'max:5120'], + "{$variantsKey}.*.remove_media_ids" => ['nullable', 'array'], + "{$variantsKey}.*.remove_media_ids.*" => ['integer'], + ]; + } +} diff --git a/app/Models/Attendance.php b/app/Models/Attendance.php index 3bad82e..eee4503 100644 --- a/app/Models/Attendance.php +++ b/app/Models/Attendance.php @@ -2,13 +2,15 @@ namespace App\Models; +use App\Models\Concerns\HasModuleMedia; +use App\Support\Media\MediaPresenter; use Carbon\Carbon; use Illuminate\Database\Eloquent\Attributes\Appends; use Illuminate\Database\Eloquent\Attributes\Guarded; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; -use Illuminate\Support\Facades\Storage; +use Spatie\MediaLibrary\HasMedia; #[Guarded(['id'])] #[Appends([ @@ -20,8 +22,21 @@ 'check_out_photo_url', 'employee_name', ])] -class Attendance extends Model +class Attendance extends Model implements HasMedia { + use HasModuleMedia; + + public static function mediaModuleName(): string + { + return 'attendance'; + } + + public function registerMediaCollections(): void + { + $this->addMediaCollection('checkin')->singleFile(); + $this->addMediaCollection('checkout')->singleFile(); + } + protected function casts(): array { return [ @@ -92,18 +107,22 @@ public function workDurationFormatted(): Attribute public function checkInPhotoUrl(): Attribute { return Attribute::make( - get: fn () => $this->check_in_photo_path - ? Storage::disk('public')->url($this->check_in_photo_path) - : null, + get: function () { + $photo = MediaPresenter::first($this, 'checkin'); + + return $photo['url'] ?? null; + }, ); } public function checkOutPhotoUrl(): Attribute { return Attribute::make( - get: fn () => $this->check_out_photo_path - ? Storage::disk('public')->url($this->check_out_photo_path) - : null, + get: function () { + $photo = MediaPresenter::first($this, 'checkout'); + + return $photo['url'] ?? null; + }, ); } } diff --git a/app/Models/CashTransaction.php b/app/Models/CashTransaction.php index 4223849..1df08f1 100644 --- a/app/Models/CashTransaction.php +++ b/app/Models/CashTransaction.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Models\Concerns\HasModuleMedia; use Illuminate\Database\Eloquent\Attributes\Appends; use Illuminate\Database\Eloquent\Attributes\Guarded; use Illuminate\Database\Eloquent\Casts\Attribute; @@ -9,6 +10,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\MorphTo; use Illuminate\Database\Eloquent\SoftDeletes; +use Spatie\MediaLibrary\HasMedia; #[Guarded(['id'])] #[Appends([ @@ -19,10 +21,21 @@ 'created_at_formatted', 'created_by_name', ])] -class CashTransaction extends Model +class CashTransaction extends Model implements HasMedia { + use HasModuleMedia; use SoftDeletes; + public static function mediaModuleName(): string + { + return 'cash'; + } + + public function registerMediaCollections(): void + { + $this->addMediaCollection('photos'); + } + protected function casts(): array { return [ diff --git a/app/Models/Concerns/HasModuleMedia.php b/app/Models/Concerns/HasModuleMedia.php new file mode 100644 index 0000000..5739450 --- /dev/null +++ b/app/Models/Concerns/HasModuleMedia.php @@ -0,0 +1,22 @@ +addMediaConversion('thumb') + ->width(80) + ->height(80) + ->sharpen(10) + ->nonQueued(); + } +} diff --git a/app/Models/EmployeeAdvance.php b/app/Models/EmployeeAdvance.php index 1de739e..02ffa1c 100644 --- a/app/Models/EmployeeAdvance.php +++ b/app/Models/EmployeeAdvance.php @@ -3,12 +3,14 @@ namespace App\Models; use App\Enums\EmployeeAdvanceStatus; +use App\Models\Concerns\HasModuleMedia; use App\Models\Concerns\HasRejection; use Illuminate\Database\Eloquent\Attributes\Appends; use Illuminate\Database\Eloquent\Attributes\Guarded; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Spatie\MediaLibrary\HasMedia; #[Guarded(['id'])] #[Appends([ @@ -23,10 +25,21 @@ 'can_verify', 'can_pay', ])] -class EmployeeAdvance extends Model +class EmployeeAdvance extends Model implements HasMedia { + use HasModuleMedia; use HasRejection; + public static function mediaModuleName(): string + { + return 'employee-advance'; + } + + public function registerMediaCollections(): void + { + $this->addMediaCollection('photos'); + } + protected function casts(): array { return [ diff --git a/app/Models/Expense.php b/app/Models/Expense.php index ed64582..fef60fc 100644 --- a/app/Models/Expense.php +++ b/app/Models/Expense.php @@ -2,19 +2,32 @@ namespace App\Models; +use App\Models\Concerns\HasModuleMedia; use Illuminate\Database\Eloquent\Attributes\Appends; use Illuminate\Database\Eloquent\Attributes\Guarded; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\SoftDeletes; +use Spatie\MediaLibrary\HasMedia; #[Guarded(['id'])] #[Appends(['amount_formatted', 'created_at_formatted', 'created_by_name'])] -class Expense extends Model +class Expense extends Model implements HasMedia { + use HasModuleMedia; use SoftDeletes; + public static function mediaModuleName(): string + { + return 'expense'; + } + + public function registerMediaCollections(): void + { + $this->addMediaCollection('photos'); + } + protected function casts(): array { return [ diff --git a/app/Models/ProductVariant.php b/app/Models/ProductVariant.php index 86fa4e3..c228d58 100644 --- a/app/Models/ProductVariant.php +++ b/app/Models/ProductVariant.php @@ -2,17 +2,30 @@ namespace App\Models; +use App\Models\Concerns\HasModuleMedia; use Illuminate\Database\Eloquent\Attributes\Guarded; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\SoftDeletes; +use Spatie\MediaLibrary\HasMedia; #[Guarded(['id'])] -class ProductVariant extends Model +class ProductVariant extends Model implements HasMedia { + use HasModuleMedia; use SoftDeletes; + public static function mediaModuleName(): string + { + return 'product'; + } + + public function registerMediaCollections(): void + { + $this->addMediaCollection('images'); + } + protected function casts(): array { return [ diff --git a/app/Models/RawMaterialPrice.php b/app/Models/RawMaterialPrice.php index 2e4fe66..c5b587e 100644 --- a/app/Models/RawMaterialPrice.php +++ b/app/Models/RawMaterialPrice.php @@ -2,19 +2,32 @@ namespace App\Models; +use App\Models\Concerns\HasModuleMedia; use Illuminate\Database\Eloquent\Attributes\Appends; use Illuminate\Database\Eloquent\Attributes\Guarded; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\SoftDeletes; +use Spatie\MediaLibrary\HasMedia; #[Guarded(['id'])] #[Appends(['price_formatted', 'stock_formatted', 'price_input', 'stock_input'])] -class RawMaterialPrice extends Model +class RawMaterialPrice extends Model implements HasMedia { + use HasModuleMedia; use SoftDeletes; + public static function mediaModuleName(): string + { + return 'raw-material'; + } + + public function registerMediaCollections(): void + { + $this->addMediaCollection('images'); + } + protected function casts(): array { return [ diff --git a/app/Models/SystemConfiguration.php b/app/Models/SystemConfiguration.php new file mode 100644 index 0000000..e8dfdad --- /dev/null +++ b/app/Models/SystemConfiguration.php @@ -0,0 +1,30 @@ +firstOrCreate(['id' => 1]); + } + + public function registerMediaCollections(): void + { + $this->addMediaCollection('logo')->singleFile(); + $this->addMediaCollection('login_cover')->singleFile(); + } +} diff --git a/app/Services/Finance/CashService.php b/app/Services/Finance/CashService.php index 28f3dfe..6e432d9 100644 --- a/app/Services/Finance/CashService.php +++ b/app/Services/Finance/CashService.php @@ -5,6 +5,8 @@ use App\Models\CashAccount; use App\Models\CashTransaction; use App\Models\User; +use App\Services\Media\MediaService; +use App\Support\Media\MediaPresenter; use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; @@ -13,6 +15,12 @@ class CashService { + private const MAX_PHOTOS = 1; + + public function __construct( + private readonly MediaService $mediaService, + ) {} + public function getDefaultAccount(): CashAccount { return CashAccount::query()->firstOrFail(); @@ -24,7 +32,7 @@ public function getDefaultAccount(): CashAccount public function paginateForIndex(CashAccount $cashAccount, array $tableQuery): LengthAwarePaginator { $query = CashTransaction::query() - ->with(['createdBy.profile', 'reference']) + ->with(['createdBy.profile', 'reference', 'media']) ->where('cash_account_id', $cashAccount->id) ->when($tableQuery['search'] !== '', function (Builder $query) use ($tableQuery): void { $search = $tableQuery['search']; @@ -37,7 +45,15 @@ public function paginateForIndex(CashAccount $cashAccount, array $tableQuery): L return $query ->paginate(10) - ->withQueryString(); + ->withQueryString() + ->through(function (CashTransaction $transaction) { + $transaction->setAttribute( + 'photos', + MediaPresenter::collection($transaction, 'photos'), + ); + + return $transaction; + }); } /** @@ -53,13 +69,17 @@ public function deposit(CashAccount $cashAccount, array $validated, User $user): $account->balance = $newBalance; $account->save(); - return CashTransaction::create([ + $transaction = CashTransaction::create([ 'cash_account_id' => $account->id, 'amount' => $amount, 'balance_after' => $newBalance, 'description' => $validated['description'], 'created_by_id' => $user->id, ]); + + $this->syncPhotos($transaction, $validated); + + return $transaction; }); } @@ -147,6 +167,8 @@ public function updateDeposit(CashTransaction $transaction, array $validated): v $transaction->description = $validated['description']; $transaction->save(); + $this->syncPhotos($transaction, $validated); + $this->recalculateBalances($transaction->cashAccount); }); } @@ -158,6 +180,7 @@ public function deleteTransaction(CashTransaction $transaction): void DB::transaction(function () use ($transaction): void { CashAccount::query()->lockForUpdate()->findOrFail($transaction->cash_account_id); + $transaction->clearMediaCollection('photos'); $transaction->delete(); $this->recalculateBalances($transaction->cashAccount); @@ -200,6 +223,22 @@ public function deleteReferencedTransaction(CashTransaction $transaction): void }); } + /** + * @param array $validated + */ + private function syncPhotos(CashTransaction $transaction, array $validated): void + { + $this->mediaService->syncCollection( + $transaction, + 'photos', + $validated['photos'] ?? null, + $validated['remove_media_ids'] ?? null, + self::MAX_PHOTOS, + required: true, + errorKey: 'photos', + ); + } + private function ensureEditable(CashTransaction $transaction): void { if ($transaction->reference_type !== null) { diff --git a/app/Services/Finance/EmployeeAdvanceService.php b/app/Services/Finance/EmployeeAdvanceService.php index d6b11e8..a88716b 100644 --- a/app/Services/Finance/EmployeeAdvanceService.php +++ b/app/Services/Finance/EmployeeAdvanceService.php @@ -4,6 +4,8 @@ use App\Enums\EmployeeAdvanceStatus; use App\Models\EmployeeAdvance; +use App\Services\Media\MediaService; +use App\Support\Media\MediaPresenter; use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Facades\DB; @@ -11,8 +13,11 @@ class EmployeeAdvanceService { + private const MAX_PHOTOS = 1; + public function __construct( private readonly CashService $cashService, + private readonly MediaService $mediaService, ) {} /** @@ -41,7 +46,7 @@ public function outstandingSummary(): array public function paginateForIndex(array $tableQuery): LengthAwarePaginator { $query = EmployeeAdvance::query() - ->with(['employee.user.profile', 'rejection']) + ->with(['employee.user.profile', 'rejection', 'media']) ->when($tableQuery['search'] !== '', function (Builder $query) use ($tableQuery): void { $search = $tableQuery['search']; $query->where(function (Builder $query) use ($search): void { @@ -56,7 +61,15 @@ public function paginateForIndex(array $tableQuery): LengthAwarePaginator return $query ->paginate(10) - ->withQueryString(); + ->withQueryString() + ->through(function (EmployeeAdvance $employeeAdvance) { + $employeeAdvance->setAttribute( + 'photos', + MediaPresenter::collection($employeeAdvance, 'photos'), + ); + + return $employeeAdvance; + }); } /** @@ -72,13 +85,17 @@ public function create(array $validated): void ]); } - EmployeeAdvance::create([ - 'employee_id' => $employee->id, - 'amount' => (int) $validated['amount'], - 'description' => $validated['description'], - 'due_date' => $validated['due_date'], - 'status' => EmployeeAdvanceStatus::PENDING, - ]); + DB::transaction(function () use ($validated, $employee): void { + $employeeAdvance = EmployeeAdvance::create([ + 'employee_id' => $employee->id, + 'amount' => (int) $validated['amount'], + 'description' => $validated['description'], + 'due_date' => $validated['due_date'], + 'status' => EmployeeAdvanceStatus::PENDING, + ]); + + $this->syncPhotos($employeeAdvance, $validated); + }); } /** @@ -89,10 +106,14 @@ public function update(EmployeeAdvance $employeeAdvance, array $validated): void $this->ensureOwnedBySubmitter($employeeAdvance); $this->ensurePending($employeeAdvance, 'Kasbon hanya dapat diubah saat status menunggu.'); - $employeeAdvance->amount = (int) $validated['amount']; - $employeeAdvance->description = $validated['description']; - $employeeAdvance->due_date = $validated['due_date']; - $employeeAdvance->save(); + DB::transaction(function () use ($employeeAdvance, $validated): void { + $employeeAdvance->amount = (int) $validated['amount']; + $employeeAdvance->description = $validated['description']; + $employeeAdvance->due_date = $validated['due_date']; + $employeeAdvance->save(); + + $this->syncPhotos($employeeAdvance, $validated); + }); } public function delete(EmployeeAdvance $employeeAdvance): void @@ -100,6 +121,7 @@ public function delete(EmployeeAdvance $employeeAdvance): void $this->ensureOwnedBySubmitter($employeeAdvance); $this->ensurePending($employeeAdvance, 'Kasbon hanya dapat dihapus saat status menunggu.'); + $employeeAdvance->clearMediaCollection('photos'); $employeeAdvance->delete(); } @@ -184,6 +206,22 @@ public function pay(EmployeeAdvance $employeeAdvance): void }); } + /** + * @param array $validated + */ + private function syncPhotos(EmployeeAdvance $employeeAdvance, array $validated): void + { + $this->mediaService->syncCollection( + $employeeAdvance, + 'photos', + $validated['photos'] ?? null, + $validated['remove_media_ids'] ?? null, + self::MAX_PHOTOS, + required: true, + errorKey: 'photos', + ); + } + private function ensureOwnedBySubmitter(EmployeeAdvance $employeeAdvance): void { if (auth()->user()?->employee?->id !== $employeeAdvance->employee_id) { diff --git a/app/Services/Finance/ExpenseService.php b/app/Services/Finance/ExpenseService.php index 61a7723..3632a51 100644 --- a/app/Services/Finance/ExpenseService.php +++ b/app/Services/Finance/ExpenseService.php @@ -4,14 +4,19 @@ use App\Models\Expense; use App\Models\User; +use App\Services\Media\MediaService; +use App\Support\Media\MediaPresenter; use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Facades\DB; class ExpenseService { + private const MAX_PHOTOS = 1; + public function __construct( private readonly CashService $cashService, + private readonly MediaService $mediaService, ) {} /** @@ -20,7 +25,7 @@ public function __construct( public function paginateForIndex(array $tableQuery): LengthAwarePaginator { $query = Expense::query() - ->with(['createdBy.profile']) + ->with(['createdBy.profile', 'media']) ->when($tableQuery['search'] !== '', function (Builder $query) use ($tableQuery): void { $search = $tableQuery['search']; $query->where(function (Builder $query) use ($search): void { @@ -32,7 +37,15 @@ public function paginateForIndex(array $tableQuery): LengthAwarePaginator return $query ->paginate(10) - ->withQueryString(); + ->withQueryString() + ->through(function (Expense $expense) { + $expense->setAttribute( + 'photos', + MediaPresenter::collection($expense, 'photos'), + ); + + return $expense; + }); } /** @@ -59,6 +72,8 @@ public function create(array $validated, User $user): void $expense->cash_transaction_id = $cashTransaction->id; $expense->save(); + + $this->syncPhotos($expense, $validated); }); } @@ -82,6 +97,8 @@ public function update(Expense $expense, array $validated): void $description, ); } + + $this->syncPhotos($expense, $validated); }); } @@ -92,10 +109,27 @@ public function delete(Expense $expense): void $this->cashService->deleteReferencedTransaction($expense->cashTransaction); } + $expense->clearMediaCollection('photos'); $expense->delete(); }); } + /** + * @param array $validated + */ + private function syncPhotos(Expense $expense, array $validated): void + { + $this->mediaService->syncCollection( + $expense, + 'photos', + $validated['photos'] ?? null, + $validated['remove_media_ids'] ?? null, + self::MAX_PHOTOS, + required: true, + errorKey: 'photos', + ); + } + private function applySorting(Builder $query, string $sort, string $direction): void { if (in_array($sort, ['created_at', 'amount', 'description'], true)) { diff --git a/app/Services/Hr/AttendanceService.php b/app/Services/Hr/AttendanceService.php index 64bf4ab..cbbcf65 100644 --- a/app/Services/Hr/AttendanceService.php +++ b/app/Services/Hr/AttendanceService.php @@ -4,15 +4,19 @@ use App\Models\Attendance; use App\Models\Employee; +use App\Services\Media\MediaService; use Carbon\CarbonInterface; use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Http; -use Illuminate\Support\Facades\Storage; use Illuminate\Validation\ValidationException; class AttendanceService { + public function __construct( + private readonly MediaService $mediaService, + ) {} + public function listForCalendar( CarbonInterface $start, CarbonInterface $end, @@ -20,7 +24,7 @@ public function listForCalendar( bool $hasScopedAccess = true, ): Collection { return Attendance::query() - ->with(['employee.user.profile']) + ->with(['employee.user.profile', 'media']) ->when(! $hasScopedAccess, function (Builder $query): void { $query->whereRaw('1 = 0'); }) @@ -40,6 +44,7 @@ public function listForCalendar( public function todayAttendanceForEmployee(Employee $employee): ?array { $attendance = Attendance::query() + ->with('media') ->where('employee_id', $employee->id) ->whereDate('attendance_date', today()) ->first(); @@ -71,15 +76,21 @@ public function checkIn(array $validated): void (float) $validated['longitude'], ); - Attendance::create([ + $attendance = Attendance::create([ 'employee_id' => $employee->id, 'attendance_date' => today(), 'check_in_at' => now(), - 'check_in_photo_path' => $this->storePhoto($validated['photo'], $employee->id, 'check-in'), 'check_in_latitude' => $validated['latitude'], 'check_in_longitude' => $validated['longitude'], 'check_in_location_tag' => $locationTag, ]); + + $this->mediaService->addBase64Image( + $attendance, + $validated['photo'], + 'checkin', + 'checkin', + ); } /** @@ -116,24 +127,24 @@ public function checkOut(array $validated): void ); $attendance->check_out_at = $checkOutAt; - $attendance->check_out_photo_path = $this->storePhoto($validated['photo'], $employee->id, 'check-out'); $attendance->check_out_latitude = $validated['latitude']; $attendance->check_out_longitude = $validated['longitude']; $attendance->check_out_location_tag = $locationTag; $attendance->work_duration_minutes = $workDurationMinutes; $attendance->save(); + + $this->mediaService->addBase64Image( + $attendance, + $validated['photo'], + 'checkout', + 'checkout', + ); } public function delete(Attendance $attendance): void { - if ($attendance->check_in_photo_path) { - Storage::disk('public')->delete($attendance->check_in_photo_path); - } - - if ($attendance->check_out_photo_path) { - Storage::disk('public')->delete($attendance->check_out_photo_path); - } - + $attendance->clearMediaCollection('checkin'); + $attendance->clearMediaCollection('checkout'); $attendance->delete(); } @@ -150,27 +161,6 @@ private function resolveAuthEmployee(): Employee return $employee; } - private function storePhoto(string $base64Photo, int $employeeId, string $type): string - { - $image = base64_decode( - (string) preg_replace('#^data:image/\w+;base64,#i', '', $base64Photo), - true, - ); - - if ($image === false) { - throw ValidationException::withMessages([ - 'photo' => 'Foto presensi tidak valid.', - ]); - } - - $filename = sprintf('%d_%s_%s.jpg', $employeeId, now()->format('Y-m-d_His'), $type); - $path = "attendances/{$filename}"; - - Storage::disk('public')->put($path, $image); - - return $path; - } - private function resolveLocationTag(string $clientTag, float $latitude, float $longitude): string { if ($clientTag !== '') { diff --git a/app/Services/Master/ProductService.php b/app/Services/Master/ProductService.php index bb6a1ae..53968d6 100644 --- a/app/Services/Master/ProductService.php +++ b/app/Services/Master/ProductService.php @@ -6,12 +6,20 @@ use App\Models\Product; use App\Models\ProductPrice; use App\Models\ProductVariant; +use App\Services\Media\MediaService; +use App\Support\Media\MediaPresenter; use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Facades\DB; class ProductService { + private const MAX_VARIANT_IMAGES = 5; + + public function __construct( + private readonly MediaService $mediaService, + ) {} + /** * @param array{search: string, sort: string, direction: 'asc'|'desc'} $tableQuery */ @@ -21,7 +29,7 @@ public function paginateForIndex(array $tableQuery, string $isActive): LengthAwa ->with([ 'categories', 'variants' => fn ($query) => $query - ->with(['prices' => fn ($query) => $query->orderBy('type')]) + ->with(['prices' => fn ($query) => $query->orderBy('type'), 'media']) ->orderBy('created_at'), ]) ->when($tableQuery['search'] !== '', function (Builder $query) use ($tableQuery): void { @@ -43,7 +51,17 @@ public function paginateForIndex(array $tableQuery, string $isActive): LengthAwa return $query ->paginate(10) - ->withQueryString(); + ->withQueryString() + ->through(function (Product $product) { + $product->variants->each(function (ProductVariant $variant): void { + $variant->setAttribute( + 'images', + MediaPresenter::collection($variant, 'images'), + ); + }); + + return $product; + }); } /** @@ -75,8 +93,8 @@ public function create(array $validated): void $product->categories()->sync($validated['category_ids']); - foreach ($validated['variants'] as $variantData) { - $this->createVariant($product, $variantData); + foreach ($validated['variants'] as $index => $variantData) { + $this->createVariant($product, $variantData, $index); } }); } @@ -102,20 +120,24 @@ public function update(Product $product, array $validated): void $product->variants() ->whereNotIn('id', $submittedVariantIds) ->get() - ->each(fn (ProductVariant $variant) => $variant->delete()); + ->each(function (ProductVariant $variant): void { + $variant->clearMediaCollection('images'); + $variant->delete(); + }); - foreach ($validated['variants'] as $variantData) { + foreach ($validated['variants'] as $index => $variantData) { if (! empty($variantData['id'])) { $variant = $product->variants()->findOrFail($variantData['id']); $variant->name = $variantData['name']; $variant->stock = $variantData['stock']; $variant->save(); $this->syncVariantPrices($variant, $variantData['prices']); + $this->syncVariantImages($variant, $variantData, $index); continue; } - $this->createVariant($product, $variantData); + $this->createVariant($product, $variantData, $index); } }); } @@ -129,6 +151,9 @@ public function toggleStatus(Product $product, bool $isActive): void public function delete(Product $product): void { DB::transaction(function () use ($product): void { + $product->variants()->each(function (ProductVariant $variant): void { + $variant->clearMediaCollection('images'); + }); $product->variants()->delete(); $product->categories()->detach(); $product->delete(); @@ -149,7 +174,7 @@ private function applySorting(Builder $query, string $sort, string $direction): /** * @param array $variantData */ - private function createVariant(Product $product, array $variantData): ProductVariant + private function createVariant(Product $product, array $variantData, int $index): ProductVariant { $variant = $product->variants()->create([ 'name' => $variantData['name'], @@ -157,10 +182,27 @@ private function createVariant(Product $product, array $variantData): ProductVar ]); $this->syncVariantPrices($variant, $variantData['prices']); + $this->syncVariantImages($variant, $variantData, $index); return $variant; } + /** + * @param array $variantData + */ + private function syncVariantImages(ProductVariant $variant, array $variantData, int $index): void + { + $this->mediaService->syncCollection( + $variant, + 'images', + $variantData['images'] ?? null, + $variantData['remove_media_ids'] ?? null, + self::MAX_VARIANT_IMAGES, + required: true, + errorKey: "variants.{$index}.images", + ); + } + /** * @param list $prices */ diff --git a/app/Services/Master/RawMaterialService.php b/app/Services/Master/RawMaterialService.php index d19820f..2c76fb5 100644 --- a/app/Services/Master/RawMaterialService.php +++ b/app/Services/Master/RawMaterialService.php @@ -4,12 +4,20 @@ use App\Models\RawMaterial; use App\Models\RawMaterialPrice; +use App\Services\Media\MediaService; +use App\Support\Media\MediaPresenter; use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Facades\DB; class RawMaterialService { + private const MAX_VARIANT_IMAGES = 5; + + public function __construct( + private readonly MediaService $mediaService, + ) {} + /** * @param array{search: string, sort: string, direction: 'asc'|'desc'} $tableQuery */ @@ -19,7 +27,7 @@ public function paginateForIndex(array $tableQuery, string $isActive): LengthAwa ->with([ 'prices' => fn ($query) => $query ->orderBy('created_at') - ->with('rawMaterial:id,unit'), + ->with(['rawMaterial:id,unit', 'media']), ]) ->when($tableQuery['search'] !== '', function (Builder $query) use ($tableQuery): void { $search = $tableQuery['search']; @@ -37,7 +45,17 @@ public function paginateForIndex(array $tableQuery, string $isActive): LengthAwa return $query ->paginate(10) - ->withQueryString(); + ->withQueryString() + ->through(function (RawMaterial $rawMaterial) { + $rawMaterial->prices->each(function (RawMaterialPrice $price): void { + $price->setAttribute( + 'images', + MediaPresenter::collection($price, 'images'), + ); + }); + + return $rawMaterial; + }); } /** @@ -51,8 +69,8 @@ public function create(array $validated): void 'unit' => $validated['unit'], ]); - foreach ($validated['prices'] as $priceData) { - $this->createPrice($rawMaterial, $priceData); + foreach ($validated['prices'] as $index => $priceData) { + $this->createPrice($rawMaterial, $priceData, $index); } }); } @@ -76,20 +94,24 @@ public function update(RawMaterial $rawMaterial, array $validated): void $rawMaterial->prices() ->whereNotIn('id', $submittedPriceIds) ->get() - ->each(fn (RawMaterialPrice $price) => $price->delete()); + ->each(function (RawMaterialPrice $price): void { + $price->clearMediaCollection('images'); + $price->delete(); + }); - foreach ($validated['prices'] as $priceData) { + foreach ($validated['prices'] as $index => $priceData) { if (! empty($priceData['id'])) { $price = $rawMaterial->prices()->findOrFail($priceData['id']); $price->variant = $priceData['variant']; $price->price = $priceData['price']; $price->stock = $priceData['stock']; $price->save(); + $this->syncPriceImages($price, $priceData, $index); continue; } - $this->createPrice($rawMaterial, $priceData); + $this->createPrice($rawMaterial, $priceData, $index); } }); } @@ -103,6 +125,9 @@ public function toggleStatus(RawMaterial $rawMaterial, bool $isActive): void public function delete(RawMaterial $rawMaterial): void { DB::transaction(function () use ($rawMaterial): void { + $rawMaterial->prices()->each(function (RawMaterialPrice $price): void { + $price->clearMediaCollection('images'); + }); $rawMaterial->prices()->delete(); $rawMaterial->delete(); }); @@ -122,12 +147,32 @@ private function applySorting(Builder $query, string $sort, string $direction): /** * @param array $priceData */ - private function createPrice(RawMaterial $rawMaterial, array $priceData): RawMaterialPrice + private function createPrice(RawMaterial $rawMaterial, array $priceData, int $index): RawMaterialPrice { - return $rawMaterial->prices()->create([ + $price = $rawMaterial->prices()->create([ 'variant' => $priceData['variant'], 'price' => $priceData['price'], 'stock' => $priceData['stock'], ]); + + $this->syncPriceImages($price, $priceData, $index); + + return $price; + } + + /** + * @param array $priceData + */ + private function syncPriceImages(RawMaterialPrice $price, array $priceData, int $index): void + { + $this->mediaService->syncCollection( + $price, + 'images', + $priceData['images'] ?? null, + $priceData['remove_media_ids'] ?? null, + self::MAX_VARIANT_IMAGES, + required: true, + errorKey: "prices.{$index}.images", + ); } } diff --git a/app/Services/Media/MediaService.php b/app/Services/Media/MediaService.php new file mode 100644 index 0000000..36b382b --- /dev/null +++ b/app/Services/Media/MediaService.php @@ -0,0 +1,133 @@ +|null $newFiles + * @param list|null $removeIds + */ + public function syncCollection( + HasMedia $model, + string $collection, + ?array $newFiles, + ?array $removeIds, + int $maxFiles, + ?string $type = null, + bool $required = false, + ?string $errorKey = null, + ): void { + $newFiles = array_values(array_filter($newFiles ?? [])); + + if ($maxFiles === 1 && $newFiles !== []) { + $model->clearMediaCollection($collection); + } elseif ($removeIds !== null && $removeIds !== []) { + $model->getMedia($collection) + ->whereIn('id', $removeIds) + ->each->delete(); + } + + if ($maxFiles === 1) { + $newFiles = array_slice($newFiles, 0, 1); + } + + $currentCount = $model->getMedia($collection)->count(); + + if ($currentCount + count($newFiles) > $maxFiles) { + throw ValidationException::withMessages([ + $errorKey ?? $collection => "Maksimal {$maxFiles} gambar per item.", + ]); + } + + foreach ($newFiles as $file) { + $this->addUploadedFile($model, $file, $collection, $type); + } + + if ($required && $model->getMedia($collection)->isEmpty()) { + throw ValidationException::withMessages([ + $errorKey ?? $collection => 'Foto wajib diisi.', + ]); + } + } + + public function addUploadedFile( + HasMedia $model, + UploadedFile $file, + string $collection, + ?string $type = null, + ): Media { + return $model->addMedia($file) + ->withCustomProperties($this->customProperties($model, $type ?? $collection)) + ->toMediaCollection($collection); + } + + public function addBase64Image( + HasMedia $model, + string $base64Photo, + string $collection, + ?string $type = null, + string $extension = 'jpg', + ): Media { + $image = base64_decode( + (string) preg_replace('#^data:image/\w+;base64,#i', '', $base64Photo), + true, + ); + + if ($image === false) { + throw ValidationException::withMessages([ + 'photo' => 'Foto tidak valid.', + ]); + } + + $filename = sprintf('%s.%s', Str::uuid(), $extension); + $tempPath = 'temp/'.$filename; + + Storage::disk('local')->put($tempPath, $image); + + try { + return $model->addMedia(Storage::disk('local')->path($tempPath)) + ->usingFileName($filename) + ->withCustomProperties($this->customProperties($model, $type ?? $collection)) + ->toMediaCollection($collection); + } finally { + Storage::disk('local')->delete($tempPath); + } + } + + public function replaceSingleFile( + HasMedia $model, + UploadedFile $file, + string $collection, + ?string $type = null, + ): Media { + $model->clearMediaCollection($collection); + + return $this->addUploadedFile($model, $file, $collection, $type); + } + + /** + * @return array{module: string, type?: string} + */ + private function customProperties(HasMedia $model, ?string $type): array + { + $properties = [ + 'module' => method_exists($model, 'mediaModuleName') + ? $model::mediaModuleName() + : 'media', + ]; + + if ($type !== null && $type !== '') { + $properties['type'] = $type; + } + + return $properties; + } +} diff --git a/app/Services/System/Setting/SystemService.php b/app/Services/System/Setting/SystemService.php index 487afc9..6eceb4d 100644 --- a/app/Services/System/Setting/SystemService.php +++ b/app/Services/System/Setting/SystemService.php @@ -2,47 +2,69 @@ namespace App\Services\System\Setting; +use App\Models\SystemConfiguration; +use App\Services\Media\MediaService; use App\Settings\SystemSettings; +use App\Support\Media\MediaPresenter; use Illuminate\Http\UploadedFile; -use Illuminate\Support\Facades\Storage; +use Illuminate\Validation\ValidationException; class SystemService { + public function __construct( + private readonly MediaService $mediaService, + ) {} + public function systemData(): array { $settings = app(SystemSettings::class); + $configuration = SystemConfiguration::instance(); + $configuration->load('media'); + + $logo = MediaPresenter::first($configuration, 'logo'); + $loginCover = MediaPresenter::first($configuration, 'login_cover'); return [ 'app_name' => $settings->app_name, 'about_app' => $settings->about_app, 'email' => $settings->email, 'phone' => $settings->phone, - 'logo' => $settings->logo, - 'logo_url' => $settings->logo ? Storage::disk('public')->url($settings->logo) : null, - 'login_cover' => $settings->login_cover, - 'login_cover_url' => $settings->login_cover ? Storage::disk('public')->url($settings->login_cover) : null, + 'logo_url' => $logo['url'] ?? null, + 'login_cover_url' => $loginCover['url'] ?? null, ]; } public function updateSystem(array $validated): void { $settings = app(SystemSettings::class); + $configuration = SystemConfiguration::instance(); $settings->app_name = $validated['app_name']; $settings->about_app = $validated['about_app'] ?? null; $settings->email = $validated['email'] ?? null; $settings->phone = $validated['phone'] ?? null; + $settings->save(); if (isset($validated['logo']) && $validated['logo'] instanceof UploadedFile) { - Storage::disk('public')->delete($settings->logo); - $settings->logo = $validated['logo']->store('settings/system', 'public'); + $this->mediaService->replaceSingleFile($configuration, $validated['logo'], 'logo', 'logo'); } if (isset($validated['login_cover']) && $validated['login_cover'] instanceof UploadedFile) { - Storage::disk('public')->delete($settings->login_cover); - $settings->login_cover = $validated['login_cover']->store('settings/system', 'public'); + $this->mediaService->replaceSingleFile($configuration, $validated['login_cover'], 'login_cover', 'login-cover'); } - $settings->save(); + $errors = []; + + if (! $configuration->hasMedia('logo')) { + $errors['logo'] = 'Logo wajib diisi.'; + } + + if (! $configuration->hasMedia('login_cover')) { + $errors['login_cover'] = 'Cover login wajib diisi.'; + } + + if ($errors !== []) { + throw ValidationException::withMessages($errors); + } } } diff --git a/app/Settings/SystemSettings.php b/app/Settings/SystemSettings.php index 83a49c2..1779547 100644 --- a/app/Settings/SystemSettings.php +++ b/app/Settings/SystemSettings.php @@ -14,10 +14,6 @@ class SystemSettings extends Settings public ?string $phone; - public ?string $logo; - - public ?string $login_cover; - public static function group(): string { return 'system'; diff --git a/app/Support/Media/MediaPresenter.php b/app/Support/Media/MediaPresenter.php new file mode 100644 index 0000000..5d13ae6 --- /dev/null +++ b/app/Support/Media/MediaPresenter.php @@ -0,0 +1,44 @@ + + */ + public static function collection(HasMedia $model, string $collectionName): array + { + return $model->getMedia($collectionName) + ->map(fn (Media $media) => self::item($media)) + ->values() + ->all(); + } + + /** + * @return array{id: int, url: string, thumb_url: string}|null + */ + public static function first(HasMedia $model, string $collectionName): ?array + { + $media = $model->getFirstMedia($collectionName); + + return $media ? self::item($media) : null; + } + + /** + * @return array{id: int, url: string, thumb_url: string} + */ + public static function item(Media $media): array + { + return [ + 'id' => $media->id, + 'url' => $media->getUrl(), + 'thumb_url' => $media->hasGeneratedConversion('thumb') + ? $media->getUrl('thumb') + : $media->getUrl(), + ]; + } +} diff --git a/app/Support/Media/ModulePathGenerator.php b/app/Support/Media/ModulePathGenerator.php new file mode 100644 index 0000000..489c0f0 --- /dev/null +++ b/app/Support/Media/ModulePathGenerator.php @@ -0,0 +1,33 @@ +getCustomProperty('module', 'media'); + $type = $media->getCustomProperty('type'); + $date = $media->created_at?->format('Y-m-d') ?? now()->format('Y-m-d'); + $id = $media->model_id; + + if ($type) { + return "{$module}/{$type}/{$date}/{$id}/"; + } + + return "{$module}/{$date}/{$id}/"; + } + + public function getPathForConversions(Media $media): string + { + return $this->getPath($media).'conversions/'; + } + + public function getPathForResponsiveImages(Media $media): string + { + return $this->getPath($media).'responsive-images/'; + } +} diff --git a/composer.json b/composer.json index ce97033..546ac6e 100644 --- a/composer.json +++ b/composer.json @@ -14,6 +14,7 @@ "laravel/framework": "^13.7", "laravel/tinker": "^3.0", "laravel/wayfinder": "^0.1.14", + "spatie/laravel-medialibrary": "^11.23", "spatie/laravel-permission": "^8.0", "spatie/laravel-settings": "^3.9", "spatie/laravel-sluggable": "^4.0" diff --git a/composer.lock b/composer.lock index dd9f563..42a7eac 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "25e9e3d1a32e479b704c046ede69c405", + "content-hash": "6e75752243def7f52763c43a3fb6eb14", "packages": [ { "name": "brick/math", @@ -135,6 +135,83 @@ ], "time": "2024-02-09T16:56:22+00:00" }, + { + "name": "composer/semver", + "version": "3.4.4", + "source": { + "type": "git", + "url": "https://github.com/composer/semver.git", + "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/semver/zipball/198166618906cb2de69b95d7d47e5fa8aa1b2b95", + "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95", + "shasum": "" + }, + "require": { + "php": "^5.3.2 || ^7.0 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.11", + "symfony/phpunit-bridge": "^3 || ^7" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\Semver\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nils Adermann", + "email": "naderman@naderman.de", + "homepage": "http://www.naderman.de" + }, + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + }, + { + "name": "Rob Bast", + "email": "rob.bast@gmail.com", + "homepage": "http://robbast.nl" + } + ], + "description": "Semver library that offers utilities, version constraint parsing and validation.", + "keywords": [ + "semantic", + "semver", + "validation", + "versioning" + ], + "support": { + "irc": "ircs://irc.libera.chat:6697/composer", + "issues": "https://github.com/composer/semver/issues", + "source": "https://github.com/composer/semver/tree/3.4.4" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + } + ], + "time": "2025-08-20T19:15:30+00:00" + }, { "name": "dflydev/dot-access-data", "version": "v3.0.3", @@ -2214,6 +2291,84 @@ ], "time": "2026-03-08T20:05:35+00:00" }, + { + "name": "maennchen/zipstream-php", + "version": "3.2.2", + "source": { + "type": "git", + "url": "https://github.com/maennchen/ZipStream-PHP.git", + "reference": "77bebeb4c6c340bb3c11c843b2cffd8bbfde4d5e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/77bebeb4c6c340bb3c11c843b2cffd8bbfde4d5e", + "reference": "77bebeb4c6c340bb3c11c843b2cffd8bbfde4d5e", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "ext-zlib": "*", + "php-64bit": "^8.3" + }, + "require-dev": { + "brianium/paratest": "^7.7", + "ext-zip": "*", + "friendsofphp/php-cs-fixer": "^3.86", + "guzzlehttp/guzzle": "^7.5", + "mikey179/vfsstream": "^1.6", + "php-coveralls/php-coveralls": "^2.5", + "phpunit/phpunit": "^12.0", + "vimeo/psalm": "^6.0" + }, + "suggest": { + "guzzlehttp/psr7": "^2.4", + "psr/http-message": "^2.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "ZipStream\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Paul Duncan", + "email": "pabs@pablotron.org" + }, + { + "name": "Jonatan Männchen", + "email": "jonatan@maennchen.ch" + }, + { + "name": "Jesse Donat", + "email": "donatj@gmail.com" + }, + { + "name": "András Kolesár", + "email": "kolesar@kolesar.hu" + } + ], + "description": "ZipStream is a library for dynamically streaming dynamic zip files from PHP without writing to the disk at all on the server.", + "keywords": [ + "stream", + "zip" + ], + "support": { + "issues": "https://github.com/maennchen/ZipStream-PHP/issues", + "source": "https://github.com/maennchen/ZipStream-PHP/tree/3.2.2" + }, + "funding": [ + { + "url": "https://github.com/maennchen", + "type": "github" + } + ], + "time": "2026-04-11T18:38:28+00:00" + }, { "name": "monolog/monolog", "version": "3.10.0", @@ -3647,6 +3802,244 @@ }, "time": "2025-12-14T04:43:48+00:00" }, + { + "name": "spatie/image", + "version": "3.9.4", + "source": { + "type": "git", + "url": "https://github.com/spatie/image.git", + "reference": "6a322b5e9268e3903d4fb6e1ff08b7dcc3aa9429" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spatie/image/zipball/6a322b5e9268e3903d4fb6e1ff08b7dcc3aa9429", + "reference": "6a322b5e9268e3903d4fb6e1ff08b7dcc3aa9429", + "shasum": "" + }, + "require": { + "ext-exif": "*", + "ext-json": "*", + "ext-mbstring": "*", + "php": "^8.2", + "spatie/image-optimizer": "^1.7.5", + "spatie/temporary-directory": "^2.2", + "symfony/process": "^6.4|^7.0|^8.0" + }, + "require-dev": { + "ext-gd": "*", + "ext-imagick": "*", + "laravel/sail": "^1.34", + "pestphp/pest": "^3.0|^4.0", + "phpstan/phpstan": "^1.10.50", + "spatie/pest-plugin-snapshots": "^2.1", + "spatie/pixelmatch-php": "^1.0", + "spatie/ray": "^1.40.1", + "symfony/var-dumper": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Spatie\\Image\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Freek Van der Herten", + "email": "freek@spatie.be", + "homepage": "https://spatie.be", + "role": "Developer" + } + ], + "description": "Manipulate images with an expressive API", + "homepage": "https://github.com/spatie/image", + "keywords": [ + "image", + "spatie" + ], + "support": { + "source": "https://github.com/spatie/image/tree/3.9.4" + }, + "funding": [ + { + "url": "https://spatie.be/open-source/support-us", + "type": "custom" + }, + { + "url": "https://github.com/spatie", + "type": "github" + } + ], + "time": "2026-03-13T14:23:45+00:00" + }, + { + "name": "spatie/image-optimizer", + "version": "1.8.1", + "source": { + "type": "git", + "url": "https://github.com/spatie/image-optimizer.git", + "reference": "2ad9ac7c19501739183359ae64ea6c15869c23d9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spatie/image-optimizer/zipball/2ad9ac7c19501739183359ae64ea6c15869c23d9", + "reference": "2ad9ac7c19501739183359ae64ea6c15869c23d9", + "shasum": "" + }, + "require": { + "ext-fileinfo": "*", + "php": "^7.3|^8.0", + "psr/log": "^1.0 | ^2.0 | ^3.0", + "symfony/process": "^4.2|^5.0|^6.0|^7.0|^8.0" + }, + "require-dev": { + "pestphp/pest": "^1.21|^2.0|^3.0|^4.0", + "phpunit/phpunit": "^8.5.21|^9.4.4|^10.0|^11.0|^12.0", + "symfony/var-dumper": "^4.2|^5.0|^6.0|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Spatie\\ImageOptimizer\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Freek Van der Herten", + "email": "freek@spatie.be", + "homepage": "https://spatie.be", + "role": "Developer" + } + ], + "description": "Easily optimize images using PHP", + "homepage": "https://github.com/spatie/image-optimizer", + "keywords": [ + "image-optimizer", + "spatie" + ], + "support": { + "issues": "https://github.com/spatie/image-optimizer/issues", + "source": "https://github.com/spatie/image-optimizer/tree/1.8.1" + }, + "time": "2025-11-26T10:57:19+00:00" + }, + { + "name": "spatie/laravel-medialibrary", + "version": "11.23.0", + "source": { + "type": "git", + "url": "https://github.com/spatie/laravel-medialibrary.git", + "reference": "98409dd203ad74a06b4ef5a7139ededc13bcf835" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spatie/laravel-medialibrary/zipball/98409dd203ad74a06b4ef5a7139ededc13bcf835", + "reference": "98409dd203ad74a06b4ef5a7139ededc13bcf835", + "shasum": "" + }, + "require": { + "composer/semver": "^3.4", + "ext-exif": "*", + "ext-fileinfo": "*", + "ext-json": "*", + "illuminate/bus": "^10.2|^11.0|^12.0|^13.0", + "illuminate/conditionable": "^10.2|^11.0|^12.0|^13.0", + "illuminate/console": "^10.2|^11.0|^12.0|^13.0", + "illuminate/database": "^10.2|^11.0|^12.0|^13.0", + "illuminate/pipeline": "^10.2|^11.0|^12.0|^13.0", + "illuminate/support": "^10.2|^11.0|^12.0|^13.0", + "maennchen/zipstream-php": "^3.1", + "php": "^8.2", + "spatie/image": "^3.3.2", + "spatie/laravel-package-tools": "^1.16.1", + "spatie/temporary-directory": "^2.2", + "symfony/console": "^6.4.1|^7.0|^8.0" + }, + "conflict": { + "php-ffmpeg/php-ffmpeg": "<0.6.1" + }, + "require-dev": { + "aws/aws-sdk-php": "^3.293.10", + "ext-imagick": "*", + "ext-pdo_sqlite": "*", + "ext-zip": "*", + "guzzlehttp/guzzle": "^7.8.1", + "larastan/larastan": "^2.7|^3.0", + "league/flysystem-aws-s3-v3": "^3.22", + "mockery/mockery": "^1.6.7", + "orchestra/testbench": "^8.36|^9.15|^10.8|^11.0", + "pestphp/pest": "^2.36|^3.0|^4.0", + "phpstan/extension-installer": "^1.3.1", + "spatie/laravel-ray": "^1.33", + "spatie/pdf-to-image": "^2.2|^3.0", + "spatie/pest-expectations": "^1.13", + "spatie/pest-plugin-snapshots": "^2.1" + }, + "suggest": { + "league/flysystem-aws-s3-v3": "Required to use AWS S3 file storage", + "php-ffmpeg/php-ffmpeg": "Required for generating video thumbnails", + "spatie/pdf-to-image": "Required for generating thumbnails of PDFs and SVGs" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Spatie\\MediaLibrary\\MediaLibraryServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Spatie\\MediaLibrary\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Freek Van der Herten", + "email": "freek@spatie.be", + "homepage": "https://spatie.be", + "role": "Developer" + } + ], + "description": "Associate files with Eloquent models", + "homepage": "https://github.com/spatie/laravel-medialibrary", + "keywords": [ + "cms", + "conversion", + "downloads", + "images", + "laravel", + "laravel-medialibrary", + "media", + "spatie" + ], + "support": { + "issues": "https://github.com/spatie/laravel-medialibrary/issues", + "source": "https://github.com/spatie/laravel-medialibrary/tree/11.23.0" + }, + "funding": [ + { + "url": "https://spatie.be/open-source/support-us", + "type": "custom" + }, + { + "url": "https://github.com/spatie", + "type": "github" + } + ], + "time": "2026-05-28T06:39:00+00:00" + }, { "name": "spatie/laravel-package-tools", "version": "1.93.1", diff --git a/config/media-library.php b/config/media-library.php new file mode 100644 index 0000000..90a4afb --- /dev/null +++ b/config/media-library.php @@ -0,0 +1,363 @@ + env('MEDIA_DISK', 'public'), + + /* + * The disk on which to store conversions (thumbnails, etc.) and responsive images + * when no disk is specified explicitly on the media collection or via + * `storingConversionsOnDisk()`. When left null, conversions are stored on the + * same disk as the original media — preserving previous behavior. + * + * This is useful when the originals live on a remote disk (e.g. S3) but the + * generated derivatives should stay local for faster access and lower egress. + */ + 'conversions_disk_name' => env('MEDIA_CONVERSIONS_DISK', null), + + /* + * The maximum file size of an item in bytes. + * Adding a larger file will result in an exception. + */ + 'max_file_size' => 1024 * 1024 * 10, // 10MB + + /* + * Uploads whose file name contains any of these extensions will be rejected. + * The check looks at every extension in the file name, so a file named + * `malicious.php.jpg` is blocked as well. Matching is case-insensitive + * and a leading dot is optional. + * + * The default list lives on the `FileAdder` class so the shipped config + * and the in-code fallback (used when the config is cached without the + * key) cannot drift. Override here to extend or shrink it. + */ + 'disallowed_extensions' => FileAdder::$defaultDisallowedExtensions, + + /* + * When this is set to an array of extensions, only uploads whose final + * extension is in the list will be accepted. Matching is case-insensitive + * and a leading dot is optional. The `disallowed_extensions` list above + * is still enforced, so an interior dangerous segment (such as the `php` + * in `shell.php.jpg`) is rejected even if the final extension is allowed. + * Leave `null` to disable allowlisting. + */ + 'allowed_extensions' => null, + + /* + * This queue connection will be used to generate derived and responsive images. + * Leave empty to use the default queue connection. + */ + 'queue_connection_name' => env('QUEUE_CONNECTION', 'sync'), + + /* + * This queue will be used to generate derived and responsive images. + * Leave empty to use the default queue. + */ + 'queue_name' => env('MEDIA_QUEUE', ''), + + /* + * By default all conversions will be performed on a queue. + */ + 'queue_conversions_by_default' => env('QUEUE_CONVERSIONS_BY_DEFAULT', false), + + /* + * Should database transactions be run after database commits? + */ + 'queue_conversions_after_database_commit' => env('QUEUE_CONVERSIONS_AFTER_DB_COMMIT', true), + + /* + * The fully qualified class name of the media model. + */ + 'media_model' => Media::class, + + /* + * The fully qualified class name of the media observer. + */ + 'media_observer' => MediaObserver::class, + + /* + * When enabled, media collections will be serialised using the default + * laravel model serialization behaviour. + * + * Keep this option disabled if using Media Library Pro components (https://medialibrary.pro) + */ + 'use_default_collection_serialization' => false, + + /* + * The fully qualified class name of the model used for temporary uploads. + * + * This model is only used in Media Library Pro (https://medialibrary.pro) + */ + 'temporary_upload_model' => TemporaryUpload::class, + + /* + * When enabled, Media Library Pro will only process temporary uploads that were uploaded + * in the same session. You can opt to disable this for stateless usage of + * the pro components. + */ + 'enable_temporary_uploads_session_affinity' => true, + + /* + * When enabled, Media Library pro will generate thumbnails for uploaded file. + */ + 'generate_thumbnails_for_temporary_uploads' => true, + + /* + * This is the class that is responsible for naming generated files. + */ + 'file_namer' => DefaultFileNamer::class, + + /* + * The class that contains the strategy for determining a media file's path. + */ + 'path_generator' => ModulePathGenerator::class, + + /* + * The class that contains the strategy for determining how to remove files. + */ + 'file_remover_class' => DefaultFileRemover::class, + + /* + * Here you can specify which path generator should be used for the given class. + */ + 'custom_path_generators' => [ + // Model::class => PathGenerator::class + // or + // 'model_morph_alias' => PathGenerator::class + ], + + /* + * When urls to files get generated, this class will be called. Use the default + * if your files are stored locally above the site root or on s3. + */ + 'url_generator' => DefaultUrlGenerator::class, + + /* + * Moves media on updating to keep path consistent. Enable it only with a custom + * PathGenerator that uses, for example, the media UUID. + */ + 'moves_media_on_update' => false, + + /* + * Whether to activate versioning when urls to files get generated. + * When activated, this attaches a ?v=xx query string to the URL. + */ + 'version_urls' => false, + + /* + * The media library will try to optimize all converted images by removing + * metadata and applying a little bit of compression. These are + * the optimizers that will be used by default. + */ + 'image_optimizers' => [ + Jpegoptim::class => [ + '-m85', // set maximum quality to 85% + '--force', // ensure that progressive generation is always done also if a little bigger + '--strip-all', // this strips out all text information such as comments and EXIF data + '--all-progressive', // this will make sure the resulting image is a progressive one + ], + Pngquant::class => [ + '--force', // required parameter for this package + ], + Optipng::class => [ + '-i0', // this will result in a non-interlaced, progressive scanned image + '-o2', // this set the optimization level to two (multiple IDAT compression trials) + '-quiet', // required parameter for this package + ], + Svgo::class => [ + '--disable=cleanupIDs', // disabling because it is known to cause troubles + ], + Gifsicle::class => [ + '-b', // required parameter for this package + '-O3', // this produces the slowest but best results + ], + Cwebp::class => [ + '-m 6', // for the slowest compression method in order to get the best compression. + '-pass 10', // for maximizing the amount of analysis pass. + '-mt', // multithreading for some speed improvements. + '-q 90', // quality factor that brings the least noticeable changes. + ], + Avifenc::class => [ + '-a cq-level=23', // constant quality level, lower values mean better quality and greater file size (0-63). + '-j all', // number of jobs (worker threads, "all" uses all available cores). + '--min 0', // min quantizer for color (0-63). + '--max 63', // max quantizer for color (0-63). + '--minalpha 0', // min quantizer for alpha (0-63). + '--maxalpha 63', // max quantizer for alpha (0-63). + '-a end-usage=q', // rate control mode set to Constant Quality mode. + '-a tune=ssim', // SSIM as tune the encoder for distortion metric. + ], + ], + + /* + * These generators will be used to create an image of media files. + */ + 'image_generators' => [ + Image::class, + Webp::class, + Avif::class, + Pdf::class, + Svg::class, + Video::class, + ], + + /* + * The path where to store temporary files while performing image conversions. + * If set to null, storage_path('media-library/temp') will be used. + */ + 'temporary_directory_path' => null, + + /* + * The engine that should perform the image conversions. + * Should be either `gd`, `imagick` or `vips`. + */ + 'image_driver' => env('IMAGE_DRIVER', 'gd'), + + /* + * FFMPEG & FFProbe binaries paths, only used if you try to generate video + * thumbnails and have installed the php-ffmpeg/php-ffmpeg composer + * dependency. + */ + 'ffmpeg_path' => env('FFMPEG_PATH', '/usr/bin/ffmpeg'), + 'ffprobe_path' => env('FFPROBE_PATH', '/usr/bin/ffprobe'), + + /* + * The timeout (in seconds) that will be used when generating video + * thumbnails via FFMPEG. + */ + 'ffmpeg_timeout' => env('FFMPEG_TIMEOUT', 900), + + /* + * The number of threads that FFMPEG should use. 0 means that FFMPEG + * may decide itself. + */ + 'ffmpeg_threads' => env('FFMPEG_THREADS', 0), + + /* + * Here you can override the class names of the jobs used by this package. Make sure + * your custom jobs extend the ones provided by the package. + */ + 'jobs' => [ + 'perform_conversions' => PerformConversionsJob::class, + 'generate_responsive_images' => GenerateResponsiveImagesJob::class, + ], + + /* + * When using the addMediaFromUrl method you may want to replace the default downloader. + * This is particularly useful when the url of the image is behind a firewall and + * need to add additional flags, possibly using curl. + */ + 'media_downloader' => DefaultDownloader::class, + + /* + * When using the addMediaFromUrl method the SSL is verified by default. + * This is option disables SSL verification when downloading remote media. + * Please note that this is a security risk and should only be false in a local environment. + */ + 'media_downloader_ssl' => env('MEDIA_DOWNLOADER_SSL', true), + + /* + * The default lifetime in minutes for temporary urls. + * This is used when you call the `getLastTemporaryUrl` or `getLastTemporaryUrl` method on a media item. + */ + 'temporary_url_default_lifetime' => env('MEDIA_TEMPORARY_URL_DEFAULT_LIFETIME', 5), + + 'remote' => [ + /* + * Any extra headers that should be included when uploading media to + * a remote disk. Even though supported headers may vary between + * different drivers, a sensible default has been provided. + * + * Supported by S3: CacheControl, Expires, StorageClass, + * ServerSideEncryption, Metadata, ACL, ContentEncoding + */ + 'extra_headers' => [ + 'CacheControl' => 'max-age=604800', + ], + ], + + 'responsive_images' => [ + /* + * This class is responsible for calculating the target widths of the responsive + * images. By default we optimize for filesize and create variations that each are 30% + * smaller than the previous one. More info in the documentation. + * + * https://docs.spatie.be/laravel-medialibrary/v9/advanced-usage/generating-responsive-images + */ + 'width_calculator' => FileSizeOptimizedWidthCalculator::class, + + /* + * By default rendering media to a responsive image will add some javascript and a tiny placeholder. + * This ensures that the browser can already determine the correct layout. + * When disabled, no tiny placeholder is generated. + */ + 'use_tiny_placeholders' => true, + + /* + * This class will generate the tiny placeholder used for progressive image loading. By default + * the media library will use a tiny blurred jpg image. + */ + 'tiny_placeholder_generator' => Blurred::class, + ], + + /* + * When enabling this option, a route will be registered that will enable + * the Media Library Pro Vue and React components to move uploaded files + * in a S3 bucket to their right place. + */ + 'enable_vapor_uploads' => env('ENABLE_MEDIA_LIBRARY_VAPOR_UPLOADS', false), + + /* + * When converting Media instances to response the media library will add + * a `loading` attribute to the `img` tag. Here you can set the default + * value of that attribute. + * + * Possible values: 'lazy', 'eager', 'auto' or null if you don't want to set any loading instruction. + * + * More info: https://css-tricks.com/native-lazy-loading/ + */ + 'default_loading_attribute_value' => null, + + /* + * You can specify a prefix for that is used for storing all media. + * If you set this to `/my-subdir`, all your media will be stored in a `/my-subdir` directory. + */ + 'prefix' => env('MEDIA_PREFIX', ''), + + /* + * When forcing lazy loading, media will be loaded even if you don't eager load media and you have + * disabled lazy loading globally in the service provider. + */ + 'force_lazy_loading' => env('FORCE_MEDIA_LIBRARY_LAZY_LOADING', true), +]; diff --git a/database/migrations/2026_06_11_120438_create_media_table.php b/database/migrations/2026_06_11_120438_create_media_table.php new file mode 100644 index 0000000..47a4be9 --- /dev/null +++ b/database/migrations/2026_06_11_120438_create_media_table.php @@ -0,0 +1,32 @@ +id(); + + $table->morphs('model'); + $table->uuid()->nullable()->unique(); + $table->string('collection_name'); + $table->string('name'); + $table->string('file_name'); + $table->string('mime_type')->nullable(); + $table->string('disk'); + $table->string('conversions_disk')->nullable(); + $table->unsignedBigInteger('size'); + $table->json('manipulations'); + $table->json('custom_properties'); + $table->json('generated_conversions'); + $table->json('responsive_images'); + $table->unsignedInteger('order_column')->nullable()->index(); + + $table->nullableTimestamps(); + }); + } +}; diff --git a/database/migrations/2026_06_11_120500_create_system_configurations_table.php b/database/migrations/2026_06_11_120500_create_system_configurations_table.php new file mode 100644 index 0000000..adf29d5 --- /dev/null +++ b/database/migrations/2026_06_11_120500_create_system_configurations_table.php @@ -0,0 +1,22 @@ +id(); + $table->timestamp('created_at')->useCurrent(); + $table->timestamp('updated_at')->useCurrent()->useCurrentOnUpdate(); + }); + } + + public function down(): void + { + Schema::dropIfExists('system_configurations'); + } +}; diff --git a/database/migrations/2026_06_11_120501_remove_photo_paths_from_attendances_table.php b/database/migrations/2026_06_11_120501_remove_photo_paths_from_attendances_table.php new file mode 100644 index 0000000..78868e4 --- /dev/null +++ b/database/migrations/2026_06_11_120501_remove_photo_paths_from_attendances_table.php @@ -0,0 +1,23 @@ +dropColumn(['check_in_photo_path', 'check_out_photo_path']); + }); + } + + public function down(): void + { + Schema::table('attendances', function (Blueprint $table) { + $table->string('check_in_photo_path', 255)->after('check_out_at'); + $table->string('check_out_photo_path', 255)->nullable()->after('check_in_photo_path'); + }); + } +}; diff --git a/database/settings/2026_06_11_120502_remove_system_media_from_settings.php b/database/settings/2026_06_11_120502_remove_system_media_from_settings.php new file mode 100644 index 0000000..d061e4f --- /dev/null +++ b/database/settings/2026_06_11_120502_remove_system_media_from_settings.php @@ -0,0 +1,15 @@ +migrator->inGroup('system', function (SettingsBlueprint $blueprint): void { + $blueprint->delete('logo'); + $blueprint->delete('login_cover'); + }); + } +}; diff --git a/resources/js/components/admin/finance/cash/CashTransactionFormModal.vue b/resources/js/components/admin/finance/cash/CashTransactionFormModal.vue index 1c705bf..14d09f1 100644 --- a/resources/js/components/admin/finance/cash/CashTransactionFormModal.vue +++ b/resources/js/components/admin/finance/cash/CashTransactionFormModal.vue @@ -3,6 +3,7 @@ import { useForm } from '@inertiajs/vue3'; import { Save } from '@lucide/vue'; import { computed, watch } from 'vue'; import { toast } from 'vue-sonner'; +import MediaImageUpload from '@/components/media/MediaImageUpload.vue'; import { Button } from '@/components/ui/button'; import { Dialog, @@ -21,7 +22,8 @@ import { import { RupiahInput } from '@/components/ui/rupiah-input'; import { Textarea } from '@/components/ui/textarea'; import { parseRupiah } from '@/lib/rupiah'; -import type { CashTransactionFormData, CashTransactionListItem } from '@/types/cash'; +import type { CashTransactionListItem } from '@/types/cash'; +import { appendRootPhotosToFormData, createMediaUploadState } from '@/types/media'; const open = defineModel('open', { default: false }); @@ -31,13 +33,15 @@ const props = defineProps<{ const isEditing = computed(() => props.transaction != null); -const form = useForm({ +const form = useForm({ amount: '', description: '', + media: createMediaUploadState(), }); function resetForm() { form.reset(); + form.media = createMediaUploadState(); form.clearErrors(); } @@ -50,6 +54,7 @@ function populateForm(transaction: CashTransactionListItem | null | undefined) { form.amount = String(transaction.amount); form.description = transaction.description; + form.media = createMediaUploadState(transaction.photos ?? []); } watch( @@ -67,8 +72,27 @@ watch(open, (isOpen) => { } }); +function buildFormData(forUpdate: boolean): FormData { + const formData = new FormData(); + + if (forUpdate) { + formData.append('_method', 'PUT'); + } + + formData.append('amount', parseRupiah(form.amount)); + formData.append('description', form.description); + appendRootPhotosToFormData(formData, form.media); + + return formData; +} + +function formError(key: string): string | undefined { + return (form.errors as Record)[key]; +} + function submit() { const options = { + forceFormData: true, preserveScroll: true, onSuccess: () => { open.value = false; @@ -80,15 +104,12 @@ function submit() { }, }; - form.transform((data) => ({ - ...data, - amount: parseRupiah(data.amount), - })); + const payload = buildFormData(isEditing.value); if (isEditing.value && props.transaction) { - form.put(`/admin/finance/cash/transactions/${props.transaction.id}`, options); + form.transform(() => payload).post(`/admin/finance/cash/transactions/${props.transaction.id}`, options); } else { - form.post('/admin/finance/cash/deposit', options); + form.transform(() => payload).post('/admin/finance/cash/deposit', options); } } @@ -114,6 +135,8 @@ function submit() { placeholder="Contoh: Setoran kas harian" rows="3" /> + diff --git a/resources/js/components/admin/finance/cash/columns.ts b/resources/js/components/admin/finance/cash/columns.ts index 492bc91..88556ec 100644 --- a/resources/js/components/admin/finance/cash/columns.ts +++ b/resources/js/components/admin/finance/cash/columns.ts @@ -1,6 +1,7 @@ import type { ColumnDef } from '@tanstack/vue-table'; import { h } from 'vue'; import DataTableActions from '@/components/admin/finance/cash/data-table-actions.vue'; +import MediaThumbnailCell from '@/components/media/MediaThumbnailCell.vue'; import { DataTableColumnHeader } from '@/components/data-table'; import { Badge } from '@/components/ui/badge'; import type { CashTransactionListItem } from '@/types/cash'; @@ -51,6 +52,12 @@ export function createColumns(onEdit: (transaction: CashTransactionListItem) => enableSorting: false, header: () => 'Keterangan', }, + { + id: 'photos', + enableSorting: false, + header: () => 'Foto', + cell: ({ row }) => h(MediaThumbnailCell, { items: row.original.photos ?? [] }), + }, { accessorKey: 'created_by_name', enableSorting: false, diff --git a/resources/js/components/admin/finance/employee-advances/EmployeeAdvanceFormModal.vue b/resources/js/components/admin/finance/employee-advances/EmployeeAdvanceFormModal.vue index ed9d426..a27cb2c 100644 --- a/resources/js/components/admin/finance/employee-advances/EmployeeAdvanceFormModal.vue +++ b/resources/js/components/admin/finance/employee-advances/EmployeeAdvanceFormModal.vue @@ -3,6 +3,7 @@ import { useForm } from '@inertiajs/vue3'; import { Save } from '@lucide/vue'; import { computed, watch } from 'vue'; import { toast } from 'vue-sonner'; +import MediaImageUpload from '@/components/media/MediaImageUpload.vue'; import { Button } from '@/components/ui/button'; import { DatePicker } from '@/components/ui/date-picker'; import { @@ -22,7 +23,8 @@ import { import { RupiahInput } from '@/components/ui/rupiah-input'; import { Textarea } from '@/components/ui/textarea'; import { parseRupiah } from '@/lib/rupiah'; -import type { EmployeeAdvanceFormData, EmployeeAdvanceListItem } from '@/types/employee-advance'; +import type { EmployeeAdvanceListItem } from '@/types/employee-advance'; +import { appendRootPhotosToFormData, createMediaUploadState } from '@/types/media'; const open = defineModel('open', { default: false }); @@ -32,14 +34,16 @@ const props = defineProps<{ const isEditing = computed(() => props.employeeAdvance != null); -const form = useForm({ +const form = useForm({ amount: '', description: '', due_date: '', + media: createMediaUploadState(), }); function resetForm() { form.reset(); + form.media = createMediaUploadState(); form.clearErrors(); } @@ -53,6 +57,7 @@ function populateForm(employeeAdvance: EmployeeAdvanceListItem | null | undefine form.amount = String(employeeAdvance.amount); form.description = employeeAdvance.description; form.due_date = employeeAdvance.due_date_input; + form.media = createMediaUploadState(employeeAdvance.photos ?? []); } watch( @@ -70,8 +75,28 @@ watch(open, (isOpen) => { } }); +function buildFormData(forUpdate: boolean): FormData { + const formData = new FormData(); + + if (forUpdate) { + formData.append('_method', 'PUT'); + } + + formData.append('amount', parseRupiah(form.amount)); + formData.append('description', form.description); + formData.append('due_date', form.due_date); + appendRootPhotosToFormData(formData, form.media); + + return formData; +} + +function formError(key: string): string | undefined { + return (form.errors as Record)[key]; +} + function submit() { const options = { + forceFormData: true, preserveScroll: true, onSuccess: () => { open.value = false; @@ -81,15 +106,12 @@ function submit() { }, }; - form.transform((data) => ({ - ...data, - amount: parseRupiah(data.amount), - })); + const payload = buildFormData(isEditing.value); if (isEditing.value && props.employeeAdvance) { - form.put(`/admin/finance/employee-advances/${props.employeeAdvance.id}`, options); + form.transform(() => payload).post(`/admin/finance/employee-advances/${props.employeeAdvance.id}`, options); } else { - form.post('/admin/finance/employee-advances', options); + form.transform(() => payload).post('/admin/finance/employee-advances', options); } } @@ -120,6 +142,8 @@ function submit() { + diff --git a/resources/js/components/admin/finance/employee-advances/columns.ts b/resources/js/components/admin/finance/employee-advances/columns.ts index 5e8a02f..c6cf17e 100644 --- a/resources/js/components/admin/finance/employee-advances/columns.ts +++ b/resources/js/components/admin/finance/employee-advances/columns.ts @@ -1,6 +1,7 @@ import type { ColumnDef } from '@tanstack/vue-table'; import { h } from 'vue'; import DataTableActions from '@/components/admin/finance/employee-advances/data-table-actions.vue'; +import MediaThumbnailCell from '@/components/media/MediaThumbnailCell.vue'; import { DataTableColumnHeader } from '@/components/data-table'; import { Badge } from '@/components/ui/badge'; import type { EmployeeAdvanceListItem } from '@/types/employee-advance'; @@ -44,6 +45,12 @@ export function createColumns( enableSorting: true, header: () => h(DataTableColumnHeader, { title: 'Keterangan', column: 'description' }), }, + { + id: 'photos', + enableSorting: false, + header: () => 'Foto', + cell: ({ row }) => h(MediaThumbnailCell, { items: row.original.photos ?? [] }), + }, { accessorKey: 'due_date_formatted', enableSorting: true, diff --git a/resources/js/components/admin/finance/expenses/ExpenseFormModal.vue b/resources/js/components/admin/finance/expenses/ExpenseFormModal.vue index 6789973..da5461d 100644 --- a/resources/js/components/admin/finance/expenses/ExpenseFormModal.vue +++ b/resources/js/components/admin/finance/expenses/ExpenseFormModal.vue @@ -3,6 +3,7 @@ import { useForm } from '@inertiajs/vue3'; import { Save } from '@lucide/vue'; import { computed, watch } from 'vue'; import { toast } from 'vue-sonner'; +import MediaImageUpload from '@/components/media/MediaImageUpload.vue'; import { Button } from '@/components/ui/button'; import { Dialog, @@ -21,7 +22,8 @@ import { import { RupiahInput } from '@/components/ui/rupiah-input'; import { Textarea } from '@/components/ui/textarea'; import { parseRupiah } from '@/lib/rupiah'; -import type { ExpenseFormData, ExpenseListItem } from '@/types/expense'; +import type { ExpenseListItem } from '@/types/expense'; +import { appendRootPhotosToFormData, createMediaUploadState } from '@/types/media'; const open = defineModel('open', { default: false }); @@ -31,13 +33,15 @@ const props = defineProps<{ const isEditing = computed(() => props.expense != null); -const form = useForm({ +const form = useForm({ amount: '', description: '', + media: createMediaUploadState(), }); function resetForm() { form.reset(); + form.media = createMediaUploadState(); form.clearErrors(); } @@ -50,6 +54,7 @@ function populateForm(expense: ExpenseListItem | null | undefined) { form.amount = String(expense.amount); form.description = expense.description; + form.media = createMediaUploadState(expense.photos ?? []); } watch( @@ -67,8 +72,27 @@ watch(open, (isOpen) => { } }); +function buildFormData(forUpdate: boolean): FormData { + const formData = new FormData(); + + if (forUpdate) { + formData.append('_method', 'PUT'); + } + + formData.append('amount', parseRupiah(form.amount)); + formData.append('description', form.description); + appendRootPhotosToFormData(formData, form.media); + + return formData; +} + +function formError(key: string): string | undefined { + return (form.errors as Record)[key]; +} + function submit() { const options = { + forceFormData: true, preserveScroll: true, onSuccess: () => { open.value = false; @@ -78,15 +102,12 @@ function submit() { }, }; - form.transform((data) => ({ - ...data, - amount: parseRupiah(data.amount), - })); + const payload = buildFormData(isEditing.value); if (isEditing.value && props.expense) { - form.put(`/admin/finance/expenses/${props.expense.id}`, options); + form.transform(() => payload).post(`/admin/finance/expenses/${props.expense.id}`, options); } else { - form.post('/admin/finance/expenses', options); + form.transform(() => payload).post('/admin/finance/expenses', options); } } @@ -112,6 +133,8 @@ function submit() { placeholder="Contoh: Pembelian perlengkapan toko" rows="3" /> + diff --git a/resources/js/components/admin/finance/expenses/columns.ts b/resources/js/components/admin/finance/expenses/columns.ts index 2e2fa64..ab12cb7 100644 --- a/resources/js/components/admin/finance/expenses/columns.ts +++ b/resources/js/components/admin/finance/expenses/columns.ts @@ -1,6 +1,7 @@ import type { ColumnDef } from '@tanstack/vue-table'; import { h } from 'vue'; import DataTableActions from '@/components/admin/finance/expenses/data-table-actions.vue'; +import MediaThumbnailCell from '@/components/media/MediaThumbnailCell.vue'; import { DataTableColumnHeader } from '@/components/data-table'; import type { ExpenseListItem } from '@/types/expense'; @@ -21,6 +22,12 @@ export function createColumns(onEdit: (expense: ExpenseListItem) => void): Colum enableSorting: true, header: () => h(DataTableColumnHeader, { title: 'Keterangan', column: 'description' }), }, + { + id: 'photos', + enableSorting: false, + header: () => 'Foto', + cell: ({ row }) => h(MediaThumbnailCell, { items: row.original.photos ?? [] }), + }, { accessorKey: 'created_by_name', enableSorting: false, diff --git a/resources/js/components/admin/master/products/ProductForm.vue b/resources/js/components/admin/master/products/ProductForm.vue index 7a925f3..819c6d0 100644 --- a/resources/js/components/admin/master/products/ProductForm.vue +++ b/resources/js/components/admin/master/products/ProductForm.vue @@ -3,6 +3,7 @@ import { useForm } from '@inertiajs/vue3'; import { Copy, Plus, Save, Trash2 } from '@lucide/vue'; import { computed, ref } from 'vue'; import { toast } from 'vue-sonner'; +import MediaImageUpload from '@/components/media/MediaImageUpload.vue'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { @@ -16,11 +17,12 @@ import { Input } from '@/components/ui/input'; import { RupiahInput } from '@/components/ui/rupiah-input'; import { Textarea } from '@/components/ui/textarea'; import { parseRupiah } from '@/lib/rupiah'; +import { appendMediaToFormData, createMediaUploadState } from '@/types/media'; import { PRICE_TYPES, - PRICE_TYPE_LABELS + PRICE_TYPE_LABELS, } from '@/types/product'; -import type { CategoryOption, ProductFormData, ProductVariantFormItem } from '@/types/product'; +import type { CategoryOption, ProductVariantFormItem } from '@/types/product'; const props = withDefaults( defineProps<{ @@ -34,6 +36,7 @@ const props = withDefaults( name?: string; stock?: number | string; prices?: Record; + images?: Array<{ id: number; url: string; thumb_url: string }>; }>; }; submitUrl: string; @@ -60,6 +63,7 @@ function createEmptyVariant(): ProductVariantFormItem { name: '', stock: '0', prices: buildEmptyPrices(), + media: createMediaUploadState(), }; } @@ -77,6 +81,7 @@ function buildInitialVariants(): ProductVariantFormItem[] { ...buildEmptyPrices(), ...(variant.prices ?? {}), }, + media: createMediaUploadState(variant.images ?? []), })); } @@ -183,21 +188,40 @@ function applyPricesToAllVariants(sourceClientId: string) { })); } -function buildSubmitPayload(): ProductFormData { - return { - name: form.name.trim(), - description: form.description.trim(), - category_ids: form.category_ids, - variants: variants.value.map((variant) => ({ - ...(variant.id ? { id: variant.id } : {}), - name: variant.name.trim(), - stock: Number.parseInt(variant.stock, 10) || 0, - prices: PRICE_TYPES.map((type) => ({ - type, - price: Number.parseInt(parseRupiah(variant.prices[type]), 10) || 0, - })), - })), - }; +function buildFormData(): FormData { + const formData = new FormData(); + + if (props.method === 'put') { + formData.append('_method', 'PUT'); + } + + formData.append('name', form.name.trim()); + formData.append('description', form.description.trim()); + + form.category_ids.forEach((categoryId) => { + formData.append('category_ids[]', String(categoryId)); + }); + + variants.value.forEach((variant, index) => { + if (variant.id) { + formData.append(`variants[${index}][id]`, String(variant.id)); + } + + formData.append(`variants[${index}][name]`, variant.name.trim()); + formData.append(`variants[${index}][stock]`, String(Number.parseInt(variant.stock, 10) || 0)); + + PRICE_TYPES.forEach((type, priceIndex) => { + formData.append(`variants[${index}][prices][${priceIndex}][type]`, type); + formData.append( + `variants[${index}][prices][${priceIndex}][price]`, + String(Number.parseInt(parseRupiah(variant.prices[type]), 10) || 0), + ); + }); + + appendMediaToFormData(formData, `variants[${index}]`, variant.media); + }); + + return formData; } function formError(key: string): string | undefined { @@ -232,18 +256,15 @@ const categoryError = computed(() => form.errors.category_ids); function submit() { const options = { + forceFormData: true, onError: () => { toast.error('Gagal menyimpan data. Periksa kembali formulir.'); }, }; - const payload = buildSubmitPayload(); + const payload = buildFormData(); - if (props.method === 'put') { - form.transform(() => payload).put(props.submitUrl, options); - } else { - form.transform(() => payload).post(props.submitUrl, options); - } + form.transform(() => payload).post(props.submitUrl, options); } @@ -374,6 +395,12 @@ function submit() { :errors="variantPriceError(variant.client_id, type) ? [variantPriceError(variant.client_id, type)!] : []" /> + +
+ +
diff --git a/resources/js/components/admin/master/products/ProductGroupedTable.vue b/resources/js/components/admin/master/products/ProductGroupedTable.vue index df18a5e..df66d05 100644 --- a/resources/js/components/admin/master/products/ProductGroupedTable.vue +++ b/resources/js/components/admin/master/products/ProductGroupedTable.vue @@ -3,6 +3,7 @@ import { Link } from '@inertiajs/vue3'; import { computed } from 'vue'; import DataTableActions from '@/components/admin/master/products/data-table-actions.vue'; import ProductStatusToggle from '@/components/admin/master/products/product-status-toggle.vue'; +import MediaThumbnailCell from '@/components/media/MediaThumbnailCell.vue'; import DataTableToolbar from '@/components/data-table/DataTableToolbar.vue'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; @@ -110,13 +111,14 @@ function rowNumber(index: number): number { Varian + Foto Stok Harga - + Belum ada varian @@ -124,6 +126,9 @@ function rowNumber(index: number): number { {{ variant.name }} + + + {{ formatStock(variant.stock) }} diff --git a/resources/js/components/admin/master/raw-materials/RawMaterialForm.vue b/resources/js/components/admin/master/raw-materials/RawMaterialForm.vue index 99297a3..e47601f 100644 --- a/resources/js/components/admin/master/raw-materials/RawMaterialForm.vue +++ b/resources/js/components/admin/master/raw-materials/RawMaterialForm.vue @@ -3,6 +3,7 @@ import { useForm } from '@inertiajs/vue3'; import { Copy, Plus, Save, Trash2 } from '@lucide/vue'; import { ref } from 'vue'; import { toast } from 'vue-sonner'; +import MediaImageUpload from '@/components/media/MediaImageUpload.vue'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { @@ -22,9 +23,9 @@ import { SelectValue, } from '@/components/ui/select'; import { parseRupiah } from '@/lib/rupiah'; +import { appendMediaToFormData, createMediaUploadState } from '@/types/media'; import type { EnumOption, - RawMaterialFormData, RawMaterialPriceFormItem, } from '@/types/raw-material'; @@ -39,6 +40,7 @@ const props = withDefaults( variant?: string; price?: string; stock?: string; + images?: Array<{ id: number; url: string; thumb_url: string }>; }>; }; submitUrl: string; @@ -61,6 +63,7 @@ function createEmptyPrice(): RawMaterialPriceFormItem { variant: '', price: '', stock: '0', + media: createMediaUploadState(), }; } @@ -75,6 +78,7 @@ function buildInitialPrices(): RawMaterialPriceFormItem[] { variant: price.variant ?? '', price: price.price ?? '', stock: price.stock ?? '0', + media: createMediaUploadState(price.images ?? []), })); } @@ -157,17 +161,29 @@ function parseStockValue(value: string): number { return Number.isNaN(parsed) ? 0 : parsed; } -function buildSubmitPayload(): RawMaterialFormData { - return { - name: form.name.trim(), - unit: form.unit, - prices: prices.value.map((price) => ({ - ...(price.id ? { id: price.id } : {}), - variant: price.variant.trim(), - price: Number.parseInt(parseRupiah(price.price), 10) || 0, - stock: parseStockValue(price.stock), - })), - }; +function buildFormData(): FormData { + const formData = new FormData(); + + if (props.method === 'put') { + formData.append('_method', 'PUT'); + } + + formData.append('name', form.name.trim()); + formData.append('unit', form.unit); + + prices.value.forEach((price, index) => { + if (price.id) { + formData.append(`prices[${index}][id]`, String(price.id)); + } + + formData.append(`prices[${index}][variant]`, price.variant.trim()); + formData.append(`prices[${index}][price]`, String(Number.parseInt(parseRupiah(price.price), 10) || 0)); + formData.append(`prices[${index}][stock]`, String(parseStockValue(price.stock))); + + appendMediaToFormData(formData, `prices[${index}]`, price.media); + }); + + return formData; } function formError(key: string): string | undefined { @@ -186,18 +202,15 @@ function priceError(clientId: string, field: string): string | undefined { function submit() { const options = { + forceFormData: true, onError: () => { toast.error('Gagal menyimpan data. Periksa kembali formulir.'); }, }; - const payload = buildSubmitPayload(); + const payload = buildFormData(); - if (props.method === 'put') { - form.transform(() => payload).put(props.submitUrl, options); - } else { - form.transform(() => payload).post(props.submitUrl, options); - } + form.transform(() => payload).post(props.submitUrl, options); } @@ -213,12 +226,8 @@ function submit() {
Nama Bahan Baku - + @@ -229,11 +238,7 @@ function submit() { - + {{ option.label }} @@ -252,27 +257,19 @@ function submit() { Harga - + + :errors="priceError(prices[0].client_id, 'price') ? [priceError(prices[0].client_id, 'price')!] : []" /> @@ -282,24 +279,14 @@ function submit() { Varian {{ index + 1 }}
- -
@@ -311,47 +298,38 @@ function submit() { Nama Varian - + @update:model-value="setPriceField(price.client_id, 'variant', String($event))" /> + :errors="priceError(price.client_id, 'variant') ? [priceError(price.client_id, 'variant')!] : []" /> Stok - + @update:model-value="setPriceField(price.client_id, 'stock', String($event))" /> + :errors="priceError(price.client_id, 'stock') ? [priceError(price.client_id, 'stock')!] : []" /> Harga - + + :errors="priceError(price.client_id, 'price') ? [priceError(price.client_id, 'price')!] : []" />
+ +
+ +
diff --git a/resources/js/components/admin/master/raw-materials/RawMaterialGroupedTable.vue b/resources/js/components/admin/master/raw-materials/RawMaterialGroupedTable.vue index bbd5809..9779e37 100644 --- a/resources/js/components/admin/master/raw-materials/RawMaterialGroupedTable.vue +++ b/resources/js/components/admin/master/raw-materials/RawMaterialGroupedTable.vue @@ -3,6 +3,7 @@ import { Link } from '@inertiajs/vue3'; import { computed } from 'vue'; import DataTableActions from '@/components/admin/master/raw-materials/data-table-actions.vue'; import RawMaterialStatusToggle from '@/components/admin/master/raw-materials/raw-material-status-toggle.vue'; +import MediaThumbnailCell from '@/components/media/MediaThumbnailCell.vue'; import DataTableToolbar from '@/components/data-table/DataTableToolbar.vue'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; @@ -99,13 +100,14 @@ function rowNumber(index: number): number { Varian + Foto Stok Harga - + Belum ada varian @@ -113,6 +115,9 @@ function rowNumber(index: number): number { {{ price.variant }} + + + {{ price.stock_formatted }} diff --git a/resources/js/components/admin/setting/ImageUploadField.vue b/resources/js/components/admin/setting/ImageUploadField.vue index c661f85..f41c3d5 100644 --- a/resources/js/components/admin/setting/ImageUploadField.vue +++ b/resources/js/components/admin/setting/ImageUploadField.vue @@ -13,6 +13,7 @@ const props = defineProps<{ currentUrl?: string | null; errors?: string[]; accept?: string; + required?: boolean; }>(); const model = defineModel(); @@ -46,7 +47,7 @@ function clearFile() {