Compare commits

..

16 Commits

Author SHA1 Message Date
Yoga Pangestu
fbdad6923c feat: enhance product and raw material models with additional stock formatting attributes and update image preview components to include descriptions 2026-08-12 22:14:08 +07:00
Yoga Pangestu
1b48f5c24f feat: implement media handling for user profiles and employee management, including photo uploads and display 2026-08-12 21:57:28 +07:00
Yoga Pangestu
5335e5ad58 feat: add receipt conversion URLs for media handling across various services and components 2026-08-12 21:35:46 +07:00
Yoga Pangestu
ef987360f4 feat: enhance raw material filtering with name selection and add distinct name retrieval 2026-08-12 21:01:03 +07:00
Yoga Pangestu
d0482c03db fix: update home redirect path to /admin/dashboard in Fortify configuration 2026-08-12 20:46:50 +07:00
Yoga Pangestu
278493b4c6 refactor: optimize product name retrieval and enhance category selection in ProductIndex component 2026-08-12 20:38:11 +07:00
Yoga Pangestu
ff8dc64a27 feat: add media handling capabilities to multiple models and improve media conversion process 2026-08-12 20:36:35 +07:00
Yoga Pangestu
a3c642e173 refactor: standardize spacing in lambda functions and SQL queries across services 2026-08-12 20:17:09 +07:00
Yoga Pangestu
7164bd96d3 feat: add validation for product prices in transaction creation and editing 2026-08-12 20:16:57 +07:00
Yoga Pangestu
806c42dfd2 refactor: remove size constraints from DialogContent components across multiple pages 2026-08-12 13:56:29 +07:00
Yoga Pangestu
0d1e722f6f feat: add tooltip for print button in TransactionCardRow component 2026-08-12 13:44:50 +07:00
Yoga Pangestu
e9faa6a322 feat: update media handling to use 'images' collection and improve path generation
- Refactored CustomPathGenerator to handle S3 keys and default paths more effectively.
- Changed media collection references from 'photos' to 'images' across various services.
- Updated methods to retrieve media URLs using the new path generation logic.
- Enhanced transaction-related components to display associated images.
2026-08-12 12:11:29 +07:00
Yoga Pangestu
8377c25173 feat: enhance DeleteConfirmDialog with loading state and confirm handler 2026-08-09 23:40:45 +07:00
Yoga Pangestu
10d2529dcd refactor: rename delete methods to destroy in Product and RawMaterialVariant controllers 2026-08-09 22:22:32 +07:00
Yoga Pangestu
59cc0a5309 fix: remove size validation from shared_prices and variants prices rules 2026-08-09 22:22:17 +07:00
Yoga Pangestu
27beb38e55 feat: add revenue trend analysis feature with chart visualization in Analysis component 2026-08-09 16:45:53 +07:00
76 changed files with 952 additions and 503 deletions

View File

@ -9,8 +9,11 @@ ## 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`

View File

@ -54,7 +54,7 @@ public function store(EmployeeRequest $request): RedirectResponse
public function edit(User $user): Response
{
$user->load(['userProfile', 'employee', 'roles']);
$user->load(['userProfile', 'employee', 'roles', 'media']);
return Inertia::render('admin/hr/employee/edit', [
'employee' => $user,

View File

@ -42,7 +42,7 @@ public function create(): Response
public function store(ProductRequest $request): RedirectResponse
{
return $this->handleAction(
fn () => $this->service->create($request->validated()),
fn () => $this->service->store($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->delete($product),
fn () => $this->service->destroy($product),
'Produk berhasil dihapus.',
'admin.master.products.index'
);

View File

@ -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->delete($product, $variant),
fn () => $this->variantService->destroy($product, $variant),
'Varian berhasil dihapus.',
'admin.master.products.index'
);

View File

@ -22,9 +22,10 @@ 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']),
filters: $request->only(['is_active', 'stock', 'name']),
),
'filters' => $request->only(['is_active', 'stock']),
'rawMaterialNames' => $this->service->getNames(),
'filters' => $request->only(['is_active', 'stock', 'name']),
]);
}

View File

@ -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->delete($rawMaterial, $variant),
fn () => $this->variantService->destroy($rawMaterial, $variant),
'Varian berhasil dihapus.',
'admin.master.raw-materials.index'
);

View File

@ -43,6 +43,7 @@ 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' => [
@ -68,6 +69,7 @@ public function index(Request $request): Response
'topProducts' => $topProducts,
'marketingSales' => $marketingSales,
'orderStats' => $orderStats,
'revenueTrend' => $revenueTrend,
]);
}
}

View File

@ -4,6 +4,8 @@
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;
@ -12,16 +14,26 @@
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,
@ -63,6 +75,8 @@ function () use ($request) {
'address' => $validated['address'] ?? null,
],
);
$this->syncPhoto($user, ['photo_key' => $validated['photo'] ?? null], 'photos');
},
'Profil berhasil diperbarui.',
'profile.edit',

View File

@ -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'), function ($user) {
? tap($request->user()->load('userProfile', 'roles', 'media'), function ($user) {
$user->setRelation('permissions', $user->getAllPermissions());
})
: null,

View File

@ -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', ...($useSamePrice ? ['size:9'] : [])],
'shared_prices' => [Rule::requiredIf(fn () => $useSamePrice), 'nullable', 'array'],
'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', ...(! $useSamePrice ? ['size:9'] : [])],
'variants.*.prices' => [Rule::requiredIf(fn () => ! $useSamePrice), 'nullable', 'array'],
'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'],

View File

@ -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', 'size:9'],
'prices' => ['required', 'array'],
'prices.*.type' => ['required', Rule::in(PriceType::values())],
'prices.*.price' => ['required', 'integer', 'min:0'],
];

View File

@ -42,6 +42,21 @@ 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',
];
}
}

View File

@ -9,6 +9,16 @@ 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')) {
@ -20,20 +30,44 @@ public function getPath(Media $media): string
public function getPathForConversions(Media $media): string
{
return $this->getPath($media).'conversions/';
$s3Key = $media->getCustomProperty('s3_key');
$dir = $s3Key ? dirname($s3Key).'/' : $this->defaultPath($media);
return $dir.'conversions/';
}
public function getPathForResponsiveImages(Media $media): string
{
return $this->getPath($media).'responsive/';
$s3Key = $media->getCustomProperty('s3_key');
$dir = $s3Key ? dirname($s3Key).'/' : $this->defaultPath($media);
return $dir.'responsive/';
}
private function defaultPath(Media $media): string
{
$module = strtolower(class_basename($media->model_type));
$date = $media->created_at->format('Y/m/d');
$id = $media->id;
$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',
];
return "{$module}/{$date}/{$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}/";
}
}

View File

@ -9,8 +9,10 @@
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'])]
@ -62,4 +64,16 @@ 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);
}
}

View File

@ -15,8 +15,10 @@
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'])]
@ -119,4 +121,15 @@ 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);
}
}

View File

@ -13,8 +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_cost_per_unit', 'formatted_other_cost', 'formatted_sewing_cost', 'status_label', 'formatted_total_material_cost'])]
#[Guarded(['id'])]
@ -110,4 +112,15 @@ 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);
}
}

View File

@ -9,8 +9,10 @@
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'])]
@ -56,4 +58,15 @@ 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);
}
}

View File

@ -16,8 +16,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(['channel_label', 'formatted_cogs', 'formatted_discount', 'formatted_nego_price', 'payment_type_label', 'status_label', 'formatted_subtotal', 'formatted_total_amount'])]
#[Guarded(['id'])]
@ -200,4 +202,15 @@ 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);
}
}

View File

@ -12,10 +12,12 @@
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'])]
#[Appends(['formatted_name', 'formatted_stock', 'formatted_reject_stock', 'formatted_retail_stock'])]
#[Guarded(['id'])]
#[ScopedBy([ProductVariantScope::class])]
class ProductVariant extends Model implements HasMedia
@ -45,6 +47,20 @@ 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);
@ -80,4 +96,15 @@ 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);
}
}

View File

@ -10,8 +10,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_discount', 'formatted_shipping_cost', 'formatted_subtotal', 'formatted_total'])]
#[Guarded(['id'])]
@ -71,4 +73,15 @@ 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);
}
}

View File

@ -13,10 +13,12 @@
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'])]
#[Appends(['formatted_price', 'formatted_stock'])]
#[Guarded(['id'])]
#[ScopedBy([RawMaterialPriceScope::class])]
class RawMaterialPrice extends Model implements HasMedia
@ -38,6 +40,13 @@ 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);
@ -55,10 +64,21 @@ public function rawMaterial(): BelongsTo
public function getPhotoUrlAttribute(): ?string
{
$media = $this->getFirstMedia('photos');
$media = $this->getFirstMedia('images');
return $media
? app(S3PresignedService::class)->getTemporaryUrl($media->file_name)
? app(S3PresignedService::class)->getTemporaryUrl($media->getPath())
: null;
}
public function registerMediaCollections(): void
{
$this->addMediaCollection('images');
}
public function registerMediaConversions(?Media $media = null): void
{
$this->addMediaConversion('thumb')
->fit(Fit::Contain, 150, 150);
}
}

View File

@ -13,8 +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(['stock_type_label', 'formatted_subtotal', 'formatted_total'])]
#[Guarded(['id'])]
@ -73,4 +75,15 @@ 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);
}
}

View File

@ -2,6 +2,7 @@
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;
@ -15,13 +16,17 @@
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(['full_name', 'name'])]
#[Appends(['avatar', 'full_name', 'name'])]
#[Guarded(['id'])]
class User extends Authenticatable
class User extends Authenticatable implements HasMedia
{
use HasFactory, HasPushSubscriptions, HasRoles, Notifiable, SoftDeletes, TwoFactorAuthenticatable;
use HasFactory, HasPushSubscriptions, HasRoles, InteractsWithMedia, Notifiable, SoftDeletes, TwoFactorAuthenticatable;
protected function casts(): array
{
@ -55,6 +60,15 @@ 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
{
@ -210,4 +224,15 @@ 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);
}
}

View File

@ -56,7 +56,7 @@ public function deposit(array $data): CashTransaction
));
if (array_key_exists('receipt_key', $data)) {
$this->syncReceipt($transaction, $data['receipt_key'] ?? null, 'receipts', 'cash_transaction_receipt', $data['file_size'] ?? null, $data['file_mime_type'] ?? null);
$this->syncReceipt($transaction, $data['receipt_key'] ?? null, 'photos', '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, 'receipts', 'cash_transaction_receipt', $data['file_size'] ?? null, $data['file_mime_type'] ?? null);
$this->syncReceipt($transaction, $data['receipt_key'] ?? null, 'photos', '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, 'receipts', 'cash_transaction_receipt', $data['file_size'] ?? null, $data['file_mime_type'] ?? null);
$this->syncReceipt($transaction, $data['receipt_key'] ?? null, 'photos', '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('receipts');
$media = $transaction->getFirstMedia('photos');
if ($media) {
Cache::forget("cash_transaction_receipt_{$media->id}");
}
$transaction->clearMediaCollection('receipts');
$transaction->clearMediaCollection('photos');
$deleted = $transaction->delete();
@ -187,24 +187,22 @@ public function destroy(CashTransaction $transaction): bool
private function formatTransaction(CashTransaction $transaction): array
{
$media = $transaction->getFirstMedia('receipts');
$media = $transaction->getFirstMedia('photos');
if (! $media) {
return $transaction->toArray() + [
'receipt_key' => null,
'receipt_url' => null,
'receipt_conversion_url' => null,
];
}
$s3Key = $media->file_name;
if (str_starts_with($s3Key, '/') || ! str_contains($s3Key, '/')) {
$s3Key = $media->getPath();
}
$s3Key = $media->getCustomProperty('s3_key') ?? $media->getPath();
return $transaction->toArray() + [
'receipt_key' => $s3Key,
'receipt_url' => $this->s3Service->getTemporaryUrl($s3Key),
'receipt_conversion_url' => $this->s3Service->getTemporaryUrl($media->getPath('thumb')),
];
}
}

View File

@ -53,7 +53,7 @@ public function store(array $data): Expense
]);
if (array_key_exists('receipt_key', $data)) {
$this->syncReceipt($expense, $data['receipt_key'] ?? null, 'receipts', 'expense_receipt', $data['file_size'] ?? null, $data['file_mime_type'] ?? null);
$this->syncReceipt($expense, $data['receipt_key'] ?? null, 'photos', '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, 'receipts', 'expense_receipt', $data['file_size'] ?? null, $data['file_mime_type'] ?? null);
$this->syncReceipt($expense, $data['receipt_key'] ?? null, 'photos', '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('receipts');
$media = $expense->getFirstMedia('photos');
if ($media) {
Cache::forget("expense_receipt_{$media->id}");
}
$expense->clearMediaCollection('receipts');
$expense->clearMediaCollection('photos');
$deleted = $expense->delete();
@ -149,29 +149,32 @@ public function destroy(Expense $expense): bool
private function formatExpense(Expense $expense): array
{
$media = $expense->getFirstMedia('receipts');
$media = $expense->getFirstMedia('photos');
if (! $media) {
return $expense->toArray() + [
'receipt_key' => null,
'receipt_url' => null,
'receipt_conversion_url' => null,
];
}
$s3Key = $media->file_name;
if (str_starts_with($s3Key, '/') || ! str_contains($s3Key, '/')) {
$s3Key = $media->getPath();
}
$s3Key = $media->getCustomProperty('s3_key') ?? $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,
];
}
}

View File

@ -168,7 +168,7 @@ public function checkIn(array $data): Attendance
]);
if (! empty($data['photo'])) {
$this->registerMediaFromBase64($attendance, $data['photo'], 'check-in', 'attendances');
$this->registerMediaFromBase64($attendance, $data['photo'], 'checkin', 'attendances');
}
NotificationService::notify(
@ -190,7 +190,7 @@ public function checkOut(Attendance $attendance, array $data): Attendance
]);
if (! empty($data['photo'])) {
$this->registerMediaFromBase64($attendance, $data['photo'], 'check-out', 'attendances');
$this->registerMediaFromBase64($attendance, $data['photo'], 'checkout', 'attendances');
}
NotificationService::notify(
@ -303,15 +303,15 @@ private function formatAttendance(Attendance $attendance): array
$toArray['work_duration_minutes'] = $checkIn->diffInMinutes($end);
}
$checkInMedia = $attendance->getFirstMedia('check-in');
$checkOutMedia = $attendance->getFirstMedia('check-out');
$checkInMedia = $attendance->getFirstMedia('checkin');
$checkOutMedia = $attendance->getFirstMedia('checkout');
$toArray['check_in_photo'] = $checkInMedia
? Cache::remember("attendance_check_in_{$checkInMedia->id}", now()->addMinutes(55), fn () => $this->s3Service->getTemporaryUrl($checkInMedia->file_name))
? Cache::remember("attendance_check_in_{$checkInMedia->id}", now()->addMinutes(55), fn () => $this->s3Service->getTemporaryUrl($checkInMedia->getPath()))
: null;
$toArray['check_out_photo'] = $checkOutMedia
? Cache::remember("attendance_check_out_{$checkOutMedia->id}", now()->addMinutes(55), fn () => $this->s3Service->getTemporaryUrl($checkOutMedia->file_name))
? Cache::remember("attendance_check_out_{$checkOutMedia->id}", now()->addMinutes(55), fn () => $this->s3Service->getTemporaryUrl($checkOutMedia->getPath()))
: null;
return $toArray;

View File

@ -6,6 +6,7 @@
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;
@ -16,13 +17,14 @@ class EmployeeService
public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', array $filters = []): LengthAwarePaginator
{
return User::query()
$paginator = 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');
@ -35,6 +37,17 @@ 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

View File

@ -42,16 +42,22 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
->paginate($perPage);
$paginator->getCollection()->each(function (Cutting $cutting) {
$cuttingMedia = $cutting->getFirstMedia('photos');
$cuttingMedia = $cutting->getFirstMedia('images');
$cutting->photo_url = $cuttingMedia
? $this->s3Service->getTemporaryUrl($cuttingMedia->file_name)
? $this->s3Service->getTemporaryUrl($cuttingMedia->getPath())
: null;
$cutting->photo_conversion_url = $cuttingMedia
? $this->s3Service->getTemporaryUrl($cuttingMedia->getPath('thumb'))
: null;
$cutting->cuttingMaterials->each(function (CuttingMaterial $material) {
$media = $material->rawMaterialPrice?->getFirstMedia('photos');
$media = $material->rawMaterialPrice?->getFirstMedia('images');
if ($material->rawMaterialPrice) {
$material->rawMaterialPrice->photo_url = $media
? $this->s3Service->getTemporaryUrl($media->file_name)
? $this->s3Service->getTemporaryUrl($media->getPath())
: null;
$material->rawMaterialPrice->photo_conversion_url = $media
? $this->s3Service->getTemporaryUrl($media->getPath('thumb'))
: null;
}
});
@ -71,7 +77,7 @@ public function getForEdit(Cutting $cutting): array
$result = $cutting->cuttingResults->first();
$materials = $cutting->cuttingMaterials->map(function (CuttingMaterial $material) {
$media = $material->rawMaterialPrice?->getFirstMedia('photos');
$media = $material->rawMaterialPrice?->getFirstMedia('images');
return [
'id' => $material->id,
@ -81,7 +87,7 @@ public function getForEdit(Cutting $cutting): array
'combination_id' => $material->combination_id,
'variant' => $material->rawMaterialPrice?->variant,
'photo_url' => $media
? $this->s3Service->getTemporaryUrl($media->file_name)
? $this->s3Service->getTemporaryUrl($media->getPath())
: null,
];
});
@ -99,10 +105,10 @@ public function getForEdit(Cutting $cutting): array
];
});
$cuttingMedia = $cutting->getFirstMedia('photos');
$photoKey = $cuttingMedia?->file_name;
$cuttingMedia = $cutting->getFirstMedia('images');
$photoKey = $cuttingMedia?->getCustomProperty('s3_key') ?? $cuttingMedia?->file_name;
$photoUrl = $cuttingMedia
? $this->s3Service->getTemporaryUrl($cuttingMedia->file_name)
? $this->s3Service->getTemporaryUrl($cuttingMedia->getPath())
: null;
return [
@ -213,7 +219,7 @@ public function store(array $data): Cutting
$this->registerMedia(
model: $cutting,
s3Key: $data['photo_key'],
collectionName: 'photos',
collectionName: 'images',
orderColumn: 1,
);
}
@ -337,7 +343,7 @@ public function destroy(Cutting $cutting): bool
}
}
$cutting->clearMediaCollection('photos');
$cutting->clearMediaCollection('images');
$cutting->cuttingResults()->delete();
$cutting->cuttingMaterials()->delete();
$cutting->cuttingMaterialCombinations()->delete();

View File

@ -47,7 +47,10 @@ 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->file_name)
? $this->s3Service->getTemporaryUrl($purchaseMedia->getPath())
: null;
$purchase->photo_conversion_url = $purchaseMedia
? $this->s3Service->getTemporaryUrl($purchaseMedia->getPath('thumb'))
: null;
$purchase->purchaseItems->each(function (PurchaseItem $item) {
@ -55,9 +58,12 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
return;
}
$media = $item->rawMaterialPrice->getMedia('photos');
$item->rawMaterialPrice->photo_url = $media->first()
? $this->s3Service->getTemporaryUrl($media->first()->file_name)
$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'))
: null;
});
});
@ -78,9 +84,9 @@ public function getForCreate(): array
->get()
->each(function (RawMaterial $rawMaterial) {
$rawMaterial->rawMaterialPrices->each(function (RawMaterialPrice $price) {
$media = $price->getFirstMedia('photos');
$media = $price->getFirstMedia('images');
$price->photo_url = $media
? $this->s3Service->getTemporaryUrl($media->file_name)
? $this->s3Service->getTemporaryUrl($media->getPath())
: null;
});
}),
@ -103,15 +109,15 @@ public function getForEdit(Purchase $purchase): array
return null;
}
$media = $item->rawMaterialPrice->getFirstMedia('photos');
$media = $item->rawMaterialPrice->getFirstMedia('images');
return [
'id' => $item->rawMaterialPrice->id,
'variant' => $item->rawMaterialPrice->variant,
'price' => $item->unit_price,
'stock' => $item->quantity,
'photo_key' => $media?->file_name,
'photo_url' => $media ? $this->s3Service->getTemporaryUrl($media->file_name) : null,
'photo_key' => $media?->getCustomProperty('s3_key') ?? $media?->file_name,
'photo_url' => $media ? $this->s3Service->getTemporaryUrl($media->getPath()) : null,
];
})->filter()->values();
@ -129,9 +135,9 @@ public function getForEdit(Purchase $purchase): array
->exists();
$purchaseMedia = $purchase->getFirstMedia('photos');
$purchasePhotoKey = $purchaseMedia?->file_name;
$purchasePhotoKey = $purchaseMedia?->getCustomProperty('s3_key') ?? $purchaseMedia?->file_name;
$purchasePhotoUrl = $purchaseMedia
? $this->s3Service->getTemporaryUrl($purchaseMedia->file_name)
? $this->s3Service->getTemporaryUrl($purchaseMedia->getPath())
: null;
return [
@ -267,7 +273,7 @@ private function storeNew(array $data): Purchase
$this->registerMedia(
model: $priceModel,
s3Key: $variantData['photo_key'],
collectionName: 'photos',
collectionName: 'images',
orderColumn: 1,
);
}
@ -412,11 +418,12 @@ public function update(Purchase $purchase, array $data): Purchase
]);
}
if (! empty($v['photo_key']) && $price->getFirstMedia('photos')?->file_name !== $v['photo_key']) {
if (! empty($v['photo_key']) && $price->getFirstMedia('images')?->getCustomProperty('s3_key') !== $v['photo_key']) {
$price->clearMediaCollection('images');
$this->registerMedia(
model: $price,
s3Key: $v['photo_key'],
collectionName: 'photos',
collectionName: 'images',
orderColumn: 1,
);
}

View File

@ -49,9 +49,12 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
return;
}
$media = $item->productVariant->getFirstMedia('photos');
$media = $item->productVariant->getFirstMedia('images');
$item->productVariant->photo_url = $media
? $this->s3Service->getTemporaryUrl($media->file_name)
? $this->s3Service->getTemporaryUrl($media->getPath())
: null;
$item->productVariant->photo_conversion_url = $media
? $this->s3Service->getTemporaryUrl($media->getPath('thumb'))
: null;
});
});

View File

@ -19,6 +19,7 @@
use App\Services\S3PresignedService;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class TransactionService
{
@ -74,14 +75,25 @@ 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('photos');
$media = $item->productVariant->getFirstMedia('images');
$item->productVariant->photo_url = $media
? $this->s3Service->getTemporaryUrl($media->file_name)
? $this->s3Service->getTemporaryUrl($media->getPath())
: null;
$item->productVariant->photo_conversion_url = $media
? $this->s3Service->getTemporaryUrl($media->getPath('thumb'))
: null;
});
@ -160,6 +172,12 @@ 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,
@ -227,6 +245,12 @@ 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;
}
@ -298,9 +322,13 @@ 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')
->with(['productPrices:id,variant_id,type,price', 'product:id,name'])
->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);
@ -315,13 +343,28 @@ 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, $stockType, &$subtotal, &$totalCost) {
return collect($items)->map(function ($item) use ($now, $prices, $capitalPrices, $variantLabels, $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 [

View File

@ -27,8 +27,9 @@ public function __construct(
public function getNames(): Collection
{
return Product::select(['id', 'name'])
->get();
return Product::select('name')
->distinct()
->pluck('name');
}
public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', array $filters = []): LengthAwarePaginator
@ -57,8 +58,9 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
$paginator->getCollection()->each(function ($product) {
$product->productVariants->each(function ($variant) {
$media = $variant->getMedia('photos');
$variant->photo_urls = $media->map(fn($m) => $this->s3Service->getTemporaryUrl($m->file_name))->toArray();
$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();
});
});
@ -160,9 +162,9 @@ public function getForEdit(Product $product): array
]);
$variants = $product->productVariants->map(function (ProductVariant $variant) {
$media = $variant->getMedia('photos');
$photoKeys = $media->pluck('file_name')->toArray();
$photoUrls = $media->map(fn($m) => $this->s3Service->getTemporaryUrl($m->file_name))->toArray();
$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();
return [
'id' => $variant->id,
@ -222,7 +224,7 @@ public function update(Product $product, array $data): Product
->whereNotIn('id', $existingVariantIds)
->each(function (ProductVariant $variant) {
$variant->productPrices()->delete();
$variant->clearMediaCollection('photos');
$variant->clearMediaCollection('images');
$variant->delete();
});
@ -379,14 +381,7 @@ public function update(Product $product, array $data): Product
continue;
}
// 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']);
}
$this->variantService->syncPhotos($variant, $variantData['photo_keys']);
}
return $product;
@ -409,7 +404,7 @@ public function destroy(Product $product): bool
$result = DB::transaction(function () use ($product) {
$product->productVariants->each(function (ProductVariant $variant) {
$variant->productPrices()->delete();
$variant->clearMediaCollection('photos');
$variant->clearMediaCollection('images');
$variant->delete();
});

View File

@ -37,9 +37,9 @@ public function getForRestock(): array
->get()
->each(function (Product $product) {
$product->productVariants->each(function (ProductVariant $variant) {
$media = $variant->getFirstMedia('photos');
$media = $variant->getFirstMedia('images');
$variant->photo_url = $media
? $this->s3Service->getTemporaryUrl($media->file_name)
? $this->s3Service->getTemporaryUrl($media->getPath())
: 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('photos');
$media = $variant->getFirstMedia('images');
$variant->photo_url = $media
? $this->s3Service->getTemporaryUrl($media->file_name)
? $this->s3Service->getTemporaryUrl($media->getPath())
: 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('photos');
$photoKeys = $media->pluck('file_name')->toArray();
$photoUrls = $media->map(fn ($m) => $this->s3Service->getTemporaryUrl($m->file_name))->toArray();
$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();
return [
'id' => $variant->id,
@ -136,8 +136,7 @@ public function update(ProductVariant $variant, array $data): ProductVariant
);
if (! empty($data['photo_keys']) && is_array($data['photo_keys'])) {
$variant->clearMediaCollection('photos');
$this->registerPhotos($variant, $data['photo_keys']);
$this->syncPhotos($variant, $data['photo_keys']);
}
});
@ -157,7 +156,7 @@ public function destroy(Product $product, ProductVariant $variant): bool
$result = DB::transaction(function () use ($variant) {
$variant->productPrices()->delete();
$variant->clearMediaCollection('photos');
$variant->clearMediaCollection('images');
return $variant->delete();
});
@ -172,25 +171,6 @@ 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'];

View File

@ -7,6 +7,7 @@
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
@ -17,6 +18,13 @@ 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()
@ -25,6 +33,7 @@ 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');
})
@ -39,10 +48,14 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
$paginator->getCollection()->each(function ($rawMaterial) {
$rawMaterial->rawMaterialPrices->each(function ($price) {
$media = $price->getMedia('photos');
$price->photo_url = $media->first()
? $this->s3Service->getTemporaryUrl($media->first()->file_name)
: null;
$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;
}
});
});
@ -92,19 +105,17 @@ public function getForEdit(RawMaterial $rawMaterial): array
]);
$variants = $rawMaterial->rawMaterialPrices->map(function (RawMaterialPrice $price) {
$media = $price->getMedia('photos');
$photoKey = $media->first()?->file_name;
$photoUrl = $media->first()
? $this->s3Service->getTemporaryUrl($media->first()->file_name)
: null;
$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();
return [
'id' => $price->id,
'variant' => $price->variant,
'price' => $price->price,
'stock' => $price->stock,
'photo_key' => $photoKey,
'photo_url' => $photoUrl,
'photo_key' => $photoKeys[0] ?? null,
'photo_url' => $photoUrls[0] ?? null,
];
});
@ -134,7 +145,7 @@ public function update(RawMaterial $rawMaterial, array $data): RawMaterial
$rawMaterial->rawMaterialPrices()
->whereNotIn('id', $existingVariantIds)
->each(function (RawMaterialPrice $price) {
$price->clearMediaCollection('photos');
$price->clearMediaCollection('images');
$price->delete();
});
@ -184,9 +195,9 @@ public function update(RawMaterial $rawMaterial, array $data): RawMaterial
]);
if (isset($variantData['photo_key'])) {
$existingKey = $priceModel->getMedia('photos')->first()?->file_name;
$existingKey = $priceModel->getMedia('images')->first()?->getCustomProperty('s3_key');
if ($existingKey !== $variantData['photo_key']) {
$priceModel->clearMediaCollection('photos');
$priceModel->clearMediaCollection('images');
if ($variantData['photo_key']) {
$this->registerPhoto($priceModel, $variantData['photo_key']);
}
@ -202,7 +213,7 @@ public function destroy(RawMaterial $rawMaterial): bool
{
return DB::transaction(function () use ($rawMaterial) {
$rawMaterial->rawMaterialPrices->each(function (RawMaterialPrice $price) {
$price->clearMediaCollection('photos');
$price->clearMediaCollection('images');
$price->delete();
});
@ -216,14 +227,4 @@ 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,
);
}
}

View File

@ -30,9 +30,9 @@ public function getForCutting(): array
->get()
->each(function (RawMaterial $rawMaterial) {
$rawMaterial->rawMaterialPrices->each(function (RawMaterialPrice $price) {
$media = $price->getFirstMedia('photos');
$media = $price->getFirstMedia('images');
$price->photo_url = $media
? $this->s3Service->getTemporaryUrl($media->file_name)
? $this->s3Service->getTemporaryUrl($media->getPath())
: null;
});
})
@ -43,11 +43,9 @@ public function getForEdit(RawMaterialPrice $variant): array
{
$variant->load('media');
$media = $variant->getMedia('photos');
$photoKey = $media->first()?->file_name;
$photoUrl = $media->first()
? $this->s3Service->getTemporaryUrl($media->first()->file_name)
: null;
$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();
return [
'id' => $variant->id,
@ -55,8 +53,8 @@ public function getForEdit(RawMaterialPrice $variant): array
'variant' => $variant->variant,
'price' => $variant->price,
'stock' => $variant->stock,
'photo_key' => $photoKey,
'photo_url' => $photoUrl,
'photo_key' => $photoKeys[0] ?? null,
'photo_url' => $photoUrls[0] ?? null,
];
}
@ -70,11 +68,11 @@ public function update(RawMaterialPrice $variant, array $data): RawMaterialPrice
]);
if (array_key_exists('photo_key', $data)) {
$existingKey = $variant->getMedia('photos')->first()?->file_name;
$existingKey = $variant->getMedia('images')->first()?->getCustomProperty('s3_key');
$newKey = $data['photo_key'];
if ($existingKey !== $newKey) {
$variant->clearMediaCollection('photos');
$variant->clearMediaCollection('images');
if ($newKey) {
$this->registerPhoto($variant, $newKey);
}
@ -95,7 +93,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('photos');
$variant->clearMediaCollection('images');
return $variant->delete();
});
@ -109,14 +107,4 @@ 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,
);
}
}

View File

@ -6,7 +6,6 @@
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;
@ -15,10 +14,8 @@
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;
@ -465,6 +462,25 @@ 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)

View File

@ -3,9 +3,12 @@
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
@ -60,31 +63,49 @@ private function registerMedia(
Model $model,
string $s3Key,
string $collectionName,
array $generatedConversions = [],
?int $fileSize = null,
?string $mimeType = null,
?int $orderColumn = null,
?string $name = null,
): void {
$defaultName = pathinfo($s3Key, PATHINFO_FILENAME);
$fileName = pathinfo($s3Key, PATHINFO_BASENAME);
Media::create([
$media = Media::create([
'model_type' => $model->getMorphClass(),
'model_id' => $model->id,
'uuid' => Str::uuid(),
'collection_name' => $collectionName,
'name' => $name ?? $defaultName,
'file_name' => $s3Key,
'name' => $name ?? pathinfo($s3Key, PATHINFO_FILENAME),
'file_name' => $fileName,
'mime_type' => $mimeType ?? 'image/jpeg',
'disk' => 's3',
'conversions_disk' => 's3',
'size' => $fileSize ?? 0,
'manipulations' => [],
'custom_properties' => [],
'generated_conversions' => $generatedConversions,
'custom_properties' => ['s3_key' => $s3Key],
'generated_conversions' => [],
'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
@ -93,7 +114,7 @@ private function syncPhoto(Model $model, array $data, string $collectionName = '
return;
}
$currentKey = $model->getFirstMedia($collectionName)?->file_name;
$currentKey = $model->getFirstMedia($collectionName)?->getCustomProperty('s3_key');
if ($data['photo_key'] === $currentKey) {
return;
@ -114,11 +135,7 @@ 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?->file_name;
if ($currentMedia && ! str_contains($currentKey, '/')) {
$currentKey = $currentMedia->getPath();
}
$currentKey = $currentMedia?->getCustomProperty('s3_key');
if ($newKey === $currentKey) {
return;
@ -135,10 +152,58 @@ 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();
}
}

221
chart.tsx
View File

@ -1,221 +0,0 @@
"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>
)
}

View File

@ -73,7 +73,7 @@
|
*/
'home' => '/dashboard',
'home' => '/admin/dashboard',
/*
|--------------------------------------------------------------------------

View File

@ -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', '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', 'revenue_trend', '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,6 +82,7 @@ public function run(): void
'analysis.cash',
'analysis.product_stock',
'analysis.revenue',
'analysis.revenue_trend',
'analysis.expense',
'analysis.busy_hours',
'analysis.profit_orders',
@ -368,6 +369,7 @@ public function run(): void
'analysis.cash',
'analysis.product_stock',
'analysis.revenue',
'analysis.revenue_trend',
'analysis.expense',
'analysis.busy_hours',
'analysis.profit_orders',

View File

@ -328,7 +328,7 @@ export default function TwoFactorSetupModal({
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && handleClose()}>
<DialogContent className="sm:max-w-md">
<DialogContent>
<DialogHeader className="flex items-center justify-center">
<GridScanIcon />
<DialogTitle>{modalConfig.title}</DialogTitle>

View File

@ -1,3 +1,4 @@
import { useState } from 'react';
import { ConfirmDialog } from '@/components/dialogs';
type DeleteConfirmDialogProps<T> = {
@ -19,10 +20,22 @@ export function DeleteConfirmDialog<T>({
variant,
onConfirm,
}: DeleteConfirmDialogProps<T>) {
const [loading, setLoading] = useState(false);
function handleConfirm() {
setLoading(true);
onConfirm();
}
return (
<ConfirmDialog
open={target !== null}
onOpenChange={onOpenChange}
onOpenChange={(open) => {
if (!open) {
setLoading(false);
}
onOpenChange(open);
}}
title={title}
description={
typeof description === 'function' && target
@ -33,7 +46,8 @@ export function DeleteConfirmDialog<T>({
}
confirmLabel={confirmLabel}
variant={variant}
onConfirm={onConfirm}
onConfirm={handleConfirm}
loading={loading}
/>
);
}

View File

@ -3,19 +3,27 @@ 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
@ -37,9 +45,10 @@ export function ImagePreviewButton({
<ImagePreviewModal
open={open}
onOpenChange={setOpen}
src={srcs[0]}
sources={srcs}
src={modalSources?.[0] ?? srcs[0]}
sources={modalSources ?? srcs}
title={title}
description={description}
/>
</>
);

View File

@ -4,6 +4,7 @@ import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
@ -13,6 +14,7 @@ type ImagePreviewModalProps = {
onOpenChange: (open: boolean) => void;
src: string | null;
title?: string;
description?: string;
alt?: string;
sources?: string[];
};
@ -22,6 +24,7 @@ export function ImagePreviewModal({
onOpenChange,
src,
title,
description,
alt = 'Preview',
sources,
}: ImagePreviewModalProps) {
@ -55,9 +58,10 @@ setCurrentIndex(0);
}}
>
<DialogContent showCloseButton>
{title && (
{(title || description) && (
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
{title && <DialogTitle>{title}</DialogTitle>}
{description && <DialogDescription>{description}</DialogDescription>}
</DialogHeader>
)}
{currentSrc && (

View File

@ -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)] 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)] 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",
className
)}
{...props}

View File

@ -26,6 +26,8 @@ import {
BarChart,
CartesianGrid,
Cell,
Line,
LineChart,
Pie,
PieChart,
ResponsiveContainer,
@ -182,6 +184,10 @@ type AnalysisProps = {
count: number;
}>;
};
revenueTrend: Array<{
date: string;
qty: number;
}>;
};
const revenueChartConfig = {
@ -230,6 +236,15 @@ 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',
@ -400,6 +415,7 @@ export default function Analysis({
topProducts,
marketingSales,
orderStats,
revenueTrend,
}: AnalysisProps) {
const { can, hasAnyRole, hasRole } = useCan();
const [startDate, setStartDate] = useState(initialFilters.start_date ?? '');
@ -495,15 +511,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, marketingSales: 9, topSuppliers: 10, topProducts: 11, topCustomers: 12, busyHours: 13 };
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 };
}
if (hasAnyRole(['admin_toko', 'direktur'])) {
return { statCards: 1, revenue: 4, revenueByChannel: 5, orderPie: 6, expense: 7, totalOrder: 8, marketingSales: 9, topProducts: 10, topCustomers: 11, busyHours: 12 };
return { statCards: 1, revenue: 4, revenueByChannel: 5, orderPie: 6, expense: 7, totalOrder: 8, revenueTrend: 9, marketingSales: 10, topProducts: 11, topCustomers: 12, busyHours: 13 };
}
if (hasRole('marketing')) {
return { statCards: 1, revenue: 2, revenueByChannel: 3, orderPie: 4, totalOrder: 5, topProducts: 6, topCustomers: 7 };
return { statCards: 1, revenue: 2, revenueByChannel: 3, orderPie: 4, totalOrder: 5, revenueTrend: 6, topProducts: 7, topCustomers: 8 };
}
return {};
@ -889,6 +905,82 @@ 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>

View File

@ -13,6 +13,7 @@ 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: {
@ -113,15 +114,17 @@ 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 (!receiptUrl) {
if (!receiptConversionUrl) {
return <span className="text-muted-foreground">-</span>;
}
return (
<ImagePreviewButton
srcs={[receiptUrl]}
srcs={[receiptConversionUrl]}
modalSrc={receiptUrl ?? undefined}
title={row.original.description}
/>
);

View File

@ -428,7 +428,7 @@ export default function EmployeeAdvanceIndex({
}
}}
>
<DialogContent className="sm:max-w-md">
<DialogContent>
<DialogHeader>
<DialogTitle>Riwayat Pembayaran</DialogTitle>
<DialogDescription>

View File

@ -10,6 +10,7 @@ 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: {
@ -51,15 +52,17 @@ 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 (!receiptUrl) {
if (!receiptConversionUrl) {
return <span className="text-muted-foreground">-</span>;
}
return (
<ImagePreviewButton
srcs={[receiptUrl]}
srcs={[receiptConversionUrl]}
modalSrc={receiptUrl ?? undefined}
title={row.original.description}
/>
);

View File

@ -657,7 +657,7 @@ export default function AttendanceIndex({
open={!!detailAttendance}
onOpenChange={(open) => !open && setDetailAttendance(null)}
>
<DialogContent className="sm:max-w-2xl">
<DialogContent>
<DialogHeader>
<DialogTitle>
{isAdmin && detailAttendance?.employee_name

View File

@ -1,5 +1,6 @@
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';
@ -9,6 +10,7 @@ export type Employee = {
email: string;
username: string;
is_active: boolean;
photo_url: string | null;
roles?: { name: string }[];
user_profile: {
full_name: string;
@ -55,6 +57,33 @@ 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',

View File

@ -28,6 +28,7 @@ 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;
@ -48,6 +49,7 @@ export type Cutting = {
price: number;
stock: number;
photo_url: string | null;
photo_conversion_url: string | null;
raw_material: {
id: number;
name: string;

View File

@ -618,7 +618,7 @@ export default function CuttingCreate({ rawMaterials }: Props) {
)}
<Dialog open={comboDialogOpen} onOpenChange={setComboDialogOpen} modal={false}>
<DialogContent className="sm:max-w-md">
<DialogContent>
<DialogHeader>
<DialogTitle>Tambah Kombinasi</DialogTitle>
</DialogHeader>

View File

@ -131,8 +131,10 @@ export function CuttingCardRow({
{cutting.photo_url && (
<div className="mt-2">
<ImagePreviewButton
srcs={[cutting.photo_url]}
title="Foto Cutting"
srcs={[cutting.photo_conversion_url ?? cutting.photo_url]}
modalSrc={cutting.photo_url}
title={productName}
description={cutting.description ?? formatDateTime(cutting.created_at)}
className="h-16 w-16"
/>
</div>

View File

@ -126,10 +126,18 @@ 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
@ -221,10 +229,18 @@ 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

View File

@ -594,7 +594,7 @@ export default function CuttingEdit({ cutting, rawMaterials }: Props) {
)}
<Dialog open={comboDialogOpen} onOpenChange={setComboDialogOpen} modal={false}>
<DialogContent className="sm:max-w-md">
<DialogContent>
<DialogHeader>
<DialogTitle>Tambah Kombinasi</DialogTitle>
</DialogHeader>

View File

@ -17,6 +17,7 @@ export type Purchase = {
total: number;
notes: string | null;
photo_url: string | null;
photo_conversion_url: string | null;
created_at: string;
supplier: {
id: number;
@ -40,6 +41,7 @@ export type Purchase = {
price: number;
stock: number;
photo_url: string | null;
photo_conversion_url: string | null;
raw_material: {
id: number;
name: string;

View File

@ -118,8 +118,10 @@ export function PurchaseCardRow({
{purchase.photo_url && (
<div className="mt-2">
<ImagePreviewButton
srcs={[purchase.photo_url]}
title="Foto Belanja"
srcs={[purchase.photo_conversion_url ?? purchase.photo_url]}
modalSrc={purchase.photo_url}
title={purchase.supplier.name}
description={formatDateTime(purchase.created_at)}
className="h-16 w-16"
/>
</div>

View File

@ -50,9 +50,9 @@ export function PurchaseItemSubRow({ purchase }: { purchase: Purchase }) {
{item.raw_material_price?.photo_url ? (
<ImagePreviewButton
srcs={[
item.raw_material_price
.photo_url,
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.variant
}

View File

@ -10,6 +10,7 @@ export type RestockItem = {
id: number;
name: string;
photo_url: string | null;
photo_conversion_url: string | null;
product: {
id: number;
name: string;

View File

@ -100,10 +100,18 @@ 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

View File

@ -19,6 +19,7 @@ export type TransactionItem = {
id: number;
name: string;
photo_url: string | null;
photo_conversion_url: string | null;
product: {
id: number;
name: string;
@ -49,6 +50,8 @@ 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;

View File

@ -236,12 +236,16 @@ 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[] = (() => {
@ -437,10 +441,14 @@ export default function TransactionCreate({
)}{' '}
pcs
·{' '}
{formatCurrency(
{getUnitPrice(variant.id) <= 0 ? (
<span className="text-destructive">Harga belum diatur</span>
) : (
formatCurrency(
stockType === 'reject'
? (variant.prices?.reject ?? 0)
: (variant.prices?.[priceType] ?? 0),
)
)}
</p>
</div>
@ -490,6 +498,7 @@ export default function TransactionCreate({
type="button"
variant="outline"
size="icon"
disabled={getUnitPrice(variant.id) <= 0}
onClick={() =>
incrementQuantity(
variant.id,
@ -882,7 +891,11 @@ 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

View File

@ -206,12 +206,16 @@ 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[] = (() => {
@ -416,10 +420,14 @@ export default function TransactionEdit({
)}{' '}
pcs
·{' '}
{formatCurrency(
{getUnitPrice(variant.id) <= 0 ? (
<span className="text-destructive">Harga belum diatur</span>
) : (
formatCurrency(
stockType === 'reject'
? (variant.prices?.reject ?? 0)
: (variant.prices?.[priceType] ?? 0),
)
)}
</p>
</div>
@ -469,6 +477,7 @@ export default function TransactionEdit({
type="button"
variant="outline"
size="icon"
disabled={getUnitPrice(variant.id) <= 0}
onClick={() =>
incrementQuantity(
variant.id,
@ -860,7 +869,11 @@ 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

View File

@ -1,4 +1,5 @@
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';
@ -9,6 +10,7 @@ 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';
@ -195,15 +197,32 @@ 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" title="Cetak Struk">
<Button variant="ghost" size="icon">
<Printer className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="top">Cetak Struk</TooltipContent>
</Tooltip>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => onPrint(transaction, 58)}

View File

@ -52,8 +52,9 @@ export function TransactionItemSubRow({ transaction }: { transaction: Transactio
{item.product_variant?.photo_url ? (
<ImagePreviewButton
srcs={[
item.product_variant.photo_url,
item.product_variant.photo_conversion_url ?? item.product_variant.photo_url,
]}
modalSrc={item.product_variant.photo_url}
title={item.product_variant.name}
/>
) : (

View File

@ -11,10 +11,13 @@ 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;

View File

@ -168,7 +168,7 @@ export default function ProductIndex({ products, categories, productNames, filte
</ComboboxEmpty>
<ComboboxList>
{(name) => (
<ComboboxItem value={name}>{name}</ComboboxItem>
<ComboboxItem key={name} value={name}>{name}</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
@ -224,6 +224,7 @@ 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) : '')
@ -239,7 +240,7 @@ export default function ProductIndex({ products, categories, productNames, filte
</ComboboxEmpty>
<ComboboxList>
{(cat) => (
<ComboboxItem value={cat}>
<ComboboxItem key={cat.id} value={cat}>
{cat.name}
</ComboboxItem>
)}

View File

@ -90,8 +90,10 @@ export function VariantSubRow({
<TableCell>
{variant.photo_urls?.length > 0 ? (
<ImagePreviewButton
srcs={variant.photo_urls}
srcs={variant.photo_conversion_urls?.length > 0 ? variant.photo_conversion_urls : variant.photo_urls}
modalSrcs={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">

View File

@ -3,7 +3,9 @@ export type RawMaterialVariant = {
variant: string;
price: number;
stock: number;
formatted_stock: string;
photo_url: string | null;
photo_conversion_url: string | null;
};
export type RawMaterial = {

View File

@ -1,12 +1,20 @@
import { Head, Link, router } from '@inertiajs/react';
import { Plus } from 'lucide-react';
import { useState } from 'react';
import { useMemo, 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,
@ -38,13 +46,15 @@ type Props = {
per_page: number;
total: number;
};
rawMaterialNames: string[];
filters: {
is_active?: string;
name?: string;
stock?: string;
};
};
export default function RawMaterialIndex({ rawMaterials, filters }: Props) {
export default function RawMaterialIndex({ rawMaterials, rawMaterialNames, filters }: Props) {
const { can } = useCan();
const [deleting, setDeleting] = useState<RawMaterial | null>(null);
const [deletingVariant, setDeletingVariant] = useState<{
@ -76,6 +86,11 @@ export default function RawMaterialIndex({ rawMaterials, filters }: Props) {
filterWithParams: false,
});
const sortedRawMaterialNames = useMemo(
() => [...rawMaterialNames].sort(),
[rawMaterialNames],
);
function handleDelete() {
if (!deleting) {
return;
@ -107,9 +122,37 @@ export default function RawMaterialIndex({ rawMaterials, filters }: Props) {
open={filterOpen}
onOpenChange={setFilterOpen}
filters={filters}
hasActiveFilters={Boolean(filters.is_active || filters.stock)}
hasActiveFilters={Boolean(filters.is_active || filters.name || 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

View File

@ -87,8 +87,10 @@ export function RawMaterialVariantSubRow({
<TableCell>
{variant.photo_url ? (
<ImagePreviewButton
srcs={[variant.photo_url]}
srcs={[variant.photo_conversion_url ?? variant.photo_url]}
modalSrc={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">

View File

@ -306,7 +306,7 @@ export default function Dashboard({
/>
<Dialog open={showCamera} onOpenChange={setShowCamera}>
<DialogContent className="sm:max-w-md">
<DialogContent>
<CameraCapture
onCapture={handleCameraCapture}
onClose={() => setShowCamera(false)}

View File

@ -3,6 +3,7 @@ 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';
@ -17,6 +18,8 @@ type UserData = {
id: number;
email: string;
username: string;
photo_key: string | null;
photo_url: string | null;
userProfile: {
full_name: string;
phone_number: string | null;
@ -38,6 +41,10 @@ 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 (
<>
@ -55,6 +62,27 @@ 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>
@ -199,7 +227,7 @@ export default function Profile({ user }: Props) {
</Card>
<div className="flex items-center gap-4">
<Button type="submit" disabled={processing}>
<Button type="submit" disabled={processing || photoUploading}>
{processing ? 'Menyimpan...' : 'Simpan'}
</Button>
</div>