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 ### `users` → User
`id` `email`(unique) `username`(unique) `password` `is_active`(bool) `last_login_at`(datetime) `created_at` `updated_at` `deleted_at` `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) - 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() - 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) - 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 ### `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` `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 public function edit(User $user): Response
{ {
$user->load(['userProfile', 'employee', 'roles']); $user->load(['userProfile', 'employee', 'roles', 'media']);
return Inertia::render('admin/hr/employee/edit', [ return Inertia::render('admin/hr/employee/edit', [
'employee' => $user, 'employee' => $user,

View File

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

View File

@ -22,9 +22,10 @@ public function index(PaginatedRequest $request): Response
return Inertia::render('admin/master/raw-material/index', [ return Inertia::render('admin/master/raw-material/index', [
'rawMaterials' => $this->service->paginated( 'rawMaterials' => $this->service->paginated(
...$request->validatedWithDefaults(), ...$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 public function destroy(RawMaterial $rawMaterial, RawMaterialPrice $variant): RedirectResponse
{ {
return $this->handleAction( return $this->handleAction(
fn () => $this->variantService->delete($rawMaterial, $variant), fn () => $this->variantService->destroy($rawMaterial, $variant),
'Varian berhasil dihapus.', 'Varian berhasil dihapus.',
'admin.master.raw-materials.index' 'admin.master.raw-materials.index'
); );

View File

@ -43,6 +43,7 @@ public function index(Request $request): Response
$topProducts = $this->service->getTopProducts($startDate, $endDate); $topProducts = $this->service->getTopProducts($startDate, $endDate);
$marketingSales = $this->service->getMarketingSales($startDate, $endDate); $marketingSales = $this->service->getMarketingSales($startDate, $endDate);
$orderStats = $this->service->getOrderStats($startDate, $endDate); $orderStats = $this->service->getOrderStats($startDate, $endDate);
$revenueTrend = $this->service->getRevenueTrend($startDate, $endDate);
return Inertia::render('admin/analysis/index', [ return Inertia::render('admin/analysis/index', [
'filters' => [ 'filters' => [
@ -68,6 +69,7 @@ public function index(Request $request): Response
'topProducts' => $topProducts, 'topProducts' => $topProducts,
'marketingSales' => $marketingSales, 'marketingSales' => $marketingSales,
'orderStats' => $orderStats, 'orderStats' => $orderStats,
'revenueTrend' => $revenueTrend,
]); ]);
} }
} }

View File

@ -4,6 +4,8 @@
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests\Settings\ProfileUpdateRequest; use App\Http\Requests\Settings\ProfileUpdateRequest;
use App\Services\Concerns\RegistersMedia;
use App\Services\S3PresignedService;
use Illuminate\Contracts\Auth\MustVerifyEmail; use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@ -12,16 +14,26 @@
class ProfileController extends Controller class ProfileController extends Controller
{ {
use RegistersMedia;
public function edit(Request $request): Response public function edit(Request $request): Response
{ {
$user = $request->user(); $user = $request->user();
$user->load('userProfile'); $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', [ return Inertia::render('settings/profile', [
'user' => [ 'user' => [
'id' => $user->id, 'id' => $user->id,
'email' => $user->email, 'email' => $user->email,
'username' => $user->username, 'username' => $user->username,
'photo_key' => $photoKey,
'photo_url' => $photoUrl,
'userProfile' => $user->userProfile ? [ 'userProfile' => $user->userProfile ? [
'full_name' => $user->userProfile->full_name, 'full_name' => $user->userProfile->full_name,
'phone_number' => $user->userProfile->phone_number, 'phone_number' => $user->userProfile->phone_number,
@ -63,6 +75,8 @@ function () use ($request) {
'address' => $validated['address'] ?? null, 'address' => $validated['address'] ?? null,
], ],
); );
$this->syncPhoto($user, ['photo_key' => $validated['photo'] ?? null], 'photos');
}, },
'Profil berhasil diperbarui.', 'Profil berhasil diperbarui.',
'profile.edit', 'profile.edit',

View File

@ -42,7 +42,7 @@ public function share(Request $request): array
'address' => app(SystemSettings::class)->address ?? '', 'address' => app(SystemSettings::class)->address ?? '',
'auth' => [ 'auth' => [
'user' => $request->user() '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()); $user->setRelation('permissions', $user->getAllPermissions());
}) })
: null, : null,

View File

@ -40,7 +40,7 @@ public function rules(): array
'category_ids' => ['required', 'array', 'min:1'], 'category_ids' => ['required', 'array', 'min:1'],
'category_ids.*' => [Rule::exists('categories', 'id')], 'category_ids.*' => [Rule::exists('categories', 'id')],
'use_same_price' => ['nullable', 'boolean'], '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.*.type' => [Rule::requiredIf(fn () => $useSamePrice), 'nullable', Rule::in(PriceType::values())],
'shared_prices.*.price' => [Rule::requiredIf(fn () => $useSamePrice), 'nullable', 'integer', 'min:0'], 'shared_prices.*.price' => [Rule::requiredIf(fn () => $useSamePrice), 'nullable', 'integer', 'min:0'],
'variants' => ['required', 'array', 'min:1'], 'variants' => ['required', 'array', 'min:1'],
@ -51,7 +51,7 @@ public function rules(): array
'variants.*.retail_stock' => ['required', 'integer', 'min:0'], 'variants.*.retail_stock' => ['required', 'integer', 'min:0'],
'variants.*.photo_keys' => ['required', 'array', 'min:1', 'max:5'], 'variants.*.photo_keys' => ['required', 'array', 'min:1', 'max:5'],
'variants.*.photo_keys.*' => ['required', 'string', 'max:500'], '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.*.id' => ['nullable', 'integer'],
'variants.*.prices.*.type' => [Rule::requiredIf(fn () => ! $useSamePrice), 'nullable', Rule::in(PriceType::values())], 'variants.*.prices.*.type' => [Rule::requiredIf(fn () => ! $useSamePrice), 'nullable', Rule::in(PriceType::values())],
'variants.*.prices.*.price' => [Rule::requiredIf(fn () => ! $useSamePrice), 'nullable', 'integer', 'min:0'], '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'], 'retail_stock' => ['required', 'integer', 'min:0'],
'photo_keys' => ['required', 'array', 'min:1', 'max:5'], 'photo_keys' => ['required', 'array', 'min:1', 'max:5'],
'photo_keys.*' => ['required', 'string', 'max:500'], 'photo_keys.*' => ['required', 'string', 'max:500'],
'prices' => ['required', 'array', 'size:9'], 'prices' => ['required', 'array'],
'prices.*.type' => ['required', Rule::in(PriceType::values())], 'prices.*.type' => ['required', Rule::in(PriceType::values())],
'prices.*.price' => ['required', 'integer', 'min:0'], 'prices.*.price' => ['required', 'integer', 'min:0'],
]; ];

View File

@ -42,6 +42,21 @@ public function rules(): array
'gender' => ['nullable', 'in:male,female'], 'gender' => ['nullable', 'in:male,female'],
'birth_date' => ['nullable', 'date'], 'birth_date' => ['nullable', 'date'],
'address' => ['nullable', 'string'], '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 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; $model = $media->model;
if ($model && method_exists($model, 'getMediaPath')) { if ($model && method_exists($model, 'getMediaPath')) {
@ -20,20 +30,44 @@ public function getPath(Media $media): string
public function getPathForConversions(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 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 private function defaultPath(Media $media): string
{ {
$module = strtolower(class_basename($media->model_type)); $moduleMap = [
$date = $media->created_at->format('Y/m/d'); 'UserProfile' => 'user-profile',
$id = $media->id; '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\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Spatie\Image\Enums\Fit;
use Spatie\MediaLibrary\HasMedia; use Spatie\MediaLibrary\HasMedia;
use Spatie\MediaLibrary\InteractsWithMedia; use Spatie\MediaLibrary\InteractsWithMedia;
use Spatie\MediaLibrary\MediaCollections\Models\Media;
#[Appends(['formatted_attendance_date', 'formatted_check_in_at', 'formatted_check_out_at'])] #[Appends(['formatted_attendance_date', 'formatted_check_in_at', 'formatted_check_out_at'])]
#[Guarded(['id'])] #[Guarded(['id'])]
@ -62,4 +64,16 @@ public function payrollAdjustments(): HasMany
{ {
return $this->hasMany(PayrollAdjustment::class); 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\HasOne;
use Illuminate\Database\Eloquent\Relations\MorphTo; use Illuminate\Database\Eloquent\Relations\MorphTo;
use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Eloquent\SoftDeletes;
use Spatie\Image\Enums\Fit;
use Spatie\MediaLibrary\HasMedia; use Spatie\MediaLibrary\HasMedia;
use Spatie\MediaLibrary\InteractsWithMedia; use Spatie\MediaLibrary\InteractsWithMedia;
use Spatie\MediaLibrary\MediaCollections\Models\Media;
#[Appends(['formatted_amount', 'formatted_balance_after', 'formatted_created_at', 'type_label'])] #[Appends(['formatted_amount', 'formatted_balance_after', 'formatted_created_at', 'type_label'])]
#[Guarded(['id'])] #[Guarded(['id'])]
@ -119,4 +121,15 @@ public function reference(): MorphTo
{ {
return $this->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\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Eloquent\SoftDeletes;
use Spatie\Image\Enums\Fit;
use Spatie\MediaLibrary\HasMedia; use Spatie\MediaLibrary\HasMedia;
use Spatie\MediaLibrary\InteractsWithMedia; 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'])] #[Appends(['formatted_cost_per_unit', 'formatted_other_cost', 'formatted_sewing_cost', 'status_label', 'formatted_total_material_cost'])]
#[Guarded(['id'])] #[Guarded(['id'])]
@ -110,4 +112,15 @@ public function submittedBy(): BelongsTo
{ {
return $this->belongsTo(User::class, 'submitted_by_id'); 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\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Eloquent\SoftDeletes;
use Spatie\Image\Enums\Fit;
use Spatie\MediaLibrary\HasMedia; use Spatie\MediaLibrary\HasMedia;
use Spatie\MediaLibrary\InteractsWithMedia; use Spatie\MediaLibrary\InteractsWithMedia;
use Spatie\MediaLibrary\MediaCollections\Models\Media;
#[Appends(['formatted_amount', 'formatted_created_at', 'formatted_date'])] #[Appends(['formatted_amount', 'formatted_created_at', 'formatted_date'])]
#[Guarded(['id'])] #[Guarded(['id'])]
@ -56,4 +58,15 @@ public function createdBy(): BelongsTo
{ {
return $this->belongsTo(User::class, 'created_by_id'); 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\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Eloquent\SoftDeletes;
use Spatie\Image\Enums\Fit;
use Spatie\MediaLibrary\HasMedia; use Spatie\MediaLibrary\HasMedia;
use Spatie\MediaLibrary\InteractsWithMedia; 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'])] #[Appends(['channel_label', 'formatted_cogs', 'formatted_discount', 'formatted_nego_price', 'payment_type_label', 'status_label', 'formatted_subtotal', 'formatted_total_amount'])]
#[Guarded(['id'])] #[Guarded(['id'])]
@ -200,4 +202,15 @@ public function orderItems(): HasMany
{ {
return $this->hasMany(OrderItem::class); 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\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Eloquent\SoftDeletes;
use Spatie\Image\Enums\Fit;
use Spatie\MediaLibrary\HasMedia; use Spatie\MediaLibrary\HasMedia;
use Spatie\MediaLibrary\InteractsWithMedia; 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'])] #[Guarded(['id'])]
#[ScopedBy([ProductVariantScope::class])] #[ScopedBy([ProductVariantScope::class])]
class ProductVariant extends Model implements HasMedia 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 public function orderItems(): HasMany
{ {
return $this->hasMany(OrderItem::class); return $this->hasMany(OrderItem::class);
@ -80,4 +96,15 @@ public function stockMutations(): HasMany
return $this->hasMany(StockMutation::class, 'stockable_id') return $this->hasMany(StockMutation::class, 'stockable_id')
->where('stockable_type', self::class); ->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\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Eloquent\SoftDeletes;
use Spatie\Image\Enums\Fit;
use Spatie\MediaLibrary\HasMedia; use Spatie\MediaLibrary\HasMedia;
use Spatie\MediaLibrary\InteractsWithMedia; use Spatie\MediaLibrary\InteractsWithMedia;
use Spatie\MediaLibrary\MediaCollections\Models\Media;
#[Appends(['formatted_discount', 'formatted_shipping_cost', 'formatted_subtotal', 'formatted_total'])] #[Appends(['formatted_discount', 'formatted_shipping_cost', 'formatted_subtotal', 'formatted_total'])]
#[Guarded(['id'])] #[Guarded(['id'])]
@ -71,4 +73,15 @@ public function supplier(): BelongsTo
{ {
return $this->belongsTo(Supplier::class); 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\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Eloquent\SoftDeletes;
use Spatie\Image\Enums\Fit;
use Spatie\MediaLibrary\HasMedia; use Spatie\MediaLibrary\HasMedia;
use Spatie\MediaLibrary\InteractsWithMedia; use Spatie\MediaLibrary\InteractsWithMedia;
use Spatie\MediaLibrary\MediaCollections\Models\Media;
#[Appends(['formatted_price'])] #[Appends(['formatted_price', 'formatted_stock'])]
#[Guarded(['id'])] #[Guarded(['id'])]
#[ScopedBy([RawMaterialPriceScope::class])] #[ScopedBy([RawMaterialPriceScope::class])]
class RawMaterialPrice extends Model implements HasMedia 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 public function cuttingMaterials(): HasMany
{ {
return $this->hasMany(CuttingMaterial::class); return $this->hasMany(CuttingMaterial::class);
@ -55,10 +64,21 @@ public function rawMaterial(): BelongsTo
public function getPhotoUrlAttribute(): ?string public function getPhotoUrlAttribute(): ?string
{ {
$media = $this->getFirstMedia('photos'); $media = $this->getFirstMedia('images');
return $media return $media
? app(S3PresignedService::class)->getTemporaryUrl($media->file_name) ? app(S3PresignedService::class)->getTemporaryUrl($media->getPath())
: null; : 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\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Eloquent\SoftDeletes;
use Spatie\Image\Enums\Fit;
use Spatie\MediaLibrary\HasMedia; use Spatie\MediaLibrary\HasMedia;
use Spatie\MediaLibrary\InteractsWithMedia; use Spatie\MediaLibrary\InteractsWithMedia;
use Spatie\MediaLibrary\MediaCollections\Models\Media;
#[Appends(['stock_type_label', 'formatted_subtotal', 'formatted_total'])] #[Appends(['stock_type_label', 'formatted_subtotal', 'formatted_total'])]
#[Guarded(['id'])] #[Guarded(['id'])]
@ -73,4 +75,15 @@ public function restockItems(): HasMany
{ {
return $this->hasMany(RestockItem::class); 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; namespace App\Models;
use App\Services\S3PresignedService;
use Illuminate\Database\Eloquent\Attributes\Appends; use Illuminate\Database\Eloquent\Attributes\Appends;
use Illuminate\Database\Eloquent\Attributes\Guarded; use Illuminate\Database\Eloquent\Attributes\Guarded;
use Illuminate\Database\Eloquent\Attributes\Scope; use Illuminate\Database\Eloquent\Attributes\Scope;
@ -15,13 +16,17 @@
use Illuminate\Notifications\Notifiable; use Illuminate\Notifications\Notifiable;
use Laravel\Fortify\TwoFactorAuthenticatable; use Laravel\Fortify\TwoFactorAuthenticatable;
use NotificationChannels\WebPush\HasPushSubscriptions; 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; use Spatie\Permission\Traits\HasRoles;
#[Appends(['full_name', 'name'])] #[Appends(['avatar', 'full_name', 'name'])]
#[Guarded(['id'])] #[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 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] #[Scope]
protected function active(Builder $query): void protected function active(Builder $query): void
{ {
@ -210,4 +224,15 @@ public function userProfile(): HasOne
{ {
return $this->hasOne(UserProfile::class); 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)) { 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( NotificationService::notify(
@ -78,7 +78,7 @@ public function withdrawal(array $data): CashTransaction
)); ));
if (array_key_exists('receipt_key', $data)) { 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( NotificationService::notify(
@ -123,7 +123,7 @@ public function update(CashTransaction $transaction, array $data): CashTransacti
]); ]);
if (array_key_exists('receipt_key', $data)) { 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; return $transaction;
@ -163,12 +163,12 @@ public function destroy(CashTransaction $transaction): bool
$cashAccount->update(['balance' => $newBalance]); $cashAccount->update(['balance' => $newBalance]);
$media = $transaction->getFirstMedia('receipts'); $media = $transaction->getFirstMedia('photos');
if ($media) { if ($media) {
Cache::forget("cash_transaction_receipt_{$media->id}"); Cache::forget("cash_transaction_receipt_{$media->id}");
} }
$transaction->clearMediaCollection('receipts'); $transaction->clearMediaCollection('photos');
$deleted = $transaction->delete(); $deleted = $transaction->delete();
@ -187,24 +187,22 @@ public function destroy(CashTransaction $transaction): bool
private function formatTransaction(CashTransaction $transaction): array private function formatTransaction(CashTransaction $transaction): array
{ {
$media = $transaction->getFirstMedia('receipts'); $media = $transaction->getFirstMedia('photos');
if (! $media) { if (! $media) {
return $transaction->toArray() + [ return $transaction->toArray() + [
'receipt_key' => null, 'receipt_key' => null,
'receipt_url' => null, 'receipt_url' => null,
'receipt_conversion_url' => null,
]; ];
} }
$s3Key = $media->file_name; $s3Key = $media->getCustomProperty('s3_key') ?? $media->getPath();
if (str_starts_with($s3Key, '/') || ! str_contains($s3Key, '/')) {
$s3Key = $media->getPath();
}
return $transaction->toArray() + [ return $transaction->toArray() + [
'receipt_key' => $s3Key, 'receipt_key' => $s3Key,
'receipt_url' => $this->s3Service->getTemporaryUrl($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)) { 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; return $expense;
@ -100,7 +100,7 @@ public function update(Expense $expense, array $data): Expense
]); ]);
if (array_key_exists('receipt_key', $data)) { 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; return $expense;
@ -125,12 +125,12 @@ public function destroy(Expense $expense): bool
type: CashTransactionType::DEPOSIT, type: CashTransactionType::DEPOSIT,
); );
$media = $expense->getFirstMedia('receipts'); $media = $expense->getFirstMedia('photos');
if ($media) { if ($media) {
Cache::forget("expense_receipt_{$media->id}"); Cache::forget("expense_receipt_{$media->id}");
} }
$expense->clearMediaCollection('receipts'); $expense->clearMediaCollection('photos');
$deleted = $expense->delete(); $deleted = $expense->delete();
@ -149,29 +149,32 @@ public function destroy(Expense $expense): bool
private function formatExpense(Expense $expense): array private function formatExpense(Expense $expense): array
{ {
$media = $expense->getFirstMedia('receipts'); $media = $expense->getFirstMedia('photos');
if (! $media) { if (! $media) {
return $expense->toArray() + [ return $expense->toArray() + [
'receipt_key' => null, 'receipt_key' => null,
'receipt_url' => null, 'receipt_url' => null,
'receipt_conversion_url' => null,
]; ];
} }
$s3Key = $media->file_name; $s3Key = $media->getCustomProperty('s3_key') ?? $media->getPath();
if (str_starts_with($s3Key, '/') || ! str_contains($s3Key, '/')) {
$s3Key = $media->getPath();
}
$cacheKey = "expense_receipt_{$media->id}"; $cacheKey = "expense_receipt_{$media->id}";
$receiptUrl = Cache::remember($cacheKey, now()->addMinutes(55), function () use ($s3Key) { $receiptUrl = Cache::remember($cacheKey, now()->addMinutes(55), function () use ($s3Key) {
return $this->s3Service->getTemporaryUrl($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() + [ return $expense->toArray() + [
'receipt_key' => $s3Key, 'receipt_key' => $s3Key,
'receipt_url' => $receiptUrl, 'receipt_url' => $receiptUrl,
'receipt_conversion_url' => $receiptConversionUrl,
]; ];
} }
} }

View File

@ -168,7 +168,7 @@ public function checkIn(array $data): Attendance
]); ]);
if (! empty($data['photo'])) { if (! empty($data['photo'])) {
$this->registerMediaFromBase64($attendance, $data['photo'], 'check-in', 'attendances'); $this->registerMediaFromBase64($attendance, $data['photo'], 'checkin', 'attendances');
} }
NotificationService::notify( NotificationService::notify(
@ -190,7 +190,7 @@ public function checkOut(Attendance $attendance, array $data): Attendance
]); ]);
if (! empty($data['photo'])) { if (! empty($data['photo'])) {
$this->registerMediaFromBase64($attendance, $data['photo'], 'check-out', 'attendances'); $this->registerMediaFromBase64($attendance, $data['photo'], 'checkout', 'attendances');
} }
NotificationService::notify( NotificationService::notify(
@ -303,15 +303,15 @@ private function formatAttendance(Attendance $attendance): array
$toArray['work_duration_minutes'] = $checkIn->diffInMinutes($end); $toArray['work_duration_minutes'] = $checkIn->diffInMinutes($end);
} }
$checkInMedia = $attendance->getFirstMedia('check-in'); $checkInMedia = $attendance->getFirstMedia('checkin');
$checkOutMedia = $attendance->getFirstMedia('check-out'); $checkOutMedia = $attendance->getFirstMedia('checkout');
$toArray['check_in_photo'] = $checkInMedia $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; : null;
$toArray['check_out_photo'] = $checkOutMedia $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; : null;
return $toArray; return $toArray;

View File

@ -6,6 +6,7 @@
use App\Enums\Role; use App\Enums\Role;
use App\Models\Employee; use App\Models\Employee;
use App\Models\User; use App\Models\User;
use App\Services\S3PresignedService;
use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB; 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 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']) ->select(['id', 'email', 'username', 'is_active'])
->where(fn ($q) => $q->whereHas('employee')->orWhereHas('roles', fn ($rq) => $rq->where('name', 'Owner'))) ->where(fn ($q) => $q->whereHas('employee')->orWhereHas('roles', fn ($rq) => $rq->where('name', 'Owner')))
->with([ ->with([
'userProfile' => fn ($q) => $q->select(['id', 'user_id', 'full_name', 'phone_number', 'gender']), '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']), 'employee' => fn ($q) => $q->select(['id', 'user_id', 'join_date', 'employment_status', 'base_salary']),
'roles' => fn ($q) => $q->select(['id', 'name']), 'roles' => fn ($q) => $q->select(['id', 'name']),
'media',
]) ])
->when(! self::hasAnyRole([Role::DEVELOPER, Role::OWNER, Role::DIREKTUR, Role::ADMIN_TOKO]), function ($q) { ->when(! self::hasAnyRole([Role::DEVELOPER, Role::OWNER, Role::DIREKTUR, Role::ADMIN_TOKO]), function ($q) {
$userRoles = auth()->user()->roles->pluck('name'); $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))) ->when($filters['gender'] ?? null, fn ($q, $gender) => $q->whereHas('userProfile', fn ($uq) => $uq->where('gender', $gender)))
->orderBy($sort, $direction) ->orderBy($sort, $direction)
->paginate($perPage); ->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 public function getAll(): Collection

View File

@ -42,16 +42,22 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
->paginate($perPage); ->paginate($perPage);
$paginator->getCollection()->each(function (Cutting $cutting) { $paginator->getCollection()->each(function (Cutting $cutting) {
$cuttingMedia = $cutting->getFirstMedia('photos'); $cuttingMedia = $cutting->getFirstMedia('images');
$cutting->photo_url = $cuttingMedia $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; : null;
$cutting->cuttingMaterials->each(function (CuttingMaterial $material) { $cutting->cuttingMaterials->each(function (CuttingMaterial $material) {
$media = $material->rawMaterialPrice?->getFirstMedia('photos'); $media = $material->rawMaterialPrice?->getFirstMedia('images');
if ($material->rawMaterialPrice) { if ($material->rawMaterialPrice) {
$material->rawMaterialPrice->photo_url = $media $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; : null;
} }
}); });
@ -71,7 +77,7 @@ public function getForEdit(Cutting $cutting): array
$result = $cutting->cuttingResults->first(); $result = $cutting->cuttingResults->first();
$materials = $cutting->cuttingMaterials->map(function (CuttingMaterial $material) { $materials = $cutting->cuttingMaterials->map(function (CuttingMaterial $material) {
$media = $material->rawMaterialPrice?->getFirstMedia('photos'); $media = $material->rawMaterialPrice?->getFirstMedia('images');
return [ return [
'id' => $material->id, 'id' => $material->id,
@ -81,7 +87,7 @@ public function getForEdit(Cutting $cutting): array
'combination_id' => $material->combination_id, 'combination_id' => $material->combination_id,
'variant' => $material->rawMaterialPrice?->variant, 'variant' => $material->rawMaterialPrice?->variant,
'photo_url' => $media 'photo_url' => $media
? $this->s3Service->getTemporaryUrl($media->file_name) ? $this->s3Service->getTemporaryUrl($media->getPath())
: null, : null,
]; ];
}); });
@ -99,10 +105,10 @@ public function getForEdit(Cutting $cutting): array
]; ];
}); });
$cuttingMedia = $cutting->getFirstMedia('photos'); $cuttingMedia = $cutting->getFirstMedia('images');
$photoKey = $cuttingMedia?->file_name; $photoKey = $cuttingMedia?->getCustomProperty('s3_key') ?? $cuttingMedia?->file_name;
$photoUrl = $cuttingMedia $photoUrl = $cuttingMedia
? $this->s3Service->getTemporaryUrl($cuttingMedia->file_name) ? $this->s3Service->getTemporaryUrl($cuttingMedia->getPath())
: null; : null;
return [ return [
@ -213,7 +219,7 @@ public function store(array $data): Cutting
$this->registerMedia( $this->registerMedia(
model: $cutting, model: $cutting,
s3Key: $data['photo_key'], s3Key: $data['photo_key'],
collectionName: 'photos', collectionName: 'images',
orderColumn: 1, orderColumn: 1,
); );
} }
@ -337,7 +343,7 @@ public function destroy(Cutting $cutting): bool
} }
} }
$cutting->clearMediaCollection('photos'); $cutting->clearMediaCollection('images');
$cutting->cuttingResults()->delete(); $cutting->cuttingResults()->delete();
$cutting->cuttingMaterials()->delete(); $cutting->cuttingMaterials()->delete();
$cutting->cuttingMaterialCombinations()->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) { $paginator->getCollection()->each(function (Purchase $purchase) {
$purchaseMedia = $purchase->getFirstMedia('photos'); $purchaseMedia = $purchase->getFirstMedia('photos');
$purchase->photo_url = $purchaseMedia $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; : null;
$purchase->purchaseItems->each(function (PurchaseItem $item) { $purchase->purchaseItems->each(function (PurchaseItem $item) {
@ -55,9 +58,12 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
return; return;
} }
$media = $item->rawMaterialPrice->getMedia('photos'); $media = $item->rawMaterialPrice->getFirstMedia('images');
$item->rawMaterialPrice->photo_url = $media->first() $item->rawMaterialPrice->photo_url = $media
? $this->s3Service->getTemporaryUrl($media->first()->file_name) ? $this->s3Service->getTemporaryUrl($media->getPath())
: null;
$item->rawMaterialPrice->photo_conversion_url = $media
? $this->s3Service->getTemporaryUrl($media->getPath('thumb'))
: null; : null;
}); });
}); });
@ -78,9 +84,9 @@ public function getForCreate(): array
->get() ->get()
->each(function (RawMaterial $rawMaterial) { ->each(function (RawMaterial $rawMaterial) {
$rawMaterial->rawMaterialPrices->each(function (RawMaterialPrice $price) { $rawMaterial->rawMaterialPrices->each(function (RawMaterialPrice $price) {
$media = $price->getFirstMedia('photos'); $media = $price->getFirstMedia('images');
$price->photo_url = $media $price->photo_url = $media
? $this->s3Service->getTemporaryUrl($media->file_name) ? $this->s3Service->getTemporaryUrl($media->getPath())
: null; : null;
}); });
}), }),
@ -103,15 +109,15 @@ public function getForEdit(Purchase $purchase): array
return null; return null;
} }
$media = $item->rawMaterialPrice->getFirstMedia('photos'); $media = $item->rawMaterialPrice->getFirstMedia('images');
return [ return [
'id' => $item->rawMaterialPrice->id, 'id' => $item->rawMaterialPrice->id,
'variant' => $item->rawMaterialPrice->variant, 'variant' => $item->rawMaterialPrice->variant,
'price' => $item->unit_price, 'price' => $item->unit_price,
'stock' => $item->quantity, 'stock' => $item->quantity,
'photo_key' => $media?->file_name, 'photo_key' => $media?->getCustomProperty('s3_key') ?? $media?->file_name,
'photo_url' => $media ? $this->s3Service->getTemporaryUrl($media->file_name) : null, 'photo_url' => $media ? $this->s3Service->getTemporaryUrl($media->getPath()) : null,
]; ];
})->filter()->values(); })->filter()->values();
@ -129,9 +135,9 @@ public function getForEdit(Purchase $purchase): array
->exists(); ->exists();
$purchaseMedia = $purchase->getFirstMedia('photos'); $purchaseMedia = $purchase->getFirstMedia('photos');
$purchasePhotoKey = $purchaseMedia?->file_name; $purchasePhotoKey = $purchaseMedia?->getCustomProperty('s3_key') ?? $purchaseMedia?->file_name;
$purchasePhotoUrl = $purchaseMedia $purchasePhotoUrl = $purchaseMedia
? $this->s3Service->getTemporaryUrl($purchaseMedia->file_name) ? $this->s3Service->getTemporaryUrl($purchaseMedia->getPath())
: null; : null;
return [ return [
@ -267,7 +273,7 @@ private function storeNew(array $data): Purchase
$this->registerMedia( $this->registerMedia(
model: $priceModel, model: $priceModel,
s3Key: $variantData['photo_key'], s3Key: $variantData['photo_key'],
collectionName: 'photos', collectionName: 'images',
orderColumn: 1, 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( $this->registerMedia(
model: $price, model: $price,
s3Key: $v['photo_key'], s3Key: $v['photo_key'],
collectionName: 'photos', collectionName: 'images',
orderColumn: 1, orderColumn: 1,
); );
} }

View File

@ -49,9 +49,12 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
return; return;
} }
$media = $item->productVariant->getFirstMedia('photos'); $media = $item->productVariant->getFirstMedia('images');
$item->productVariant->photo_url = $media $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; : null;
}); });
}); });

View File

@ -19,6 +19,7 @@
use App\Services\S3PresignedService; use App\Services\S3PresignedService;
use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class TransactionService class TransactionService
{ {
@ -74,14 +75,25 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
$order->channel_label = $order->channel->label(); $order->channel_label = $order->channel->label();
$order->price_type_label = $order->price_type->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) { $order->orderItems->each(function (OrderItem $item) {
if (! $item->productVariant) { if (! $item->productVariant) {
return; return;
} }
$media = $item->productVariant->getFirstMedia('photos'); $media = $item->productVariant->getFirstMedia('images');
$item->productVariant->photo_url = $media $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; : null;
}); });
@ -160,6 +172,12 @@ public function store(array $data): Order
$negoPrice = ! empty($data['nego_price']) ? (int) $data['nego_price'] : null; $negoPrice = ! empty($data['nego_price']) ? (int) $data['nego_price'] : null;
$totalAmount = $subtotal - $discount - ($negoPrice ?? 0); $totalAmount = $subtotal - $discount - ($negoPrice ?? 0);
if ($totalAmount <= 0) {
throw ValidationException::withMessages([
'discount' => 'Total harga tidak boleh nol atau kurang.',
]);
}
$order = Order::create([ $order = Order::create([
'created_by_id' => auth()->id(), 'created_by_id' => auth()->id(),
'customer_id' => $data['customer_id'] ?? null, '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; $negoPrice = ! empty($data['nego_price']) ? (int) $data['nego_price'] : null;
$totalAmount = $subtotal - $discount - ($negoPrice ?? 0); $totalAmount = $subtotal - $discount - ($negoPrice ?? 0);
if ($totalAmount <= 0) {
throw ValidationException::withMessages([
'discount' => 'Total harga tidak boleh nol atau kurang.',
]);
}
foreach ($itemRows as &$row) { foreach ($itemRows as &$row) {
$row['order_id'] = $order->id; $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(); $variantIds = collect($items)->pluck('product_variant_id')->unique()->all();
$variants = ProductVariant::query() $variants = ProductVariant::query()
->whereKey($variantIds) ->whereKey($variantIds)
->with('productPrices:id,variant_id,type,price') ->with(['productPrices:id,variant_id,type,price', 'product:id,name'])
->get(); ->get();
$variantLabels = $variants->mapWithKeys(fn (ProductVariant $v) => [
$v->id => $v->product->name.' - '.$v->name,
]);
$prices = $variants->mapWithKeys(function (ProductVariant $variant) use ($resolvedPriceType) { $prices = $variants->mapWithKeys(function (ProductVariant $variant) use ($resolvedPriceType) {
$price = $variant->productPrices $price = $variant->productPrices
->first(fn ($p) => $p->type === $resolvedPriceType); ->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 [$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']; $quantity = (int) $item['quantity'];
$unitPrice = (int) ($prices[$item['product_variant_id']] ?? 0); $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; $itemSubtotal = $unitPrice * $quantity;
$subtotal += $itemSubtotal; $subtotal += $itemSubtotal;
$capitalPrice = (int) ($capitalPrices[$item['product_variant_id']] ?? 0); $capitalPrice = (int) ($capitalPrices[$item['product_variant_id']] ?? 0);
if ($capitalPrice <= 0) {
throw ValidationException::withMessages([
'items' => 'Harga modal untuk "'.$label.'" belum diatur.',
]);
}
$totalCost += $capitalPrice * $quantity; $totalCost += $capitalPrice * $quantity;
return [ return [

View File

@ -27,8 +27,9 @@ public function __construct(
public function getNames(): Collection public function getNames(): Collection
{ {
return Product::select(['id', 'name']) return Product::select('name')
->get(); ->distinct()
->pluck('name');
} }
public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', array $filters = []): LengthAwarePaginator public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', array $filters = []): LengthAwarePaginator
@ -40,10 +41,10 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
'productVariants:id,product_id,name,stock,reject_stock,retail_stock', 'productVariants:id,product_id,name,stock,reject_stock,retail_stock',
'productVariants.productPrices:id,variant_id,type,price', 'productVariants.productPrices:id,variant_id,type,price',
]) ])
->when($search, fn($q) => $q->where('name', 'like', "%{$search}%")) ->when($search, fn ($q) => $q->where('name', 'like', "%{$search}%"))
->when($filters['name'] ?? null, fn($q, $name) => $q->where('name', 'like', "%{$name}%")) ->when($filters['name'] ?? null, fn ($q, $name) => $q->where('name', 'like', "%{$name}%"))
->when($filters['status'] ?? null, fn($q, $status) => $q->where('status', $status)) ->when($filters['status'] ?? null, fn ($q, $status) => $q->where('status', $status))
->when($filters['category'] ?? null, fn($q, $categoryId) => $q->whereHas('categories', function ($cq) use ($categoryId) { ->when($filters['category'] ?? null, fn ($q, $categoryId) => $q->whereHas('categories', function ($cq) use ($categoryId) {
$cq->where('categories.id', $categoryId); $cq->where('categories.id', $categoryId);
})) }))
->when(($filters['stock'] ?? null) === 'empty', function ($q) { ->when(($filters['stock'] ?? null) === 'empty', function ($q) {
@ -57,8 +58,9 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
$paginator->getCollection()->each(function ($product) { $paginator->getCollection()->each(function ($product) {
$product->productVariants->each(function ($variant) { $product->productVariants->each(function ($variant) {
$media = $variant->getMedia('photos'); $media = $variant->getMedia('images');
$variant->photo_urls = $media->map(fn($m) => $this->s3Service->getTemporaryUrl($m->file_name))->toArray(); $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();
}); });
}); });
@ -84,7 +86,7 @@ public function store(array $data): Product
// Bulk insert variants // Bulk insert variants
$now = now(); $now = now();
$variantRows = collect($data['variants'])->map(fn($v) => [ $variantRows = collect($data['variants'])->map(fn ($v) => [
'product_id' => $product->id, 'product_id' => $product->id,
'name' => $v['name'], 'name' => $v['name'],
'stock' => $v['stock'], 'stock' => $v['stock'],
@ -98,7 +100,7 @@ public function store(array $data): Product
// Map variant name -> variant ID // Map variant name -> variant ID
$insertedVariants = ProductVariant::where('product_id', $product->id)->get(); $insertedVariants = ProductVariant::where('product_id', $product->id)->get();
$variantMap = $insertedVariants->mapWithKeys(fn($v) => [$v->name => $v->id]); $variantMap = $insertedVariants->mapWithKeys(fn ($v) => [$v->name => $v->id]);
// Bulk insert prices // Bulk insert prices
$priceRows = []; $priceRows = [];
@ -144,7 +146,7 @@ public function store(array $data): Product
NotificationService::notify( NotificationService::notify(
roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO], roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO],
title: 'Produk Baru', title: 'Produk Baru',
body: "Produk \"{$product->name}\" berhasil ditambahkan" . ' oleh ' . auth()->user()->full_name . '.', body: "Produk \"{$product->name}\" berhasil ditambahkan".' oleh '.auth()->user()->full_name.'.',
url: route('admin.master.products.index'), url: route('admin.master.products.index'),
); );
@ -160,9 +162,9 @@ public function getForEdit(Product $product): array
]); ]);
$variants = $product->productVariants->map(function (ProductVariant $variant) { $variants = $product->productVariants->map(function (ProductVariant $variant) {
$media = $variant->getMedia('photos'); $media = $variant->getMedia('images');
$photoKeys = $media->pluck('file_name')->toArray(); $photoKeys = $media->map(fn ($m) => $m->getCustomProperty('s3_key') ?? $m->file_name)->toArray();
$photoUrls = $media->map(fn($m) => $this->s3Service->getTemporaryUrl($m->file_name))->toArray(); $photoUrls = $media->map(fn ($m) => $this->s3Service->getTemporaryUrl($m->getPath()))->toArray();
return [ return [
'id' => $variant->id, 'id' => $variant->id,
@ -172,7 +174,7 @@ public function getForEdit(Product $product): array
'retail_stock' => $variant->retail_stock, 'retail_stock' => $variant->retail_stock,
'photo_keys' => $photoKeys, 'photo_keys' => $photoKeys,
'photo_urls' => $photoUrls, 'photo_urls' => $photoUrls,
'prices' => $variant->productPrices->map(fn($p) => [ 'prices' => $variant->productPrices->map(fn ($p) => [
'type' => $p->type->value, 'type' => $p->type->value,
'price' => $p->price, 'price' => $p->price,
]), ]),
@ -222,7 +224,7 @@ public function update(Product $product, array $data): Product
->whereNotIn('id', $existingVariantIds) ->whereNotIn('id', $existingVariantIds)
->each(function (ProductVariant $variant) { ->each(function (ProductVariant $variant) {
$variant->productPrices()->delete(); $variant->productPrices()->delete();
$variant->clearMediaCollection('photos'); $variant->clearMediaCollection('images');
$variant->delete(); $variant->delete();
}); });
@ -230,7 +232,7 @@ public function update(Product $product, array $data): Product
$existingVariantsMap = ProductVariant::whereIn('id', $existingVariantIds) $existingVariantsMap = ProductVariant::whereIn('id', $existingVariantIds)
->with('media') ->with('media')
->get() ->get()
->mapWithKeys(fn($v) => [$v->id => $v]); ->mapWithKeys(fn ($v) => [$v->id => $v]);
// Collect old stock data + identify changed variants // Collect old stock data + identify changed variants
$oldStockDataMap = []; $oldStockDataMap = [];
@ -256,7 +258,7 @@ public function update(Product $product, array $data): Product
// Bulk update changed variants (only changed ones, not all) // Bulk update changed variants (only changed ones, not all)
if ($changedIds !== []) { if ($changedIds !== []) {
$changedUpdates = collect($data['variants']) $changedUpdates = collect($data['variants'])
->filter(fn($v) => isset($v['id']) && in_array($v['id'], $changedIds)); ->filter(fn ($v) => isset($v['id']) && in_array($v['id'], $changedIds));
foreach ($changedUpdates as $variantData) { foreach ($changedUpdates as $variantData) {
$existingVariantsMap[$variantData['id']]->update([ $existingVariantsMap[$variantData['id']]->update([
@ -269,11 +271,11 @@ public function update(Product $product, array $data): Product
} }
// Bulk create new variants // Bulk create new variants
$newVariantsData = collect($data['variants'])->filter(fn($v) => ! isset($v['id'])); $newVariantsData = collect($data['variants'])->filter(fn ($v) => ! isset($v['id']));
$newVariantIdMap = []; $newVariantIdMap = [];
if ($newVariantsData->isNotEmpty()) { if ($newVariantsData->isNotEmpty()) {
$newVariantRows = $newVariantsData->map(fn($v) => [ $newVariantRows = $newVariantsData->map(fn ($v) => [
'product_id' => $product->id, 'product_id' => $product->id,
'name' => $v['name'], 'name' => $v['name'],
'stock' => $v['stock'], 'stock' => $v['stock'],
@ -290,7 +292,7 @@ public function update(Product $product, array $data): Product
->whereIn('name', $newVariantsData->pluck('name')->toArray()) ->whereIn('name', $newVariantsData->pluck('name')->toArray())
->get(); ->get();
$newVariantIdMap = $newlyCreated->mapWithKeys(fn($v) => [$v->name => $v->id])->toArray(); $newVariantIdMap = $newlyCreated->mapWithKeys(fn ($v) => [$v->name => $v->id])->toArray();
} }
// Build variant_id lookup: existing by id, new by name // Build variant_id lookup: existing by id, new by name
@ -354,7 +356,7 @@ public function update(Product $product, array $data): Product
} }
} }
$changedModels = collect($changedIds)->map(fn($id) => $existingVariantsMap[$id])->filter(); $changedModels = collect($changedIds)->map(fn ($id) => $existingVariantsMap[$id])->filter();
$this->stockMutationService->recordBulkAdjustment( $this->stockMutationService->recordBulkAdjustment(
$changedModels, $changedModels,
$oldStockDataMap, $oldStockDataMap,
@ -379,14 +381,7 @@ public function update(Product $product, array $data): Product
continue; continue;
} }
// Compare existing photo keys vs new ones $this->variantService->syncPhotos($variant, $variantData['photo_keys']);
$existingKeys = $variant->getMedia('photos')->pluck('file_name')->sort()->values()->toArray();
$newKeys = collect($variantData['photo_keys'])->sort()->values()->toArray();
if ($existingKeys !== $newKeys) {
$variant->clearMediaCollection('photos');
$this->variantService->registerPhotos($variant, $variantData['photo_keys']);
}
} }
return $product; return $product;
@ -395,7 +390,7 @@ public function update(Product $product, array $data): Product
NotificationService::notify( NotificationService::notify(
roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO], roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO],
title: 'Produk Diperbarui', title: 'Produk Diperbarui',
body: "Produk \"{$product->name}\" berhasil diperbarui" . ' oleh ' . auth()->user()->full_name . '.', body: "Produk \"{$product->name}\" berhasil diperbarui".' oleh '.auth()->user()->full_name.'.',
url: route('admin.master.products.index'), url: route('admin.master.products.index'),
); );
@ -409,7 +404,7 @@ public function destroy(Product $product): bool
$result = DB::transaction(function () use ($product) { $result = DB::transaction(function () use ($product) {
$product->productVariants->each(function (ProductVariant $variant) { $product->productVariants->each(function (ProductVariant $variant) {
$variant->productPrices()->delete(); $variant->productPrices()->delete();
$variant->clearMediaCollection('photos'); $variant->clearMediaCollection('images');
$variant->delete(); $variant->delete();
}); });
@ -421,7 +416,7 @@ public function destroy(Product $product): bool
NotificationService::notify( NotificationService::notify(
roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO], roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO],
title: 'Produk Dihapus', title: 'Produk Dihapus',
body: "Produk \"{$product->name}\" berhasil dihapus" . ' oleh ' . auth()->user()->full_name . '.', body: "Produk \"{$product->name}\" berhasil dihapus".' oleh '.auth()->user()->full_name.'.',
url: route('admin.master.products.index'), url: route('admin.master.products.index'),
); );
@ -446,7 +441,7 @@ public function approve(Product $product): void
NotificationService::notify( NotificationService::notify(
roles: [Role::OWNER, Role::DEVELOPER], roles: [Role::OWNER, Role::DEVELOPER],
title: 'Produk Disetujui', title: 'Produk Disetujui',
body: "Produk \"{$product->name}\" telah disetujui oleh " . auth()->user()->full_name . '.', body: "Produk \"{$product->name}\" telah disetujui oleh ".auth()->user()->full_name.'.',
url: route('admin.master.products.index'), url: route('admin.master.products.index'),
additionalUser: $product->createdBy ?? null, additionalUser: $product->createdBy ?? null,
); );
@ -462,7 +457,7 @@ public function reject(Product $product, string $reason = ''): void
NotificationService::notify( NotificationService::notify(
roles: [Role::OWNER, Role::DEVELOPER], roles: [Role::OWNER, Role::DEVELOPER],
title: 'Produk Ditolak', title: 'Produk Ditolak',
body: "Produk \"{$product->name}\" telah ditolak oleh " . auth()->user()->full_name . '.', body: "Produk \"{$product->name}\" telah ditolak oleh ".auth()->user()->full_name.'.',
url: route('admin.master.products.index'), url: route('admin.master.products.index'),
additionalUser: $product->createdBy ?? null, additionalUser: $product->createdBy ?? null,
); );
@ -478,7 +473,7 @@ public function resubmit(Product $product): void
NotificationService::notify( NotificationService::notify(
roles: [Role::OWNER, Role::DEVELOPER], roles: [Role::OWNER, Role::DEVELOPER],
title: 'Produk Diajukan Ulang', title: 'Produk Diajukan Ulang',
body: "Produk \"{$product->name}\" telah diajukan ulang oleh " . auth()->user()->full_name . '.', body: "Produk \"{$product->name}\" telah diajukan ulang oleh ".auth()->user()->full_name.'.',
url: route('admin.master.products.index'), url: route('admin.master.products.index'),
); );
} }

View File

@ -37,9 +37,9 @@ public function getForRestock(): array
->get() ->get()
->each(function (Product $product) { ->each(function (Product $product) {
$product->productVariants->each(function (ProductVariant $variant) { $product->productVariants->each(function (ProductVariant $variant) {
$media = $variant->getFirstMedia('photos'); $media = $variant->getFirstMedia('images');
$variant->photo_url = $media $variant->photo_url = $media
? $this->s3Service->getTemporaryUrl($media->file_name) ? $this->s3Service->getTemporaryUrl($media->getPath())
: null; : null;
$capitalPrice = $variant->productPrices $capitalPrice = $variant->productPrices
@ -50,7 +50,7 @@ public function getForRestock(): array
->first(fn ($price) => $price->type === PriceType::REJECT); ->first(fn ($price) => $price->type === PriceType::REJECT);
$variant->reject_price = $rejectPrice?->price ?? 0; $variant->reject_price = $rejectPrice?->price ?? 0;
}); });
}); })->toArray();
} }
public function getForTransaction(): array public function getForTransaction(): array
@ -66,15 +66,15 @@ public function getForTransaction(): array
->get() ->get()
->each(function (Product $product) { ->each(function (Product $product) {
$product->productVariants->each(function (ProductVariant $variant) { $product->productVariants->each(function (ProductVariant $variant) {
$media = $variant->getFirstMedia('photos'); $media = $variant->getFirstMedia('images');
$variant->photo_url = $media $variant->photo_url = $media
? $this->s3Service->getTemporaryUrl($media->file_name) ? $this->s3Service->getTemporaryUrl($media->getPath())
: null; : null;
$prices = $variant->productPrices->mapWithKeys(fn ($p) => [$p->type->value => $p->price]); $prices = $variant->productPrices->mapWithKeys(fn ($p) => [$p->type->value => $p->price]);
$variant->prices = $prices; $variant->prices = $prices;
}); });
}); })->toArray();
} }
public function getForEdit(ProductVariant $variant): array public function getForEdit(ProductVariant $variant): array
@ -84,9 +84,9 @@ public function getForEdit(ProductVariant $variant): array
'media', 'media',
]); ]);
$media = $variant->getMedia('photos'); $media = $variant->getMedia('images');
$photoKeys = $media->pluck('file_name')->toArray(); $photoKeys = $media->map(fn ($m) => $m->getCustomProperty('s3_key') ?? $m->file_name)->toArray();
$photoUrls = $media->map(fn ($m) => $this->s3Service->getTemporaryUrl($m->file_name))->toArray(); $photoUrls = $media->map(fn ($m) => $this->s3Service->getTemporaryUrl($m->getPath()))->toArray();
return [ return [
'id' => $variant->id, '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'])) { if (! empty($data['photo_keys']) && is_array($data['photo_keys'])) {
$variant->clearMediaCollection('photos'); $this->syncPhotos($variant, $data['photo_keys']);
$this->registerPhotos($variant, $data['photo_keys']);
} }
}); });
@ -157,7 +156,7 @@ public function destroy(Product $product, ProductVariant $variant): bool
$result = DB::transaction(function () use ($variant) { $result = DB::transaction(function () use ($variant) {
$variant->productPrices()->delete(); $variant->productPrices()->delete();
$variant->clearMediaCollection('photos'); $variant->clearMediaCollection('images');
return $variant->delete(); return $variant->delete();
}); });
@ -172,25 +171,6 @@ public function destroy(Product $product, ProductVariant $variant): bool
return $result; 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 public function transferStock(ProductVariant $variant, array $data): ProductVariant
{ {
$quantity = (int) $data['quantity']; $quantity = (int) $data['quantity'];

View File

@ -7,6 +7,7 @@
use App\Services\Concerns\RegistersMedia; use App\Services\Concerns\RegistersMedia;
use App\Services\S3PresignedService; use App\Services\S3PresignedService;
use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
class RawMaterialService class RawMaterialService
@ -17,6 +18,13 @@ public function __construct(
private S3PresignedService $s3Service, 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 public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', array $filters = []): LengthAwarePaginator
{ {
$paginator = RawMaterial::query() $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', 'rawMaterialPrices:id,raw_material_id,variant,price,stock',
]) ])
->when($search, fn ($q) => $q->where('name', 'like', "%{$search}%")) ->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) { ->when(($filters['is_active'] ?? null) !== null && ($filters['is_active'] ?? null) !== '', function ($q) use ($filters) {
$q->where('is_active', $filters['is_active'] === 'true'); $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) { $paginator->getCollection()->each(function ($rawMaterial) {
$rawMaterial->rawMaterialPrices->each(function ($price) { $rawMaterial->rawMaterialPrices->each(function ($price) {
$media = $price->getMedia('photos'); $media = $price->getFirstMedia('images');
$price->photo_url = $media->first() if ($media) {
? $this->s3Service->getTemporaryUrl($media->first()->file_name) $price->photo_url = $this->s3Service->getTemporaryUrl($media->getPath());
: null; $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) { $variants = $rawMaterial->rawMaterialPrices->map(function (RawMaterialPrice $price) {
$media = $price->getMedia('photos'); $media = $price->getMedia('images');
$photoKey = $media->first()?->file_name; $photoKeys = $media->map(fn ($m) => $m->getCustomProperty('s3_key') ?? $m->file_name)->toArray();
$photoUrl = $media->first() $photoUrls = $media->map(fn ($m) => $this->s3Service->getTemporaryUrl($m->getPath()))->toArray();
? $this->s3Service->getTemporaryUrl($media->first()->file_name)
: null;
return [ return [
'id' => $price->id, 'id' => $price->id,
'variant' => $price->variant, 'variant' => $price->variant,
'price' => $price->price, 'price' => $price->price,
'stock' => $price->stock, 'stock' => $price->stock,
'photo_key' => $photoKey, 'photo_key' => $photoKeys[0] ?? null,
'photo_url' => $photoUrl, 'photo_url' => $photoUrls[0] ?? null,
]; ];
}); });
@ -134,7 +145,7 @@ public function update(RawMaterial $rawMaterial, array $data): RawMaterial
$rawMaterial->rawMaterialPrices() $rawMaterial->rawMaterialPrices()
->whereNotIn('id', $existingVariantIds) ->whereNotIn('id', $existingVariantIds)
->each(function (RawMaterialPrice $price) { ->each(function (RawMaterialPrice $price) {
$price->clearMediaCollection('photos'); $price->clearMediaCollection('images');
$price->delete(); $price->delete();
}); });
@ -184,9 +195,9 @@ public function update(RawMaterial $rawMaterial, array $data): RawMaterial
]); ]);
if (isset($variantData['photo_key'])) { 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']) { if ($existingKey !== $variantData['photo_key']) {
$priceModel->clearMediaCollection('photos'); $priceModel->clearMediaCollection('images');
if ($variantData['photo_key']) { if ($variantData['photo_key']) {
$this->registerPhoto($priceModel, $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) { return DB::transaction(function () use ($rawMaterial) {
$rawMaterial->rawMaterialPrices->each(function (RawMaterialPrice $price) { $rawMaterial->rawMaterialPrices->each(function (RawMaterialPrice $price) {
$price->clearMediaCollection('photos'); $price->clearMediaCollection('images');
$price->delete(); $price->delete();
}); });
@ -216,14 +227,4 @@ public function toggleStatus(RawMaterial $rawMaterial): void
'is_active' => ! $rawMaterial->is_active, '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() ->get()
->each(function (RawMaterial $rawMaterial) { ->each(function (RawMaterial $rawMaterial) {
$rawMaterial->rawMaterialPrices->each(function (RawMaterialPrice $price) { $rawMaterial->rawMaterialPrices->each(function (RawMaterialPrice $price) {
$media = $price->getFirstMedia('photos'); $media = $price->getFirstMedia('images');
$price->photo_url = $media $price->photo_url = $media
? $this->s3Service->getTemporaryUrl($media->file_name) ? $this->s3Service->getTemporaryUrl($media->getPath())
: null; : null;
}); });
}) })
@ -43,11 +43,9 @@ public function getForEdit(RawMaterialPrice $variant): array
{ {
$variant->load('media'); $variant->load('media');
$media = $variant->getMedia('photos'); $media = $variant->getMedia('images');
$photoKey = $media->first()?->file_name; $photoKeys = $media->map(fn ($m) => $m->getCustomProperty('s3_key') ?? $m->file_name)->toArray();
$photoUrl = $media->first() $photoUrls = $media->map(fn ($m) => $this->s3Service->getTemporaryUrl($m->getPath()))->toArray();
? $this->s3Service->getTemporaryUrl($media->first()->file_name)
: null;
return [ return [
'id' => $variant->id, 'id' => $variant->id,
@ -55,8 +53,8 @@ public function getForEdit(RawMaterialPrice $variant): array
'variant' => $variant->variant, 'variant' => $variant->variant,
'price' => $variant->price, 'price' => $variant->price,
'stock' => $variant->stock, 'stock' => $variant->stock,
'photo_key' => $photoKey, 'photo_key' => $photoKeys[0] ?? null,
'photo_url' => $photoUrl, 'photo_url' => $photoUrls[0] ?? null,
]; ];
} }
@ -70,11 +68,11 @@ public function update(RawMaterialPrice $variant, array $data): RawMaterialPrice
]); ]);
if (array_key_exists('photo_key', $data)) { 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']; $newKey = $data['photo_key'];
if ($existingKey !== $newKey) { if ($existingKey !== $newKey) {
$variant->clearMediaCollection('photos'); $variant->clearMediaCollection('images');
if ($newKey) { if ($newKey) {
$this->registerPhoto($variant, $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 public function destroy(RawMaterial $rawMaterial, RawMaterialPrice $variant): bool
{ {
$result = DB::transaction(function () use ($variant) { $result = DB::transaction(function () use ($variant) {
$variant->clearMediaCollection('photos'); $variant->clearMediaCollection('images');
return $variant->delete(); return $variant->delete();
}); });
@ -109,14 +107,4 @@ public function destroy(RawMaterial $rawMaterial, RawMaterialPrice $variant): bo
return $result; 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\OrderChannel;
use App\Enums\OrderStatus; use App\Enums\OrderStatus;
use App\Enums\PaymentType; use App\Enums\PaymentType;
use App\Enums\PriceType;
use App\Enums\RawMaterialUnit; use App\Enums\RawMaterialUnit;
use App\Models\Attendance; use App\Models\Attendance;
use App\Models\CashAccount; use App\Models\CashAccount;
@ -15,10 +14,8 @@
use App\Models\Expense; use App\Models\Expense;
use App\Models\LeaveRequest; use App\Models\LeaveRequest;
use App\Models\Order; use App\Models\Order;
use App\Models\ProductVariant;
use App\Models\Purchase; use App\Models\Purchase;
use App\Models\PurchaseItem; use App\Models\PurchaseItem;
use App\Models\RawMaterialPrice;
use App\Models\RestockItem; use App\Models\RestockItem;
use App\Models\User; use App\Models\User;
use Carbon\Carbon; use Carbon\Carbon;
@ -465,6 +462,25 @@ public function getTopProducts(?string $startDate, ?string $endDate): array
->toArray(); ->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 public function getMarketingSales(?string $startDate, ?string $endDate): array
{ {
$query = Order::where('orders.status', OrderStatus::COMPLETED) $query = Order::where('orders.status', OrderStatus::COMPLETED)
@ -541,7 +557,7 @@ public function getOrderStats(?string $startDate, ?string $endDate): array
->groupBy('marketing_id') ->groupBy('marketing_id')
->with('marketing:id') ->with('marketing:id')
->get() ->get()
->map(fn($item) => [ ->map(fn ($item) => [
'name' => $item->marketing?->userProfile->full_name ?? '-', 'name' => $item->marketing?->userProfile->full_name ?? '-',
'count' => $item->count, 'count' => $item->count,
'total' => (int) $item->total, 'total' => (int) $item->total,

View File

@ -3,9 +3,12 @@
namespace App\Services\Concerns; namespace App\Services\Concerns;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Spatie\MediaLibrary\Conversions\ConversionCollection;
use Spatie\MediaLibrary\Conversions\Jobs\PerformConversionsJob;
use Spatie\MediaLibrary\MediaCollections\Models\Media; use Spatie\MediaLibrary\MediaCollections\Models\Media;
trait RegistersMedia trait RegistersMedia
@ -60,31 +63,49 @@ private function registerMedia(
Model $model, Model $model,
string $s3Key, string $s3Key,
string $collectionName, string $collectionName,
array $generatedConversions = [],
?int $fileSize = null, ?int $fileSize = null,
?string $mimeType = null, ?string $mimeType = null,
?int $orderColumn = null, ?int $orderColumn = null,
?string $name = null, ?string $name = null,
): void { ): void {
$defaultName = pathinfo($s3Key, PATHINFO_FILENAME); $fileName = pathinfo($s3Key, PATHINFO_BASENAME);
Media::create([ $media = Media::create([
'model_type' => $model->getMorphClass(), 'model_type' => $model->getMorphClass(),
'model_id' => $model->id, 'model_id' => $model->id,
'uuid' => Str::uuid(), 'uuid' => Str::uuid(),
'collection_name' => $collectionName, 'collection_name' => $collectionName,
'name' => $name ?? $defaultName, 'name' => $name ?? pathinfo($s3Key, PATHINFO_FILENAME),
'file_name' => $s3Key, 'file_name' => $fileName,
'mime_type' => $mimeType ?? 'image/jpeg', 'mime_type' => $mimeType ?? 'image/jpeg',
'disk' => 's3', 'disk' => 's3',
'conversions_disk' => 's3', 'conversions_disk' => 's3',
'size' => $fileSize ?? 0, 'size' => $fileSize ?? 0,
'manipulations' => [], 'manipulations' => [],
'custom_properties' => [], 'custom_properties' => ['s3_key' => $s3Key],
'generated_conversions' => $generatedConversions, 'generated_conversions' => [],
'responsive_images' => [], 'responsive_images' => [],
'order_column' => $orderColumn ?? 1, '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 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; return;
} }
$currentKey = $model->getFirstMedia($collectionName)?->file_name; $currentKey = $model->getFirstMedia($collectionName)?->getCustomProperty('s3_key');
if ($data['photo_key'] === $currentKey) { if ($data['photo_key'] === $currentKey) {
return; 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 private function syncReceipt(Model $model, ?string $newKey, string $collectionName = 'receipts', string $cachePrefix = 'receipt', ?int $fileSize = null, ?string $fileMimeType = null): void
{ {
$currentMedia = $model->getFirstMedia($collectionName); $currentMedia = $model->getFirstMedia($collectionName);
$currentKey = $currentMedia?->file_name; $currentKey = $currentMedia?->getCustomProperty('s3_key');
if ($currentMedia && ! str_contains($currentKey, '/')) {
$currentKey = $currentMedia->getPath();
}
if ($newKey === $currentKey) { if ($newKey === $currentKey) {
return; return;
@ -135,10 +152,58 @@ private function syncReceipt(Model $model, ?string $newKey, string $collectionNa
model: $model, model: $model,
s3Key: $newKey, s3Key: $newKey,
collectionName: $collectionName, collectionName: $collectionName,
generatedConversions: ['thumb' => true],
fileSize: $fileSize, fileSize: $fileSize,
mimeType: $fileMimeType, 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();
}
} }

View File

@ -22,14 +22,14 @@ public function getAttendanceStats(): array
$totalEmployees = Employee::whereHas( $totalEmployees = Employee::whereHas(
'user', 'user',
fn($q) => $q fn ($q) => $q
->where('is_active', true) ->where('is_active', true)
->whereHas( ->whereHas(
'roles', 'roles',
fn($r) => $r fn ($r) => $r
->whereHas( ->whereHas(
'permissions', 'permissions',
fn($p) => $p fn ($p) => $p
->where('name', 'attendances.create') ->where('name', 'attendances.create')
) )
) )
@ -38,14 +38,14 @@ public function getAttendanceStats(): array
$present = Attendance::where('attendance_date', $today) $present = Attendance::where('attendance_date', $today)
->whereHas( ->whereHas(
'employee.user', 'employee.user',
fn($q) => $q fn ($q) => $q
->where('is_active', true) ->where('is_active', true)
->whereHas( ->whereHas(
'roles', 'roles',
fn($r) => $r fn ($r) => $r
->whereHas( ->whereHas(
'permissions', 'permissions',
fn($p) => $p fn ($p) => $p
->where('name', 'attendances.create') ->where('name', 'attendances.create')
) )
) )
@ -56,14 +56,14 @@ public function getAttendanceStats(): array
->where('end_date', '>=', $today) ->where('end_date', '>=', $today)
->whereHas( ->whereHas(
'employee.user', 'employee.user',
fn($q) => $q fn ($q) => $q
->where('is_active', true) ->where('is_active', true)
->whereHas( ->whereHas(
'roles', 'roles',
fn($r) => $r fn ($r) => $r
->whereHas( ->whereHas(
'permissions', 'permissions',
fn($p) => $p fn ($p) => $p
->where('name', 'attendances.create') ->where('name', 'attendances.create')
) )
) )
@ -161,7 +161,7 @@ public function getOrderStats(): array
->groupBy('marketing_id') ->groupBy('marketing_id')
->with('marketing:id') ->with('marketing:id')
->get() ->get()
->map(fn($item) => [ ->map(fn ($item) => [
'name' => $item->marketing?->userProfile->full_name ?? '-', 'name' => $item->marketing?->userProfile->full_name ?? '-',
'count' => $item->count, 'count' => $item->count,
'total' => (int) $item->total, 'total' => (int) $item->total,

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 = [ $permissions = [
'dashboard' => ['view', 'attendance', 'revenue', 'expense', 'orders_channel', 'orders_payment', 'orders_marketing', 'orders_status', 'cash'], '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'], 'employees' => ['view', 'create', 'update', 'delete', 'toggle_status', 'reset_password'],
'attendances' => ['view', 'create', 'delete', 'manage'], 'attendances' => ['view', 'create', 'delete', 'manage'],
'leave_requests' => ['view', 'create', 'update', 'delete', 'verify'], 'leave_requests' => ['view', 'create', 'update', 'delete', 'verify'],
@ -82,6 +82,7 @@ public function run(): void
'analysis.cash', 'analysis.cash',
'analysis.product_stock', 'analysis.product_stock',
'analysis.revenue', 'analysis.revenue',
'analysis.revenue_trend',
'analysis.expense', 'analysis.expense',
'analysis.busy_hours', 'analysis.busy_hours',
'analysis.profit_orders', 'analysis.profit_orders',
@ -368,6 +369,7 @@ public function run(): void
'analysis.cash', 'analysis.cash',
'analysis.product_stock', 'analysis.product_stock',
'analysis.revenue', 'analysis.revenue',
'analysis.revenue_trend',
'analysis.expense', 'analysis.expense',
'analysis.busy_hours', 'analysis.busy_hours',
'analysis.profit_orders', 'analysis.profit_orders',

View File

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

View File

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

View File

@ -3,19 +3,27 @@ import { ImagePreviewModal } from '@/components/dialogs';
type ImagePreviewButtonProps = { type ImagePreviewButtonProps = {
srcs: string[]; srcs: string[];
modalSrc?: string;
modalSrcs?: string[];
title?: string; title?: string;
description?: string;
alt?: string; alt?: string;
className?: string; className?: string;
}; };
export function ImagePreviewButton({ export function ImagePreviewButton({
srcs, srcs,
modalSrc,
modalSrcs,
title, title,
description,
alt, alt,
className = 'h-10 w-10', className = 'h-10 w-10',
}: ImagePreviewButtonProps) { }: ImagePreviewButtonProps) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const modalSources = modalSrcs ?? (modalSrc ? [modalSrc] : null);
return ( return (
<> <>
<button <button
@ -37,9 +45,10 @@ export function ImagePreviewButton({
<ImagePreviewModal <ImagePreviewModal
open={open} open={open}
onOpenChange={setOpen} onOpenChange={setOpen}
src={srcs[0]} src={modalSources?.[0] ?? srcs[0]}
sources={srcs} sources={modalSources ?? srcs}
title={title} title={title}
description={description}
/> />
</> </>
); );

View File

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

View File

@ -59,7 +59,7 @@ function DialogContent({
<DialogPrimitive.Content <DialogPrimitive.Content
data-slot="dialog-content" data-slot="dialog-content"
className={cn( 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 className
)} )}
{...props} {...props}

View File

@ -26,6 +26,8 @@ import {
BarChart, BarChart,
CartesianGrid, CartesianGrid,
Cell, Cell,
Line,
LineChart,
Pie, Pie,
PieChart, PieChart,
ResponsiveContainer, ResponsiveContainer,
@ -182,6 +184,10 @@ type AnalysisProps = {
count: number; count: number;
}>; }>;
}; };
revenueTrend: Array<{
date: string;
qty: number;
}>;
}; };
const revenueChartConfig = { const revenueChartConfig = {
@ -230,6 +236,15 @@ const expenseChartConfig = {
const expenseKeys = ['total', 'purchase', 'expense', 'advance'] as const; 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> = { const CHANNEL_COLORS: Record<string, string> = {
store: '#22c55e', store: '#22c55e',
shopee: '#ee4d2d', shopee: '#ee4d2d',
@ -400,6 +415,7 @@ export default function Analysis({
topProducts, topProducts,
marketingSales, marketingSales,
orderStats, orderStats,
revenueTrend,
}: AnalysisProps) { }: AnalysisProps) {
const { can, hasAnyRole, hasRole } = useCan(); const { can, hasAnyRole, hasRole } = useCan();
const [startDate, setStartDate] = useState(initialFilters.start_date ?? ''); const [startDate, setStartDate] = useState(initialFilters.start_date ?? '');
@ -495,15 +511,15 @@ export default function Analysis({
const sectionOrder = useMemo(() => { const sectionOrder = useMemo(() => {
if (hasAnyRole(['owner', 'developer'])) { 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'])) { 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')) { 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 {}; return {};
@ -889,6 +905,82 @@ export default function Analysis({
</div> </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') && ( {can('analysis.marketing_sales') && (
<Card style={{ order: sectionOrder.marketingSales ?? 99 }}> <Card style={{ order: sectionOrder.marketingSales ?? 99 }}>
<CardHeader> <CardHeader>

View File

@ -13,6 +13,7 @@ export type CashTransaction = {
description: string; description: string;
receipt_key: string | null; receipt_key: string | null;
receipt_url: string | null; receipt_url: string | null;
receipt_conversion_url: string | null;
created_at: string; created_at: string;
formatted_created_at: string; formatted_created_at: string;
created_by: { created_by: {
@ -113,15 +114,17 @@ export function createTransactionColumns(
id: 'receipt', id: 'receipt',
header: () => <span>Bukti</span>, header: () => <span>Bukti</span>,
cell: ({ row }) => { cell: ({ row }) => {
const receiptConversionUrl = row.original.receipt_conversion_url;
const receiptUrl = row.original.receipt_url; const receiptUrl = row.original.receipt_url;
if (!receiptUrl) { if (!receiptConversionUrl) {
return <span className="text-muted-foreground">-</span>; return <span className="text-muted-foreground">-</span>;
} }
return ( return (
<ImagePreviewButton <ImagePreviewButton
srcs={[receiptUrl]} srcs={[receiptConversionUrl]}
modalSrc={receiptUrl ?? undefined}
title={row.original.description} title={row.original.description}
/> />
); );

View File

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

View File

@ -10,6 +10,7 @@ export type Expense = {
description: string; description: string;
receipt_key: string | null; receipt_key: string | null;
receipt_url: string | null; receipt_url: string | null;
receipt_conversion_url: string | null;
created_at: string; created_at: string;
formatted_created_at: string; formatted_created_at: string;
created_by: { created_by: {
@ -51,15 +52,17 @@ export function createExpenseColumns(
id: 'receipt', id: 'receipt',
header: () => <span>Bukti</span>, header: () => <span>Bukti</span>,
cell: ({ row }) => { cell: ({ row }) => {
const receiptConversionUrl = row.original.receipt_conversion_url;
const receiptUrl = row.original.receipt_url; const receiptUrl = row.original.receipt_url;
if (!receiptUrl) { if (!receiptConversionUrl) {
return <span className="text-muted-foreground">-</span>; return <span className="text-muted-foreground">-</span>;
} }
return ( return (
<ImagePreviewButton <ImagePreviewButton
srcs={[receiptUrl]} srcs={[receiptConversionUrl]}
modalSrc={receiptUrl ?? undefined}
title={row.original.description} title={row.original.description}
/> />
); );

View File

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

View File

@ -1,5 +1,6 @@
import type { ColumnDef } from '@tanstack/react-table'; import type { ColumnDef } from '@tanstack/react-table';
import { KeyRound, Pencil, Trash2 } from 'lucide-react'; import { KeyRound, Pencil, Trash2 } from 'lucide-react';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { RowActions } from '@/components/data-display'; import { RowActions } from '@/components/data-display';
import { ToggleStatus } from '@/components/data-display'; import { ToggleStatus } from '@/components/data-display';
import { formatCurrency } from '@/lib/utils'; import { formatCurrency } from '@/lib/utils';
@ -9,6 +10,7 @@ export type Employee = {
email: string; email: string;
username: string; username: string;
is_active: boolean; is_active: boolean;
photo_url: string | null;
roles?: { name: string }[]; roles?: { name: string }[];
user_profile: { user_profile: {
full_name: string; full_name: string;
@ -55,6 +57,33 @@ export function createEmployeeColumns(
} = params; } = params;
const columns: ColumnDef<Employee>[] = [ 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', accessorKey: 'user_profile.full_name',
id: 'full_name', id: 'full_name',

View File

@ -28,6 +28,7 @@ export type Cutting = {
total_material_cost: number | null; total_material_cost: number | null;
cost_per_unit: number | null; cost_per_unit: number | null;
photo_url: string | null; photo_url: string | null;
photo_conversion_url: string | null;
created_at: string; created_at: string;
created_by: { created_by: {
id: number; id: number;
@ -48,6 +49,7 @@ export type Cutting = {
price: number; price: number;
stock: number; stock: number;
photo_url: string | null; photo_url: string | null;
photo_conversion_url: string | null;
raw_material: { raw_material: {
id: number; id: number;
name: string; name: string;

View File

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

View File

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

View File

@ -126,10 +126,18 @@ acc[name] = [];
?.photo_url ? ( ?.photo_url ? (
<ImagePreviewButton <ImagePreviewButton
srcs={[ srcs={[
item
.raw_material_price
.photo_conversion_url ??
item item
.raw_material_price .raw_material_price
.photo_url, .photo_url,
]} ]}
modalSrc={
item
.raw_material_price
.photo_url
}
title={ title={
item item
.raw_material_price .raw_material_price
@ -221,10 +229,18 @@ acc[name] = [];
?.photo_url ? ( ?.photo_url ? (
<ImagePreviewButton <ImagePreviewButton
srcs={[ srcs={[
item
.raw_material_price
.photo_conversion_url ??
item item
.raw_material_price .raw_material_price
.photo_url, .photo_url,
]} ]}
modalSrc={
item
.raw_material_price
.photo_url
}
title={ title={
item item
.raw_material_price .raw_material_price

View File

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

View File

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

View File

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

View File

@ -50,9 +50,9 @@ export function PurchaseItemSubRow({ purchase }: { purchase: Purchase }) {
{item.raw_material_price?.photo_url ? ( {item.raw_material_price?.photo_url ? (
<ImagePreviewButton <ImagePreviewButton
srcs={[ srcs={[
item.raw_material_price item.raw_material_price.photo_conversion_url ?? item.raw_material_price.photo_url,
.photo_url,
]} ]}
modalSrc={item.raw_material_price.photo_url}
title={ title={
item.raw_material_price.variant item.raw_material_price.variant
} }

View File

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

View File

@ -100,10 +100,18 @@ acc[name] = [];
?.photo_url ? ( ?.photo_url ? (
<ImagePreviewButton <ImagePreviewButton
srcs={[ srcs={[
item
.product_variant
.photo_conversion_url ??
item item
.product_variant .product_variant
.photo_url, .photo_url,
]} ]}
modalSrc={
item
.product_variant
.photo_url
}
title={ title={
item item
.product_variant .product_variant

View File

@ -19,6 +19,7 @@ export type TransactionItem = {
id: number; id: number;
name: string; name: string;
photo_url: string | null; photo_url: string | null;
photo_conversion_url: string | null;
product: { product: {
id: number; id: number;
name: string; name: string;
@ -49,6 +50,8 @@ export type Transaction = {
nego_price: number | null; nego_price: number | null;
total_amount: number; total_amount: number;
notes: string | null; notes: string | null;
photo_url: string | null;
photo_conversion_url: string | null;
created_at: string; created_at: string;
created_by: { created_by: {
id: number; id: number;

View File

@ -236,12 +236,16 @@ export default function TransactionCreate({
const incrementQuantity = useCallback( const incrementQuantity = useCallback(
(variantId: number, amount: number) => { (variantId: number, amount: number) => {
if (amount > 0 && getUnitPrice(variantId) <= 0) {
toast.error('Harga produk ini belum diatur.');
return;
}
setQuantities((prev) => ({ setQuantities((prev) => ({
...prev, ...prev,
[variantId]: Math.max(0, (prev[variantId] ?? 0) + amount), [variantId]: Math.max(0, (prev[variantId] ?? 0) + amount),
})); }));
}, },
[], [getUnitPrice],
); );
const cartItems: CartLine[] = (() => { const cartItems: CartLine[] = (() => {
@ -437,10 +441,14 @@ export default function TransactionCreate({
)}{' '} )}{' '}
pcs pcs
·{' '} ·{' '}
{formatCurrency( {getUnitPrice(variant.id) <= 0 ? (
<span className="text-destructive">Harga belum diatur</span>
) : (
formatCurrency(
stockType === 'reject' stockType === 'reject'
? (variant.prices?.reject ?? 0) ? (variant.prices?.reject ?? 0)
: (variant.prices?.[priceType] ?? 0), : (variant.prices?.[priceType] ?? 0),
)
)} )}
</p> </p>
</div> </div>
@ -490,6 +498,7 @@ export default function TransactionCreate({
type="button" type="button"
variant="outline" variant="outline"
size="icon" size="icon"
disabled={getUnitPrice(variant.id) <= 0}
onClick={() => onClick={() =>
incrementQuantity( incrementQuantity(
variant.id, variant.id,
@ -882,7 +891,11 @@ export default function TransactionCreate({
!selectedProductId || !selectedProductId ||
Object.values(quantities).every( Object.values(quantities).every(
(q) => q <= 0, (q) => q <= 0,
) ) ||
Object.entries(quantities).some(
([id, q]) => q > 0 && getUnitPrice(Number(id)) <= 0,
) ||
total <= 0
} }
> >
{processing {processing

View File

@ -206,12 +206,16 @@ export default function TransactionEdit({
const incrementQuantity = useCallback( const incrementQuantity = useCallback(
(variantId: number, amount: number) => { (variantId: number, amount: number) => {
if (amount > 0 && getUnitPrice(variantId) <= 0) {
toast.error('Harga produk ini belum diatur.');
return;
}
setQuantities((prev) => ({ setQuantities((prev) => ({
...prev, ...prev,
[variantId]: Math.max(0, (prev[variantId] ?? 0) + amount), [variantId]: Math.max(0, (prev[variantId] ?? 0) + amount),
})); }));
}, },
[], [getUnitPrice],
); );
const cartItems: CartLine[] = (() => { const cartItems: CartLine[] = (() => {
@ -416,10 +420,14 @@ export default function TransactionEdit({
)}{' '} )}{' '}
pcs pcs
·{' '} ·{' '}
{formatCurrency( {getUnitPrice(variant.id) <= 0 ? (
<span className="text-destructive">Harga belum diatur</span>
) : (
formatCurrency(
stockType === 'reject' stockType === 'reject'
? (variant.prices?.reject ?? 0) ? (variant.prices?.reject ?? 0)
: (variant.prices?.[priceType] ?? 0), : (variant.prices?.[priceType] ?? 0),
)
)} )}
</p> </p>
</div> </div>
@ -469,6 +477,7 @@ export default function TransactionEdit({
type="button" type="button"
variant="outline" variant="outline"
size="icon" size="icon"
disabled={getUnitPrice(variant.id) <= 0}
onClick={() => onClick={() =>
incrementQuantity( incrementQuantity(
variant.id, variant.id,
@ -860,7 +869,11 @@ export default function TransactionEdit({
uploading || uploading ||
Object.values(quantities).every( Object.values(quantities).every(
(q) => q <= 0, (q) => q <= 0,
) ) ||
Object.entries(quantities).some(
([id, q]) => q > 0 && getUnitPrice(Number(id)) <= 0,
) ||
total <= 0
} }
> >
{processing {processing

View File

@ -1,4 +1,5 @@
import { CheckCircle, ChevronDown, Pencil, Printer, Send, Trash2, XCircle } from 'lucide-react'; import { CheckCircle, ChevronDown, Pencil, Printer, Send, Trash2, XCircle } from 'lucide-react';
import { ImagePreviewButton } from '@/components/dialogs';
import { RowActions } from '@/components/data-display'; import { RowActions } from '@/components/data-display';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@ -9,6 +10,7 @@ import {
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'; } from '@/components/ui/dropdown-menu';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { useCan } from '@/hooks/use-can'; import { useCan } from '@/hooks/use-can';
import type { PaperWidth } from '@/hooks/use-thermal-printer'; import type { PaperWidth } from '@/hooks/use-thermal-printer';
import { formatDateTime, formatNumber } from '@/lib/format'; import { formatDateTime, formatNumber } from '@/lib/format';
@ -195,15 +197,32 @@ export function TransactionCardRow({
{formatCurrency(transaction.cogs)} {formatCurrency(transaction.cogs)}
</span> </span>
</div> </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>
<div className="flex shrink-0 items-center gap-1"> <div className="flex shrink-0 items-center gap-1">
<DropdownMenu> <DropdownMenu>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" title="Cetak Struk"> <Button variant="ghost" size="icon">
<Printer className="h-4 w-4" /> <Printer className="h-4 w-4" />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="top">Cetak Struk</TooltipContent>
</Tooltip>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
<DropdownMenuItem <DropdownMenuItem
onClick={() => onPrint(transaction, 58)} onClick={() => onPrint(transaction, 58)}

View File

@ -52,8 +52,9 @@ export function TransactionItemSubRow({ transaction }: { transaction: Transactio
{item.product_variant?.photo_url ? ( {item.product_variant?.photo_url ? (
<ImagePreviewButton <ImagePreviewButton
srcs={[ 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} title={item.product_variant.name}
/> />
) : ( ) : (

View File

@ -11,10 +11,13 @@ export type ProductVariant = {
id: number; id: number;
name: string; name: string;
stock: number; stock: number;
formatted_stock: string formatted_stock: string;
reject_stock: number; reject_stock: number;
formatted_reject_stock: string;
retail_stock: number; retail_stock: number;
formatted_retail_stock: string;
photo_urls: string[]; photo_urls: string[];
photo_conversion_urls: string[];
product_prices: { product_prices: {
id: number; id: number;
type: string; type: string;

View File

@ -168,7 +168,7 @@ export default function ProductIndex({ products, categories, productNames, filte
</ComboboxEmpty> </ComboboxEmpty>
<ComboboxList> <ComboboxList>
{(name) => ( {(name) => (
<ComboboxItem value={name}>{name}</ComboboxItem> <ComboboxItem key={name} value={name}>{name}</ComboboxItem>
)} )}
</ComboboxList> </ComboboxList>
</ComboboxContent> </ComboboxContent>
@ -224,6 +224,7 @@ export default function ProductIndex({ products, categories, productNames, filte
<Combobox <Combobox
items={categories} items={categories}
itemToStringLabel={(cat) => cat.name} itemToStringLabel={(cat) => cat.name}
getOptionAsValue={(cat) => String(cat.id)}
value={selectedCategory} value={selectedCategory}
onValueChange={(value) => onValueChange={(value) =>
applyFilter('category', value ? String(value.id) : '') applyFilter('category', value ? String(value.id) : '')
@ -239,7 +240,7 @@ export default function ProductIndex({ products, categories, productNames, filte
</ComboboxEmpty> </ComboboxEmpty>
<ComboboxList> <ComboboxList>
{(cat) => ( {(cat) => (
<ComboboxItem value={cat}> <ComboboxItem key={cat.id} value={cat}>
{cat.name} {cat.name}
</ComboboxItem> </ComboboxItem>
)} )}

View File

@ -90,8 +90,10 @@ export function VariantSubRow({
<TableCell> <TableCell>
{variant.photo_urls?.length > 0 ? ( {variant.photo_urls?.length > 0 ? (
<ImagePreviewButton <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} 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"> <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; variant: string;
price: number; price: number;
stock: number; stock: number;
formatted_stock: string;
photo_url: string | null; photo_url: string | null;
photo_conversion_url: string | null;
}; };
export type RawMaterial = { export type RawMaterial = {

View File

@ -1,12 +1,20 @@
import { Head, Link, router } from '@inertiajs/react'; import { Head, Link, router } from '@inertiajs/react';
import { Plus } from 'lucide-react'; import { Plus } from 'lucide-react';
import { useState } from 'react'; import { useMemo, useState } from 'react';
import { CardTable } from '@/components/data-display'; import { CardTable } from '@/components/data-display';
import { DeleteConfirmDialog } from '@/components/dialogs'; import { DeleteConfirmDialog } from '@/components/dialogs';
import { FilterPopover } from '@/components/data-display'; import { FilterPopover } from '@/components/data-display';
import { useCardTableExpand } from '@/components/hooks/use-card-table-expand'; import { useCardTableExpand } from '@/components/hooks/use-card-table-expand';
import { PageHeader } from '@/components/layout'; import { PageHeader } from '@/components/layout';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import {
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxList,
} from '@/components/ui/combobox';
import { import {
Select, Select,
SelectContent, SelectContent,
@ -38,13 +46,15 @@ type Props = {
per_page: number; per_page: number;
total: number; total: number;
}; };
rawMaterialNames: string[];
filters: { filters: {
is_active?: string; is_active?: string;
name?: string;
stock?: string; stock?: string;
}; };
}; };
export default function RawMaterialIndex({ rawMaterials, filters }: Props) { export default function RawMaterialIndex({ rawMaterials, rawMaterialNames, filters }: Props) {
const { can } = useCan(); const { can } = useCan();
const [deleting, setDeleting] = useState<RawMaterial | null>(null); const [deleting, setDeleting] = useState<RawMaterial | null>(null);
const [deletingVariant, setDeletingVariant] = useState<{ const [deletingVariant, setDeletingVariant] = useState<{
@ -76,6 +86,11 @@ export default function RawMaterialIndex({ rawMaterials, filters }: Props) {
filterWithParams: false, filterWithParams: false,
}); });
const sortedRawMaterialNames = useMemo(
() => [...rawMaterialNames].sort(),
[rawMaterialNames],
);
function handleDelete() { function handleDelete() {
if (!deleting) { if (!deleting) {
return; return;
@ -107,9 +122,37 @@ export default function RawMaterialIndex({ rawMaterials, filters }: Props) {
open={filterOpen} open={filterOpen}
onOpenChange={setFilterOpen} onOpenChange={setFilterOpen}
filters={filters} filters={filters}
hasActiveFilters={Boolean(filters.is_active || filters.stock)} hasActiveFilters={Boolean(filters.is_active || filters.name || filters.stock)}
onClear={clearFilters} 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"> <div className="flex flex-col gap-2">
<label className="text-xs text-muted-foreground">Status</label> <label className="text-xs text-muted-foreground">Status</label>
<Select <Select

View File

@ -87,8 +87,10 @@ export function RawMaterialVariantSubRow({
<TableCell> <TableCell>
{variant.photo_url ? ( {variant.photo_url ? (
<ImagePreviewButton <ImagePreviewButton
srcs={[variant.photo_url]} srcs={[variant.photo_conversion_url ?? variant.photo_url]}
modalSrc={variant.photo_url}
title={variant.variant} 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"> <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}> <Dialog open={showCamera} onOpenChange={setShowCamera}>
<DialogContent className="sm:max-w-md"> <DialogContent>
<CameraCapture <CameraCapture
onCapture={handleCameraCapture} onCapture={handleCameraCapture}
onClose={() => setShowCamera(false)} onClose={() => setShowCamera(false)}

View File

@ -3,6 +3,7 @@ import { useState } from 'react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import ProfileController from '@/actions/App/Http/Controllers/Settings/ProfileController'; import ProfileController from '@/actions/App/Http/Controllers/Settings/ProfileController';
import { DatePicker } from '@/components/inputs'; import { DatePicker } from '@/components/inputs';
import { FileUpload } from '@/components/inputs';
import { InputError } from '@/components/ui'; import { InputError } from '@/components/ui';
import { PhoneNumberInput } from '@/components/inputs'; import { PhoneNumberInput } from '@/components/inputs';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@ -17,6 +18,8 @@ type UserData = {
id: number; id: number;
email: string; email: string;
username: string; username: string;
photo_key: string | null;
photo_url: string | null;
userProfile: { userProfile: {
full_name: string; full_name: string;
phone_number: string | null; phone_number: string | null;
@ -38,6 +41,10 @@ export default function Profile({ user }: Props) {
? new Date(user.userProfile.birth_date) ? new Date(user.userProfile.birth_date)
: undefined, : undefined,
); );
const [photoKey, setPhotoKey] = useState<string | null>(
user.photo_key ?? null,
);
const [photoUploading, setPhotoUploading] = useState(false);
return ( return (
<> <>
@ -55,6 +62,27 @@ export default function Profile({ user }: Props) {
> >
{({ processing, errors }) => ( {({ processing, errors }) => (
<div className="grid gap-6"> <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> <Card>
<CardHeader> <CardHeader>
<CardTitle>Akun</CardTitle> <CardTitle>Akun</CardTitle>
@ -199,7 +227,7 @@ export default function Profile({ user }: Props) {
</Card> </Card>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<Button type="submit" disabled={processing}> <Button type="submit" disabled={processing || photoUploading}>
{processing ? 'Menyimpan...' : 'Simpan'} {processing ? 'Menyimpan...' : 'Simpan'}
</Button> </Button>
</div> </div>