Compare commits
No commits in common. "fbdad6923c10137143f55a13c6c9b586baf7798b" and "485ae8e5ce0e301dd3122f9e02aa64d565d8c8b0" have entirely different histories.
fbdad6923c
...
485ae8e5ce
@ -9,11 +9,8 @@ ## Auth & User
|
||||
### `users` → User
|
||||
`id` `email`(unique) `username`(unique) `password` `is_active`(bool) `last_login_at`(datetime) `created_at` `updated_at` `deleted_at`
|
||||
- Casts: email_verified_at(datetime), is_active(bool), last_login_at(datetime), password(hashed), two_factor_confirmed_at(datetime)
|
||||
- Implements: HasMedia (Spatie Media Library)
|
||||
- Media Collections: photos (single photo, with thumb conversion)
|
||||
- Scopes: active()
|
||||
- Relations: userProfile(HasOne→UserProfile), employee(HasOne→Employee), attendances(HasMany→Attendance), cashAccounts(HasMany→CashAccount,created_by_id), cashTransactions(HasMany→CashTransaction,created_by_id), createdCuttings(HasMany→Cutting,created_by_id), submittedCuttings(HasMany→Cutting,submitted_by_id), createdExpenses(HasMany→Expense,created_by_id), createdOrders(HasMany→Order,created_by_id), marketingOrders(HasMany→Order,marketing_id), orderItems(HasMany→OrderItem), createdPurchases(HasMany→Purchase,created_by_id), createdRestocks(HasMany→Restock,created_by_id), stokOpnamesCreated(HasMany→StokOpname,created_by_id), stokOpnamesVerified(HasMany→StokOpname,verified_by_id), employeeAdvancesPaid(HasMany→EmployeeAdvance,paid_by_id), employeeAdvancesVerified(HasMany→EmployeeAdvance,verified_by_id), paidPayrolls(HasMany→Payroll,paid_by_id), payrollPeriodsClosed(HasMany→PayrollPeriod,closed_by_id), notifications(HasMany→AppNotification), rejections(HasMany→Rejection,rejected_by_id), pushSubscriptions(HasMany→PushSubscription,morph)
|
||||
- Accessor: avatar → temporary S3 URL dari media 'photos' (atau null)
|
||||
|
||||
### `user_profiles` → UserProfile
|
||||
`id` `user_id`(FK→users,unique) `full_name`(200) `phone_number`(20,null) `gender`(enum,null) `birth_date`(date,null) `address`(text,null) `created_at` `updated_at` `deleted_at`
|
||||
|
||||
@ -54,7 +54,7 @@ public function store(EmployeeRequest $request): RedirectResponse
|
||||
|
||||
public function edit(User $user): Response
|
||||
{
|
||||
$user->load(['userProfile', 'employee', 'roles', 'media']);
|
||||
$user->load(['userProfile', 'employee', 'roles']);
|
||||
|
||||
return Inertia::render('admin/hr/employee/edit', [
|
||||
'employee' => $user,
|
||||
|
||||
@ -42,7 +42,7 @@ public function create(): Response
|
||||
public function store(ProductRequest $request): RedirectResponse
|
||||
{
|
||||
return $this->handleAction(
|
||||
fn () => $this->service->store($request->validated()),
|
||||
fn () => $this->service->create($request->validated()),
|
||||
'Produk berhasil ditambahkan.',
|
||||
'admin.master.products.index',
|
||||
'admin.master.products.create'
|
||||
@ -71,7 +71,7 @@ public function update(ProductRequest $request, Product $product): RedirectRespo
|
||||
public function destroy(Product $product): RedirectResponse
|
||||
{
|
||||
return $this->handleAction(
|
||||
fn () => $this->service->destroy($product),
|
||||
fn () => $this->service->delete($product),
|
||||
'Produk berhasil dihapus.',
|
||||
'admin.master.products.index'
|
||||
);
|
||||
|
||||
@ -39,7 +39,7 @@ public function update(ProductVariantRequest $request, Product $product, Product
|
||||
public function destroy(Product $product, ProductVariant $variant): RedirectResponse
|
||||
{
|
||||
return $this->handleAction(
|
||||
fn () => $this->variantService->destroy($product, $variant),
|
||||
fn () => $this->variantService->delete($product, $variant),
|
||||
'Varian berhasil dihapus.',
|
||||
'admin.master.products.index'
|
||||
);
|
||||
|
||||
@ -22,10 +22,9 @@ public function index(PaginatedRequest $request): Response
|
||||
return Inertia::render('admin/master/raw-material/index', [
|
||||
'rawMaterials' => $this->service->paginated(
|
||||
...$request->validatedWithDefaults(),
|
||||
filters: $request->only(['is_active', 'stock', 'name']),
|
||||
filters: $request->only(['is_active', 'stock']),
|
||||
),
|
||||
'rawMaterialNames' => $this->service->getNames(),
|
||||
'filters' => $request->only(['is_active', 'stock', 'name']),
|
||||
'filters' => $request->only(['is_active', 'stock']),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@ -38,7 +38,7 @@ public function update(RawMaterialVariantRequest $request, RawMaterial $rawMater
|
||||
public function destroy(RawMaterial $rawMaterial, RawMaterialPrice $variant): RedirectResponse
|
||||
{
|
||||
return $this->handleAction(
|
||||
fn () => $this->variantService->destroy($rawMaterial, $variant),
|
||||
fn () => $this->variantService->delete($rawMaterial, $variant),
|
||||
'Varian berhasil dihapus.',
|
||||
'admin.master.raw-materials.index'
|
||||
);
|
||||
|
||||
@ -43,7 +43,6 @@ public function index(Request $request): Response
|
||||
$topProducts = $this->service->getTopProducts($startDate, $endDate);
|
||||
$marketingSales = $this->service->getMarketingSales($startDate, $endDate);
|
||||
$orderStats = $this->service->getOrderStats($startDate, $endDate);
|
||||
$revenueTrend = $this->service->getRevenueTrend($startDate, $endDate);
|
||||
|
||||
return Inertia::render('admin/analysis/index', [
|
||||
'filters' => [
|
||||
@ -69,7 +68,6 @@ public function index(Request $request): Response
|
||||
'topProducts' => $topProducts,
|
||||
'marketingSales' => $marketingSales,
|
||||
'orderStats' => $orderStats,
|
||||
'revenueTrend' => $revenueTrend,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,8 +4,6 @@
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Settings\ProfileUpdateRequest;
|
||||
use App\Services\Concerns\RegistersMedia;
|
||||
use App\Services\S3PresignedService;
|
||||
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
@ -14,26 +12,16 @@
|
||||
|
||||
class ProfileController extends Controller
|
||||
{
|
||||
use RegistersMedia;
|
||||
|
||||
public function edit(Request $request): Response
|
||||
{
|
||||
$user = $request->user();
|
||||
$user->load('userProfile');
|
||||
|
||||
$media = $user->getFirstMedia('photos');
|
||||
$photoKey = $media?->getCustomProperty('s3_key') ?? $media?->getPath();
|
||||
$photoUrl = $media
|
||||
? app(S3PresignedService::class)->getTemporaryUrl($media->getPath())
|
||||
: null;
|
||||
|
||||
return Inertia::render('settings/profile', [
|
||||
'user' => [
|
||||
'id' => $user->id,
|
||||
'email' => $user->email,
|
||||
'username' => $user->username,
|
||||
'photo_key' => $photoKey,
|
||||
'photo_url' => $photoUrl,
|
||||
'userProfile' => $user->userProfile ? [
|
||||
'full_name' => $user->userProfile->full_name,
|
||||
'phone_number' => $user->userProfile->phone_number,
|
||||
@ -75,8 +63,6 @@ function () use ($request) {
|
||||
'address' => $validated['address'] ?? null,
|
||||
],
|
||||
);
|
||||
|
||||
$this->syncPhoto($user, ['photo_key' => $validated['photo'] ?? null], 'photos');
|
||||
},
|
||||
'Profil berhasil diperbarui.',
|
||||
'profile.edit',
|
||||
|
||||
@ -42,7 +42,7 @@ public function share(Request $request): array
|
||||
'address' => app(SystemSettings::class)->address ?? '',
|
||||
'auth' => [
|
||||
'user' => $request->user()
|
||||
? tap($request->user()->load('userProfile', 'roles', 'media'), function ($user) {
|
||||
? tap($request->user()->load('userProfile', 'roles'), function ($user) {
|
||||
$user->setRelation('permissions', $user->getAllPermissions());
|
||||
})
|
||||
: null,
|
||||
|
||||
@ -40,7 +40,7 @@ public function rules(): array
|
||||
'category_ids' => ['required', 'array', 'min:1'],
|
||||
'category_ids.*' => [Rule::exists('categories', 'id')],
|
||||
'use_same_price' => ['nullable', 'boolean'],
|
||||
'shared_prices' => [Rule::requiredIf(fn () => $useSamePrice), 'nullable', 'array'],
|
||||
'shared_prices' => [Rule::requiredIf(fn () => $useSamePrice), 'nullable', 'array', ...($useSamePrice ? ['size:9'] : [])],
|
||||
'shared_prices.*.type' => [Rule::requiredIf(fn () => $useSamePrice), 'nullable', Rule::in(PriceType::values())],
|
||||
'shared_prices.*.price' => [Rule::requiredIf(fn () => $useSamePrice), 'nullable', 'integer', 'min:0'],
|
||||
'variants' => ['required', 'array', 'min:1'],
|
||||
@ -51,7 +51,7 @@ public function rules(): array
|
||||
'variants.*.retail_stock' => ['required', 'integer', 'min:0'],
|
||||
'variants.*.photo_keys' => ['required', 'array', 'min:1', 'max:5'],
|
||||
'variants.*.photo_keys.*' => ['required', 'string', 'max:500'],
|
||||
'variants.*.prices' => [Rule::requiredIf(fn () => ! $useSamePrice), 'nullable', 'array'],
|
||||
'variants.*.prices' => [Rule::requiredIf(fn () => ! $useSamePrice), 'nullable', 'array', ...(! $useSamePrice ? ['size:9'] : [])],
|
||||
'variants.*.prices.*.id' => ['nullable', 'integer'],
|
||||
'variants.*.prices.*.type' => [Rule::requiredIf(fn () => ! $useSamePrice), 'nullable', Rule::in(PriceType::values())],
|
||||
'variants.*.prices.*.price' => [Rule::requiredIf(fn () => ! $useSamePrice), 'nullable', 'integer', 'min:0'],
|
||||
|
||||
@ -31,7 +31,7 @@ public function rules(): array
|
||||
'retail_stock' => ['required', 'integer', 'min:0'],
|
||||
'photo_keys' => ['required', 'array', 'min:1', 'max:5'],
|
||||
'photo_keys.*' => ['required', 'string', 'max:500'],
|
||||
'prices' => ['required', 'array'],
|
||||
'prices' => ['required', 'array', 'size:9'],
|
||||
'prices.*.type' => ['required', Rule::in(PriceType::values())],
|
||||
'prices.*.price' => ['required', 'integer', 'min:0'],
|
||||
];
|
||||
|
||||
@ -42,21 +42,6 @@ public function rules(): array
|
||||
'gender' => ['nullable', 'in:male,female'],
|
||||
'birth_date' => ['nullable', 'date'],
|
||||
'address' => ['nullable', 'string'],
|
||||
'photo' => ['nullable', 'string', 'max:500'],
|
||||
];
|
||||
}
|
||||
|
||||
public function attributes(): array
|
||||
{
|
||||
return [
|
||||
'email' => 'email',
|
||||
'username' => 'username',
|
||||
'full_name' => 'nama lengkap',
|
||||
'phone_number' => 'nomor telepon',
|
||||
'gender' => 'jenis kelamin',
|
||||
'birth_date' => 'tanggal lahir',
|
||||
'address' => 'alamat',
|
||||
'photo' => 'foto profil',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -9,16 +9,6 @@ class CustomPathGenerator implements PathGenerator
|
||||
{
|
||||
public function getPath(Media $media): string
|
||||
{
|
||||
$s3Key = $media->getCustomProperty('s3_key');
|
||||
|
||||
if ($s3Key) {
|
||||
return dirname($s3Key).'/';
|
||||
}
|
||||
|
||||
if (str_contains($media->file_name, '/')) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$model = $media->model;
|
||||
|
||||
if ($model && method_exists($model, 'getMediaPath')) {
|
||||
@ -30,44 +20,20 @@ public function getPath(Media $media): string
|
||||
|
||||
public function getPathForConversions(Media $media): string
|
||||
{
|
||||
$s3Key = $media->getCustomProperty('s3_key');
|
||||
$dir = $s3Key ? dirname($s3Key).'/' : $this->defaultPath($media);
|
||||
|
||||
return $dir.'conversions/';
|
||||
return $this->getPath($media).'conversions/';
|
||||
}
|
||||
|
||||
public function getPathForResponsiveImages(Media $media): string
|
||||
{
|
||||
$s3Key = $media->getCustomProperty('s3_key');
|
||||
$dir = $s3Key ? dirname($s3Key).'/' : $this->defaultPath($media);
|
||||
|
||||
return $dir.'responsive/';
|
||||
return $this->getPath($media).'responsive/';
|
||||
}
|
||||
|
||||
private function defaultPath(Media $media): string
|
||||
{
|
||||
$moduleMap = [
|
||||
'UserProfile' => 'user-profile',
|
||||
'Attendance' => 'attendance',
|
||||
'SystemConfiguration' => 'setting',
|
||||
'HomepageConfiguration' => 'homepage',
|
||||
'ProductVariant' => 'product',
|
||||
'Cutting' => 'cutting',
|
||||
'RawMaterialPrice' => 'raw-material',
|
||||
'CashTransaction' => 'cash',
|
||||
'Expense' => 'expense',
|
||||
'Order' => 'order',
|
||||
'Restock' => 'restock',
|
||||
'Purchase' => 'purchase',
|
||||
'OwnerVerificationRequest' => 'owner_verification_request',
|
||||
];
|
||||
$module = strtolower(class_basename($media->model_type));
|
||||
$date = $media->created_at->format('Y/m/d');
|
||||
$id = $media->id;
|
||||
|
||||
$modelClass = class_basename($media->model_type);
|
||||
$module = $moduleMap[$modelClass] ?? strtolower($modelClass);
|
||||
$collection = $media->collection_name;
|
||||
$date = $media->created_at->format('Y-m-d');
|
||||
$id = $media->model_id;
|
||||
|
||||
return "{$module}/{$collection}/{$date}/{$id}/";
|
||||
return "{$module}/{$date}/{$id}/";
|
||||
}
|
||||
}
|
||||
|
||||
@ -9,10 +9,8 @@
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Spatie\Image\Enums\Fit;
|
||||
use Spatie\MediaLibrary\HasMedia;
|
||||
use Spatie\MediaLibrary\InteractsWithMedia;
|
||||
use Spatie\MediaLibrary\MediaCollections\Models\Media;
|
||||
|
||||
#[Appends(['formatted_attendance_date', 'formatted_check_in_at', 'formatted_check_out_at'])]
|
||||
#[Guarded(['id'])]
|
||||
@ -64,16 +62,4 @@ public function payrollAdjustments(): HasMany
|
||||
{
|
||||
return $this->hasMany(PayrollAdjustment::class);
|
||||
}
|
||||
|
||||
public function registerMediaCollections(): void
|
||||
{
|
||||
$this->addMediaCollection('checkin');
|
||||
$this->addMediaCollection('checkout');
|
||||
}
|
||||
|
||||
public function registerMediaConversions(?Media $media = null): void
|
||||
{
|
||||
$this->addMediaConversion('thumb')
|
||||
->fit(Fit::Contain, 150, 150);
|
||||
}
|
||||
}
|
||||
|
||||
@ -15,10 +15,8 @@
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Spatie\Image\Enums\Fit;
|
||||
use Spatie\MediaLibrary\HasMedia;
|
||||
use Spatie\MediaLibrary\InteractsWithMedia;
|
||||
use Spatie\MediaLibrary\MediaCollections\Models\Media;
|
||||
|
||||
#[Appends(['formatted_amount', 'formatted_balance_after', 'formatted_created_at', 'type_label'])]
|
||||
#[Guarded(['id'])]
|
||||
@ -121,15 +119,4 @@ public function reference(): MorphTo
|
||||
{
|
||||
return $this->morphTo();
|
||||
}
|
||||
|
||||
public function registerMediaCollections(): void
|
||||
{
|
||||
$this->addMediaCollection('photos');
|
||||
}
|
||||
|
||||
public function registerMediaConversions(?Media $media = null): void
|
||||
{
|
||||
$this->addMediaConversion('thumb')
|
||||
->fit(Fit::Contain, 150, 150);
|
||||
}
|
||||
}
|
||||
|
||||
@ -13,10 +13,8 @@
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Spatie\Image\Enums\Fit;
|
||||
use Spatie\MediaLibrary\HasMedia;
|
||||
use Spatie\MediaLibrary\InteractsWithMedia;
|
||||
use Spatie\MediaLibrary\MediaCollections\Models\Media;
|
||||
|
||||
#[Appends(['formatted_cost_per_unit', 'formatted_other_cost', 'formatted_sewing_cost', 'status_label', 'formatted_total_material_cost'])]
|
||||
#[Guarded(['id'])]
|
||||
@ -112,15 +110,4 @@ public function submittedBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'submitted_by_id');
|
||||
}
|
||||
|
||||
public function registerMediaCollections(): void
|
||||
{
|
||||
$this->addMediaCollection('photos');
|
||||
}
|
||||
|
||||
public function registerMediaConversions(?Media $media = null): void
|
||||
{
|
||||
$this->addMediaConversion('thumb')
|
||||
->fit(Fit::Contain, 150, 150);
|
||||
}
|
||||
}
|
||||
|
||||
@ -9,10 +9,8 @@
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Spatie\Image\Enums\Fit;
|
||||
use Spatie\MediaLibrary\HasMedia;
|
||||
use Spatie\MediaLibrary\InteractsWithMedia;
|
||||
use Spatie\MediaLibrary\MediaCollections\Models\Media;
|
||||
|
||||
#[Appends(['formatted_amount', 'formatted_created_at', 'formatted_date'])]
|
||||
#[Guarded(['id'])]
|
||||
@ -58,15 +56,4 @@ public function createdBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by_id');
|
||||
}
|
||||
|
||||
public function registerMediaCollections(): void
|
||||
{
|
||||
$this->addMediaCollection('photos');
|
||||
}
|
||||
|
||||
public function registerMediaConversions(?Media $media = null): void
|
||||
{
|
||||
$this->addMediaConversion('thumb')
|
||||
->fit(Fit::Contain, 150, 150);
|
||||
}
|
||||
}
|
||||
|
||||
@ -16,10 +16,8 @@
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Spatie\Image\Enums\Fit;
|
||||
use Spatie\MediaLibrary\HasMedia;
|
||||
use Spatie\MediaLibrary\InteractsWithMedia;
|
||||
use Spatie\MediaLibrary\MediaCollections\Models\Media;
|
||||
|
||||
#[Appends(['channel_label', 'formatted_cogs', 'formatted_discount', 'formatted_nego_price', 'payment_type_label', 'status_label', 'formatted_subtotal', 'formatted_total_amount'])]
|
||||
#[Guarded(['id'])]
|
||||
@ -202,15 +200,4 @@ public function orderItems(): HasMany
|
||||
{
|
||||
return $this->hasMany(OrderItem::class);
|
||||
}
|
||||
|
||||
public function registerMediaCollections(): void
|
||||
{
|
||||
$this->addMediaCollection('photos');
|
||||
}
|
||||
|
||||
public function registerMediaConversions(?Media $media = null): void
|
||||
{
|
||||
$this->addMediaConversion('thumb')
|
||||
->fit(Fit::Contain, 150, 150);
|
||||
}
|
||||
}
|
||||
|
||||
@ -12,12 +12,10 @@
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Spatie\Image\Enums\Fit;
|
||||
use Spatie\MediaLibrary\HasMedia;
|
||||
use Spatie\MediaLibrary\InteractsWithMedia;
|
||||
use Spatie\MediaLibrary\MediaCollections\Models\Media;
|
||||
|
||||
#[Appends(['formatted_name', 'formatted_stock', 'formatted_reject_stock', 'formatted_retail_stock'])]
|
||||
#[Appends(['formatted_name', 'formatted_stock'])]
|
||||
#[Guarded(['id'])]
|
||||
#[ScopedBy([ProductVariantScope::class])]
|
||||
class ProductVariant extends Model implements HasMedia
|
||||
@ -47,20 +45,6 @@ protected function formattedStock(): Attribute
|
||||
);
|
||||
}
|
||||
|
||||
protected function formattedRejectStock(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn () => number_format($this->reject_stock, 0, ',', '.'),
|
||||
);
|
||||
}
|
||||
|
||||
protected function formattedRetailStock(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn () => number_format($this->retail_stock, 0, ',', '.'),
|
||||
);
|
||||
}
|
||||
|
||||
public function orderItems(): HasMany
|
||||
{
|
||||
return $this->hasMany(OrderItem::class);
|
||||
@ -96,15 +80,4 @@ public function stockMutations(): HasMany
|
||||
return $this->hasMany(StockMutation::class, 'stockable_id')
|
||||
->where('stockable_type', self::class);
|
||||
}
|
||||
|
||||
public function registerMediaCollections(): void
|
||||
{
|
||||
$this->addMediaCollection('images');
|
||||
}
|
||||
|
||||
public function registerMediaConversions(?Media $media = null): void
|
||||
{
|
||||
$this->addMediaConversion('thumb')
|
||||
->fit(Fit::Contain, 150, 150);
|
||||
}
|
||||
}
|
||||
|
||||
@ -10,10 +10,8 @@
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Spatie\Image\Enums\Fit;
|
||||
use Spatie\MediaLibrary\HasMedia;
|
||||
use Spatie\MediaLibrary\InteractsWithMedia;
|
||||
use Spatie\MediaLibrary\MediaCollections\Models\Media;
|
||||
|
||||
#[Appends(['formatted_discount', 'formatted_shipping_cost', 'formatted_subtotal', 'formatted_total'])]
|
||||
#[Guarded(['id'])]
|
||||
@ -73,15 +71,4 @@ public function supplier(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Supplier::class);
|
||||
}
|
||||
|
||||
public function registerMediaCollections(): void
|
||||
{
|
||||
$this->addMediaCollection('photos');
|
||||
}
|
||||
|
||||
public function registerMediaConversions(?Media $media = null): void
|
||||
{
|
||||
$this->addMediaConversion('thumb')
|
||||
->fit(Fit::Contain, 150, 150);
|
||||
}
|
||||
}
|
||||
|
||||
@ -13,12 +13,10 @@
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Spatie\Image\Enums\Fit;
|
||||
use Spatie\MediaLibrary\HasMedia;
|
||||
use Spatie\MediaLibrary\InteractsWithMedia;
|
||||
use Spatie\MediaLibrary\MediaCollections\Models\Media;
|
||||
|
||||
#[Appends(['formatted_price', 'formatted_stock'])]
|
||||
#[Appends(['formatted_price'])]
|
||||
#[Guarded(['id'])]
|
||||
#[ScopedBy([RawMaterialPriceScope::class])]
|
||||
class RawMaterialPrice extends Model implements HasMedia
|
||||
@ -40,13 +38,6 @@ protected function formattedPrice(): Attribute
|
||||
);
|
||||
}
|
||||
|
||||
protected function formattedStock(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn () => number_format($this->stock, 0, ',', '.'),
|
||||
);
|
||||
}
|
||||
|
||||
public function cuttingMaterials(): HasMany
|
||||
{
|
||||
return $this->hasMany(CuttingMaterial::class);
|
||||
@ -64,21 +55,10 @@ public function rawMaterial(): BelongsTo
|
||||
|
||||
public function getPhotoUrlAttribute(): ?string
|
||||
{
|
||||
$media = $this->getFirstMedia('images');
|
||||
$media = $this->getFirstMedia('photos');
|
||||
|
||||
return $media
|
||||
? app(S3PresignedService::class)->getTemporaryUrl($media->getPath())
|
||||
? app(S3PresignedService::class)->getTemporaryUrl($media->file_name)
|
||||
: null;
|
||||
}
|
||||
|
||||
public function registerMediaCollections(): void
|
||||
{
|
||||
$this->addMediaCollection('images');
|
||||
}
|
||||
|
||||
public function registerMediaConversions(?Media $media = null): void
|
||||
{
|
||||
$this->addMediaConversion('thumb')
|
||||
->fit(Fit::Contain, 150, 150);
|
||||
}
|
||||
}
|
||||
|
||||
@ -13,10 +13,8 @@
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Spatie\Image\Enums\Fit;
|
||||
use Spatie\MediaLibrary\HasMedia;
|
||||
use Spatie\MediaLibrary\InteractsWithMedia;
|
||||
use Spatie\MediaLibrary\MediaCollections\Models\Media;
|
||||
|
||||
#[Appends(['stock_type_label', 'formatted_subtotal', 'formatted_total'])]
|
||||
#[Guarded(['id'])]
|
||||
@ -75,15 +73,4 @@ public function restockItems(): HasMany
|
||||
{
|
||||
return $this->hasMany(RestockItem::class);
|
||||
}
|
||||
|
||||
public function registerMediaCollections(): void
|
||||
{
|
||||
$this->addMediaCollection('photos');
|
||||
}
|
||||
|
||||
public function registerMediaConversions(?Media $media = null): void
|
||||
{
|
||||
$this->addMediaConversion('thumb')
|
||||
->fit(Fit::Contain, 150, 150);
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,7 +2,6 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Services\S3PresignedService;
|
||||
use Illuminate\Database\Eloquent\Attributes\Appends;
|
||||
use Illuminate\Database\Eloquent\Attributes\Guarded;
|
||||
use Illuminate\Database\Eloquent\Attributes\Scope;
|
||||
@ -16,17 +15,13 @@
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Laravel\Fortify\TwoFactorAuthenticatable;
|
||||
use NotificationChannels\WebPush\HasPushSubscriptions;
|
||||
use Spatie\Image\Enums\Fit;
|
||||
use Spatie\MediaLibrary\HasMedia;
|
||||
use Spatie\MediaLibrary\InteractsWithMedia;
|
||||
use Spatie\MediaLibrary\MediaCollections\Models\Media;
|
||||
use Spatie\Permission\Traits\HasRoles;
|
||||
|
||||
#[Appends(['avatar', 'full_name', 'name'])]
|
||||
#[Appends(['full_name', 'name'])]
|
||||
#[Guarded(['id'])]
|
||||
class User extends Authenticatable implements HasMedia
|
||||
class User extends Authenticatable
|
||||
{
|
||||
use HasFactory, HasPushSubscriptions, HasRoles, InteractsWithMedia, Notifiable, SoftDeletes, TwoFactorAuthenticatable;
|
||||
use HasFactory, HasPushSubscriptions, HasRoles, Notifiable, SoftDeletes, TwoFactorAuthenticatable;
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
@ -60,15 +55,6 @@ protected function fullName(): Attribute
|
||||
);
|
||||
}
|
||||
|
||||
protected function avatar(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn () => $this->getFirstMedia('photos')
|
||||
? app(S3PresignedService::class)->getTemporaryUrl($this->getFirstMedia('photos')->getPath())
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
#[Scope]
|
||||
protected function active(Builder $query): void
|
||||
{
|
||||
@ -224,15 +210,4 @@ public function userProfile(): HasOne
|
||||
{
|
||||
return $this->hasOne(UserProfile::class);
|
||||
}
|
||||
|
||||
public function registerMediaCollections(): void
|
||||
{
|
||||
$this->addMediaCollection('photos');
|
||||
}
|
||||
|
||||
public function registerMediaConversions(?Media $media = null): void
|
||||
{
|
||||
$this->addMediaConversion('thumb')
|
||||
->fit(Fit::Contain, 150, 150);
|
||||
}
|
||||
}
|
||||
|
||||
@ -56,7 +56,7 @@ public function deposit(array $data): CashTransaction
|
||||
));
|
||||
|
||||
if (array_key_exists('receipt_key', $data)) {
|
||||
$this->syncReceipt($transaction, $data['receipt_key'] ?? null, 'photos', 'cash_transaction_receipt', $data['file_size'] ?? null, $data['file_mime_type'] ?? null);
|
||||
$this->syncReceipt($transaction, $data['receipt_key'] ?? null, 'receipts', 'cash_transaction_receipt', $data['file_size'] ?? null, $data['file_mime_type'] ?? null);
|
||||
}
|
||||
|
||||
NotificationService::notify(
|
||||
@ -78,7 +78,7 @@ public function withdrawal(array $data): CashTransaction
|
||||
));
|
||||
|
||||
if (array_key_exists('receipt_key', $data)) {
|
||||
$this->syncReceipt($transaction, $data['receipt_key'] ?? null, 'photos', 'cash_transaction_receipt', $data['file_size'] ?? null, $data['file_mime_type'] ?? null);
|
||||
$this->syncReceipt($transaction, $data['receipt_key'] ?? null, 'receipts', 'cash_transaction_receipt', $data['file_size'] ?? null, $data['file_mime_type'] ?? null);
|
||||
}
|
||||
|
||||
NotificationService::notify(
|
||||
@ -123,7 +123,7 @@ public function update(CashTransaction $transaction, array $data): CashTransacti
|
||||
]);
|
||||
|
||||
if (array_key_exists('receipt_key', $data)) {
|
||||
$this->syncReceipt($transaction, $data['receipt_key'] ?? null, 'photos', 'cash_transaction_receipt', $data['file_size'] ?? null, $data['file_mime_type'] ?? null);
|
||||
$this->syncReceipt($transaction, $data['receipt_key'] ?? null, 'receipts', 'cash_transaction_receipt', $data['file_size'] ?? null, $data['file_mime_type'] ?? null);
|
||||
}
|
||||
|
||||
return $transaction;
|
||||
@ -163,12 +163,12 @@ public function destroy(CashTransaction $transaction): bool
|
||||
|
||||
$cashAccount->update(['balance' => $newBalance]);
|
||||
|
||||
$media = $transaction->getFirstMedia('photos');
|
||||
$media = $transaction->getFirstMedia('receipts');
|
||||
if ($media) {
|
||||
Cache::forget("cash_transaction_receipt_{$media->id}");
|
||||
}
|
||||
|
||||
$transaction->clearMediaCollection('photos');
|
||||
$transaction->clearMediaCollection('receipts');
|
||||
|
||||
$deleted = $transaction->delete();
|
||||
|
||||
@ -187,22 +187,24 @@ public function destroy(CashTransaction $transaction): bool
|
||||
|
||||
private function formatTransaction(CashTransaction $transaction): array
|
||||
{
|
||||
$media = $transaction->getFirstMedia('photos');
|
||||
$media = $transaction->getFirstMedia('receipts');
|
||||
|
||||
if (! $media) {
|
||||
return $transaction->toArray() + [
|
||||
'receipt_key' => null,
|
||||
'receipt_url' => null,
|
||||
'receipt_conversion_url' => null,
|
||||
];
|
||||
}
|
||||
|
||||
$s3Key = $media->getCustomProperty('s3_key') ?? $media->getPath();
|
||||
$s3Key = $media->file_name;
|
||||
|
||||
if (str_starts_with($s3Key, '/') || ! str_contains($s3Key, '/')) {
|
||||
$s3Key = $media->getPath();
|
||||
}
|
||||
|
||||
return $transaction->toArray() + [
|
||||
'receipt_key' => $s3Key,
|
||||
'receipt_url' => $this->s3Service->getTemporaryUrl($s3Key),
|
||||
'receipt_conversion_url' => $this->s3Service->getTemporaryUrl($media->getPath('thumb')),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -53,7 +53,7 @@ public function store(array $data): Expense
|
||||
]);
|
||||
|
||||
if (array_key_exists('receipt_key', $data)) {
|
||||
$this->syncReceipt($expense, $data['receipt_key'] ?? null, 'photos', 'expense_receipt', $data['file_size'] ?? null, $data['file_mime_type'] ?? null);
|
||||
$this->syncReceipt($expense, $data['receipt_key'] ?? null, 'receipts', 'expense_receipt', $data['file_size'] ?? null, $data['file_mime_type'] ?? null);
|
||||
}
|
||||
|
||||
return $expense;
|
||||
@ -100,7 +100,7 @@ public function update(Expense $expense, array $data): Expense
|
||||
]);
|
||||
|
||||
if (array_key_exists('receipt_key', $data)) {
|
||||
$this->syncReceipt($expense, $data['receipt_key'] ?? null, 'photos', 'expense_receipt', $data['file_size'] ?? null, $data['file_mime_type'] ?? null);
|
||||
$this->syncReceipt($expense, $data['receipt_key'] ?? null, 'receipts', 'expense_receipt', $data['file_size'] ?? null, $data['file_mime_type'] ?? null);
|
||||
}
|
||||
|
||||
return $expense;
|
||||
@ -125,12 +125,12 @@ public function destroy(Expense $expense): bool
|
||||
type: CashTransactionType::DEPOSIT,
|
||||
);
|
||||
|
||||
$media = $expense->getFirstMedia('photos');
|
||||
$media = $expense->getFirstMedia('receipts');
|
||||
if ($media) {
|
||||
Cache::forget("expense_receipt_{$media->id}");
|
||||
}
|
||||
|
||||
$expense->clearMediaCollection('photos');
|
||||
$expense->clearMediaCollection('receipts');
|
||||
|
||||
$deleted = $expense->delete();
|
||||
|
||||
@ -149,32 +149,29 @@ public function destroy(Expense $expense): bool
|
||||
|
||||
private function formatExpense(Expense $expense): array
|
||||
{
|
||||
$media = $expense->getFirstMedia('photos');
|
||||
$media = $expense->getFirstMedia('receipts');
|
||||
|
||||
if (! $media) {
|
||||
return $expense->toArray() + [
|
||||
'receipt_key' => null,
|
||||
'receipt_url' => null,
|
||||
'receipt_conversion_url' => null,
|
||||
];
|
||||
}
|
||||
|
||||
$s3Key = $media->getCustomProperty('s3_key') ?? $media->getPath();
|
||||
$s3Key = $media->file_name;
|
||||
|
||||
if (str_starts_with($s3Key, '/') || ! str_contains($s3Key, '/')) {
|
||||
$s3Key = $media->getPath();
|
||||
}
|
||||
|
||||
$cacheKey = "expense_receipt_{$media->id}";
|
||||
$receiptUrl = Cache::remember($cacheKey, now()->addMinutes(55), function () use ($s3Key) {
|
||||
return $this->s3Service->getTemporaryUrl($s3Key);
|
||||
});
|
||||
|
||||
$conversionCacheKey = "expense_receipt_conversion_{$media->id}";
|
||||
$receiptConversionUrl = Cache::remember($conversionCacheKey, now()->addMinutes(55), function () use ($media) {
|
||||
return $this->s3Service->getTemporaryUrl($media->getPath('thumb'));
|
||||
});
|
||||
|
||||
return $expense->toArray() + [
|
||||
'receipt_key' => $s3Key,
|
||||
'receipt_url' => $receiptUrl,
|
||||
'receipt_conversion_url' => $receiptConversionUrl,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -168,7 +168,7 @@ public function checkIn(array $data): Attendance
|
||||
]);
|
||||
|
||||
if (! empty($data['photo'])) {
|
||||
$this->registerMediaFromBase64($attendance, $data['photo'], 'checkin', 'attendances');
|
||||
$this->registerMediaFromBase64($attendance, $data['photo'], 'check-in', 'attendances');
|
||||
}
|
||||
|
||||
NotificationService::notify(
|
||||
@ -190,7 +190,7 @@ public function checkOut(Attendance $attendance, array $data): Attendance
|
||||
]);
|
||||
|
||||
if (! empty($data['photo'])) {
|
||||
$this->registerMediaFromBase64($attendance, $data['photo'], 'checkout', 'attendances');
|
||||
$this->registerMediaFromBase64($attendance, $data['photo'], 'check-out', 'attendances');
|
||||
}
|
||||
|
||||
NotificationService::notify(
|
||||
@ -303,15 +303,15 @@ private function formatAttendance(Attendance $attendance): array
|
||||
$toArray['work_duration_minutes'] = $checkIn->diffInMinutes($end);
|
||||
}
|
||||
|
||||
$checkInMedia = $attendance->getFirstMedia('checkin');
|
||||
$checkOutMedia = $attendance->getFirstMedia('checkout');
|
||||
$checkInMedia = $attendance->getFirstMedia('check-in');
|
||||
$checkOutMedia = $attendance->getFirstMedia('check-out');
|
||||
|
||||
$toArray['check_in_photo'] = $checkInMedia
|
||||
? Cache::remember("attendance_check_in_{$checkInMedia->id}", now()->addMinutes(55), fn () => $this->s3Service->getTemporaryUrl($checkInMedia->getPath()))
|
||||
? Cache::remember("attendance_check_in_{$checkInMedia->id}", now()->addMinutes(55), fn () => $this->s3Service->getTemporaryUrl($checkInMedia->file_name))
|
||||
: null;
|
||||
|
||||
$toArray['check_out_photo'] = $checkOutMedia
|
||||
? Cache::remember("attendance_check_out_{$checkOutMedia->id}", now()->addMinutes(55), fn () => $this->s3Service->getTemporaryUrl($checkOutMedia->getPath()))
|
||||
? Cache::remember("attendance_check_out_{$checkOutMedia->id}", now()->addMinutes(55), fn () => $this->s3Service->getTemporaryUrl($checkOutMedia->file_name))
|
||||
: null;
|
||||
|
||||
return $toArray;
|
||||
|
||||
@ -6,7 +6,6 @@
|
||||
use App\Enums\Role;
|
||||
use App\Models\Employee;
|
||||
use App\Models\User;
|
||||
use App\Services\S3PresignedService;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
@ -17,14 +16,13 @@ class EmployeeService
|
||||
|
||||
public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', array $filters = []): LengthAwarePaginator
|
||||
{
|
||||
$paginator = User::query()
|
||||
return User::query()
|
||||
->select(['id', 'email', 'username', 'is_active'])
|
||||
->where(fn ($q) => $q->whereHas('employee')->orWhereHas('roles', fn ($rq) => $rq->where('name', 'Owner')))
|
||||
->with([
|
||||
'userProfile' => fn ($q) => $q->select(['id', 'user_id', 'full_name', 'phone_number', 'gender']),
|
||||
'employee' => fn ($q) => $q->select(['id', 'user_id', 'join_date', 'employment_status', 'base_salary']),
|
||||
'roles' => fn ($q) => $q->select(['id', 'name']),
|
||||
'media',
|
||||
])
|
||||
->when(! self::hasAnyRole([Role::DEVELOPER, Role::OWNER, Role::DIREKTUR, Role::ADMIN_TOKO]), function ($q) {
|
||||
$userRoles = auth()->user()->roles->pluck('name');
|
||||
@ -37,17 +35,6 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
|
||||
->when($filters['gender'] ?? null, fn ($q, $gender) => $q->whereHas('userProfile', fn ($uq) => $uq->where('gender', $gender)))
|
||||
->orderBy($sort, $direction)
|
||||
->paginate($perPage);
|
||||
|
||||
$paginator->getCollection()->transform(function (User $user) {
|
||||
$media = $user->getFirstMedia('photos');
|
||||
$user->photo_url = $media
|
||||
? app(S3PresignedService::class)->getTemporaryUrl($media->getPath())
|
||||
: null;
|
||||
|
||||
return $user;
|
||||
});
|
||||
|
||||
return $paginator;
|
||||
}
|
||||
|
||||
public function getAll(): Collection
|
||||
|
||||
@ -42,22 +42,16 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
|
||||
->paginate($perPage);
|
||||
|
||||
$paginator->getCollection()->each(function (Cutting $cutting) {
|
||||
$cuttingMedia = $cutting->getFirstMedia('images');
|
||||
$cuttingMedia = $cutting->getFirstMedia('photos');
|
||||
$cutting->photo_url = $cuttingMedia
|
||||
? $this->s3Service->getTemporaryUrl($cuttingMedia->getPath())
|
||||
: null;
|
||||
$cutting->photo_conversion_url = $cuttingMedia
|
||||
? $this->s3Service->getTemporaryUrl($cuttingMedia->getPath('thumb'))
|
||||
? $this->s3Service->getTemporaryUrl($cuttingMedia->file_name)
|
||||
: null;
|
||||
|
||||
$cutting->cuttingMaterials->each(function (CuttingMaterial $material) {
|
||||
$media = $material->rawMaterialPrice?->getFirstMedia('images');
|
||||
$media = $material->rawMaterialPrice?->getFirstMedia('photos');
|
||||
if ($material->rawMaterialPrice) {
|
||||
$material->rawMaterialPrice->photo_url = $media
|
||||
? $this->s3Service->getTemporaryUrl($media->getPath())
|
||||
: null;
|
||||
$material->rawMaterialPrice->photo_conversion_url = $media
|
||||
? $this->s3Service->getTemporaryUrl($media->getPath('thumb'))
|
||||
? $this->s3Service->getTemporaryUrl($media->file_name)
|
||||
: null;
|
||||
}
|
||||
});
|
||||
@ -77,7 +71,7 @@ public function getForEdit(Cutting $cutting): array
|
||||
$result = $cutting->cuttingResults->first();
|
||||
|
||||
$materials = $cutting->cuttingMaterials->map(function (CuttingMaterial $material) {
|
||||
$media = $material->rawMaterialPrice?->getFirstMedia('images');
|
||||
$media = $material->rawMaterialPrice?->getFirstMedia('photos');
|
||||
|
||||
return [
|
||||
'id' => $material->id,
|
||||
@ -87,7 +81,7 @@ public function getForEdit(Cutting $cutting): array
|
||||
'combination_id' => $material->combination_id,
|
||||
'variant' => $material->rawMaterialPrice?->variant,
|
||||
'photo_url' => $media
|
||||
? $this->s3Service->getTemporaryUrl($media->getPath())
|
||||
? $this->s3Service->getTemporaryUrl($media->file_name)
|
||||
: null,
|
||||
];
|
||||
});
|
||||
@ -105,10 +99,10 @@ public function getForEdit(Cutting $cutting): array
|
||||
];
|
||||
});
|
||||
|
||||
$cuttingMedia = $cutting->getFirstMedia('images');
|
||||
$photoKey = $cuttingMedia?->getCustomProperty('s3_key') ?? $cuttingMedia?->file_name;
|
||||
$cuttingMedia = $cutting->getFirstMedia('photos');
|
||||
$photoKey = $cuttingMedia?->file_name;
|
||||
$photoUrl = $cuttingMedia
|
||||
? $this->s3Service->getTemporaryUrl($cuttingMedia->getPath())
|
||||
? $this->s3Service->getTemporaryUrl($cuttingMedia->file_name)
|
||||
: null;
|
||||
|
||||
return [
|
||||
@ -219,7 +213,7 @@ public function store(array $data): Cutting
|
||||
$this->registerMedia(
|
||||
model: $cutting,
|
||||
s3Key: $data['photo_key'],
|
||||
collectionName: 'images',
|
||||
collectionName: 'photos',
|
||||
orderColumn: 1,
|
||||
);
|
||||
}
|
||||
@ -343,7 +337,7 @@ public function destroy(Cutting $cutting): bool
|
||||
}
|
||||
}
|
||||
|
||||
$cutting->clearMediaCollection('images');
|
||||
$cutting->clearMediaCollection('photos');
|
||||
$cutting->cuttingResults()->delete();
|
||||
$cutting->cuttingMaterials()->delete();
|
||||
$cutting->cuttingMaterialCombinations()->delete();
|
||||
|
||||
@ -47,10 +47,7 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
|
||||
$paginator->getCollection()->each(function (Purchase $purchase) {
|
||||
$purchaseMedia = $purchase->getFirstMedia('photos');
|
||||
$purchase->photo_url = $purchaseMedia
|
||||
? $this->s3Service->getTemporaryUrl($purchaseMedia->getPath())
|
||||
: null;
|
||||
$purchase->photo_conversion_url = $purchaseMedia
|
||||
? $this->s3Service->getTemporaryUrl($purchaseMedia->getPath('thumb'))
|
||||
? $this->s3Service->getTemporaryUrl($purchaseMedia->file_name)
|
||||
: null;
|
||||
|
||||
$purchase->purchaseItems->each(function (PurchaseItem $item) {
|
||||
@ -58,12 +55,9 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
|
||||
return;
|
||||
}
|
||||
|
||||
$media = $item->rawMaterialPrice->getFirstMedia('images');
|
||||
$item->rawMaterialPrice->photo_url = $media
|
||||
? $this->s3Service->getTemporaryUrl($media->getPath())
|
||||
: null;
|
||||
$item->rawMaterialPrice->photo_conversion_url = $media
|
||||
? $this->s3Service->getTemporaryUrl($media->getPath('thumb'))
|
||||
$media = $item->rawMaterialPrice->getMedia('photos');
|
||||
$item->rawMaterialPrice->photo_url = $media->first()
|
||||
? $this->s3Service->getTemporaryUrl($media->first()->file_name)
|
||||
: null;
|
||||
});
|
||||
});
|
||||
@ -84,9 +78,9 @@ public function getForCreate(): array
|
||||
->get()
|
||||
->each(function (RawMaterial $rawMaterial) {
|
||||
$rawMaterial->rawMaterialPrices->each(function (RawMaterialPrice $price) {
|
||||
$media = $price->getFirstMedia('images');
|
||||
$media = $price->getFirstMedia('photos');
|
||||
$price->photo_url = $media
|
||||
? $this->s3Service->getTemporaryUrl($media->getPath())
|
||||
? $this->s3Service->getTemporaryUrl($media->file_name)
|
||||
: null;
|
||||
});
|
||||
}),
|
||||
@ -109,15 +103,15 @@ public function getForEdit(Purchase $purchase): array
|
||||
return null;
|
||||
}
|
||||
|
||||
$media = $item->rawMaterialPrice->getFirstMedia('images');
|
||||
$media = $item->rawMaterialPrice->getFirstMedia('photos');
|
||||
|
||||
return [
|
||||
'id' => $item->rawMaterialPrice->id,
|
||||
'variant' => $item->rawMaterialPrice->variant,
|
||||
'price' => $item->unit_price,
|
||||
'stock' => $item->quantity,
|
||||
'photo_key' => $media?->getCustomProperty('s3_key') ?? $media?->file_name,
|
||||
'photo_url' => $media ? $this->s3Service->getTemporaryUrl($media->getPath()) : null,
|
||||
'photo_key' => $media?->file_name,
|
||||
'photo_url' => $media ? $this->s3Service->getTemporaryUrl($media->file_name) : null,
|
||||
];
|
||||
})->filter()->values();
|
||||
|
||||
@ -135,9 +129,9 @@ public function getForEdit(Purchase $purchase): array
|
||||
->exists();
|
||||
|
||||
$purchaseMedia = $purchase->getFirstMedia('photos');
|
||||
$purchasePhotoKey = $purchaseMedia?->getCustomProperty('s3_key') ?? $purchaseMedia?->file_name;
|
||||
$purchasePhotoKey = $purchaseMedia?->file_name;
|
||||
$purchasePhotoUrl = $purchaseMedia
|
||||
? $this->s3Service->getTemporaryUrl($purchaseMedia->getPath())
|
||||
? $this->s3Service->getTemporaryUrl($purchaseMedia->file_name)
|
||||
: null;
|
||||
|
||||
return [
|
||||
@ -273,7 +267,7 @@ private function storeNew(array $data): Purchase
|
||||
$this->registerMedia(
|
||||
model: $priceModel,
|
||||
s3Key: $variantData['photo_key'],
|
||||
collectionName: 'images',
|
||||
collectionName: 'photos',
|
||||
orderColumn: 1,
|
||||
);
|
||||
}
|
||||
@ -418,12 +412,11 @@ public function update(Purchase $purchase, array $data): Purchase
|
||||
]);
|
||||
}
|
||||
|
||||
if (! empty($v['photo_key']) && $price->getFirstMedia('images')?->getCustomProperty('s3_key') !== $v['photo_key']) {
|
||||
$price->clearMediaCollection('images');
|
||||
if (! empty($v['photo_key']) && $price->getFirstMedia('photos')?->file_name !== $v['photo_key']) {
|
||||
$this->registerMedia(
|
||||
model: $price,
|
||||
s3Key: $v['photo_key'],
|
||||
collectionName: 'images',
|
||||
collectionName: 'photos',
|
||||
orderColumn: 1,
|
||||
);
|
||||
}
|
||||
|
||||
@ -49,12 +49,9 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
|
||||
return;
|
||||
}
|
||||
|
||||
$media = $item->productVariant->getFirstMedia('images');
|
||||
$media = $item->productVariant->getFirstMedia('photos');
|
||||
$item->productVariant->photo_url = $media
|
||||
? $this->s3Service->getTemporaryUrl($media->getPath())
|
||||
: null;
|
||||
$item->productVariant->photo_conversion_url = $media
|
||||
? $this->s3Service->getTemporaryUrl($media->getPath('thumb'))
|
||||
? $this->s3Service->getTemporaryUrl($media->file_name)
|
||||
: null;
|
||||
});
|
||||
});
|
||||
|
||||
@ -19,7 +19,6 @@
|
||||
use App\Services\S3PresignedService;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class TransactionService
|
||||
{
|
||||
@ -75,25 +74,14 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
|
||||
$order->channel_label = $order->channel->label();
|
||||
$order->price_type_label = $order->price_type->label();
|
||||
|
||||
$orderMedia = $order->getFirstMedia('photos');
|
||||
$order->photo_url = $orderMedia
|
||||
? $this->s3Service->getTemporaryUrl($orderMedia->getPath())
|
||||
: null;
|
||||
$order->photo_conversion_url = $orderMedia
|
||||
? $this->s3Service->getTemporaryUrl($orderMedia->getPath('thumb'))
|
||||
: null;
|
||||
|
||||
$order->orderItems->each(function (OrderItem $item) {
|
||||
if (! $item->productVariant) {
|
||||
return;
|
||||
}
|
||||
|
||||
$media = $item->productVariant->getFirstMedia('images');
|
||||
$media = $item->productVariant->getFirstMedia('photos');
|
||||
$item->productVariant->photo_url = $media
|
||||
? $this->s3Service->getTemporaryUrl($media->getPath())
|
||||
: null;
|
||||
$item->productVariant->photo_conversion_url = $media
|
||||
? $this->s3Service->getTemporaryUrl($media->getPath('thumb'))
|
||||
? $this->s3Service->getTemporaryUrl($media->file_name)
|
||||
: null;
|
||||
});
|
||||
|
||||
@ -172,12 +160,6 @@ public function store(array $data): Order
|
||||
$negoPrice = ! empty($data['nego_price']) ? (int) $data['nego_price'] : null;
|
||||
$totalAmount = $subtotal - $discount - ($negoPrice ?? 0);
|
||||
|
||||
if ($totalAmount <= 0) {
|
||||
throw ValidationException::withMessages([
|
||||
'discount' => 'Total harga tidak boleh nol atau kurang.',
|
||||
]);
|
||||
}
|
||||
|
||||
$order = Order::create([
|
||||
'created_by_id' => auth()->id(),
|
||||
'customer_id' => $data['customer_id'] ?? null,
|
||||
@ -245,12 +227,6 @@ public function update(Order $order, array $data): Order
|
||||
$negoPrice = ! empty($data['nego_price']) ? (int) $data['nego_price'] : null;
|
||||
$totalAmount = $subtotal - $discount - ($negoPrice ?? 0);
|
||||
|
||||
if ($totalAmount <= 0) {
|
||||
throw ValidationException::withMessages([
|
||||
'discount' => 'Total harga tidak boleh nol atau kurang.',
|
||||
]);
|
||||
}
|
||||
|
||||
foreach ($itemRows as &$row) {
|
||||
$row['order_id'] = $order->id;
|
||||
}
|
||||
@ -322,13 +298,9 @@ private function buildItemRows(array $items, string $stockType, string $priceTyp
|
||||
$variantIds = collect($items)->pluck('product_variant_id')->unique()->all();
|
||||
$variants = ProductVariant::query()
|
||||
->whereKey($variantIds)
|
||||
->with(['productPrices:id,variant_id,type,price', 'product:id,name'])
|
||||
->with('productPrices:id,variant_id,type,price')
|
||||
->get();
|
||||
|
||||
$variantLabels = $variants->mapWithKeys(fn (ProductVariant $v) => [
|
||||
$v->id => $v->product->name.' - '.$v->name,
|
||||
]);
|
||||
|
||||
$prices = $variants->mapWithKeys(function (ProductVariant $variant) use ($resolvedPriceType) {
|
||||
$price = $variant->productPrices
|
||||
->first(fn ($p) => $p->type === $resolvedPriceType);
|
||||
@ -343,28 +315,13 @@ private function buildItemRows(array $items, string $stockType, string $priceTyp
|
||||
return [$variant->id => $price?->price ?? 0];
|
||||
});
|
||||
|
||||
return collect($items)->map(function ($item) use ($now, $prices, $capitalPrices, $variantLabels, $stockType, &$subtotal, &$totalCost) {
|
||||
return collect($items)->map(function ($item) use ($now, $prices, $capitalPrices, $stockType, &$subtotal, &$totalCost) {
|
||||
$quantity = (int) $item['quantity'];
|
||||
$unitPrice = (int) ($prices[$item['product_variant_id']] ?? 0);
|
||||
$label = $variantLabels[$item['product_variant_id']] ?? ' Produk';
|
||||
|
||||
if ($unitPrice <= 0) {
|
||||
throw ValidationException::withMessages([
|
||||
'items' => 'Harga untuk "'.$label.'" belum diatur.',
|
||||
]);
|
||||
}
|
||||
|
||||
$itemSubtotal = $unitPrice * $quantity;
|
||||
$subtotal += $itemSubtotal;
|
||||
|
||||
$capitalPrice = (int) ($capitalPrices[$item['product_variant_id']] ?? 0);
|
||||
|
||||
if ($capitalPrice <= 0) {
|
||||
throw ValidationException::withMessages([
|
||||
'items' => 'Harga modal untuk "'.$label.'" belum diatur.',
|
||||
]);
|
||||
}
|
||||
|
||||
$totalCost += $capitalPrice * $quantity;
|
||||
|
||||
return [
|
||||
|
||||
@ -27,9 +27,8 @@ public function __construct(
|
||||
|
||||
public function getNames(): Collection
|
||||
{
|
||||
return Product::select('name')
|
||||
->distinct()
|
||||
->pluck('name');
|
||||
return Product::select(['id', 'name'])
|
||||
->get();
|
||||
}
|
||||
|
||||
public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', array $filters = []): LengthAwarePaginator
|
||||
@ -41,10 +40,10 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
|
||||
'productVariants:id,product_id,name,stock,reject_stock,retail_stock',
|
||||
'productVariants.productPrices:id,variant_id,type,price',
|
||||
])
|
||||
->when($search, fn ($q) => $q->where('name', 'like', "%{$search}%"))
|
||||
->when($filters['name'] ?? null, fn ($q, $name) => $q->where('name', 'like', "%{$name}%"))
|
||||
->when($filters['status'] ?? null, fn ($q, $status) => $q->where('status', $status))
|
||||
->when($filters['category'] ?? null, fn ($q, $categoryId) => $q->whereHas('categories', function ($cq) use ($categoryId) {
|
||||
->when($search, fn($q) => $q->where('name', 'like', "%{$search}%"))
|
||||
->when($filters['name'] ?? null, fn($q, $name) => $q->where('name', 'like', "%{$name}%"))
|
||||
->when($filters['status'] ?? null, fn($q, $status) => $q->where('status', $status))
|
||||
->when($filters['category'] ?? null, fn($q, $categoryId) => $q->whereHas('categories', function ($cq) use ($categoryId) {
|
||||
$cq->where('categories.id', $categoryId);
|
||||
}))
|
||||
->when(($filters['stock'] ?? null) === 'empty', function ($q) {
|
||||
@ -58,9 +57,8 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
|
||||
|
||||
$paginator->getCollection()->each(function ($product) {
|
||||
$product->productVariants->each(function ($variant) {
|
||||
$media = $variant->getMedia('images');
|
||||
$variant->photo_urls = $media->map(fn ($m) => $this->s3Service->getTemporaryUrl($m->getPath()))->toArray();
|
||||
$variant->photo_conversion_urls = $media->map(fn ($m) => $this->s3Service->getTemporaryUrl($m->getPath('thumb')))->toArray();
|
||||
$media = $variant->getMedia('photos');
|
||||
$variant->photo_urls = $media->map(fn($m) => $this->s3Service->getTemporaryUrl($m->file_name))->toArray();
|
||||
});
|
||||
});
|
||||
|
||||
@ -86,7 +84,7 @@ public function store(array $data): Product
|
||||
|
||||
// Bulk insert variants
|
||||
$now = now();
|
||||
$variantRows = collect($data['variants'])->map(fn ($v) => [
|
||||
$variantRows = collect($data['variants'])->map(fn($v) => [
|
||||
'product_id' => $product->id,
|
||||
'name' => $v['name'],
|
||||
'stock' => $v['stock'],
|
||||
@ -100,7 +98,7 @@ public function store(array $data): Product
|
||||
|
||||
// Map variant name -> variant ID
|
||||
$insertedVariants = ProductVariant::where('product_id', $product->id)->get();
|
||||
$variantMap = $insertedVariants->mapWithKeys(fn ($v) => [$v->name => $v->id]);
|
||||
$variantMap = $insertedVariants->mapWithKeys(fn($v) => [$v->name => $v->id]);
|
||||
|
||||
// Bulk insert prices
|
||||
$priceRows = [];
|
||||
@ -146,7 +144,7 @@ public function store(array $data): Product
|
||||
NotificationService::notify(
|
||||
roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO],
|
||||
title: 'Produk Baru',
|
||||
body: "Produk \"{$product->name}\" berhasil ditambahkan".' oleh '.auth()->user()->full_name.'.',
|
||||
body: "Produk \"{$product->name}\" berhasil ditambahkan" . ' oleh ' . auth()->user()->full_name . '.',
|
||||
url: route('admin.master.products.index'),
|
||||
);
|
||||
|
||||
@ -162,9 +160,9 @@ public function getForEdit(Product $product): array
|
||||
]);
|
||||
|
||||
$variants = $product->productVariants->map(function (ProductVariant $variant) {
|
||||
$media = $variant->getMedia('images');
|
||||
$photoKeys = $media->map(fn ($m) => $m->getCustomProperty('s3_key') ?? $m->file_name)->toArray();
|
||||
$photoUrls = $media->map(fn ($m) => $this->s3Service->getTemporaryUrl($m->getPath()))->toArray();
|
||||
$media = $variant->getMedia('photos');
|
||||
$photoKeys = $media->pluck('file_name')->toArray();
|
||||
$photoUrls = $media->map(fn($m) => $this->s3Service->getTemporaryUrl($m->file_name))->toArray();
|
||||
|
||||
return [
|
||||
'id' => $variant->id,
|
||||
@ -174,7 +172,7 @@ public function getForEdit(Product $product): array
|
||||
'retail_stock' => $variant->retail_stock,
|
||||
'photo_keys' => $photoKeys,
|
||||
'photo_urls' => $photoUrls,
|
||||
'prices' => $variant->productPrices->map(fn ($p) => [
|
||||
'prices' => $variant->productPrices->map(fn($p) => [
|
||||
'type' => $p->type->value,
|
||||
'price' => $p->price,
|
||||
]),
|
||||
@ -224,7 +222,7 @@ public function update(Product $product, array $data): Product
|
||||
->whereNotIn('id', $existingVariantIds)
|
||||
->each(function (ProductVariant $variant) {
|
||||
$variant->productPrices()->delete();
|
||||
$variant->clearMediaCollection('images');
|
||||
$variant->clearMediaCollection('photos');
|
||||
$variant->delete();
|
||||
});
|
||||
|
||||
@ -232,7 +230,7 @@ public function update(Product $product, array $data): Product
|
||||
$existingVariantsMap = ProductVariant::whereIn('id', $existingVariantIds)
|
||||
->with('media')
|
||||
->get()
|
||||
->mapWithKeys(fn ($v) => [$v->id => $v]);
|
||||
->mapWithKeys(fn($v) => [$v->id => $v]);
|
||||
|
||||
// Collect old stock data + identify changed variants
|
||||
$oldStockDataMap = [];
|
||||
@ -258,7 +256,7 @@ public function update(Product $product, array $data): Product
|
||||
// Bulk update changed variants (only changed ones, not all)
|
||||
if ($changedIds !== []) {
|
||||
$changedUpdates = collect($data['variants'])
|
||||
->filter(fn ($v) => isset($v['id']) && in_array($v['id'], $changedIds));
|
||||
->filter(fn($v) => isset($v['id']) && in_array($v['id'], $changedIds));
|
||||
|
||||
foreach ($changedUpdates as $variantData) {
|
||||
$existingVariantsMap[$variantData['id']]->update([
|
||||
@ -271,11 +269,11 @@ public function update(Product $product, array $data): Product
|
||||
}
|
||||
|
||||
// Bulk create new variants
|
||||
$newVariantsData = collect($data['variants'])->filter(fn ($v) => ! isset($v['id']));
|
||||
$newVariantsData = collect($data['variants'])->filter(fn($v) => ! isset($v['id']));
|
||||
$newVariantIdMap = [];
|
||||
|
||||
if ($newVariantsData->isNotEmpty()) {
|
||||
$newVariantRows = $newVariantsData->map(fn ($v) => [
|
||||
$newVariantRows = $newVariantsData->map(fn($v) => [
|
||||
'product_id' => $product->id,
|
||||
'name' => $v['name'],
|
||||
'stock' => $v['stock'],
|
||||
@ -292,7 +290,7 @@ public function update(Product $product, array $data): Product
|
||||
->whereIn('name', $newVariantsData->pluck('name')->toArray())
|
||||
->get();
|
||||
|
||||
$newVariantIdMap = $newlyCreated->mapWithKeys(fn ($v) => [$v->name => $v->id])->toArray();
|
||||
$newVariantIdMap = $newlyCreated->mapWithKeys(fn($v) => [$v->name => $v->id])->toArray();
|
||||
}
|
||||
|
||||
// Build variant_id lookup: existing by id, new by name
|
||||
@ -356,7 +354,7 @@ public function update(Product $product, array $data): Product
|
||||
}
|
||||
}
|
||||
|
||||
$changedModels = collect($changedIds)->map(fn ($id) => $existingVariantsMap[$id])->filter();
|
||||
$changedModels = collect($changedIds)->map(fn($id) => $existingVariantsMap[$id])->filter();
|
||||
$this->stockMutationService->recordBulkAdjustment(
|
||||
$changedModels,
|
||||
$oldStockDataMap,
|
||||
@ -381,7 +379,14 @@ public function update(Product $product, array $data): Product
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->variantService->syncPhotos($variant, $variantData['photo_keys']);
|
||||
// Compare existing photo keys vs new ones
|
||||
$existingKeys = $variant->getMedia('photos')->pluck('file_name')->sort()->values()->toArray();
|
||||
$newKeys = collect($variantData['photo_keys'])->sort()->values()->toArray();
|
||||
|
||||
if ($existingKeys !== $newKeys) {
|
||||
$variant->clearMediaCollection('photos');
|
||||
$this->variantService->registerPhotos($variant, $variantData['photo_keys']);
|
||||
}
|
||||
}
|
||||
|
||||
return $product;
|
||||
@ -390,7 +395,7 @@ public function update(Product $product, array $data): Product
|
||||
NotificationService::notify(
|
||||
roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO],
|
||||
title: 'Produk Diperbarui',
|
||||
body: "Produk \"{$product->name}\" berhasil diperbarui".' oleh '.auth()->user()->full_name.'.',
|
||||
body: "Produk \"{$product->name}\" berhasil diperbarui" . ' oleh ' . auth()->user()->full_name . '.',
|
||||
url: route('admin.master.products.index'),
|
||||
);
|
||||
|
||||
@ -404,7 +409,7 @@ public function destroy(Product $product): bool
|
||||
$result = DB::transaction(function () use ($product) {
|
||||
$product->productVariants->each(function (ProductVariant $variant) {
|
||||
$variant->productPrices()->delete();
|
||||
$variant->clearMediaCollection('images');
|
||||
$variant->clearMediaCollection('photos');
|
||||
$variant->delete();
|
||||
});
|
||||
|
||||
@ -416,7 +421,7 @@ public function destroy(Product $product): bool
|
||||
NotificationService::notify(
|
||||
roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO],
|
||||
title: 'Produk Dihapus',
|
||||
body: "Produk \"{$product->name}\" berhasil dihapus".' oleh '.auth()->user()->full_name.'.',
|
||||
body: "Produk \"{$product->name}\" berhasil dihapus" . ' oleh ' . auth()->user()->full_name . '.',
|
||||
url: route('admin.master.products.index'),
|
||||
);
|
||||
|
||||
@ -441,7 +446,7 @@ public function approve(Product $product): void
|
||||
NotificationService::notify(
|
||||
roles: [Role::OWNER, Role::DEVELOPER],
|
||||
title: 'Produk Disetujui',
|
||||
body: "Produk \"{$product->name}\" telah disetujui oleh ".auth()->user()->full_name.'.',
|
||||
body: "Produk \"{$product->name}\" telah disetujui oleh " . auth()->user()->full_name . '.',
|
||||
url: route('admin.master.products.index'),
|
||||
additionalUser: $product->createdBy ?? null,
|
||||
);
|
||||
@ -457,7 +462,7 @@ public function reject(Product $product, string $reason = ''): void
|
||||
NotificationService::notify(
|
||||
roles: [Role::OWNER, Role::DEVELOPER],
|
||||
title: 'Produk Ditolak',
|
||||
body: "Produk \"{$product->name}\" telah ditolak oleh ".auth()->user()->full_name.'.',
|
||||
body: "Produk \"{$product->name}\" telah ditolak oleh " . auth()->user()->full_name . '.',
|
||||
url: route('admin.master.products.index'),
|
||||
additionalUser: $product->createdBy ?? null,
|
||||
);
|
||||
@ -473,7 +478,7 @@ public function resubmit(Product $product): void
|
||||
NotificationService::notify(
|
||||
roles: [Role::OWNER, Role::DEVELOPER],
|
||||
title: 'Produk Diajukan Ulang',
|
||||
body: "Produk \"{$product->name}\" telah diajukan ulang oleh ".auth()->user()->full_name.'.',
|
||||
body: "Produk \"{$product->name}\" telah diajukan ulang oleh " . auth()->user()->full_name . '.',
|
||||
url: route('admin.master.products.index'),
|
||||
);
|
||||
}
|
||||
|
||||
@ -37,9 +37,9 @@ public function getForRestock(): array
|
||||
->get()
|
||||
->each(function (Product $product) {
|
||||
$product->productVariants->each(function (ProductVariant $variant) {
|
||||
$media = $variant->getFirstMedia('images');
|
||||
$media = $variant->getFirstMedia('photos');
|
||||
$variant->photo_url = $media
|
||||
? $this->s3Service->getTemporaryUrl($media->getPath())
|
||||
? $this->s3Service->getTemporaryUrl($media->file_name)
|
||||
: null;
|
||||
|
||||
$capitalPrice = $variant->productPrices
|
||||
@ -50,7 +50,7 @@ public function getForRestock(): array
|
||||
->first(fn ($price) => $price->type === PriceType::REJECT);
|
||||
$variant->reject_price = $rejectPrice?->price ?? 0;
|
||||
});
|
||||
})->toArray();
|
||||
});
|
||||
}
|
||||
|
||||
public function getForTransaction(): array
|
||||
@ -66,15 +66,15 @@ public function getForTransaction(): array
|
||||
->get()
|
||||
->each(function (Product $product) {
|
||||
$product->productVariants->each(function (ProductVariant $variant) {
|
||||
$media = $variant->getFirstMedia('images');
|
||||
$media = $variant->getFirstMedia('photos');
|
||||
$variant->photo_url = $media
|
||||
? $this->s3Service->getTemporaryUrl($media->getPath())
|
||||
? $this->s3Service->getTemporaryUrl($media->file_name)
|
||||
: null;
|
||||
|
||||
$prices = $variant->productPrices->mapWithKeys(fn ($p) => [$p->type->value => $p->price]);
|
||||
$variant->prices = $prices;
|
||||
});
|
||||
})->toArray();
|
||||
});
|
||||
}
|
||||
|
||||
public function getForEdit(ProductVariant $variant): array
|
||||
@ -84,9 +84,9 @@ public function getForEdit(ProductVariant $variant): array
|
||||
'media',
|
||||
]);
|
||||
|
||||
$media = $variant->getMedia('images');
|
||||
$photoKeys = $media->map(fn ($m) => $m->getCustomProperty('s3_key') ?? $m->file_name)->toArray();
|
||||
$photoUrls = $media->map(fn ($m) => $this->s3Service->getTemporaryUrl($m->getPath()))->toArray();
|
||||
$media = $variant->getMedia('photos');
|
||||
$photoKeys = $media->pluck('file_name')->toArray();
|
||||
$photoUrls = $media->map(fn ($m) => $this->s3Service->getTemporaryUrl($m->file_name))->toArray();
|
||||
|
||||
return [
|
||||
'id' => $variant->id,
|
||||
@ -136,7 +136,8 @@ public function update(ProductVariant $variant, array $data): ProductVariant
|
||||
);
|
||||
|
||||
if (! empty($data['photo_keys']) && is_array($data['photo_keys'])) {
|
||||
$this->syncPhotos($variant, $data['photo_keys']);
|
||||
$variant->clearMediaCollection('photos');
|
||||
$this->registerPhotos($variant, $data['photo_keys']);
|
||||
}
|
||||
});
|
||||
|
||||
@ -156,7 +157,7 @@ public function destroy(Product $product, ProductVariant $variant): bool
|
||||
|
||||
$result = DB::transaction(function () use ($variant) {
|
||||
$variant->productPrices()->delete();
|
||||
$variant->clearMediaCollection('images');
|
||||
$variant->clearMediaCollection('photos');
|
||||
|
||||
return $variant->delete();
|
||||
});
|
||||
@ -171,6 +172,25 @@ public function destroy(Product $product, ProductVariant $variant): bool
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function registerPhotos(ProductVariant $variant, array $photoKeys): void
|
||||
{
|
||||
foreach ($photoKeys as $order => $s3Key) {
|
||||
$this->registerMedia(
|
||||
model: $variant,
|
||||
s3Key: $s3Key,
|
||||
collectionName: 'photos',
|
||||
orderColumn: $order + 1,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public function getTemporaryUrls(ProductVariant $variant): array
|
||||
{
|
||||
$media = $variant->getMedia('photos');
|
||||
|
||||
return $media->map(fn ($m) => $this->s3Service->getTemporaryUrl($m->file_name))->toArray();
|
||||
}
|
||||
|
||||
public function transferStock(ProductVariant $variant, array $data): ProductVariant
|
||||
{
|
||||
$quantity = (int) $data['quantity'];
|
||||
|
||||
@ -7,7 +7,6 @@
|
||||
use App\Services\Concerns\RegistersMedia;
|
||||
use App\Services\S3PresignedService;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class RawMaterialService
|
||||
@ -18,13 +17,6 @@ public function __construct(
|
||||
private S3PresignedService $s3Service,
|
||||
) {}
|
||||
|
||||
public function getNames(): Collection
|
||||
{
|
||||
return RawMaterial::select('name')
|
||||
->distinct()
|
||||
->pluck('name');
|
||||
}
|
||||
|
||||
public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', array $filters = []): LengthAwarePaginator
|
||||
{
|
||||
$paginator = RawMaterial::query()
|
||||
@ -33,7 +25,6 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
|
||||
'rawMaterialPrices:id,raw_material_id,variant,price,stock',
|
||||
])
|
||||
->when($search, fn ($q) => $q->where('name', 'like', "%{$search}%"))
|
||||
->when($filters['name'] ?? null, fn ($q, $name) => $q->where('name', 'like', "%{$name}%"))
|
||||
->when(($filters['is_active'] ?? null) !== null && ($filters['is_active'] ?? null) !== '', function ($q) use ($filters) {
|
||||
$q->where('is_active', $filters['is_active'] === 'true');
|
||||
})
|
||||
@ -48,14 +39,10 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
|
||||
|
||||
$paginator->getCollection()->each(function ($rawMaterial) {
|
||||
$rawMaterial->rawMaterialPrices->each(function ($price) {
|
||||
$media = $price->getFirstMedia('images');
|
||||
if ($media) {
|
||||
$price->photo_url = $this->s3Service->getTemporaryUrl($media->getPath());
|
||||
$price->photo_conversion_url = $this->s3Service->getTemporaryUrl($media->getPath('thumb'));
|
||||
} else {
|
||||
$price->photo_url = null;
|
||||
$price->photo_conversion_url = null;
|
||||
}
|
||||
$media = $price->getMedia('photos');
|
||||
$price->photo_url = $media->first()
|
||||
? $this->s3Service->getTemporaryUrl($media->first()->file_name)
|
||||
: null;
|
||||
});
|
||||
});
|
||||
|
||||
@ -105,17 +92,19 @@ public function getForEdit(RawMaterial $rawMaterial): array
|
||||
]);
|
||||
|
||||
$variants = $rawMaterial->rawMaterialPrices->map(function (RawMaterialPrice $price) {
|
||||
$media = $price->getMedia('images');
|
||||
$photoKeys = $media->map(fn ($m) => $m->getCustomProperty('s3_key') ?? $m->file_name)->toArray();
|
||||
$photoUrls = $media->map(fn ($m) => $this->s3Service->getTemporaryUrl($m->getPath()))->toArray();
|
||||
$media = $price->getMedia('photos');
|
||||
$photoKey = $media->first()?->file_name;
|
||||
$photoUrl = $media->first()
|
||||
? $this->s3Service->getTemporaryUrl($media->first()->file_name)
|
||||
: null;
|
||||
|
||||
return [
|
||||
'id' => $price->id,
|
||||
'variant' => $price->variant,
|
||||
'price' => $price->price,
|
||||
'stock' => $price->stock,
|
||||
'photo_key' => $photoKeys[0] ?? null,
|
||||
'photo_url' => $photoUrls[0] ?? null,
|
||||
'photo_key' => $photoKey,
|
||||
'photo_url' => $photoUrl,
|
||||
];
|
||||
});
|
||||
|
||||
@ -145,7 +134,7 @@ public function update(RawMaterial $rawMaterial, array $data): RawMaterial
|
||||
$rawMaterial->rawMaterialPrices()
|
||||
->whereNotIn('id', $existingVariantIds)
|
||||
->each(function (RawMaterialPrice $price) {
|
||||
$price->clearMediaCollection('images');
|
||||
$price->clearMediaCollection('photos');
|
||||
$price->delete();
|
||||
});
|
||||
|
||||
@ -195,9 +184,9 @@ public function update(RawMaterial $rawMaterial, array $data): RawMaterial
|
||||
]);
|
||||
|
||||
if (isset($variantData['photo_key'])) {
|
||||
$existingKey = $priceModel->getMedia('images')->first()?->getCustomProperty('s3_key');
|
||||
$existingKey = $priceModel->getMedia('photos')->first()?->file_name;
|
||||
if ($existingKey !== $variantData['photo_key']) {
|
||||
$priceModel->clearMediaCollection('images');
|
||||
$priceModel->clearMediaCollection('photos');
|
||||
if ($variantData['photo_key']) {
|
||||
$this->registerPhoto($priceModel, $variantData['photo_key']);
|
||||
}
|
||||
@ -213,7 +202,7 @@ public function destroy(RawMaterial $rawMaterial): bool
|
||||
{
|
||||
return DB::transaction(function () use ($rawMaterial) {
|
||||
$rawMaterial->rawMaterialPrices->each(function (RawMaterialPrice $price) {
|
||||
$price->clearMediaCollection('images');
|
||||
$price->clearMediaCollection('photos');
|
||||
$price->delete();
|
||||
});
|
||||
|
||||
@ -227,4 +216,14 @@ public function toggleStatus(RawMaterial $rawMaterial): void
|
||||
'is_active' => ! $rawMaterial->is_active,
|
||||
]);
|
||||
}
|
||||
|
||||
private function registerPhoto(RawMaterialPrice $price, string $s3Key): void
|
||||
{
|
||||
$this->registerMedia(
|
||||
model: $price,
|
||||
s3Key: $s3Key,
|
||||
collectionName: 'photos',
|
||||
orderColumn: 1,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -30,9 +30,9 @@ public function getForCutting(): array
|
||||
->get()
|
||||
->each(function (RawMaterial $rawMaterial) {
|
||||
$rawMaterial->rawMaterialPrices->each(function (RawMaterialPrice $price) {
|
||||
$media = $price->getFirstMedia('images');
|
||||
$media = $price->getFirstMedia('photos');
|
||||
$price->photo_url = $media
|
||||
? $this->s3Service->getTemporaryUrl($media->getPath())
|
||||
? $this->s3Service->getTemporaryUrl($media->file_name)
|
||||
: null;
|
||||
});
|
||||
})
|
||||
@ -43,9 +43,11 @@ public function getForEdit(RawMaterialPrice $variant): array
|
||||
{
|
||||
$variant->load('media');
|
||||
|
||||
$media = $variant->getMedia('images');
|
||||
$photoKeys = $media->map(fn ($m) => $m->getCustomProperty('s3_key') ?? $m->file_name)->toArray();
|
||||
$photoUrls = $media->map(fn ($m) => $this->s3Service->getTemporaryUrl($m->getPath()))->toArray();
|
||||
$media = $variant->getMedia('photos');
|
||||
$photoKey = $media->first()?->file_name;
|
||||
$photoUrl = $media->first()
|
||||
? $this->s3Service->getTemporaryUrl($media->first()->file_name)
|
||||
: null;
|
||||
|
||||
return [
|
||||
'id' => $variant->id,
|
||||
@ -53,8 +55,8 @@ public function getForEdit(RawMaterialPrice $variant): array
|
||||
'variant' => $variant->variant,
|
||||
'price' => $variant->price,
|
||||
'stock' => $variant->stock,
|
||||
'photo_key' => $photoKeys[0] ?? null,
|
||||
'photo_url' => $photoUrls[0] ?? null,
|
||||
'photo_key' => $photoKey,
|
||||
'photo_url' => $photoUrl,
|
||||
];
|
||||
}
|
||||
|
||||
@ -68,11 +70,11 @@ public function update(RawMaterialPrice $variant, array $data): RawMaterialPrice
|
||||
]);
|
||||
|
||||
if (array_key_exists('photo_key', $data)) {
|
||||
$existingKey = $variant->getMedia('images')->first()?->getCustomProperty('s3_key');
|
||||
$existingKey = $variant->getMedia('photos')->first()?->file_name;
|
||||
$newKey = $data['photo_key'];
|
||||
|
||||
if ($existingKey !== $newKey) {
|
||||
$variant->clearMediaCollection('images');
|
||||
$variant->clearMediaCollection('photos');
|
||||
if ($newKey) {
|
||||
$this->registerPhoto($variant, $newKey);
|
||||
}
|
||||
@ -93,7 +95,7 @@ public function update(RawMaterialPrice $variant, array $data): RawMaterialPrice
|
||||
public function destroy(RawMaterial $rawMaterial, RawMaterialPrice $variant): bool
|
||||
{
|
||||
$result = DB::transaction(function () use ($variant) {
|
||||
$variant->clearMediaCollection('images');
|
||||
$variant->clearMediaCollection('photos');
|
||||
|
||||
return $variant->delete();
|
||||
});
|
||||
@ -107,4 +109,14 @@ public function destroy(RawMaterial $rawMaterial, RawMaterialPrice $variant): bo
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function registerPhoto(RawMaterialPrice $variant, string $s3Key): void
|
||||
{
|
||||
$this->registerMedia(
|
||||
model: $variant,
|
||||
s3Key: $s3Key,
|
||||
collectionName: 'photos',
|
||||
orderColumn: 1,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,6 +6,7 @@
|
||||
use App\Enums\OrderChannel;
|
||||
use App\Enums\OrderStatus;
|
||||
use App\Enums\PaymentType;
|
||||
use App\Enums\PriceType;
|
||||
use App\Enums\RawMaterialUnit;
|
||||
use App\Models\Attendance;
|
||||
use App\Models\CashAccount;
|
||||
@ -14,8 +15,10 @@
|
||||
use App\Models\Expense;
|
||||
use App\Models\LeaveRequest;
|
||||
use App\Models\Order;
|
||||
use App\Models\ProductVariant;
|
||||
use App\Models\Purchase;
|
||||
use App\Models\PurchaseItem;
|
||||
use App\Models\RawMaterialPrice;
|
||||
use App\Models\RestockItem;
|
||||
use App\Models\User;
|
||||
use Carbon\Carbon;
|
||||
@ -462,25 +465,6 @@ public function getTopProducts(?string $startDate, ?string $endDate): array
|
||||
->toArray();
|
||||
}
|
||||
|
||||
public function getRevenueTrend(?string $startDate, ?string $endDate): array
|
||||
{
|
||||
$query = Order::where('orders.status', OrderStatus::COMPLETED);
|
||||
$this->applyDateFilter($query, $startDate, $endDate, 'orders.created_at');
|
||||
|
||||
return (clone $query)
|
||||
->join('order_items', 'orders.id', '=', 'order_items.order_id')
|
||||
->selectRaw('DATE(orders.created_at) as date')
|
||||
->selectRaw('SUM(order_items.quantity) as qty')
|
||||
->groupBy(DB::raw('DATE(orders.created_at)'))
|
||||
->orderBy(DB::raw('DATE(orders.created_at)'))
|
||||
->get()
|
||||
->map(fn ($item) => [
|
||||
'date' => $item->date,
|
||||
'qty' => (int) $item->qty,
|
||||
])
|
||||
->toArray();
|
||||
}
|
||||
|
||||
public function getMarketingSales(?string $startDate, ?string $endDate): array
|
||||
{
|
||||
$query = Order::where('orders.status', OrderStatus::COMPLETED)
|
||||
@ -557,7 +541,7 @@ public function getOrderStats(?string $startDate, ?string $endDate): array
|
||||
->groupBy('marketing_id')
|
||||
->with('marketing:id')
|
||||
->get()
|
||||
->map(fn ($item) => [
|
||||
->map(fn($item) => [
|
||||
'name' => $item->marketing?->userProfile->full_name ?? '-',
|
||||
'count' => $item->count,
|
||||
'total' => (int) $item->total,
|
||||
|
||||
@ -3,12 +3,9 @@
|
||||
namespace App\Services\Concerns;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\Bus;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Spatie\MediaLibrary\Conversions\ConversionCollection;
|
||||
use Spatie\MediaLibrary\Conversions\Jobs\PerformConversionsJob;
|
||||
use Spatie\MediaLibrary\MediaCollections\Models\Media;
|
||||
|
||||
trait RegistersMedia
|
||||
@ -63,49 +60,31 @@ private function registerMedia(
|
||||
Model $model,
|
||||
string $s3Key,
|
||||
string $collectionName,
|
||||
array $generatedConversions = [],
|
||||
?int $fileSize = null,
|
||||
?string $mimeType = null,
|
||||
?int $orderColumn = null,
|
||||
?string $name = null,
|
||||
): void {
|
||||
$fileName = pathinfo($s3Key, PATHINFO_BASENAME);
|
||||
$defaultName = pathinfo($s3Key, PATHINFO_FILENAME);
|
||||
|
||||
$media = Media::create([
|
||||
Media::create([
|
||||
'model_type' => $model->getMorphClass(),
|
||||
'model_id' => $model->id,
|
||||
'uuid' => Str::uuid(),
|
||||
'collection_name' => $collectionName,
|
||||
'name' => $name ?? pathinfo($s3Key, PATHINFO_FILENAME),
|
||||
'file_name' => $fileName,
|
||||
'name' => $name ?? $defaultName,
|
||||
'file_name' => $s3Key,
|
||||
'mime_type' => $mimeType ?? 'image/jpeg',
|
||||
'disk' => 's3',
|
||||
'conversions_disk' => 's3',
|
||||
'size' => $fileSize ?? 0,
|
||||
'manipulations' => [],
|
||||
'custom_properties' => ['s3_key' => $s3Key],
|
||||
'generated_conversions' => [],
|
||||
'custom_properties' => [],
|
||||
'generated_conversions' => $generatedConversions,
|
||||
'responsive_images' => [],
|
||||
'order_column' => $orderColumn ?? 1,
|
||||
]);
|
||||
|
||||
$this->generateMediaConversions($media);
|
||||
}
|
||||
|
||||
private function generateMediaConversions(Media $media): void
|
||||
{
|
||||
$model = $media->model;
|
||||
|
||||
if (! $model || ! method_exists($model, 'registerMediaConversions')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$conversions = ConversionCollection::createForMedia($media);
|
||||
|
||||
if ($conversions->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
Bus::dispatch(new PerformConversionsJob($conversions, $media));
|
||||
}
|
||||
|
||||
private function syncPhoto(Model $model, array $data, string $collectionName = 'photos'): void
|
||||
@ -114,7 +93,7 @@ private function syncPhoto(Model $model, array $data, string $collectionName = '
|
||||
return;
|
||||
}
|
||||
|
||||
$currentKey = $model->getFirstMedia($collectionName)?->getCustomProperty('s3_key');
|
||||
$currentKey = $model->getFirstMedia($collectionName)?->file_name;
|
||||
|
||||
if ($data['photo_key'] === $currentKey) {
|
||||
return;
|
||||
@ -135,7 +114,11 @@ private function syncPhoto(Model $model, array $data, string $collectionName = '
|
||||
private function syncReceipt(Model $model, ?string $newKey, string $collectionName = 'receipts', string $cachePrefix = 'receipt', ?int $fileSize = null, ?string $fileMimeType = null): void
|
||||
{
|
||||
$currentMedia = $model->getFirstMedia($collectionName);
|
||||
$currentKey = $currentMedia?->getCustomProperty('s3_key');
|
||||
$currentKey = $currentMedia?->file_name;
|
||||
|
||||
if ($currentMedia && ! str_contains($currentKey, '/')) {
|
||||
$currentKey = $currentMedia->getPath();
|
||||
}
|
||||
|
||||
if ($newKey === $currentKey) {
|
||||
return;
|
||||
@ -152,58 +135,10 @@ private function syncReceipt(Model $model, ?string $newKey, string $collectionNa
|
||||
model: $model,
|
||||
s3Key: $newKey,
|
||||
collectionName: $collectionName,
|
||||
generatedConversions: ['thumb' => true],
|
||||
fileSize: $fileSize,
|
||||
mimeType: $fileMimeType,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private function registerPhoto(Model $model, string $s3Key, string $collectionName = 'images'): void
|
||||
{
|
||||
$this->registerMedia(
|
||||
model: $model,
|
||||
s3Key: $s3Key,
|
||||
collectionName: $collectionName,
|
||||
orderColumn: 1,
|
||||
);
|
||||
}
|
||||
|
||||
public function registerPhotos(Model $model, array $photoKeys, string $collectionName = 'images'): void
|
||||
{
|
||||
foreach ($photoKeys as $order => $s3Key) {
|
||||
$this->registerMedia(
|
||||
model: $model,
|
||||
s3Key: $s3Key,
|
||||
collectionName: $collectionName,
|
||||
orderColumn: $order + 1,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public function syncPhotos(Model $model, array $photoKeys, string $collectionName = 'images'): void
|
||||
{
|
||||
$existingMedia = $model->getMedia($collectionName);
|
||||
$existingKeys = $existingMedia
|
||||
->map(fn (Media $m) => $m->getCustomProperty('s3_key') ?? $m->file_name)
|
||||
->toArray();
|
||||
|
||||
foreach ($existingMedia as $media) {
|
||||
$key = $media->getCustomProperty('s3_key') ?? $media->file_name;
|
||||
|
||||
if (! in_array($key, $photoKeys)) {
|
||||
$media->delete();
|
||||
}
|
||||
}
|
||||
|
||||
$newKeys = array_values(array_diff($photoKeys, $existingKeys));
|
||||
|
||||
$this->registerPhotos($model, $newKeys, $collectionName);
|
||||
}
|
||||
|
||||
public function getTemporaryUrls(Model $model, string $collectionName = 'images'): array
|
||||
{
|
||||
$media = $model->getMedia($collectionName);
|
||||
|
||||
return $media->map(fn ($m) => $this->s3Service->getTemporaryUrl($m->getPath()))->toArray();
|
||||
}
|
||||
}
|
||||
|
||||
@ -22,14 +22,14 @@ public function getAttendanceStats(): array
|
||||
|
||||
$totalEmployees = Employee::whereHas(
|
||||
'user',
|
||||
fn ($q) => $q
|
||||
fn($q) => $q
|
||||
->where('is_active', true)
|
||||
->whereHas(
|
||||
'roles',
|
||||
fn ($r) => $r
|
||||
fn($r) => $r
|
||||
->whereHas(
|
||||
'permissions',
|
||||
fn ($p) => $p
|
||||
fn($p) => $p
|
||||
->where('name', 'attendances.create')
|
||||
)
|
||||
)
|
||||
@ -38,14 +38,14 @@ public function getAttendanceStats(): array
|
||||
$present = Attendance::where('attendance_date', $today)
|
||||
->whereHas(
|
||||
'employee.user',
|
||||
fn ($q) => $q
|
||||
fn($q) => $q
|
||||
->where('is_active', true)
|
||||
->whereHas(
|
||||
'roles',
|
||||
fn ($r) => $r
|
||||
fn($r) => $r
|
||||
->whereHas(
|
||||
'permissions',
|
||||
fn ($p) => $p
|
||||
fn($p) => $p
|
||||
->where('name', 'attendances.create')
|
||||
)
|
||||
)
|
||||
@ -56,14 +56,14 @@ public function getAttendanceStats(): array
|
||||
->where('end_date', '>=', $today)
|
||||
->whereHas(
|
||||
'employee.user',
|
||||
fn ($q) => $q
|
||||
fn($q) => $q
|
||||
->where('is_active', true)
|
||||
->whereHas(
|
||||
'roles',
|
||||
fn ($r) => $r
|
||||
fn($r) => $r
|
||||
->whereHas(
|
||||
'permissions',
|
||||
fn ($p) => $p
|
||||
fn($p) => $p
|
||||
->where('name', 'attendances.create')
|
||||
)
|
||||
)
|
||||
@ -161,7 +161,7 @@ public function getOrderStats(): array
|
||||
->groupBy('marketing_id')
|
||||
->with('marketing:id')
|
||||
->get()
|
||||
->map(fn ($item) => [
|
||||
->map(fn($item) => [
|
||||
'name' => $item->marketing?->userProfile->full_name ?? '-',
|
||||
'count' => $item->count,
|
||||
'total' => (int) $item->total,
|
||||
|
||||
221
chart.tsx
Normal file
221
chart.tsx
Normal file
@ -0,0 +1,221 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Bar, BarChart, CartesianGrid, XAxis } from "recharts"
|
||||
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card"
|
||||
import {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
type ChartConfig,
|
||||
} from "@/components/ui/chart"
|
||||
|
||||
export const description = "An interactive bar chart"
|
||||
|
||||
const chartData = [
|
||||
{ date: "2024-04-01", desktop: 222, mobile: 150 },
|
||||
{ date: "2024-04-02", desktop: 97, mobile: 180 },
|
||||
{ date: "2024-04-03", desktop: 167, mobile: 120 },
|
||||
{ date: "2024-04-04", desktop: 242, mobile: 260 },
|
||||
{ date: "2024-04-05", desktop: 373, mobile: 290 },
|
||||
{ date: "2024-04-06", desktop: 301, mobile: 340 },
|
||||
{ date: "2024-04-07", desktop: 245, mobile: 180 },
|
||||
{ date: "2024-04-08", desktop: 409, mobile: 320 },
|
||||
{ date: "2024-04-09", desktop: 59, mobile: 110 },
|
||||
{ date: "2024-04-10", desktop: 261, mobile: 190 },
|
||||
{ date: "2024-04-11", desktop: 327, mobile: 350 },
|
||||
{ date: "2024-04-12", desktop: 292, mobile: 210 },
|
||||
{ date: "2024-04-13", desktop: 342, mobile: 380 },
|
||||
{ date: "2024-04-14", desktop: 137, mobile: 220 },
|
||||
{ date: "2024-04-15", desktop: 120, mobile: 170 },
|
||||
{ date: "2024-04-16", desktop: 138, mobile: 190 },
|
||||
{ date: "2024-04-17", desktop: 446, mobile: 360 },
|
||||
{ date: "2024-04-18", desktop: 364, mobile: 410 },
|
||||
{ date: "2024-04-19", desktop: 243, mobile: 180 },
|
||||
{ date: "2024-04-20", desktop: 89, mobile: 150 },
|
||||
{ date: "2024-04-21", desktop: 137, mobile: 200 },
|
||||
{ date: "2024-04-22", desktop: 224, mobile: 170 },
|
||||
{ date: "2024-04-23", desktop: 138, mobile: 230 },
|
||||
{ date: "2024-04-24", desktop: 387, mobile: 290 },
|
||||
{ date: "2024-04-25", desktop: 215, mobile: 250 },
|
||||
{ date: "2024-04-26", desktop: 75, mobile: 130 },
|
||||
{ date: "2024-04-27", desktop: 383, mobile: 420 },
|
||||
{ date: "2024-04-28", desktop: 122, mobile: 180 },
|
||||
{ date: "2024-04-29", desktop: 315, mobile: 240 },
|
||||
{ date: "2024-04-30", desktop: 454, mobile: 380 },
|
||||
{ date: "2024-05-01", desktop: 165, mobile: 220 },
|
||||
{ date: "2024-05-02", desktop: 293, mobile: 310 },
|
||||
{ date: "2024-05-03", desktop: 247, mobile: 190 },
|
||||
{ date: "2024-05-04", desktop: 385, mobile: 420 },
|
||||
{ date: "2024-05-05", desktop: 481, mobile: 390 },
|
||||
{ date: "2024-05-06", desktop: 498, mobile: 520 },
|
||||
{ date: "2024-05-07", desktop: 388, mobile: 300 },
|
||||
{ date: "2024-05-08", desktop: 149, mobile: 210 },
|
||||
{ date: "2024-05-09", desktop: 227, mobile: 180 },
|
||||
{ date: "2024-05-10", desktop: 293, mobile: 330 },
|
||||
{ date: "2024-05-11", desktop: 335, mobile: 270 },
|
||||
{ date: "2024-05-12", desktop: 197, mobile: 240 },
|
||||
{ date: "2024-05-13", desktop: 197, mobile: 160 },
|
||||
{ date: "2024-05-14", desktop: 448, mobile: 490 },
|
||||
{ date: "2024-05-15", desktop: 473, mobile: 380 },
|
||||
{ date: "2024-05-16", desktop: 338, mobile: 400 },
|
||||
{ date: "2024-05-17", desktop: 499, mobile: 420 },
|
||||
{ date: "2024-05-18", desktop: 315, mobile: 350 },
|
||||
{ date: "2024-05-19", desktop: 235, mobile: 180 },
|
||||
{ date: "2024-05-20", desktop: 177, mobile: 230 },
|
||||
{ date: "2024-05-21", desktop: 82, mobile: 140 },
|
||||
{ date: "2024-05-22", desktop: 81, mobile: 120 },
|
||||
{ date: "2024-05-23", desktop: 252, mobile: 290 },
|
||||
{ date: "2024-05-24", desktop: 294, mobile: 220 },
|
||||
{ date: "2024-05-25", desktop: 201, mobile: 250 },
|
||||
{ date: "2024-05-26", desktop: 213, mobile: 170 },
|
||||
{ date: "2024-05-27", desktop: 420, mobile: 460 },
|
||||
{ date: "2024-05-28", desktop: 233, mobile: 190 },
|
||||
{ date: "2024-05-29", desktop: 78, mobile: 130 },
|
||||
{ date: "2024-05-30", desktop: 340, mobile: 280 },
|
||||
{ date: "2024-05-31", desktop: 178, mobile: 230 },
|
||||
{ date: "2024-06-01", desktop: 178, mobile: 200 },
|
||||
{ date: "2024-06-02", desktop: 470, mobile: 410 },
|
||||
{ date: "2024-06-03", desktop: 103, mobile: 160 },
|
||||
{ date: "2024-06-04", desktop: 439, mobile: 380 },
|
||||
{ date: "2024-06-05", desktop: 88, mobile: 140 },
|
||||
{ date: "2024-06-06", desktop: 294, mobile: 250 },
|
||||
{ date: "2024-06-07", desktop: 323, mobile: 370 },
|
||||
{ date: "2024-06-08", desktop: 385, mobile: 320 },
|
||||
{ date: "2024-06-09", desktop: 438, mobile: 480 },
|
||||
{ date: "2024-06-10", desktop: 155, mobile: 200 },
|
||||
{ date: "2024-06-11", desktop: 92, mobile: 150 },
|
||||
{ date: "2024-06-12", desktop: 492, mobile: 420 },
|
||||
{ date: "2024-06-13", desktop: 81, mobile: 130 },
|
||||
{ date: "2024-06-14", desktop: 426, mobile: 380 },
|
||||
{ date: "2024-06-15", desktop: 307, mobile: 350 },
|
||||
{ date: "2024-06-16", desktop: 371, mobile: 310 },
|
||||
{ date: "2024-06-17", desktop: 475, mobile: 520 },
|
||||
{ date: "2024-06-18", desktop: 107, mobile: 170 },
|
||||
{ date: "2024-06-19", desktop: 341, mobile: 290 },
|
||||
{ date: "2024-06-20", desktop: 408, mobile: 450 },
|
||||
{ date: "2024-06-21", desktop: 169, mobile: 210 },
|
||||
{ date: "2024-06-22", desktop: 317, mobile: 270 },
|
||||
{ date: "2024-06-23", desktop: 480, mobile: 530 },
|
||||
{ date: "2024-06-24", desktop: 132, mobile: 180 },
|
||||
{ date: "2024-06-25", desktop: 141, mobile: 190 },
|
||||
{ date: "2024-06-26", desktop: 434, mobile: 380 },
|
||||
{ date: "2024-06-27", desktop: 448, mobile: 490 },
|
||||
{ date: "2024-06-28", desktop: 149, mobile: 200 },
|
||||
{ date: "2024-06-29", desktop: 103, mobile: 160 },
|
||||
{ date: "2024-06-30", desktop: 446, mobile: 400 },
|
||||
]
|
||||
|
||||
const chartConfig = {
|
||||
views: {
|
||||
label: "Page Views",
|
||||
},
|
||||
desktop: {
|
||||
label: "Desktop",
|
||||
color: "var(--chart-2)",
|
||||
},
|
||||
mobile: {
|
||||
label: "Mobile",
|
||||
color: "var(--chart-1)",
|
||||
},
|
||||
} satisfies ChartConfig
|
||||
|
||||
export function ChartBarInteractive() {
|
||||
const [activeChart, setActiveChart] =
|
||||
React.useState<keyof typeof chartConfig>("desktop")
|
||||
|
||||
const total = React.useMemo(
|
||||
() => ({
|
||||
desktop: chartData.reduce((acc, curr) => acc + curr.desktop, 0),
|
||||
mobile: chartData.reduce((acc, curr) => acc + curr.mobile, 0),
|
||||
}),
|
||||
[]
|
||||
)
|
||||
|
||||
return (
|
||||
<Card className="py-0">
|
||||
<CardHeader className="flex flex-col items-stretch border-b p-0! sm:flex-row">
|
||||
<div className="flex flex-1 flex-col justify-center gap-1 px-6 pt-4 pb-3 sm:py-0!">
|
||||
<CardTitle>Bar Chart - Interactive</CardTitle>
|
||||
<CardDescription>
|
||||
Showing total visitors for the last 3 months
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex">
|
||||
{["desktop", "mobile"].map((key) => {
|
||||
const chart = key as keyof typeof chartConfig
|
||||
return (
|
||||
<button
|
||||
key={chart}
|
||||
data-active={activeChart === chart}
|
||||
className="relative z-30 flex flex-1 flex-col justify-center gap-1 border-t px-6 py-4 text-left even:border-l data-[active=true]:bg-muted/50 sm:border-t-0 sm:border-l sm:px-8 sm:py-6"
|
||||
onClick={() => setActiveChart(chart)}
|
||||
>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{chartConfig[chart].label}
|
||||
</span>
|
||||
<span className="text-lg leading-none font-bold sm:text-3xl">
|
||||
{total[key as keyof typeof total].toLocaleString()}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="px-2 sm:p-6">
|
||||
<ChartContainer
|
||||
config={chartConfig}
|
||||
className="aspect-auto h-[250px] w-full"
|
||||
>
|
||||
<BarChart
|
||||
accessibilityLayer
|
||||
data={chartData}
|
||||
margin={{
|
||||
left: 12,
|
||||
right: 12,
|
||||
}}
|
||||
>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
minTickGap={32}
|
||||
tickFormatter={(value) => {
|
||||
const date = new Date(value)
|
||||
return date.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})
|
||||
}}
|
||||
/>
|
||||
<ChartTooltip
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
className="w-[150px]"
|
||||
nameKey="views"
|
||||
labelFormatter={(value) => {
|
||||
return new Date(value).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
})
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Bar dataKey={activeChart} fill={`var(--color-${activeChart})`} />
|
||||
</BarChart>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@ -73,7 +73,7 @@
|
||||
|
|
||||
*/
|
||||
|
||||
'home' => '/admin/dashboard',
|
||||
'home' => '/dashboard',
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
|
||||
@ -15,7 +15,7 @@ public function run(): void
|
||||
|
||||
$permissions = [
|
||||
'dashboard' => ['view', 'attendance', 'revenue', 'expense', 'orders_channel', 'orders_payment', 'orders_marketing', 'orders_status', 'cash'],
|
||||
'analysis' => ['view', 'attendance', 'cash', 'product_stock', 'revenue', 'revenue_trend', 'expense', 'busy_hours', 'profit_orders', 'profit_hpp', 'profit_gross', 'profit_margin', 'top_customers', 'top_products', 'top_suppliers', 'marketing_sales', 'raw_materials'],
|
||||
'analysis' => ['view', 'attendance', 'cash', 'product_stock', 'revenue', 'expense', 'busy_hours', 'profit_orders', 'profit_hpp', 'profit_gross', 'profit_margin', 'top_customers', 'top_products', 'top_suppliers', 'marketing_sales', 'raw_materials'],
|
||||
'employees' => ['view', 'create', 'update', 'delete', 'toggle_status', 'reset_password'],
|
||||
'attendances' => ['view', 'create', 'delete', 'manage'],
|
||||
'leave_requests' => ['view', 'create', 'update', 'delete', 'verify'],
|
||||
@ -82,7 +82,6 @@ public function run(): void
|
||||
'analysis.cash',
|
||||
'analysis.product_stock',
|
||||
'analysis.revenue',
|
||||
'analysis.revenue_trend',
|
||||
'analysis.expense',
|
||||
'analysis.busy_hours',
|
||||
'analysis.profit_orders',
|
||||
@ -369,7 +368,6 @@ public function run(): void
|
||||
'analysis.cash',
|
||||
'analysis.product_stock',
|
||||
'analysis.revenue',
|
||||
'analysis.revenue_trend',
|
||||
'analysis.expense',
|
||||
'analysis.busy_hours',
|
||||
'analysis.profit_orders',
|
||||
|
||||
@ -328,7 +328,7 @@ export default function TwoFactorSetupModal({
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && handleClose()}>
|
||||
<DialogContent>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader className="flex items-center justify-center">
|
||||
<GridScanIcon />
|
||||
<DialogTitle>{modalConfig.title}</DialogTitle>
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
import { useState } from 'react';
|
||||
import { ConfirmDialog } from '@/components/dialogs';
|
||||
|
||||
type DeleteConfirmDialogProps<T> = {
|
||||
@ -20,22 +19,10 @@ export function DeleteConfirmDialog<T>({
|
||||
variant,
|
||||
onConfirm,
|
||||
}: DeleteConfirmDialogProps<T>) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
function handleConfirm() {
|
||||
setLoading(true);
|
||||
onConfirm();
|
||||
}
|
||||
|
||||
return (
|
||||
<ConfirmDialog
|
||||
open={target !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setLoading(false);
|
||||
}
|
||||
onOpenChange(open);
|
||||
}}
|
||||
onOpenChange={onOpenChange}
|
||||
title={title}
|
||||
description={
|
||||
typeof description === 'function' && target
|
||||
@ -46,8 +33,7 @@ export function DeleteConfirmDialog<T>({
|
||||
}
|
||||
confirmLabel={confirmLabel}
|
||||
variant={variant}
|
||||
onConfirm={handleConfirm}
|
||||
loading={loading}
|
||||
onConfirm={onConfirm}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -3,27 +3,19 @@ import { ImagePreviewModal } from '@/components/dialogs';
|
||||
|
||||
type ImagePreviewButtonProps = {
|
||||
srcs: string[];
|
||||
modalSrc?: string;
|
||||
modalSrcs?: string[];
|
||||
title?: string;
|
||||
description?: string;
|
||||
alt?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function ImagePreviewButton({
|
||||
srcs,
|
||||
modalSrc,
|
||||
modalSrcs,
|
||||
title,
|
||||
description,
|
||||
alt,
|
||||
className = 'h-10 w-10',
|
||||
}: ImagePreviewButtonProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const modalSources = modalSrcs ?? (modalSrc ? [modalSrc] : null);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
@ -45,10 +37,9 @@ export function ImagePreviewButton({
|
||||
<ImagePreviewModal
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
src={modalSources?.[0] ?? srcs[0]}
|
||||
sources={modalSources ?? srcs}
|
||||
src={srcs[0]}
|
||||
sources={srcs}
|
||||
title={title}
|
||||
description={description}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@ -4,7 +4,6 @@ import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
@ -14,7 +13,6 @@ type ImagePreviewModalProps = {
|
||||
onOpenChange: (open: boolean) => void;
|
||||
src: string | null;
|
||||
title?: string;
|
||||
description?: string;
|
||||
alt?: string;
|
||||
sources?: string[];
|
||||
};
|
||||
@ -24,7 +22,6 @@ export function ImagePreviewModal({
|
||||
onOpenChange,
|
||||
src,
|
||||
title,
|
||||
description,
|
||||
alt = 'Preview',
|
||||
sources,
|
||||
}: ImagePreviewModalProps) {
|
||||
@ -58,10 +55,9 @@ setCurrentIndex(0);
|
||||
}}
|
||||
>
|
||||
<DialogContent showCloseButton>
|
||||
{(title || description) && (
|
||||
{title && (
|
||||
<DialogHeader>
|
||||
{title && <DialogTitle>{title}</DialogTitle>}
|
||||
{description && <DialogDescription>{description}</DialogDescription>}
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
</DialogHeader>
|
||||
)}
|
||||
{currentSrc && (
|
||||
|
||||
@ -59,7 +59,7 @@ function DialogContent({
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] max-h-[85vh] overflow-y-auto translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg",
|
||||
"fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@ -26,8 +26,6 @@ import {
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Cell,
|
||||
Line,
|
||||
LineChart,
|
||||
Pie,
|
||||
PieChart,
|
||||
ResponsiveContainer,
|
||||
@ -184,10 +182,6 @@ type AnalysisProps = {
|
||||
count: number;
|
||||
}>;
|
||||
};
|
||||
revenueTrend: Array<{
|
||||
date: string;
|
||||
qty: number;
|
||||
}>;
|
||||
};
|
||||
|
||||
const revenueChartConfig = {
|
||||
@ -236,15 +230,6 @@ const expenseChartConfig = {
|
||||
|
||||
const expenseKeys = ['total', 'purchase', 'expense', 'advance'] as const;
|
||||
|
||||
const revenueTrendChartConfig = {
|
||||
qty: {
|
||||
label: 'Qty',
|
||||
color: 'var(--chart-1)',
|
||||
},
|
||||
} satisfies ChartConfig;
|
||||
|
||||
const revenueTrendKeys = ['qty'] as const;
|
||||
|
||||
const CHANNEL_COLORS: Record<string, string> = {
|
||||
store: '#22c55e',
|
||||
shopee: '#ee4d2d',
|
||||
@ -415,7 +400,6 @@ export default function Analysis({
|
||||
topProducts,
|
||||
marketingSales,
|
||||
orderStats,
|
||||
revenueTrend,
|
||||
}: AnalysisProps) {
|
||||
const { can, hasAnyRole, hasRole } = useCan();
|
||||
const [startDate, setStartDate] = useState(initialFilters.start_date ?? '');
|
||||
@ -511,15 +495,15 @@ export default function Analysis({
|
||||
|
||||
const sectionOrder = useMemo(() => {
|
||||
if (hasAnyRole(['owner', 'developer'])) {
|
||||
return { statCards: 1, revenue: 4, revenueByChannel: 5, orderPie: 6, expense: 7, totalOrder: 8, revenueTrend: 9, marketingSales: 10, topSuppliers: 11, topProducts: 12, topCustomers: 13, busyHours: 14 };
|
||||
return { statCards: 1, revenue: 4, revenueByChannel: 5, orderPie: 6, expense: 7, totalOrder: 8, marketingSales: 9, topSuppliers: 10, topProducts: 11, topCustomers: 12, busyHours: 13 };
|
||||
}
|
||||
|
||||
if (hasAnyRole(['admin_toko', 'direktur'])) {
|
||||
return { statCards: 1, revenue: 4, revenueByChannel: 5, orderPie: 6, expense: 7, totalOrder: 8, revenueTrend: 9, marketingSales: 10, topProducts: 11, topCustomers: 12, busyHours: 13 };
|
||||
return { statCards: 1, revenue: 4, revenueByChannel: 5, orderPie: 6, expense: 7, totalOrder: 8, marketingSales: 9, topProducts: 10, topCustomers: 11, busyHours: 12 };
|
||||
}
|
||||
|
||||
if (hasRole('marketing')) {
|
||||
return { statCards: 1, revenue: 2, revenueByChannel: 3, orderPie: 4, totalOrder: 5, revenueTrend: 6, topProducts: 7, topCustomers: 8 };
|
||||
return { statCards: 1, revenue: 2, revenueByChannel: 3, orderPie: 4, totalOrder: 5, topProducts: 6, topCustomers: 7 };
|
||||
}
|
||||
|
||||
return {};
|
||||
@ -905,82 +889,6 @@ export default function Analysis({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{can('analysis.revenue_trend') && (
|
||||
<Card className="py-0" style={{ order: sectionOrder.revenueTrend ?? 99 }}>
|
||||
<CardHeader className="flex flex-col items-stretch border-b p-0! sm:flex-row">
|
||||
<div className="flex flex-1 flex-col justify-center gap-1 px-6 pt-4 pb-3 sm:py-0!">
|
||||
<CardTitle>Trend Pendapatan</CardTitle>
|
||||
</div>
|
||||
<div className="flex flex-col sm:flex-row">
|
||||
{revenueTrendKeys.map((key) => {
|
||||
const total = revenueTrend.reduce((acc, item) => acc + (item[key] ?? 0), 0);
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
data-active={false}
|
||||
className="relative z-30 flex flex-1 flex-col justify-center gap-1 border-t px-4 py-2 text-left even:border-l sm:border-t-0 sm:border-l sm:px-5 sm:py-3"
|
||||
>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{revenueTrendChartConfig[key].label}
|
||||
</span>
|
||||
<span className="text-xs leading-none font-semibold sm:text-sm">
|
||||
{total.toLocaleString('id-ID')}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="px-2 sm:p-6">
|
||||
{revenueTrend.length > 0 ? (
|
||||
<ChartContainer config={revenueTrendChartConfig} className="aspect-auto h-[250px] w-full">
|
||||
<LineChart data={revenueTrend} margin={{ left: 12, right: 12 }}>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
minTickGap={32}
|
||||
tickFormatter={(value) => {
|
||||
const date = new Date(value);
|
||||
return date.toLocaleDateString('id-ID', { day: 'numeric', month: 'short' });
|
||||
}}
|
||||
/>
|
||||
<ChartTooltip
|
||||
cursor={false}
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
className="w-[150px]"
|
||||
nameKey="views"
|
||||
labelFormatter={(value) => {
|
||||
return new Date(value).toLocaleDateString('id-ID', { day: 'numeric', month: 'short', year: 'numeric' });
|
||||
}}
|
||||
formatter={(value, name, item, index) => (
|
||||
<>
|
||||
<div
|
||||
className="h-2.5 w-2.5 shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)"
|
||||
style={{ "--color-bg": item.payload?.fill ?? item.color, "--color-border": item.payload?.fill ?? item.color } as React.CSSProperties}
|
||||
/>
|
||||
<span className="text-muted-foreground">{revenueTrendChartConfig[item.dataKey as keyof typeof revenueTrendChartConfig]?.label ?? name}</span>
|
||||
<span className="ml-auto font-mono font-medium text-foreground tabular-nums">
|
||||
{Number(value).toLocaleString('id-ID')}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Line dataKey="qty" type="monotone" stroke="var(--color-qty)" strokeWidth={2} dot={false} />
|
||||
</LineChart>
|
||||
</ChartContainer>
|
||||
) : (
|
||||
<div className="flex h-[300px] items-center justify-center text-muted-foreground">Belum ada data trend pendapatan</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{can('analysis.marketing_sales') && (
|
||||
<Card style={{ order: sectionOrder.marketingSales ?? 99 }}>
|
||||
<CardHeader>
|
||||
|
||||
@ -13,7 +13,6 @@ export type CashTransaction = {
|
||||
description: string;
|
||||
receipt_key: string | null;
|
||||
receipt_url: string | null;
|
||||
receipt_conversion_url: string | null;
|
||||
created_at: string;
|
||||
formatted_created_at: string;
|
||||
created_by: {
|
||||
@ -114,17 +113,15 @@ export function createTransactionColumns(
|
||||
id: 'receipt',
|
||||
header: () => <span>Bukti</span>,
|
||||
cell: ({ row }) => {
|
||||
const receiptConversionUrl = row.original.receipt_conversion_url;
|
||||
const receiptUrl = row.original.receipt_url;
|
||||
|
||||
if (!receiptConversionUrl) {
|
||||
if (!receiptUrl) {
|
||||
return <span className="text-muted-foreground">-</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<ImagePreviewButton
|
||||
srcs={[receiptConversionUrl]}
|
||||
modalSrc={receiptUrl ?? undefined}
|
||||
srcs={[receiptUrl]}
|
||||
title={row.original.description}
|
||||
/>
|
||||
);
|
||||
|
||||
@ -428,7 +428,7 @@ export default function EmployeeAdvanceIndex({
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Riwayat Pembayaran</DialogTitle>
|
||||
<DialogDescription>
|
||||
|
||||
@ -10,7 +10,6 @@ export type Expense = {
|
||||
description: string;
|
||||
receipt_key: string | null;
|
||||
receipt_url: string | null;
|
||||
receipt_conversion_url: string | null;
|
||||
created_at: string;
|
||||
formatted_created_at: string;
|
||||
created_by: {
|
||||
@ -52,17 +51,15 @@ export function createExpenseColumns(
|
||||
id: 'receipt',
|
||||
header: () => <span>Bukti</span>,
|
||||
cell: ({ row }) => {
|
||||
const receiptConversionUrl = row.original.receipt_conversion_url;
|
||||
const receiptUrl = row.original.receipt_url;
|
||||
|
||||
if (!receiptConversionUrl) {
|
||||
if (!receiptUrl) {
|
||||
return <span className="text-muted-foreground">-</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<ImagePreviewButton
|
||||
srcs={[receiptConversionUrl]}
|
||||
modalSrc={receiptUrl ?? undefined}
|
||||
srcs={[receiptUrl]}
|
||||
title={row.original.description}
|
||||
/>
|
||||
);
|
||||
|
||||
@ -657,7 +657,7 @@ export default function AttendanceIndex({
|
||||
open={!!detailAttendance}
|
||||
onOpenChange={(open) => !open && setDetailAttendance(null)}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogContent className="sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{isAdmin && detailAttendance?.employee_name
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
import { KeyRound, Pencil, Trash2 } from 'lucide-react';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { RowActions } from '@/components/data-display';
|
||||
import { ToggleStatus } from '@/components/data-display';
|
||||
import { formatCurrency } from '@/lib/utils';
|
||||
@ -10,7 +9,6 @@ export type Employee = {
|
||||
email: string;
|
||||
username: string;
|
||||
is_active: boolean;
|
||||
photo_url: string | null;
|
||||
roles?: { name: string }[];
|
||||
user_profile: {
|
||||
full_name: string;
|
||||
@ -57,33 +55,6 @@ export function createEmployeeColumns(
|
||||
} = params;
|
||||
|
||||
const columns: ColumnDef<Employee>[] = [
|
||||
{
|
||||
id: 'photo',
|
||||
header: () => <span>Foto</span>,
|
||||
meta: { className: 'w-[60px]' },
|
||||
cell: ({ row }) => {
|
||||
const employee = row.original;
|
||||
const fullName = employee.user_profile?.full_name ?? '';
|
||||
const initials = fullName
|
||||
.split(' ')
|
||||
.map((n) => n[0])
|
||||
.join('')
|
||||
.toUpperCase()
|
||||
.slice(0, 2);
|
||||
|
||||
return (
|
||||
<Avatar className="h-10 w-10">
|
||||
<AvatarImage
|
||||
src={employee.photo_url ?? undefined}
|
||||
alt={fullName}
|
||||
/>
|
||||
<AvatarFallback className="text-xs">
|
||||
{initials || '-'}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'user_profile.full_name',
|
||||
id: 'full_name',
|
||||
|
||||
@ -28,7 +28,6 @@ export type Cutting = {
|
||||
total_material_cost: number | null;
|
||||
cost_per_unit: number | null;
|
||||
photo_url: string | null;
|
||||
photo_conversion_url: string | null;
|
||||
created_at: string;
|
||||
created_by: {
|
||||
id: number;
|
||||
@ -49,7 +48,6 @@ export type Cutting = {
|
||||
price: number;
|
||||
stock: number;
|
||||
photo_url: string | null;
|
||||
photo_conversion_url: string | null;
|
||||
raw_material: {
|
||||
id: number;
|
||||
name: string;
|
||||
|
||||
@ -618,7 +618,7 @@ export default function CuttingCreate({ rawMaterials }: Props) {
|
||||
)}
|
||||
|
||||
<Dialog open={comboDialogOpen} onOpenChange={setComboDialogOpen} modal={false}>
|
||||
<DialogContent>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Tambah Kombinasi</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
@ -131,10 +131,8 @@ export function CuttingCardRow({
|
||||
{cutting.photo_url && (
|
||||
<div className="mt-2">
|
||||
<ImagePreviewButton
|
||||
srcs={[cutting.photo_conversion_url ?? cutting.photo_url]}
|
||||
modalSrc={cutting.photo_url}
|
||||
title={productName}
|
||||
description={cutting.description ?? formatDateTime(cutting.created_at)}
|
||||
srcs={[cutting.photo_url]}
|
||||
title="Foto Cutting"
|
||||
className="h-16 w-16"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -126,18 +126,10 @@ acc[name] = [];
|
||||
?.photo_url ? (
|
||||
<ImagePreviewButton
|
||||
srcs={[
|
||||
item
|
||||
.raw_material_price
|
||||
.photo_conversion_url ??
|
||||
item
|
||||
.raw_material_price
|
||||
.photo_url,
|
||||
]}
|
||||
modalSrc={
|
||||
item
|
||||
.raw_material_price
|
||||
.photo_url
|
||||
}
|
||||
title={
|
||||
item
|
||||
.raw_material_price
|
||||
@ -229,18 +221,10 @@ acc[name] = [];
|
||||
?.photo_url ? (
|
||||
<ImagePreviewButton
|
||||
srcs={[
|
||||
item
|
||||
.raw_material_price
|
||||
.photo_conversion_url ??
|
||||
item
|
||||
.raw_material_price
|
||||
.photo_url,
|
||||
]}
|
||||
modalSrc={
|
||||
item
|
||||
.raw_material_price
|
||||
.photo_url
|
||||
}
|
||||
title={
|
||||
item
|
||||
.raw_material_price
|
||||
|
||||
@ -594,7 +594,7 @@ export default function CuttingEdit({ cutting, rawMaterials }: Props) {
|
||||
)}
|
||||
|
||||
<Dialog open={comboDialogOpen} onOpenChange={setComboDialogOpen} modal={false}>
|
||||
<DialogContent>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Tambah Kombinasi</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
@ -17,7 +17,6 @@ export type Purchase = {
|
||||
total: number;
|
||||
notes: string | null;
|
||||
photo_url: string | null;
|
||||
photo_conversion_url: string | null;
|
||||
created_at: string;
|
||||
supplier: {
|
||||
id: number;
|
||||
@ -41,7 +40,6 @@ export type Purchase = {
|
||||
price: number;
|
||||
stock: number;
|
||||
photo_url: string | null;
|
||||
photo_conversion_url: string | null;
|
||||
raw_material: {
|
||||
id: number;
|
||||
name: string;
|
||||
|
||||
@ -118,10 +118,8 @@ export function PurchaseCardRow({
|
||||
{purchase.photo_url && (
|
||||
<div className="mt-2">
|
||||
<ImagePreviewButton
|
||||
srcs={[purchase.photo_conversion_url ?? purchase.photo_url]}
|
||||
modalSrc={purchase.photo_url}
|
||||
title={purchase.supplier.name}
|
||||
description={formatDateTime(purchase.created_at)}
|
||||
srcs={[purchase.photo_url]}
|
||||
title="Foto Belanja"
|
||||
className="h-16 w-16"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -50,9 +50,9 @@ export function PurchaseItemSubRow({ purchase }: { purchase: Purchase }) {
|
||||
{item.raw_material_price?.photo_url ? (
|
||||
<ImagePreviewButton
|
||||
srcs={[
|
||||
item.raw_material_price.photo_conversion_url ?? item.raw_material_price.photo_url,
|
||||
item.raw_material_price
|
||||
.photo_url,
|
||||
]}
|
||||
modalSrc={item.raw_material_price.photo_url}
|
||||
title={
|
||||
item.raw_material_price.variant
|
||||
}
|
||||
|
||||
@ -10,7 +10,6 @@ export type RestockItem = {
|
||||
id: number;
|
||||
name: string;
|
||||
photo_url: string | null;
|
||||
photo_conversion_url: string | null;
|
||||
product: {
|
||||
id: number;
|
||||
name: string;
|
||||
|
||||
@ -100,18 +100,10 @@ acc[name] = [];
|
||||
?.photo_url ? (
|
||||
<ImagePreviewButton
|
||||
srcs={[
|
||||
item
|
||||
.product_variant
|
||||
.photo_conversion_url ??
|
||||
item
|
||||
.product_variant
|
||||
.photo_url,
|
||||
]}
|
||||
modalSrc={
|
||||
item
|
||||
.product_variant
|
||||
.photo_url
|
||||
}
|
||||
title={
|
||||
item
|
||||
.product_variant
|
||||
|
||||
@ -19,7 +19,6 @@ export type TransactionItem = {
|
||||
id: number;
|
||||
name: string;
|
||||
photo_url: string | null;
|
||||
photo_conversion_url: string | null;
|
||||
product: {
|
||||
id: number;
|
||||
name: string;
|
||||
@ -50,8 +49,6 @@ export type Transaction = {
|
||||
nego_price: number | null;
|
||||
total_amount: number;
|
||||
notes: string | null;
|
||||
photo_url: string | null;
|
||||
photo_conversion_url: string | null;
|
||||
created_at: string;
|
||||
created_by: {
|
||||
id: number;
|
||||
|
||||
@ -236,16 +236,12 @@ export default function TransactionCreate({
|
||||
|
||||
const incrementQuantity = useCallback(
|
||||
(variantId: number, amount: number) => {
|
||||
if (amount > 0 && getUnitPrice(variantId) <= 0) {
|
||||
toast.error('Harga produk ini belum diatur.');
|
||||
return;
|
||||
}
|
||||
setQuantities((prev) => ({
|
||||
...prev,
|
||||
[variantId]: Math.max(0, (prev[variantId] ?? 0) + amount),
|
||||
}));
|
||||
},
|
||||
[getUnitPrice],
|
||||
[],
|
||||
);
|
||||
|
||||
const cartItems: CartLine[] = (() => {
|
||||
@ -441,14 +437,10 @@ export default function TransactionCreate({
|
||||
)}{' '}
|
||||
pcs
|
||||
·{' '}
|
||||
{getUnitPrice(variant.id) <= 0 ? (
|
||||
<span className="text-destructive">Harga belum diatur</span>
|
||||
) : (
|
||||
formatCurrency(
|
||||
{formatCurrency(
|
||||
stockType === 'reject'
|
||||
? (variant.prices?.reject ?? 0)
|
||||
: (variant.prices?.[priceType] ?? 0),
|
||||
)
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
@ -498,7 +490,6 @@ export default function TransactionCreate({
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
disabled={getUnitPrice(variant.id) <= 0}
|
||||
onClick={() =>
|
||||
incrementQuantity(
|
||||
variant.id,
|
||||
@ -891,11 +882,7 @@ export default function TransactionCreate({
|
||||
!selectedProductId ||
|
||||
Object.values(quantities).every(
|
||||
(q) => q <= 0,
|
||||
) ||
|
||||
Object.entries(quantities).some(
|
||||
([id, q]) => q > 0 && getUnitPrice(Number(id)) <= 0,
|
||||
) ||
|
||||
total <= 0
|
||||
)
|
||||
}
|
||||
>
|
||||
{processing
|
||||
|
||||
@ -206,16 +206,12 @@ export default function TransactionEdit({
|
||||
|
||||
const incrementQuantity = useCallback(
|
||||
(variantId: number, amount: number) => {
|
||||
if (amount > 0 && getUnitPrice(variantId) <= 0) {
|
||||
toast.error('Harga produk ini belum diatur.');
|
||||
return;
|
||||
}
|
||||
setQuantities((prev) => ({
|
||||
...prev,
|
||||
[variantId]: Math.max(0, (prev[variantId] ?? 0) + amount),
|
||||
}));
|
||||
},
|
||||
[getUnitPrice],
|
||||
[],
|
||||
);
|
||||
|
||||
const cartItems: CartLine[] = (() => {
|
||||
@ -420,14 +416,10 @@ export default function TransactionEdit({
|
||||
)}{' '}
|
||||
pcs
|
||||
·{' '}
|
||||
{getUnitPrice(variant.id) <= 0 ? (
|
||||
<span className="text-destructive">Harga belum diatur</span>
|
||||
) : (
|
||||
formatCurrency(
|
||||
{formatCurrency(
|
||||
stockType === 'reject'
|
||||
? (variant.prices?.reject ?? 0)
|
||||
: (variant.prices?.[priceType] ?? 0),
|
||||
)
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
@ -477,7 +469,6 @@ export default function TransactionEdit({
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
disabled={getUnitPrice(variant.id) <= 0}
|
||||
onClick={() =>
|
||||
incrementQuantity(
|
||||
variant.id,
|
||||
@ -869,11 +860,7 @@ export default function TransactionEdit({
|
||||
uploading ||
|
||||
Object.values(quantities).every(
|
||||
(q) => q <= 0,
|
||||
) ||
|
||||
Object.entries(quantities).some(
|
||||
([id, q]) => q > 0 && getUnitPrice(Number(id)) <= 0,
|
||||
) ||
|
||||
total <= 0
|
||||
)
|
||||
}
|
||||
>
|
||||
{processing
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
import { CheckCircle, ChevronDown, Pencil, Printer, Send, Trash2, XCircle } from 'lucide-react';
|
||||
import { ImagePreviewButton } from '@/components/dialogs';
|
||||
import { RowActions } from '@/components/data-display';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@ -10,7 +9,6 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { useCan } from '@/hooks/use-can';
|
||||
import type { PaperWidth } from '@/hooks/use-thermal-printer';
|
||||
import { formatDateTime, formatNumber } from '@/lib/format';
|
||||
@ -197,32 +195,15 @@ export function TransactionCardRow({
|
||||
{formatCurrency(transaction.cogs)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{transaction.photo_url && (
|
||||
<div className="mt-2">
|
||||
<ImagePreviewButton
|
||||
srcs={[transaction.photo_conversion_url ?? transaction.photo_url]}
|
||||
modalSrc={transaction.photo_url}
|
||||
title={transaction.customer?.name ?? transaction.order_number}
|
||||
description={transaction.notes ?? formatDateTime(transaction.created_at)}
|
||||
className="h-16 w-16"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<DropdownMenu>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<Button variant="ghost" size="icon" title="Cetak Struk">
|
||||
<Printer className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">Cetak Struk</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() => onPrint(transaction, 58)}
|
||||
|
||||
@ -52,9 +52,8 @@ export function TransactionItemSubRow({ transaction }: { transaction: Transactio
|
||||
{item.product_variant?.photo_url ? (
|
||||
<ImagePreviewButton
|
||||
srcs={[
|
||||
item.product_variant.photo_conversion_url ?? item.product_variant.photo_url,
|
||||
item.product_variant.photo_url,
|
||||
]}
|
||||
modalSrc={item.product_variant.photo_url}
|
||||
title={item.product_variant.name}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@ -11,13 +11,10 @@ export type ProductVariant = {
|
||||
id: number;
|
||||
name: string;
|
||||
stock: number;
|
||||
formatted_stock: string;
|
||||
formatted_stock: string
|
||||
reject_stock: number;
|
||||
formatted_reject_stock: string;
|
||||
retail_stock: number;
|
||||
formatted_retail_stock: string;
|
||||
photo_urls: string[];
|
||||
photo_conversion_urls: string[];
|
||||
product_prices: {
|
||||
id: number;
|
||||
type: string;
|
||||
|
||||
@ -168,7 +168,7 @@ export default function ProductIndex({ products, categories, productNames, filte
|
||||
</ComboboxEmpty>
|
||||
<ComboboxList>
|
||||
{(name) => (
|
||||
<ComboboxItem key={name} value={name}>{name}</ComboboxItem>
|
||||
<ComboboxItem value={name}>{name}</ComboboxItem>
|
||||
)}
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
@ -224,7 +224,6 @@ export default function ProductIndex({ products, categories, productNames, filte
|
||||
<Combobox
|
||||
items={categories}
|
||||
itemToStringLabel={(cat) => cat.name}
|
||||
getOptionAsValue={(cat) => String(cat.id)}
|
||||
value={selectedCategory}
|
||||
onValueChange={(value) =>
|
||||
applyFilter('category', value ? String(value.id) : '')
|
||||
@ -240,7 +239,7 @@ export default function ProductIndex({ products, categories, productNames, filte
|
||||
</ComboboxEmpty>
|
||||
<ComboboxList>
|
||||
{(cat) => (
|
||||
<ComboboxItem key={cat.id} value={cat}>
|
||||
<ComboboxItem value={cat}>
|
||||
{cat.name}
|
||||
</ComboboxItem>
|
||||
)}
|
||||
|
||||
@ -90,10 +90,8 @@ export function VariantSubRow({
|
||||
<TableCell>
|
||||
{variant.photo_urls?.length > 0 ? (
|
||||
<ImagePreviewButton
|
||||
srcs={variant.photo_conversion_urls?.length > 0 ? variant.photo_conversion_urls : variant.photo_urls}
|
||||
modalSrcs={variant.photo_urls}
|
||||
srcs={variant.photo_urls}
|
||||
title={variant.name}
|
||||
description={`Stok Bagus: ${variant.formatted_stock} | Stok Reject: ${variant.formatted_reject_stock} | Stok Ecer: ${variant.formatted_retail_stock}`}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-md bg-muted text-xs text-muted-foreground">
|
||||
|
||||
@ -3,9 +3,7 @@ export type RawMaterialVariant = {
|
||||
variant: string;
|
||||
price: number;
|
||||
stock: number;
|
||||
formatted_stock: string;
|
||||
photo_url: string | null;
|
||||
photo_conversion_url: string | null;
|
||||
};
|
||||
|
||||
export type RawMaterial = {
|
||||
|
||||
@ -1,20 +1,12 @@
|
||||
import { Head, Link, router } from '@inertiajs/react';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { CardTable } from '@/components/data-display';
|
||||
import { DeleteConfirmDialog } from '@/components/dialogs';
|
||||
import { FilterPopover } from '@/components/data-display';
|
||||
import { useCardTableExpand } from '@/components/hooks/use-card-table-expand';
|
||||
import { PageHeader } from '@/components/layout';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxContent,
|
||||
ComboboxEmpty,
|
||||
ComboboxInput,
|
||||
ComboboxItem,
|
||||
ComboboxList,
|
||||
} from '@/components/ui/combobox';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@ -46,15 +38,13 @@ type Props = {
|
||||
per_page: number;
|
||||
total: number;
|
||||
};
|
||||
rawMaterialNames: string[];
|
||||
filters: {
|
||||
is_active?: string;
|
||||
name?: string;
|
||||
stock?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export default function RawMaterialIndex({ rawMaterials, rawMaterialNames, filters }: Props) {
|
||||
export default function RawMaterialIndex({ rawMaterials, filters }: Props) {
|
||||
const { can } = useCan();
|
||||
const [deleting, setDeleting] = useState<RawMaterial | null>(null);
|
||||
const [deletingVariant, setDeletingVariant] = useState<{
|
||||
@ -86,11 +76,6 @@ export default function RawMaterialIndex({ rawMaterials, rawMaterialNames, filte
|
||||
filterWithParams: false,
|
||||
});
|
||||
|
||||
const sortedRawMaterialNames = useMemo(
|
||||
() => [...rawMaterialNames].sort(),
|
||||
[rawMaterialNames],
|
||||
);
|
||||
|
||||
function handleDelete() {
|
||||
if (!deleting) {
|
||||
return;
|
||||
@ -122,37 +107,9 @@ export default function RawMaterialIndex({ rawMaterials, rawMaterialNames, filte
|
||||
open={filterOpen}
|
||||
onOpenChange={setFilterOpen}
|
||||
filters={filters}
|
||||
hasActiveFilters={Boolean(filters.is_active || filters.name || filters.stock)}
|
||||
hasActiveFilters={Boolean(filters.is_active || filters.stock)}
|
||||
onClear={clearFilters}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-xs text-muted-foreground">
|
||||
Nama Bahan Baku
|
||||
</label>
|
||||
<Combobox
|
||||
items={sortedRawMaterialNames}
|
||||
value={filters.name ?? ''}
|
||||
onValueChange={(value) =>
|
||||
applyFilter('name', (value as string) ?? '')
|
||||
}
|
||||
>
|
||||
<ComboboxInput
|
||||
placeholder="Pilih bahan baku..."
|
||||
className="w-full"
|
||||
/>
|
||||
<ComboboxContent>
|
||||
<ComboboxEmpty>
|
||||
Tidak ada bahan baku ditemukan.
|
||||
</ComboboxEmpty>
|
||||
<ComboboxList>
|
||||
{(name) => (
|
||||
<ComboboxItem key={name} value={name}>{name}</ComboboxItem>
|
||||
)}
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-xs text-muted-foreground">Status</label>
|
||||
<Select
|
||||
|
||||
@ -87,10 +87,8 @@ export function RawMaterialVariantSubRow({
|
||||
<TableCell>
|
||||
{variant.photo_url ? (
|
||||
<ImagePreviewButton
|
||||
srcs={[variant.photo_conversion_url ?? variant.photo_url]}
|
||||
modalSrc={variant.photo_url}
|
||||
srcs={[variant.photo_url]}
|
||||
title={variant.variant}
|
||||
description={`Stok: ${variant.formatted_stock}`}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-md bg-muted text-xs text-muted-foreground">
|
||||
|
||||
@ -306,7 +306,7 @@ export default function Dashboard({
|
||||
/>
|
||||
|
||||
<Dialog open={showCamera} onOpenChange={setShowCamera}>
|
||||
<DialogContent>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<CameraCapture
|
||||
onCapture={handleCameraCapture}
|
||||
onClose={() => setShowCamera(false)}
|
||||
|
||||
@ -3,7 +3,6 @@ import { useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import ProfileController from '@/actions/App/Http/Controllers/Settings/ProfileController';
|
||||
import { DatePicker } from '@/components/inputs';
|
||||
import { FileUpload } from '@/components/inputs';
|
||||
import { InputError } from '@/components/ui';
|
||||
import { PhoneNumberInput } from '@/components/inputs';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@ -18,8 +17,6 @@ type UserData = {
|
||||
id: number;
|
||||
email: string;
|
||||
username: string;
|
||||
photo_key: string | null;
|
||||
photo_url: string | null;
|
||||
userProfile: {
|
||||
full_name: string;
|
||||
phone_number: string | null;
|
||||
@ -41,10 +38,6 @@ export default function Profile({ user }: Props) {
|
||||
? new Date(user.userProfile.birth_date)
|
||||
: undefined,
|
||||
);
|
||||
const [photoKey, setPhotoKey] = useState<string | null>(
|
||||
user.photo_key ?? null,
|
||||
);
|
||||
const [photoUploading, setPhotoUploading] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -62,27 +55,6 @@ export default function Profile({ user }: Props) {
|
||||
>
|
||||
{({ processing, errors }) => (
|
||||
<div className="grid gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Foto Profil</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<input
|
||||
type="hidden"
|
||||
name="photo"
|
||||
value={photoKey ?? ''}
|
||||
/>
|
||||
<FileUpload
|
||||
value={photoKey}
|
||||
onChange={setPhotoKey}
|
||||
existingUrl={user.photo_url}
|
||||
folder="profile"
|
||||
onUploadingChange={setPhotoUploading}
|
||||
/>
|
||||
<InputError message={errors.photo} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Akun</CardTitle>
|
||||
@ -227,7 +199,7 @@ export default function Profile({ user }: Props) {
|
||||
</Card>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<Button type="submit" disabled={processing || photoUploading}>
|
||||
<Button type="submit" disabled={processing}>
|
||||
{processing ? 'Menyimpan...' : 'Simpan'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user