Add media management features across various models and services. Implement media handling in Product, RawMaterial, Attendance, CashTransaction, EmployeeAdvance, and Expense models. Introduce ValidatesMediaUploads trait for consistent media validation in requests. Enhance services to support media synchronization and retrieval, improving user experience with image uploads and management.
This commit is contained in:
parent
c152b27aa8
commit
f8322566da
@ -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(),
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
32
app/Http/Requests/Concerns/ValidatesMediaUploads.php
Normal file
32
app/Http/Requests/Concerns/ValidatesMediaUploads.php
Normal file
@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Concerns;
|
||||
|
||||
trait ValidatesMediaUploads
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
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<string, mixed>
|
||||
*/
|
||||
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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 [
|
||||
|
||||
22
app/Models/Concerns/HasModuleMedia.php
Normal file
22
app/Models/Concerns/HasModuleMedia.php
Normal file
@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Concerns;
|
||||
|
||||
use Spatie\MediaLibrary\InteractsWithMedia;
|
||||
use Spatie\MediaLibrary\MediaCollections\Models\Media;
|
||||
|
||||
trait HasModuleMedia
|
||||
{
|
||||
use InteractsWithMedia;
|
||||
|
||||
abstract public static function mediaModuleName(): string;
|
||||
|
||||
public function registerMediaConversions(?Media $media = null): void
|
||||
{
|
||||
$this->addMediaConversion('thumb')
|
||||
->width(80)
|
||||
->height(80)
|
||||
->sharpen(10)
|
||||
->nonQueued();
|
||||
}
|
||||
}
|
||||
@ -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 [
|
||||
|
||||
@ -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 [
|
||||
|
||||
@ -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 [
|
||||
|
||||
@ -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 [
|
||||
|
||||
30
app/Models/SystemConfiguration.php
Normal file
30
app/Models/SystemConfiguration.php
Normal file
@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\HasModuleMedia;
|
||||
use Illuminate\Database\Eloquent\Attributes\Guarded;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Spatie\MediaLibrary\HasMedia;
|
||||
|
||||
#[Guarded(['id'])]
|
||||
class SystemConfiguration extends Model implements HasMedia
|
||||
{
|
||||
use HasModuleMedia;
|
||||
|
||||
public static function mediaModuleName(): string
|
||||
{
|
||||
return 'setting';
|
||||
}
|
||||
|
||||
public static function instance(): self
|
||||
{
|
||||
return static::query()->firstOrCreate(['id' => 1]);
|
||||
}
|
||||
|
||||
public function registerMediaCollections(): void
|
||||
{
|
||||
$this->addMediaCollection('logo')->singleFile();
|
||||
$this->addMediaCollection('login_cover')->singleFile();
|
||||
}
|
||||
}
|
||||
@ -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<string, mixed> $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) {
|
||||
|
||||
@ -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<string, mixed> $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) {
|
||||
|
||||
@ -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<string, mixed> $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)) {
|
||||
|
||||
@ -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 !== '') {
|
||||
|
||||
@ -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<string, mixed> $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<string, mixed> $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<array{type: string, price: float|int|string}> $prices
|
||||
*/
|
||||
|
||||
@ -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<string, mixed> $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<string, mixed> $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",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
133
app/Services/Media/MediaService.php
Normal file
133
app/Services/Media/MediaService.php
Normal file
@ -0,0 +1,133 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Media;
|
||||
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Spatie\MediaLibrary\HasMedia;
|
||||
use Spatie\MediaLibrary\MediaCollections\Models\Media;
|
||||
|
||||
class MediaService
|
||||
{
|
||||
/**
|
||||
* @param list<UploadedFile>|null $newFiles
|
||||
* @param list<int>|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;
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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';
|
||||
|
||||
44
app/Support/Media/MediaPresenter.php
Normal file
44
app/Support/Media/MediaPresenter.php
Normal file
@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\Media;
|
||||
|
||||
use Spatie\MediaLibrary\HasMedia;
|
||||
use Spatie\MediaLibrary\MediaCollections\Models\Media;
|
||||
|
||||
class MediaPresenter
|
||||
{
|
||||
/**
|
||||
* @return list<array{id: int, url: string, thumb_url: string}>
|
||||
*/
|
||||
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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
33
app/Support/Media/ModulePathGenerator.php
Normal file
33
app/Support/Media/ModulePathGenerator.php
Normal file
@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\Media;
|
||||
|
||||
use Spatie\MediaLibrary\MediaCollections\Models\Media;
|
||||
use Spatie\MediaLibrary\Support\PathGenerator\PathGenerator;
|
||||
|
||||
class ModulePathGenerator implements PathGenerator
|
||||
{
|
||||
public function getPath(Media $media): string
|
||||
{
|
||||
$module = $media->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/';
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
|
||||
395
composer.lock
generated
395
composer.lock
generated
@ -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",
|
||||
|
||||
363
config/media-library.php
Normal file
363
config/media-library.php
Normal file
@ -0,0 +1,363 @@
|
||||
<?php
|
||||
|
||||
use App\Support\Media\ModulePathGenerator;
|
||||
use Spatie\ImageOptimizer\Optimizers\Avifenc;
|
||||
use Spatie\ImageOptimizer\Optimizers\Cwebp;
|
||||
use Spatie\ImageOptimizer\Optimizers\Gifsicle;
|
||||
use Spatie\ImageOptimizer\Optimizers\Jpegoptim;
|
||||
use Spatie\ImageOptimizer\Optimizers\Optipng;
|
||||
use Spatie\ImageOptimizer\Optimizers\Pngquant;
|
||||
use Spatie\ImageOptimizer\Optimizers\Svgo;
|
||||
use Spatie\MediaLibrary\Conversions\ImageGenerators\Avif;
|
||||
use Spatie\MediaLibrary\Conversions\ImageGenerators\Image;
|
||||
use Spatie\MediaLibrary\Conversions\ImageGenerators\Pdf;
|
||||
use Spatie\MediaLibrary\Conversions\ImageGenerators\Svg;
|
||||
use Spatie\MediaLibrary\Conversions\ImageGenerators\Video;
|
||||
use Spatie\MediaLibrary\Conversions\ImageGenerators\Webp;
|
||||
use Spatie\MediaLibrary\Conversions\Jobs\PerformConversionsJob;
|
||||
use Spatie\MediaLibrary\Downloaders\DefaultDownloader;
|
||||
use Spatie\MediaLibrary\MediaCollections\FileAdder;
|
||||
use Spatie\MediaLibrary\MediaCollections\Models\Media;
|
||||
use Spatie\MediaLibrary\MediaCollections\Models\Observers\MediaObserver;
|
||||
use Spatie\MediaLibrary\ResponsiveImages\Jobs\GenerateResponsiveImagesJob;
|
||||
use Spatie\MediaLibrary\ResponsiveImages\TinyPlaceholderGenerator\Blurred;
|
||||
use Spatie\MediaLibrary\ResponsiveImages\WidthCalculator\FileSizeOptimizedWidthCalculator;
|
||||
use Spatie\MediaLibrary\Support\FileNamer\DefaultFileNamer;
|
||||
use Spatie\MediaLibrary\Support\FileRemover\DefaultFileRemover;
|
||||
use Spatie\MediaLibrary\Support\UrlGenerator\DefaultUrlGenerator;
|
||||
use Spatie\MediaLibraryPro\Models\TemporaryUpload;
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
* The disk on which to store added files and derived images by default. Choose
|
||||
* one or more of the disks you've configured in config/filesystems.php.
|
||||
*/
|
||||
'disk_name' => 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),
|
||||
];
|
||||
32
database/migrations/2026_06_11_120438_create_media_table.php
Normal file
32
database/migrations/2026_06_11_120438_create_media_table.php
Normal file
@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('media', function (Blueprint $table) {
|
||||
$table->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();
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('system_configurations', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->timestamp('created_at')->useCurrent();
|
||||
$table->timestamp('updated_at')->useCurrent()->useCurrentOnUpdate();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('system_configurations');
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('attendances', function (Blueprint $table) {
|
||||
$table->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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
use Spatie\LaravelSettings\Migrations\SettingsBlueprint;
|
||||
use Spatie\LaravelSettings\Migrations\SettingsMigration;
|
||||
|
||||
return new class extends SettingsMigration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
$this->migrator->inGroup('system', function (SettingsBlueprint $blueprint): void {
|
||||
$blueprint->delete('logo');
|
||||
$blueprint->delete('login_cover');
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -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<boolean>('open', { default: false });
|
||||
|
||||
@ -31,13 +33,15 @@ const props = defineProps<{
|
||||
|
||||
const isEditing = computed(() => props.transaction != null);
|
||||
|
||||
const form = useForm<CashTransactionFormData>({
|
||||
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<string, string>)[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);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@ -114,6 +135,8 @@ function submit() {
|
||||
placeholder="Contoh: Setoran kas harian" rows="3" />
|
||||
<FieldError :errors="form.errors.description ? [form.errors.description] : []" />
|
||||
</Field>
|
||||
<MediaImageUpload id="cash-photos" v-model="form.media" label="Foto Bukti" required
|
||||
:errors="formError('photos') ? [formError('photos')!] : []" />
|
||||
</FieldSet>
|
||||
</FieldGroup>
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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<boolean>('open', { default: false });
|
||||
|
||||
@ -32,14 +34,16 @@ const props = defineProps<{
|
||||
|
||||
const isEditing = computed(() => props.employeeAdvance != null);
|
||||
|
||||
const form = useForm<EmployeeAdvanceFormData>({
|
||||
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<string, string>)[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);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@ -120,6 +142,8 @@ function submit() {
|
||||
<DatePicker id="employee-advance-due-date" v-model="form.due_date" />
|
||||
<FieldError :errors="form.errors.due_date ? [form.errors.due_date] : []" />
|
||||
</Field>
|
||||
<MediaImageUpload id="employee-advance-photos" v-model="form.media" label="Foto Bukti" required
|
||||
:errors="formError('photos') ? [formError('photos')!] : []" />
|
||||
</FieldSet>
|
||||
</FieldGroup>
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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<boolean>('open', { default: false });
|
||||
|
||||
@ -31,13 +33,15 @@ const props = defineProps<{
|
||||
|
||||
const isEditing = computed(() => props.expense != null);
|
||||
|
||||
const form = useForm<ExpenseFormData>({
|
||||
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<string, string>)[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);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@ -112,6 +133,8 @@ function submit() {
|
||||
placeholder="Contoh: Pembelian perlengkapan toko" rows="3" />
|
||||
<FieldError :errors="form.errors.description ? [form.errors.description] : []" />
|
||||
</Field>
|
||||
<MediaImageUpload id="expense-photos" v-model="form.media" label="Foto Bukti" required
|
||||
:errors="formError('photos') ? [formError('photos')!] : []" />
|
||||
</FieldSet>
|
||||
</FieldGroup>
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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<string, string>;
|
||||
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);
|
||||
}
|
||||
</script>
|
||||
|
||||
@ -374,6 +395,12 @@ function submit() {
|
||||
:errors="variantPriceError(variant.client_id, type) ? [variantPriceError(variant.client_id, type)!] : []" />
|
||||
</Field>
|
||||
</FieldSet>
|
||||
|
||||
<div class="mt-4">
|
||||
<MediaImageUpload :id="`variant_images_${variant.client_id}`" v-model="variant.media"
|
||||
label="Foto Varian" :max-files="5" required
|
||||
:errors="variantError(variant.client_id, 'images') ? [variantError(variant.client_id, 'images')!] : []" />
|
||||
</div>
|
||||
</FieldGroup>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@ -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 {
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Varian</TableHead>
|
||||
<TableHead>Foto</TableHead>
|
||||
<TableHead>Stok</TableHead>
|
||||
<TableHead>Harga</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow v-if="!product.variants.length" :key="`${product.id}-empty`">
|
||||
<TableCell colspan="3" class="text-muted-foreground">
|
||||
<TableCell colspan="4" class="text-muted-foreground">
|
||||
Belum ada varian
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@ -124,6 +126,9 @@ function rowNumber(index: number): number {
|
||||
<TableCell class="font-medium">
|
||||
{{ variant.name }}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<MediaThumbnailCell :items="variant.images ?? []" />
|
||||
</TableCell>
|
||||
<TableCell class="tabular-nums">
|
||||
{{ formatStock(variant.stock) }}
|
||||
</TableCell>
|
||||
|
||||
@ -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);
|
||||
}
|
||||
</script>
|
||||
|
||||
@ -213,12 +226,8 @@ function submit() {
|
||||
<FieldSet class="grid gap-4 md:grid-cols-2">
|
||||
<Field>
|
||||
<FieldLabel for="name" required>Nama Bahan Baku</FieldLabel>
|
||||
<Input
|
||||
id="name"
|
||||
v-model="form.name"
|
||||
type="text"
|
||||
placeholder="Masukkan nama bahan baku"
|
||||
/>
|
||||
<Input id="name" v-model="form.name" type="text"
|
||||
placeholder="Masukkan nama bahan baku" />
|
||||
<FieldError :errors="form.errors.name ? [form.errors.name] : []" />
|
||||
</Field>
|
||||
|
||||
@ -229,11 +238,7 @@ function submit() {
|
||||
<SelectValue placeholder="Pilih satuan" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="option in units"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
>
|
||||
<SelectItem v-for="option in units" :key="option.value" :value="option.value">
|
||||
{{ option.label }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
@ -252,27 +257,19 @@ function submit() {
|
||||
<CardContent>
|
||||
<FieldGroup>
|
||||
<label class="mb-4 flex cursor-pointer items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="size-4 rounded border-input"
|
||||
:checked="useSamePrice"
|
||||
@change="toggleUseSamePrice(($event.target as HTMLInputElement).checked)"
|
||||
>
|
||||
<span class="text-sm">Gunakan harga yang sama untuk semua varian</span>
|
||||
<input type="checkbox" class="size-4 rounded border-input" :checked="useSamePrice"
|
||||
@change="toggleUseSamePrice(($event.target as HTMLInputElement).checked)">
|
||||
<span class="text-sm">Gunakan harga yang sama untuk semua varian</span>
|
||||
</label>
|
||||
|
||||
<Field v-if="useSamePrice && prices[0]">
|
||||
<FieldLabel :for="`shared_price_${prices[0].client_id}`" required>
|
||||
Harga
|
||||
</FieldLabel>
|
||||
<RupiahInput
|
||||
:id="`shared_price_${prices[0].client_id}`"
|
||||
:model-value="prices[0].price"
|
||||
@update:model-value="setSharedPrice"
|
||||
/>
|
||||
<RupiahInput :id="`shared_price_${prices[0].client_id}`" :model-value="prices[0].price"
|
||||
@update:model-value="setSharedPrice" />
|
||||
<FieldError
|
||||
:errors="priceError(prices[0].client_id, 'price') ? [priceError(prices[0].client_id, 'price')!] : []"
|
||||
/>
|
||||
:errors="priceError(prices[0].client_id, 'price') ? [priceError(prices[0].client_id, 'price')!] : []" />
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</CardContent>
|
||||
@ -282,24 +279,14 @@ function submit() {
|
||||
<CardHeader class="flex flex-row items-start justify-between gap-4">
|
||||
<CardTitle>Varian {{ index + 1 }}</CardTitle>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
v-if="prices.length > 1 && !useSamePrice"
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@click="applyPriceToAllVariants(price.client_id)"
|
||||
>
|
||||
<Button v-if="prices.length > 1 && !useSamePrice" type="button" variant="outline" size="sm"
|
||||
@click="applyPriceToAllVariants(price.client_id)">
|
||||
<Copy class="size-4" />
|
||||
Terapkan Harga ke Semua
|
||||
</Button>
|
||||
<Button
|
||||
v-if="prices.length > 1"
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
<Button v-if="prices.length > 1" type="button" variant="outline" size="icon"
|
||||
class="text-destructive hover:text-destructive size-8"
|
||||
@click="removePrice(price.client_id)"
|
||||
>
|
||||
@click="removePrice(price.client_id)">
|
||||
<Trash2 class="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
@ -311,47 +298,38 @@ function submit() {
|
||||
<FieldLabel :for="`variant_${price.client_id}`" required>
|
||||
Nama Varian
|
||||
</FieldLabel>
|
||||
<Input
|
||||
:id="`variant_${price.client_id}`"
|
||||
:model-value="price.variant"
|
||||
type="text"
|
||||
<Input :id="`variant_${price.client_id}`" :model-value="price.variant" type="text"
|
||||
placeholder="Contoh: Premium / 40s"
|
||||
@update:model-value="setPriceField(price.client_id, 'variant', String($event))"
|
||||
/>
|
||||
@update:model-value="setPriceField(price.client_id, 'variant', String($event))" />
|
||||
<FieldError
|
||||
:errors="priceError(price.client_id, 'variant') ? [priceError(price.client_id, 'variant')!] : []"
|
||||
/>
|
||||
:errors="priceError(price.client_id, 'variant') ? [priceError(price.client_id, 'variant')!] : []" />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel :for="`stock_${price.client_id}`" required>
|
||||
Stok
|
||||
</FieldLabel>
|
||||
<Input
|
||||
:id="`stock_${price.client_id}`"
|
||||
:model-value="price.stock"
|
||||
type="number"
|
||||
min="0"
|
||||
<Input :id="`stock_${price.client_id}`" :model-value="price.stock" type="number" min="0"
|
||||
step="0.0001"
|
||||
@update:model-value="setPriceField(price.client_id, 'stock', String($event))"
|
||||
/>
|
||||
@update:model-value="setPriceField(price.client_id, 'stock', String($event))" />
|
||||
<FieldError
|
||||
:errors="priceError(price.client_id, 'stock') ? [priceError(price.client_id, 'stock')!] : []"
|
||||
/>
|
||||
:errors="priceError(price.client_id, 'stock') ? [priceError(price.client_id, 'stock')!] : []" />
|
||||
</Field>
|
||||
<Field v-if="!useSamePrice || prices.length === 1">
|
||||
<FieldLabel :for="`price_${price.client_id}`" required>
|
||||
Harga
|
||||
</FieldLabel>
|
||||
<RupiahInput
|
||||
:id="`price_${price.client_id}`"
|
||||
:model-value="price.price"
|
||||
@update:model-value="setPriceValue(price.client_id, $event)"
|
||||
/>
|
||||
<RupiahInput :id="`price_${price.client_id}`" :model-value="price.price"
|
||||
@update:model-value="setPriceValue(price.client_id, $event)" />
|
||||
<FieldError
|
||||
:errors="priceError(price.client_id, 'price') ? [priceError(price.client_id, 'price')!] : []"
|
||||
/>
|
||||
:errors="priceError(price.client_id, 'price') ? [priceError(price.client_id, 'price')!] : []" />
|
||||
</Field>
|
||||
</FieldSet>
|
||||
|
||||
<div class="mt-4">
|
||||
<MediaImageUpload :id="`price_images_${price.client_id}`" v-model="price.media"
|
||||
label="Foto Varian" :max-files="5" required
|
||||
:errors="priceError(price.client_id, 'images') ? [priceError(price.client_id, 'images')!] : []" />
|
||||
</div>
|
||||
</FieldGroup>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@ -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 {
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Varian</TableHead>
|
||||
<TableHead>Foto</TableHead>
|
||||
<TableHead>Stok</TableHead>
|
||||
<TableHead>Harga</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow v-if="!material.prices.length" :key="`${material.id}-empty`">
|
||||
<TableCell colspan="3" class="text-muted-foreground">
|
||||
<TableCell colspan="4" class="text-muted-foreground">
|
||||
Belum ada varian
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@ -113,6 +115,9 @@ function rowNumber(index: number): number {
|
||||
<TableCell class="font-medium">
|
||||
{{ price.variant }}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<MediaThumbnailCell :items="price.images ?? []" />
|
||||
</TableCell>
|
||||
<TableCell class="tabular-nums">
|
||||
{{ price.stock_formatted }}
|
||||
</TableCell>
|
||||
|
||||
@ -13,6 +13,7 @@ const props = defineProps<{
|
||||
currentUrl?: string | null;
|
||||
errors?: string[];
|
||||
accept?: string;
|
||||
required?: boolean;
|
||||
}>();
|
||||
|
||||
const model = defineModel<File | null>();
|
||||
@ -46,7 +47,7 @@ function clearFile() {
|
||||
|
||||
<template>
|
||||
<Field>
|
||||
<FieldLabel :for="id">
|
||||
<FieldLabel :for="id" :required="required">
|
||||
{{ label }}
|
||||
</FieldLabel>
|
||||
<FieldDescription v-if="description">
|
||||
@ -54,34 +55,20 @@ function clearFile() {
|
||||
</FieldDescription>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div
|
||||
v-if="displayUrl"
|
||||
:class="cn(
|
||||
'relative overflow-hidden rounded-lg border bg-muted/30',
|
||||
id === 'favicon' ? 'size-20' : 'aspect-video max-w-md',
|
||||
)"
|
||||
>
|
||||
<div v-if="displayUrl" :class="cn(
|
||||
'relative overflow-hidden rounded-lg border bg-muted/30',
|
||||
id === 'favicon' ? 'size-20' : 'aspect-video max-w-md',
|
||||
)">
|
||||
<img :src="displayUrl" :alt="label" class="size-full object-cover">
|
||||
<Button
|
||||
v-if="model"
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
class="absolute top-2 right-2 size-7"
|
||||
@click="clearFile"
|
||||
>
|
||||
<Button v-if="model" type="button" variant="secondary" size="icon" class="absolute top-2 right-2 size-7"
|
||||
@click="clearFile">
|
||||
<X class="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<Input
|
||||
:id="id"
|
||||
type="file"
|
||||
:accept="accept ?? 'image/*'"
|
||||
class="max-w-md cursor-pointer"
|
||||
@change="onFileChange"
|
||||
/>
|
||||
<Input :id="id" type="file" :accept="accept ?? 'image/*'" class="max-w-md cursor-pointer"
|
||||
@change="onFileChange" />
|
||||
<ImagePlus v-if="!displayUrl" class="text-muted-foreground size-5 shrink-0" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -94,11 +94,11 @@ function submit() {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<FieldGroup class="grid gap-6 sm:grid-cols-2">
|
||||
<ImageUploadField id="logo" v-model="form.logo" label="Logo"
|
||||
<ImageUploadField id="logo" v-model="form.logo" label="Logo" required
|
||||
description="Disarankan PNG transparan, maks. 2 MB." :current-url="data.logo_url"
|
||||
:errors="form.errors.logo ? [form.errors.logo] : []" />
|
||||
|
||||
<ImageUploadField id="login_cover" v-model="form.login_cover" label="Cover Login"
|
||||
<ImageUploadField id="login_cover" v-model="form.login_cover" label="Cover Login" required
|
||||
description="Gambar latar halaman login, maks. 5 MB." :current-url="data.login_cover_url"
|
||||
:errors="form.errors.login_cover ? [form.errors.login_cover] : []" />
|
||||
</FieldGroup>
|
||||
|
||||
156
resources/js/components/media/MediaImageUpload.vue
Normal file
156
resources/js/components/media/MediaImageUpload.vue
Normal file
@ -0,0 +1,156 @@
|
||||
<script setup lang="ts">
|
||||
import { ImagePlus, X } from '@lucide/vue';
|
||||
import { computed, onBeforeUnmount, ref } from 'vue';
|
||||
import MediaPreviewDialog from '@/components/media/MediaPreviewDialog.vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Field, FieldDescription, FieldError, FieldLabel } from '@/components/ui/field';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
createMediaUploadState,
|
||||
mediaCount
|
||||
} from '@/types/media';
|
||||
import type { MediaItem, MediaUploadState } from '@/types/media';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
label?: string;
|
||||
description?: string;
|
||||
maxFiles?: number;
|
||||
required?: boolean;
|
||||
existing?: MediaItem[];
|
||||
errors?: string[];
|
||||
id?: string;
|
||||
}>(),
|
||||
{
|
||||
label: 'Foto',
|
||||
maxFiles: 1,
|
||||
required: false,
|
||||
existing: () => [],
|
||||
errors: () => [],
|
||||
id: 'media-upload',
|
||||
},
|
||||
);
|
||||
|
||||
const state = defineModel<MediaUploadState>({
|
||||
default: () => createMediaUploadState(),
|
||||
});
|
||||
|
||||
const previewUrls = ref<string[]>([]);
|
||||
const previewOpen = ref(false);
|
||||
const previewUrl = ref<string | null>(null);
|
||||
|
||||
const remainingSlots = computed(() => props.maxFiles - mediaCount(state.value));
|
||||
const canAddMore = computed(() => remainingSlots.value > 0);
|
||||
|
||||
const visibleExisting = computed(() =>
|
||||
state.value.existing.filter((item) => !state.value.removeIds.includes(item.id)),
|
||||
);
|
||||
|
||||
function onFileChange(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const files = Array.from(input.files ?? []);
|
||||
|
||||
if (files.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const allowed = files.slice(0, remainingSlots.value);
|
||||
|
||||
if (props.maxFiles === 1) {
|
||||
previewUrls.value.forEach((url) => URL.revokeObjectURL(url));
|
||||
previewUrls.value = [];
|
||||
state.value.newFiles = [];
|
||||
|
||||
state.value.existing.forEach((item) => {
|
||||
if (!state.value.removeIds.includes(item.id)) {
|
||||
state.value.removeIds = [...state.value.removeIds, item.id];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
allowed.forEach((file) => {
|
||||
state.value.newFiles.push(file);
|
||||
previewUrls.value.push(URL.createObjectURL(file));
|
||||
});
|
||||
|
||||
input.value = '';
|
||||
}
|
||||
|
||||
function removeExisting(id: number) {
|
||||
if (!state.value.removeIds.includes(id)) {
|
||||
state.value.removeIds = [...state.value.removeIds, id];
|
||||
}
|
||||
}
|
||||
|
||||
function removeNew(index: number) {
|
||||
const url = previewUrls.value[index];
|
||||
|
||||
if (url) {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
previewUrls.value.splice(index, 1);
|
||||
state.value.newFiles.splice(index, 1);
|
||||
}
|
||||
|
||||
function openPreview(url: string) {
|
||||
previewUrl.value = url;
|
||||
previewOpen.value = true;
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
previewUrls.value.forEach((url) => URL.revokeObjectURL(url));
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Field>
|
||||
<FieldLabel :for="id" :required="required">
|
||||
{{ label }}
|
||||
</FieldLabel>
|
||||
<FieldDescription v-if="description">
|
||||
{{ description }}
|
||||
</FieldDescription>
|
||||
<FieldDescription v-else-if="maxFiles > 1">
|
||||
Maks. {{ maxFiles }} gambar. Klik thumbnail untuk memperbesar.
|
||||
</FieldDescription>
|
||||
<FieldDescription v-else>
|
||||
Klik thumbnail untuk memperbesar.
|
||||
</FieldDescription>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<button v-for="item in visibleExisting" :key="`existing-${item.id}`" type="button"
|
||||
class="group relative size-14 overflow-hidden rounded-md border bg-muted/30"
|
||||
@click="openPreview(item.url)">
|
||||
<img :src="item.thumb_url" :alt="label" class="size-full object-cover">
|
||||
<Button type="button" variant="secondary" size="icon"
|
||||
class="absolute top-0.5 right-0.5 size-5 opacity-0 transition-opacity group-hover:opacity-100"
|
||||
@click.stop="removeExisting(item.id)">
|
||||
<X class="size-3" />
|
||||
</Button>
|
||||
</button>
|
||||
|
||||
<button v-for="(url, index) in previewUrls" :key="`new-${index}`" type="button"
|
||||
class="group relative size-14 overflow-hidden rounded-md border bg-muted/30" @click="openPreview(url)">
|
||||
<img :src="url" :alt="label" class="size-full object-cover">
|
||||
<Button type="button" variant="secondary" size="icon"
|
||||
class="absolute top-0.5 right-0.5 size-5 opacity-0 transition-opacity group-hover:opacity-100"
|
||||
@click.stop="removeNew(index)">
|
||||
<X class="size-3" />
|
||||
</Button>
|
||||
</button>
|
||||
|
||||
<label v-if="canAddMore" :for="id"
|
||||
class="flex size-14 cursor-pointer flex-col items-center justify-center gap-1 rounded-md border border-dashed text-muted-foreground hover:bg-muted/30">
|
||||
<ImagePlus class="size-4" />
|
||||
<span class="text-[10px]">Tambah</span>
|
||||
<Input :id="id" type="file" accept="image/*" :multiple="maxFiles > 1" class="sr-only"
|
||||
@change="onFileChange" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<FieldError :errors="errors" />
|
||||
|
||||
<MediaPreviewDialog v-model:open="previewOpen" :url="previewUrl" :title="label" />
|
||||
</Field>
|
||||
</template>
|
||||
31
resources/js/components/media/MediaPreviewDialog.vue
Normal file
31
resources/js/components/media/MediaPreviewDialog.vue
Normal file
@ -0,0 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
|
||||
const open = defineModel<boolean>('open', { default: false });
|
||||
|
||||
defineProps<{
|
||||
url: string | null;
|
||||
title?: string;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog v-model:open="open">
|
||||
<DialogContent class="sm:max-wd p-2 sm:p-4">
|
||||
<DialogHeader class="sr-only">
|
||||
<DialogTitle>{{ title ?? 'Pratinjau Gambar' }}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<img
|
||||
v-if="url"
|
||||
:src="url"
|
||||
:alt="title ?? 'Pratinjau gambar'"
|
||||
class="max-h-[80vh] w-full rounded-md object-contain"
|
||||
>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
41
resources/js/components/media/MediaThumbnailCell.vue
Normal file
41
resources/js/components/media/MediaThumbnailCell.vue
Normal file
@ -0,0 +1,41 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import MediaPreviewDialog from '@/components/media/MediaPreviewDialog.vue';
|
||||
import type { MediaItem } from '@/types/media';
|
||||
|
||||
const props = defineProps<{
|
||||
items: MediaItem[];
|
||||
maxVisible?: number;
|
||||
}>();
|
||||
|
||||
const previewOpen = ref(false);
|
||||
const previewUrl = ref<string | null>(null);
|
||||
|
||||
const visibleItems = computed(() => props.items.slice(0, props.maxVisible ?? 3));
|
||||
const hiddenCount = computed(() => Math.max(props.items.length - visibleItems.value.length, 0));
|
||||
|
||||
function openPreview(url: string) {
|
||||
previewUrl.value = url;
|
||||
previewOpen.value = true;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="items.length" class="flex items-center gap-1">
|
||||
<button
|
||||
v-for="item in visibleItems"
|
||||
:key="item.id"
|
||||
type="button"
|
||||
class="size-8 overflow-hidden rounded border bg-muted/30"
|
||||
@click="openPreview(item.url)"
|
||||
>
|
||||
<img :src="item.thumb_url" alt="Foto" class="size-full object-cover">
|
||||
</button>
|
||||
<span v-if="hiddenCount > 0" class="text-muted-foreground text-xs">
|
||||
+{{ hiddenCount }}
|
||||
</span>
|
||||
</div>
|
||||
<span v-else class="text-muted-foreground">-</span>
|
||||
|
||||
<MediaPreviewDialog v-model:open="previewOpen" :url="previewUrl" />
|
||||
</template>
|
||||
@ -6,11 +6,12 @@ import ProductForm from '@/components/admin/master/products/ProductForm.vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import AdminLayout from '@/layouts/AdminLayout.vue';
|
||||
import {
|
||||
PRICE_TYPES,
|
||||
type CategoryOption,
|
||||
type EnumOption,
|
||||
type ProductListItem,
|
||||
PRICE_TYPES
|
||||
|
||||
|
||||
|
||||
} from '@/types/product';
|
||||
import type { CategoryOption, EnumOption, ProductListItem } from '@/types/product';
|
||||
|
||||
const props = defineProps<{
|
||||
product: ProductListItem & { description?: string | null };
|
||||
@ -26,6 +27,7 @@ const initialData = computed(() => ({
|
||||
id: variant.id,
|
||||
name: variant.name,
|
||||
stock: variant.stock,
|
||||
images: variant.images ?? [],
|
||||
prices: Object.fromEntries(
|
||||
PRICE_TYPES.map((type) => [
|
||||
type,
|
||||
|
||||
@ -20,6 +20,7 @@ const initialData = computed(() => ({
|
||||
variant: price.variant,
|
||||
price: price.price_input,
|
||||
stock: price.stock_input,
|
||||
images: price.images ?? [],
|
||||
})),
|
||||
}));
|
||||
</script>
|
||||
|
||||
@ -8,8 +8,6 @@ export type AttendanceListItem = {
|
||||
check_in_at_formatted: string;
|
||||
check_out_at: string | null;
|
||||
check_out_at_formatted: string | null;
|
||||
check_in_photo_path: string;
|
||||
check_out_photo_path: string | null;
|
||||
check_in_photo_url: string | null;
|
||||
check_out_photo_url: string | null;
|
||||
check_in_location_tag: string | null;
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
import type { MediaItem, MediaUploadState } from '@/types/media';
|
||||
|
||||
export type CashAccount = {
|
||||
id: number;
|
||||
name: string;
|
||||
@ -17,11 +19,13 @@ export type CashTransactionListItem = {
|
||||
description: string;
|
||||
created_at_formatted: string;
|
||||
created_by_name: string;
|
||||
photos?: MediaItem[];
|
||||
};
|
||||
|
||||
export type CashTransactionFormData = {
|
||||
amount: string;
|
||||
description: string;
|
||||
media: MediaUploadState;
|
||||
};
|
||||
|
||||
export type PaginatedCashTransactions = {
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
import type { MediaItem, MediaUploadState } from '@/types/media';
|
||||
|
||||
export type EmployeeAdvanceListItem = {
|
||||
id: number;
|
||||
employee_id: number;
|
||||
@ -14,12 +16,14 @@ export type EmployeeAdvanceListItem = {
|
||||
is_editable: boolean;
|
||||
can_verify: boolean;
|
||||
can_pay: boolean;
|
||||
photos?: MediaItem[];
|
||||
};
|
||||
|
||||
export type EmployeeAdvanceFormData = {
|
||||
amount: string;
|
||||
description: string;
|
||||
due_date: string;
|
||||
media: MediaUploadState;
|
||||
};
|
||||
|
||||
export type PaginatedEmployeeAdvances = {
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
import type { MediaItem, MediaUploadState } from '@/types/media';
|
||||
|
||||
export type ExpenseListItem = {
|
||||
id: number;
|
||||
amount: number;
|
||||
@ -5,11 +7,13 @@ export type ExpenseListItem = {
|
||||
description: string;
|
||||
created_at_formatted: string;
|
||||
created_by_name: string;
|
||||
photos?: MediaItem[];
|
||||
};
|
||||
|
||||
export type ExpenseFormData = {
|
||||
amount: string;
|
||||
description: string;
|
||||
media: MediaUploadState;
|
||||
};
|
||||
|
||||
export type PaginatedExpenses = {
|
||||
|
||||
47
resources/js/types/media.ts
Normal file
47
resources/js/types/media.ts
Normal file
@ -0,0 +1,47 @@
|
||||
export type MediaItem = {
|
||||
id: number;
|
||||
url: string;
|
||||
thumb_url: string;
|
||||
};
|
||||
|
||||
export type MediaUploadState = {
|
||||
existing: MediaItem[];
|
||||
newFiles: File[];
|
||||
removeIds: number[];
|
||||
};
|
||||
|
||||
export function createMediaUploadState(existing: MediaItem[] = []): MediaUploadState {
|
||||
return {
|
||||
existing: [...existing],
|
||||
newFiles: [],
|
||||
removeIds: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function mediaCount(state: MediaUploadState): number {
|
||||
return state.existing.length + state.newFiles.length;
|
||||
}
|
||||
|
||||
export function appendMediaToFormData(
|
||||
formData: FormData,
|
||||
prefix: string,
|
||||
state: MediaUploadState,
|
||||
): void {
|
||||
state.newFiles.forEach((file) => {
|
||||
formData.append(`${prefix}[images][]`, file);
|
||||
});
|
||||
|
||||
state.removeIds.forEach((id) => {
|
||||
formData.append(`${prefix}[remove_media_ids][]`, String(id));
|
||||
});
|
||||
}
|
||||
|
||||
export function appendRootPhotosToFormData(formData: FormData, state: MediaUploadState): void {
|
||||
state.newFiles.forEach((file) => {
|
||||
formData.append('photos[]', file);
|
||||
});
|
||||
|
||||
state.removeIds.forEach((id) => {
|
||||
formData.append('remove_media_ids[]', String(id));
|
||||
});
|
||||
}
|
||||
@ -1,3 +1,5 @@
|
||||
import type { MediaItem, MediaUploadState } from '@/types/media';
|
||||
|
||||
export type EnumOption = {
|
||||
value: string;
|
||||
label: string;
|
||||
@ -26,6 +28,7 @@ export type ProductVariantItem = {
|
||||
name: string;
|
||||
stock: number;
|
||||
prices: ProductPriceItem[];
|
||||
images?: MediaItem[];
|
||||
};
|
||||
|
||||
export type ProductListItem = {
|
||||
@ -42,6 +45,7 @@ export type ProductVariantFormItem = {
|
||||
name: string;
|
||||
stock: string;
|
||||
prices: Record<string, string>;
|
||||
media: MediaUploadState;
|
||||
};
|
||||
|
||||
export type ProductFormData = {
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
import type { MediaItem, MediaUploadState } from '@/types/media';
|
||||
|
||||
export type EnumOption = {
|
||||
value: string;
|
||||
label: string;
|
||||
@ -12,6 +14,7 @@ export type RawMaterialPrice = {
|
||||
price_formatted: string;
|
||||
price_input: string;
|
||||
stock_input: string;
|
||||
images?: MediaItem[];
|
||||
};
|
||||
|
||||
export type RawMaterialListItem = {
|
||||
@ -30,6 +33,7 @@ export type RawMaterialPriceFormItem = {
|
||||
variant: string;
|
||||
price: string;
|
||||
stock: string;
|
||||
media: MediaUploadState;
|
||||
};
|
||||
|
||||
export type RawMaterialFormData = {
|
||||
|
||||
@ -7,9 +7,7 @@ export type SystemSettingsData = {
|
||||
about_app: string | null;
|
||||
email: string | null;
|
||||
phone: string | null;
|
||||
logo: string | null;
|
||||
logo_url: string | null;
|
||||
login_cover: string | null;
|
||||
login_cover_url: string | null;
|
||||
};
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user