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() {