Compare commits

...

39 Commits

Author SHA1 Message Date
Yoga Pangestu
919e8228d2 feat: implement form history logging for CRUD operations and add activity log management interface 2026-08-16 20:41:48 +07:00
Yoga Pangestu
581761398f feat: enhance product title display by including product name alongside variant name in transaction and stok opname components 2026-08-16 20:20:01 +07:00
Yoga Pangestu
aaa85a45ed feat: remove unused myAttendance data and related UI components from analysis page 2026-08-16 20:04:24 +07:00
Yoga Pangestu
097b7f7192 feat: add cashier role checks to filter data in analysis and dashboard services 2026-08-16 20:00:17 +07:00
Yoga Pangestu
05707aad7d feat: add stok opname management functionality
- Implemented StokOpnameEdit component for editing stock opname entries.
- Created StokOpnameIndex component for listing and managing stock opnames.
- Added StokOpnameCardRow and StokOpnameItemSubRow components for displaying stock opname details.
- Introduced routes for stok opname CRUD operations and actions (submit, verify, reject, cancel).
- Integrated UI components for better user interaction and data presentation.
2026-08-16 19:38:36 +07:00
Yoga Pangestu
28f88bb69c feat: add revenue by stock type calculation and update analysis components 2026-08-16 18:06:05 +07:00
Yoga Pangestu
731c8aa6da feat: enhance transaction management by adding cashier role checks and updating price type logic 2026-08-16 16:20:51 +07:00
Yoga Pangestu
85ba5631b3 feat: simplify permission checks by removing redundant role conditions in useCan hook 2026-08-16 15:41:13 +07:00
Yoga Pangestu
953a395f24 feat: enhance variant deletion handling to update loaded variants on success 2026-08-16 15:32:25 +07:00
Yoga Pangestu
078d4e2461 feat: remove product price deletion logic in update and destroy methods of ProductService and ProductVariantService 2026-08-16 15:31:28 +07:00
Yoga Pangestu
b0cb117e60 feat: update button visibility logic to check variant length in ProductCreate and ProductEdit components 2026-08-16 15:20:38 +07:00
Yoga Pangestu
8ea9378c78 feat: update variant and price deletion logic to preserve media collections in ProductService and RawMaterialService 2026-08-16 15:17:32 +07:00
Yoga Pangestu
50ece97933 feat: add getForEdit method to RestockService for enhanced restock item retrieval and media handling 2026-08-16 15:13:34 +07:00
Yoga Pangestu
3535480266 feat: update photo conversion URL handling across services to check for generated conversions before retrieving URLs 2026-08-16 14:39:18 +07:00
Yoga Pangestu
0998c81803 feat: split 'reject' price type into 'reject_capital' and 'reject_selling', update related components and database migration 2026-08-16 14:27:19 +07:00
Yoga Pangestu
0947cf3737 feat: add salary type to cash transaction enum and update related components 2026-08-16 14:26:55 +07:00
Yoga Pangestu
c415493ee1 feat: implement push notification subscription handling and improve service worker configuration 2026-08-16 13:42:37 +07:00
Yoga Pangestu
cc3cd03e41 feat: enhance raw material management by adding validation for updates and deletions, and improve error handling in the UI 2026-08-15 22:40:36 +07:00
Yoga Pangestu
6fa9224983 feat: fix raw material photo display in Cutting and Purchase pages by adding conversion URLs 2026-08-15 20:30:04 +07:00
Yoga Pangestu
ef136bd1e7 feat: add total price calculations for raw materials and products in analysis components 2026-08-15 20:16:49 +07:00
Yoga Pangestu
25a316ccf0 feat: add negotiation price to marketing sales calculations and update header label for discounts 2026-08-15 20:03:08 +07:00
Yoga Pangestu
dd3a2aa1eb feat: update material usage and stock fields to use decimal type for improved precision 2026-08-15 20:03:00 +07:00
Yoga Pangestu
cff231097a feat: simplify WhatsApp share text generation by removing unnecessary variables and logic 2026-08-15 19:45:53 +07:00
Yoga Pangestu
a07cb4bb45 feat: add confirmation dialogs for cancelling and refunding transactions in TransactionCardRow 2026-08-15 16:26:42 +07:00
Yoga Pangestu
a10519ab9d feat: enhance Restock feature by adding photo URLs to Restock model and displaying images in RestockCard 2026-08-15 16:26:12 +07:00
Yoga Pangestu
bac9f79087 feat: enhance AttendanceIndex component by adding 'Belum Pulang' badge and improving check-out display logic 2026-08-15 16:15:40 +07:00
Yoga Pangestu
17273c9479 feat: enhance Employee data structure by adding resign_date and improving salary display in employee columns 2026-08-15 16:12:06 +07:00
Yoga Pangestu
de18ac5ce8 feat: refactor AttendanceIndex component to improve code organization and enhance badge styling for check-out status 2026-08-15 15:48:59 +07:00
Yoga Pangestu
00ff74bab6 feat: enhance mobile experience by closing sidebar on link click 2026-08-15 15:28:24 +07:00
Yoga Pangestu
7371a9e517 feat: update notification retrieval to use pagination and enhance loading experience 2026-08-15 15:23:44 +07:00
Yoga Pangestu
41a32b08f2 feat: refactor getForRestock and getForTransaction methods to improve media handling and streamline data retrieval 2026-08-15 14:59:06 +07:00
Yoga Pangestu
0d1e6ba1fe feat: simplify pay method in PayrollPeriodService by removing cash transaction handling 2026-08-15 14:02:21 +07:00
Yoga Pangestu
4758b19d2a feat: add byMonth method to AttendanceController and corresponding service logic 2026-08-15 13:46:30 +07:00
Yoga Pangestu
86cc8637bd feat: remove summary calculation and related component from Transaction management 2026-08-15 13:19:37 +07:00
Yoga Pangestu
bdfb75f2f7 feat: reorganize imports and update SidebarTrigger icon 2026-08-15 13:17:32 +07:00
Yoga Pangestu
e8c46ec18b feat: add interactivity to DashboardPieChart and ChartLegendContent components 2026-08-15 13:14:25 +07:00
Yoga Pangestu
fd6793af50 feat: enhance FilterPopover component with scrollable content 2026-08-15 13:02:42 +07:00
Yoga Pangestu
10f277a268 feat: add loading state management in DeleteConfirmDialog component 2026-08-15 12:59:52 +07:00
Yoga Pangestu
ae376342af feat: add delete all notifications feature in NotificationController and update NotificationBell component 2026-08-15 11:09:23 +07:00
105 changed files with 4976 additions and 780 deletions

View File

@ -65,7 +65,7 @@ ## Module Overview
| 7 | Cutting | `admin/manage/cuttings` | `Admin/Manage/CuttingController` | `Admin/Manage/CuttingService` |
| 8 | Transaksi | `admin/manage/transactions` | `Admin/Manage/TransactionController` | `Admin/Manage/TransactionService` |
| 9 | Restock | `admin/manage/restocks` | `Admin/Manage/RestockController` | `Admin/Manage/RestockService` |
| 10 | Stok Opname | `admin/manage/stok-opnames` | (via StockMutationController) | — |
| 10 | Stok Opname | `admin/manage/stok-opnames` | `Admin/Manage/StokOpnameController` | `Admin/Manage/StokOpnameService` |
| 11 | Kas Toko | `admin/finance/cash-accounts` | `Admin/Finance/CashAccountController` | `Admin/Finance/Cash/CashAccountService`, `Admin/Finance/Cash/CashTransactionService` |
| 12 | Pengeluaran | `admin/finance/expenses` | `Admin/Finance/ExpenseController` | `Admin/Finance/ExpenseService` |
| 13 | Kasbon | `admin/finance/employee-advances` | `Admin/Finance/EmployeeAdvanceController` | `Admin/Finance/EmployeeAdvanceService` |

View File

@ -81,8 +81,8 @@ ### `raw_materials` → RawMaterial
- Relations: rawMaterialPrices(HasMany→RawMaterialPrice)
### `raw_material_prices` → RawMaterialPrice
`id` `raw_material_id`(FK→raw_materials) `variant`(200) `price`(uint) `stock`(uint,default:0) `created_at` `updated_at` `deleted_at`
- Casts: price(int), stock(int)
`id` `raw_material_id`(FK→raw_materials) `variant`(200) `price`(uint) `stock`(decimal(10,2),default:0) `created_at` `updated_at` `deleted_at`
- Casts: price(int), stock(decimal:2)
- GlobalScope: orderBy(variant)
- Relations: rawMaterial(BelongsTo→RawMaterial,withTrashed), cuttingMaterials(HasMany→CuttingMaterial), purchaseItems(HasMany→PurchaseItem)
- Accessor: photo_url → first media presigned S3 URL
@ -199,8 +199,8 @@ ### `cutting_material_combinations` → CuttingMaterialCombination
- Relations: cutting(BelongsTo→Cutting), cuttingMaterials(HasMany→CuttingMaterial,combination_id), user(BelongsTo→User)
### `cutting_materials` → CuttingMaterial
`id` `user_id`(FK→users,null) `cutting_id`(FK→cuttings,null) `raw_material_price_id`(FK→raw_material_prices) `combination_id`(FK→cutting_material_combinations,null) `material_usage`(int) `material_result`(int,null) `created_at` `updated_at` `deleted_at`
- Casts: material_usage(int), material_result(int)
`id` `user_id`(FK→users,null) `cutting_id`(FK→cuttings,null) `raw_material_price_id`(FK→raw_material_prices) `combination_id`(FK→cutting_material_combinations,null) `material_usage`(decimal(10,2)) `material_result`(int,null) `created_at` `updated_at` `deleted_at`
- Casts: material_usage(decimal:2), material_result(int)
- Accessor: formatted_material_result → 'X.XXX', formatted_material_usage → 'X.XXX'
- Relations: cutting(BelongsTo→Cutting), rawMaterialPrice(BelongsTo→RawMaterialPrice), combination(BelongsTo→CuttingMaterialCombination), user(BelongsTo→User)
@ -300,7 +300,7 @@ ## Enums
| `PayrollPeriodStatus` | open, closed | payroll_periods.status |
| `PayrollStatus` | unpaid, paid, cancelled | payrolls.status |
| `Permission` | — | Permission action names |
| `PriceType` | retail, wholesale, capital | product_prices.type, orders.price_type |
| `PriceType` | retail, wholesale, capital, reject_capital, reject_selling | product_prices.type, orders.price_type |
| `ProductStatus` | active, draft, inactive, pending, rejected | products.status |
| `ProductStockQuality` | good, reject | order_items.stock_quality, restocks.stock_type, stok_opname_items.stock_quality |
| `RawMaterialUnit` | kg, meter, yard | raw_materials.unit |

View File

@ -108,7 +108,7 @@ public function fix(): array
private function resolvePriceType(string $orderPriceType, string $stockQuality): string
{
if ($stockQuality === ProductStockQuality::REJECT->value) {
return PriceType::REJECT->value;
return PriceType::REJECT_SELLING->value;
}
$map = [

View File

@ -12,6 +12,7 @@ enum CashTransactionType: string
case EXPENSE = 'expense';
case WITHDRAWAL = 'withdrawal';
case EMPLOYEE_ADVANCE = 'employee_advance';
case SALARY = 'salary';
public function label(): string
{
@ -20,6 +21,7 @@ public function label(): string
self::EXPENSE => 'Pengeluaran',
self::WITHDRAWAL => 'Withdrawal',
self::EMPLOYEE_ADVANCE => 'Kasbon',
self::SALARY => 'Gaji',
};
}
}

View File

@ -16,7 +16,8 @@ enum PriceType: string
case TIKTOK = 'tiktok';
case SHOPEE = 'shopee';
case CAPITAL = 'capital';
case REJECT = 'reject';
case REJECT_CAPITAL = 'reject_capital';
case REJECT_SELLING = 'reject_selling';
public function label(): string
{
@ -29,7 +30,8 @@ public function label(): string
self::TIKTOK => 'TikTok',
self::SHOPEE => 'Shopee',
self::CAPITAL => 'Modal',
self::REJECT => 'Reject',
self::REJECT_CAPITAL => 'Reject Modal',
self::REJECT_SELLING => 'Reject Jual',
};
}
}

View File

@ -52,4 +52,12 @@ public function byDate(Request $request): ?array
return $this->service->getByDate($date);
}
public function byMonth(Request $request): array
{
$year = $request->integer('year', now()->year);
$month = $request->integer('month', now()->month);
return $this->service->getByMonthData($year, $month);
}
}

View File

@ -0,0 +1,121 @@
<?php
namespace App\Http\Controllers\Admin\Manage;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\Manage\StokOpnameRequest;
use App\Http\Requests\Admin\Manage\StokOpnameVerifyRequest;
use App\Http\Requests\PaginatedRequest;
use App\Models\StokOpname;
use App\Services\Admin\Manage\StokOpnameService;
use App\Services\Admin\Master\Product\ProductVariantService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Inertia\Inertia;
use Inertia\Response;
class StokOpnameController extends Controller
{
public function __construct(
private StokOpnameService $service,
private ProductVariantService $productVariantService,
) {}
public function index(PaginatedRequest $request): Response
{
return Inertia::render('admin/manage/stok-opname/index', [
'stokOpnames' => $this->service->paginated(
...$request->validatedWithDefaults(),
),
'highlight' => $request->input('highlight'),
]);
}
public function create(): Response
{
return Inertia::render('admin/manage/stok-opname/create', [
'products' => $this->productVariantService->getForStokOpname(),
]);
}
public function store(StokOpnameRequest $request): RedirectResponse
{
return $this->handleAction(
fn () => $this->service->store($request->validated()),
'Stok opname berhasil ditambahkan.',
'admin.manage.stok-opnames.index',
'admin.manage.stok-opnames.create'
);
}
public function edit(StokOpname $stokOpname): Response
{
return Inertia::render('admin/manage/stok-opname/edit', [
'stokOpname' => $this->service->getForEdit($stokOpname),
'products' => $this->productVariantService->getForStokOpname(),
]);
}
public function update(StokOpnameRequest $request, StokOpname $stokOpname): RedirectResponse
{
return $this->handleAction(
fn () => $this->service->update($stokOpname, $request->validated()),
'Stok opname berhasil diperbarui.',
'admin.manage.stok-opnames.index',
'admin.manage.stok-opnames.edit',
['stokOpname' => $stokOpname]
);
}
public function destroy(StokOpname $stokOpname): RedirectResponse
{
return $this->handleAction(
fn () => $this->service->destroy($stokOpname),
'Stok opname berhasil dihapus.',
'admin.manage.stok-opnames.index'
);
}
public function submit(StokOpname $stokOpname): RedirectResponse
{
return $this->handleAction(
fn () => $this->service->submit($stokOpname),
'Stok opname berhasil disubmit.',
'admin.manage.stok-opnames.index'
);
}
public function verify(StokOpnameVerifyRequest $request, StokOpname $stokOpname): RedirectResponse
{
return $this->handleAction(
fn () => $this->service->verify($stokOpname, $request->validated()),
'Stok opname berhasil diverifikasi.',
'admin.manage.stok-opnames.index'
);
}
public function reject(StokOpname $stokOpname): RedirectResponse
{
return $this->handleAction(
fn () => $this->service->reject($stokOpname),
'Stok opname ditolak.',
'admin.manage.stok-opnames.index'
);
}
public function cancel(StokOpname $stokOpname): RedirectResponse
{
return $this->handleAction(
fn () => $this->service->cancel($stokOpname),
'Stok opname berhasil dibatalkan.',
'admin.manage.stok-opnames.index'
);
}
public function items(StokOpname $stokOpname): JsonResponse
{
return response()->json([
'items' => $this->service->getItems($stokOpname),
]);
}
}

View File

@ -5,6 +5,7 @@
use App\Enums\OrderChannel;
use App\Enums\PaymentType;
use App\Enums\PriceType;
use App\Enums\Role;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\Manage\TransactionRequest;
use App\Http\Requests\PaginatedRequest;
@ -37,7 +38,6 @@ public function index(PaginatedRequest $request): Response
filters: $filters,
user: $user,
),
'summary' => $this->service->getSummary($filters, $user),
'filters' => $filters,
'filterOptions' => $this->service->getFilterOptions(),
'highlight' => $request->input('highlight'),
@ -46,13 +46,20 @@ public function index(PaginatedRequest $request): Response
public function create(): Response
{
$user = auth()->user();
$isCashier = $user->hasRole(Role::CASHIER);
return Inertia::render('admin/manage/transaction/create', [
'products' => $this->productVariantService->getForTransaction(),
'customers' => $this->customerService->getAll(),
'employees' => $this->getEmployees(),
'channelOptions' => OrderChannel::toSelect(),
'paymentTypeOptions' => PaymentType::toSelect(),
'priceTypeOptions' => PriceType::toSelect()->filter(fn ($p) => $p['value'] !== PriceType::CAPITAL->value)->values(),
'priceTypeOptions' => PriceType::toSelect()
->filter(fn ($p) => ! in_array($p['value'], [PriceType::CAPITAL->value, PriceType::REJECT_CAPITAL->value]))
->when($isCashier, fn ($q) => $q->filter(fn ($p) => in_array($p['value'], [PriceType::RETAIL->value, PriceType::REJECT_SELLING->value])))
->when(! $isCashier, fn ($q) => $q->filter(fn ($p) => $p['value'] !== PriceType::RETAIL->value))
->values(),
]);
}
@ -68,6 +75,9 @@ public function store(TransactionRequest $request): RedirectResponse
public function edit(Order $transaction): Response
{
$user = auth()->user();
$isCashier = $user->hasRole(Role::CASHIER);
return Inertia::render('admin/manage/transaction/edit', [
'transaction' => $this->service->getForEdit($transaction),
'products' => $this->productVariantService->getForTransaction(),
@ -75,7 +85,11 @@ public function edit(Order $transaction): Response
'employees' => $this->getEmployees(),
'channelOptions' => OrderChannel::toSelect(),
'paymentTypeOptions' => PaymentType::toSelect(),
'priceTypeOptions' => PriceType::toSelect()->filter(fn ($p) => $p['value'] !== PriceType::CAPITAL->value)->values(),
'priceTypeOptions' => PriceType::toSelect()
->filter(fn ($p) => ! in_array($p['value'], [PriceType::CAPITAL->value, PriceType::REJECT_CAPITAL->value]))
->when($isCashier, fn ($q) => $q->filter(fn ($p) => in_array($p['value'], [PriceType::RETAIL->value, PriceType::REJECT_SELLING->value])))
->when(! $isCashier, fn ($q) => $q->filter(fn ($p) => $p['value'] !== PriceType::RETAIL->value))
->values(),
]);
}

View File

@ -9,6 +9,7 @@
use App\Services\Admin\Master\RawMaterial\RawMaterialService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
use Inertia\Response;
@ -55,13 +56,15 @@ public function edit(RawMaterial $rawMaterial): Response
public function update(RawMaterialRequest $request, RawMaterial $rawMaterial): RedirectResponse
{
return $this->handleAction(
fn () => $this->service->update($rawMaterial, $request->validated()),
'Bahan baku berhasil diperbarui.',
'admin.master.raw-materials.index',
'admin.master.raw-materials.edit',
['rawMaterial' => $rawMaterial]
);
try {
$this->service->update($rawMaterial, $request->validated());
} catch (ValidationException $e) {
return back()->withErrors($e->errors());
}
Inertia::flash('toast', ['type' => 'success', 'message' => 'Bahan baku berhasil diperbarui.']);
return to_route('admin.master.raw-materials.index');
}
public function destroy(RawMaterial $rawMaterial): RedirectResponse

View File

@ -0,0 +1,25 @@
<?php
namespace App\Http\Controllers\Admin\System;
use App\Http\Controllers\Controller;
use App\Http\Requests\PaginatedRequest;
use App\Services\Admin\System\FormHistoryService;
use Inertia\Inertia;
use Inertia\Response;
class FormHistoryController extends Controller
{
public function __construct(
private FormHistoryService $service
) {}
public function index(PaginatedRequest $request): Response
{
return Inertia::render('admin/system/form-histories/index', [
'formHistories' => $this->service->paginated(...$request->validatedWithDefaults()),
'modules' => $this->service->getModules(),
'events' => $this->service->getEvents(),
]);
}
}

View File

@ -30,6 +30,7 @@ public function index(Request $request): Response
$cashOverview = $this->service->getCashOverview($startDate, $endDate);
$rawMaterialStock = $this->service->getRawMaterialStock();
$productStock = $this->service->getProductStock();
$revenueByStockType = $this->service->getRevenueByStockType($startDate, $endDate, $user);
$revenueSummary = $this->service->getRevenueSummary($startDate, $endDate, $user);
$monthlyRevenue = $this->service->getMonthlyRevenue($startDate, $endDate, $user);
$monthlyRevenueByChannel = $this->service->getMonthlyRevenueByChannel($startDate, $endDate, $user);
@ -56,6 +57,7 @@ public function index(Request $request): Response
'cashOverview' => $cashOverview,
'rawMaterialStock' => $rawMaterialStock,
'productStock' => $productStock,
'revenueByStockType' => $revenueByStockType,
'revenueSummary' => $revenueSummary,
'monthlyRevenue' => $monthlyRevenue,
'monthlyRevenueByChannel' => $monthlyRevenueByChannel,

View File

@ -14,8 +14,7 @@ public function index(Request $request): JsonResponse
$notifications = $request->user()
->notifications()
->orderBy('created_at', 'desc')
->limit(20)
->get();
->paginate(15);
return response()->json($notifications);
}
@ -67,4 +66,11 @@ public function markAllAsRead(Request $request): JsonResponse
return response()->json(['message' => 'All notifications marked as read.']);
}
public function deleteAll(Request $request): JsonResponse
{
$request->user()->notifications()->delete();
return response()->json(['message' => 'All notifications deleted.']);
}
}

View File

@ -23,7 +23,7 @@ public function rules(): array
'cutting_result' => ['required', 'integer', 'min:1'],
'materials' => ['required', 'array', 'min:1'],
'materials.*.raw_material_price_id' => ['required', 'integer', Rule::exists('raw_material_prices', 'id')],
'materials.*.material_usage' => ['required', 'integer', 'min:1'],
'materials.*.material_usage' => ['required', 'numeric', 'min:0.01', 'decimal:0,2'],
'materials.*.material_result' => ['required', 'integer'],
'materials.*.combination_index' => ['nullable', 'integer'],
'combinations' => ['nullable', 'array'],

View File

@ -0,0 +1,40 @@
<?php
namespace App\Http\Requests\Admin\Manage;
use App\Enums\ProductStockQuality;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StokOpnameRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()->can('stok_opnames.create')
|| $this->user()->can('stok_opnames.update');
}
public function rules(): array
{
return [
'opname_date' => ['required', 'date'],
'items' => ['required', 'array', 'min:1'],
'items.*.product_variant_id' => ['required', 'integer', Rule::exists('product_variants', 'id')],
'items.*.stock_quality' => ['required', Rule::in(ProductStockQuality::values())],
'items.*.physical_stock' => ['required', 'integer', 'min:0'],
'notes' => ['nullable', 'string', 'max:100'],
];
}
public function attributes(): array
{
return [
'opname_date' => 'Tanggal Opname',
'items' => 'Item produk',
'items.*.product_variant_id' => 'Varian produk',
'items.*.stock_quality' => 'Jenis stok',
'items.*.physical_stock' => 'Stok fisik',
'notes' => 'Keterangan',
];
}
}

View File

@ -0,0 +1,27 @@
<?php
namespace App\Http\Requests\Admin\Manage;
use Illuminate\Foundation\Http\FormRequest;
class StokOpnameVerifyRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()->can('stok_opnames.verify');
}
public function rules(): array
{
return [
'verification_notes' => ['nullable', 'string', 'max:100'],
];
}
public function attributes(): array
{
return [
'verification_notes' => 'Catatan Verifikasi',
];
}
}

View File

@ -30,7 +30,7 @@ public function rules(): array
return [
'stock_type' => ['sometimes', 'required', Rule::in(ProductStockQuality::values())],
'channel' => ['sometimes', 'required', Rule::in(OrderChannel::values())],
'price_type' => ['sometimes', 'required', Rule::in(array_diff(PriceType::values(), [PriceType::CAPITAL->value]))],
'price_type' => ['sometimes', 'required', Rule::in(array_diff(PriceType::values(), [PriceType::CAPITAL->value, PriceType::REJECT_CAPITAL->value]))],
'payment_type' => ['sometimes', 'required', Rule::in(PaymentType::values())],
'customer_id' => ['nullable', 'integer', Rule::exists('customers', 'id')],
'marketing_id' => ['nullable', 'integer', Rule::exists('users', 'id')],

View File

@ -32,7 +32,7 @@ public function rules(): array
'variants.*.id' => ['nullable', 'integer'],
'variants.*.variant' => ['required', 'string', 'max:200'],
'variants.*.price' => ['required', 'integer', 'min:0'],
'variants.*.stock' => ['required', 'integer', 'min:0'],
'variants.*.stock' => ['required', 'numeric', 'min:0', 'decimal:0,2'],
'variants.*.photo_key' => ['required', 'string', 'max:500'],
];
}

View File

@ -25,7 +25,7 @@ public function rules(): array
return [
'variant' => ['required', 'string', 'max:200'],
'price' => ['required', 'integer', 'min:0'],
'stock' => ['required', 'integer', 'min:0'],
'stock' => ['required', 'numeric', 'min:0', 'decimal:0,2'],
'photo_key' => ['required', 'string', 'max:500'],
];
}

View File

@ -19,7 +19,7 @@ class CuttingMaterial extends Model
protected function casts(): array
{
return [
'material_usage' => 'integer',
'material_usage' => 'decimal:2',
'material_result' => 'integer',
];
}
@ -34,7 +34,7 @@ protected function formattedMaterialResult(): Attribute
protected function formattedMaterialUsage(): Attribute
{
return Attribute::make(
get: fn () => number_format($this->material_usage, 0, ',', '.'),
get: fn () => number_format((float) $this->material_usage, 2, ',', '.'),
);
}

View File

@ -0,0 +1,36 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Appends;
use Illuminate\Database\Eloquent\Attributes\Guarded;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Appends(['formatted_created_at'])]
#[Guarded(['id'])]
class FormHistory extends Model
{
use HasFactory;
protected function casts(): array
{
return [
'attribute_changes' => 'array',
];
}
protected function formattedCreatedAt(): Attribute
{
return Attribute::make(
get: fn () => $this->created_at?->translatedFormat('l, d F Y H:i'),
);
}
public function causer(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

View File

@ -29,7 +29,7 @@ protected function casts(): array
{
return [
'price' => 'integer',
'stock' => 'integer',
'stock' => 'decimal:2',
];
}
@ -43,7 +43,7 @@ protected function formattedPrice(): Attribute
protected function formattedStock(): Attribute
{
return Attribute::make(
get: fn () => number_format($this->stock, 0, ',', '.'),
get: fn () => number_format((float) $this->stock, 2, ',', '.'),
);
}

View File

@ -201,7 +201,9 @@ private function formatTransaction(CashTransaction $transaction): array
return $transaction->toArray() + [
'receipt_key' => $s3Key,
'receipt_url' => $this->s3Service->getTemporaryUrl($s3Key),
'receipt_conversion_url' => $this->s3Service->getTemporaryUrl($media->getPath('thumb')),
'receipt_conversion_url' => $media->getGeneratedConversions()->contains('thumb')
? $this->s3Service->getTemporaryUrl($media->getPath('thumb'))
: $this->s3Service->getTemporaryUrl($s3Key),
];
}
}

View File

@ -165,10 +165,9 @@ private function formatExpense(Expense $expense): array
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'));
});
$receiptConversionUrl = $media->getGeneratedConversions()->contains('thumb')
? $this->s3Service->getTemporaryUrl($media->getPath('thumb'))
: $receiptUrl;
return $expense->toArray() + [
'receipt_key' => $s3Key,

View File

@ -130,7 +130,7 @@ public function pay(Payroll $payroll): Payroll
$cashTransaction = $this->debitCash(
amount: $payroll->total_amount,
description: 'Pembayaran gaji karyawan',
type: CashTransactionType::EXPENSE,
type: CashTransactionType::SALARY,
);
$payroll->update([

View File

@ -29,7 +29,7 @@ public function __construct(
public function getIndexData(int $year, int $month): array
{
$user = auth()->user();
$isAdmin = self::hasAnyRole([Role::DEVELOPER, Role::OWNER]);
$isAdmin = self::hasAnyRole([Role::DEVELOPER, Role::OWNER, Role::ADMIN_TOKO, Role::DIREKTUR]);
$hrSettings = app(HRSettings::class);
$employeeId = $isAdmin ? null : $user->employee?->id;
@ -37,7 +37,7 @@ public function getIndexData(int $year, int $month): array
return [
'attendances' => $this->getByMonth($year, $month, $employeeId),
'leaves' => $this->getLeavesByMonth($year, $month, $employeeId),
'employees' => $isAdmin ? $this->employeeService->getAll() : [],
'employees' => $isAdmin ? $this->getEmployeesWithAttendancePermission() : [],
'todayAttendance' => $isAdmin ? null : $this->getToday(),
'currentYear' => $year,
'currentMonth' => $month,
@ -57,9 +57,9 @@ public function getByMonth(int $year, int $month, ?int $employeeId = null): Coll
return Attendance::with(['employee.user.userProfile', 'media'])
->whereYear('attendance_date', $year)
->whereMonth('attendance_date', $month)
->when($employeeId, fn ($q) => $q->where('employee_id', $employeeId))
->when($employeeId, fn($q) => $q->where('employee_id', $employeeId))
->get()
->map(fn (Attendance $attendance) => $this->formatAttendance($attendance));
->map(fn(Attendance $attendance) => $this->formatAttendance($attendance));
}
public function getByDate(string $date): ?array
@ -72,6 +72,18 @@ public function getByDate(string $date): ?array
return $attendance ? $this->formatAttendance($attendance) : null;
}
public function getByMonthData(int $year, int $month): array
{
$employeeId = null;
return [
'attendances' => $this->getByMonth($year, $month, $employeeId),
'leaves' => $this->getLeavesByMonth($year, $month, $employeeId),
'employees' => $this->getEmployeesWithAttendancePermission(),
'monthStats' => $this->getMonthStats($year, $month, $employeeId),
];
}
public function getToday(): ?array
{
return $this->getByDate(now()->toDateString());
@ -86,9 +98,9 @@ public function getLeavesByMonth(int $year, int $month, ?int $employeeId = null)
->with('employee.user.userProfile')
->where('start_date', '<=', $endOfMonth)
->where('end_date', '>=', $startOfMonth)
->when($employeeId, fn ($q) => $q->where('employee_id', $employeeId))
->when($employeeId, fn($q) => $q->where('employee_id', $employeeId))
->get()
->map(fn (LeaveRequest $leave) => [
->map(fn(LeaveRequest $leave) => [
'id' => $leave->id,
'employee_id' => $leave->employee_id,
'start_date' => $leave->start_date->toDateString(),
@ -174,7 +186,7 @@ public function checkIn(array $data): Attendance
NotificationService::notify(
roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR],
title: 'Presensi Masuk',
body: 'Presensi masuk oleh '.auth()->user()->full_name.'.',
body: 'Presensi masuk oleh ' . auth()->user()->full_name . '.',
url: route('admin.hr.attendances.index', ['highlight' => $attendance->id]),
);
@ -196,7 +208,7 @@ public function checkOut(Attendance $attendance, array $data): Attendance
NotificationService::notify(
roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR],
title: 'Presensi Pulang',
body: 'Presensi pulang oleh '.auth()->user()->full_name.'.',
body: 'Presensi pulang oleh ' . auth()->user()->full_name . '.',
url: route('admin.hr.attendances.index', ['highlight' => $attendance->id]),
);
@ -211,8 +223,8 @@ private function getMonthStatsForEmployee(int $year, int $month, Carbon $startOf
->where('employee_id', $employeeId);
$attendanceDates = $attendanceQuery->pluck('attendance_date')
->map(fn ($d) => Carbon::parse($d)->toDateString())
->filter(fn ($d) => ! in_array(Carbon::parse($d)->dayOfWeek, [Carbon::SATURDAY, Carbon::SUNDAY]))
->map(fn($d) => Carbon::parse($d)->toDateString())
->filter(fn($d) => ! in_array(Carbon::parse($d)->dayOfWeek, [Carbon::SATURDAY, Carbon::SUNDAY]))
->unique()
->values();
$attendanceCount = $attendanceDates->count();
@ -252,18 +264,31 @@ private function getMonthStatsForEmployee(int $year, int $month, Carbon $startOf
private function getMonthStatsForAll(int $year, int $month, Carbon $startOfMonth, Carbon $statEnd, int $workingDays): array
{
$totalEmployees = Employee::whereHas('user', fn ($q) => $q->where('is_active', true))->count();
$totalEmployees = Employee::whereHas('user', function ($q) {
$q->active()
->whereHas('roles', fn($rq) => $rq->whereHas('permissions', fn($pq) => $pq->where('name', 'attendances.view')));
})->count();
$presentCount = Attendance::whereYear('attendance_date', $year)
->whereMonth('attendance_date', $month)
->where('attendance_date', '<=', $statEnd->toDateString())
->whereHas('employee', fn ($q) => $q->whereHas('user', fn ($uq) => $uq->where('is_active', true)))
->whereHas('employee', function ($q) {
$q->whereHas('user', function ($uq) {
$uq->active()
->whereHas('roles', fn($rq) => $rq->whereHas('permissions', fn($pq) => $pq->where('name', 'attendances.view')));
});
})
->count();
$leaveRequests = LeaveRequest::approved()
->where('start_date', '<=', $statEnd)
->where('end_date', '>=', $startOfMonth)
->whereHas('employee', fn ($q) => $q->whereHas('user', fn ($uq) => $uq->where('is_active', true)))
->whereHas('employee', function ($q) {
$q->whereHas('user', function ($uq) {
$uq->active()
->whereHas('roles', fn($rq) => $rq->whereHas('permissions', fn($pq) => $pq->where('name', 'attendances.view')));
});
})
->get();
$leaveDays = 0;
@ -307,13 +332,25 @@ private function formatAttendance(Attendance $attendance): array
$checkOutMedia = $attendance->getFirstMedia('checkout');
$toArray['check_in_photo'] = $checkInMedia
? Cache::remember("attendance_check_in_{$checkInMedia->id}", now()->addMinutes(55), fn () => $this->s3Service->getTemporaryUrl($checkInMedia->getPath()))
? Cache::remember("attendance_check_in_{$checkInMedia->id}", now()->addMinutes(55), fn() => $this->s3Service->getTemporaryUrl($checkInMedia->getPath()))
: null;
$toArray['check_out_photo'] = $checkOutMedia
? Cache::remember("attendance_check_out_{$checkOutMedia->id}", now()->addMinutes(55), fn () => $this->s3Service->getTemporaryUrl($checkOutMedia->getPath()))
? Cache::remember("attendance_check_out_{$checkOutMedia->id}", now()->addMinutes(55), fn() => $this->s3Service->getTemporaryUrl($checkOutMedia->getPath()))
: null;
return $toArray;
}
private function getEmployeesWithAttendancePermission(): Collection
{
return Employee::with(['user.userProfile', 'user.roles'])
->whereHas('user', fn ($q) => $q->where('is_active', true)
->whereHas('roles', fn ($rq) => $rq->whereHas('permissions', fn ($pq) => $pq->where('name', 'attendances.view'))))
->get()
->map(fn (Employee $employee) => [
'id' => $employee->id,
'name' => $employee->user?->userProfile?->full_name ?? '-',
]);
}
}

View File

@ -22,7 +22,7 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
->where(fn ($q) => $q->whereHas('employee')->orWhereHas('roles', fn ($rq) => $rq->where('name', 'Owner')))
->with([
'userProfile' => fn ($q) => $q->select(['id', 'user_id', 'full_name', 'phone_number', 'gender']),
'employee' => fn ($q) => $q->select(['id', 'user_id', 'join_date', 'employment_status', 'base_salary']),
'employee' => fn ($q) => $q->select(['id', 'user_id', 'join_date', 'resign_date', 'employment_status', 'base_salary']),
'roles' => fn ($q) => $q->select(['id', 'name']),
'media',
])

View File

@ -58,7 +58,9 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
fn ($media) => $this->s3Service->getTemporaryUrl($media->getPath())
)->toArray();
$cutting->photo_conversion_urls = $cuttingMedia->map(
fn ($media) => $this->s3Service->getTemporaryUrl($media->getPath('thumb'))
fn ($media) => $media->getGeneratedConversions()->contains('thumb')
? $this->s3Service->getTemporaryUrl($media->getPath('thumb'))
: $this->s3Service->getTemporaryUrl($media->getPath())
)->toArray();
});
@ -84,7 +86,9 @@ public function getMaterials(Cutting $cutting): Collection
? $this->s3Service->getTemporaryUrl($media->getPath())
: null;
$material->rawMaterialPrice->photo_conversion_url = $media
? $this->s3Service->getTemporaryUrl($media->getPath('thumb'))
? ($media->getGeneratedConversions()->contains('thumb')
? $this->s3Service->getTemporaryUrl($media->getPath('thumb'))
: $this->s3Service->getTemporaryUrl($media->getPath()))
: null;
});
}
@ -124,7 +128,9 @@ public function getForShow(Cutting $cutting): array
fn ($media) => $this->s3Service->getTemporaryUrl($media->getPath())
)->toArray();
$cutting->photo_conversion_urls = $cuttingMedia->map(
fn ($media) => $this->s3Service->getTemporaryUrl($media->getPath('thumb'))
fn ($media) => $media->getGeneratedConversions()->contains('thumb')
? $this->s3Service->getTemporaryUrl($media->getPath('thumb'))
: $this->s3Service->getTemporaryUrl($media->getPath())
)->toArray();
$cutting->cuttingMaterials->each(function (CuttingMaterial $material) {
@ -134,7 +140,9 @@ public function getForShow(Cutting $cutting): array
? $this->s3Service->getTemporaryUrl($media->getPath())
: null;
$material->rawMaterialPrice->photo_conversion_url = $media
? $this->s3Service->getTemporaryUrl($media->getPath('thumb'))
? ($media->getGeneratedConversions()->contains('thumb')
? $this->s3Service->getTemporaryUrl($media->getPath('thumb'))
: $this->s3Service->getTemporaryUrl($media->getPath()))
: null;
}
});
@ -259,7 +267,7 @@ public function store(array $data): Cutting
$pricesMap = RawMaterialPrice::whereIn('id', $priceIds)->get()->keyBy('id');
foreach ($data['materials'] as $materialData) {
$usage = (int) ($materialData['material_usage'] ?? 0);
$usage = (float) ($materialData['material_usage'] ?? 0);
if ($usage <= 0) {
continue;
}
@ -320,7 +328,7 @@ public function store(array $data): Cutting
'updated_at' => $now,
]);
$usage = (int) ($materialData['material_usage'] ?? 0);
$usage = (float) ($materialData['material_usage'] ?? 0);
if ($price && $usage > 0) {
$stockDecrementMap[$materialData['raw_material_price_id']] = ($stockDecrementMap[$materialData['raw_material_price_id']] ?? 0) + $usage;
}
@ -393,7 +401,7 @@ public function update(Cutting $cutting, array $data): Cutting
$pricesMap = RawMaterialPrice::whereIn('id', $priceIds)->get()->keyBy('id');
foreach ($data['materials'] as $materialData) {
$usage = (int) ($materialData['material_usage'] ?? 0);
$usage = (float) ($materialData['material_usage'] ?? 0);
if ($usage <= 0) {
continue;
}
@ -452,7 +460,7 @@ public function update(Cutting $cutting, array $data): Cutting
'updated_at' => $now,
]);
$usage = (int) ($materialData['material_usage'] ?? 0);
$usage = (float) ($materialData['material_usage'] ?? 0);
if ($price && $usage > 0) {
$stockDecrementMap[$materialData['raw_material_price_id']] = ($stockDecrementMap[$materialData['raw_material_price_id']] ?? 0) + $usage;
}

View File

@ -56,7 +56,9 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
fn ($media) => $this->s3Service->getTemporaryUrl($media->getPath())
)->toArray();
$purchase->photo_conversion_urls = $purchaseMedia->map(
fn ($media) => $this->s3Service->getTemporaryUrl($media->getPath('thumb'))
fn ($media) => $media->getGeneratedConversions()->contains('thumb')
? $this->s3Service->getTemporaryUrl($media->getPath('thumb'))
: $this->s3Service->getTemporaryUrl($media->getPath())
)->toArray();
});
@ -80,7 +82,9 @@ public function getItems(Purchase $purchase): Collection
? $this->s3Service->getTemporaryUrl($media->getPath())
: null;
$item->rawMaterialPrice->photo_conversion_url = $media
? $this->s3Service->getTemporaryUrl($media->getPath('thumb'))
? ($media->getGeneratedConversions()->contains('thumb')
? $this->s3Service->getTemporaryUrl($media->getPath('thumb'))
: $this->s3Service->getTemporaryUrl($media->getPath()))
: null;
});
}

View File

@ -46,9 +46,46 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
->orderBy($sort, $direction)
->paginate($perPage);
$paginator->getCollection()->each(function (Restock $restock) {
$restockMedia = $restock->getMedia('photos');
$restock->photo_urls = $restockMedia->map(
fn ($media) => $this->s3Service->getTemporaryUrl($media->getPath())
)->toArray();
$restock->photo_conversion_urls = $restockMedia->map(
fn ($media) => $media->getGeneratedConversions()->contains('thumb')
? $this->s3Service->getTemporaryUrl($media->getPath('thumb'))
: $this->s3Service->getTemporaryUrl($media->getPath())
)->toArray();
});
return $paginator;
}
public function getForEdit(Restock $restock): array
{
$restock->load([
'restockItems' => fn ($q) => $q
->select(['id', 'restock_id', 'product_variant_id', 'quantity', 'unit_price', 'subtotal']),
'restockItems.productVariant:id,name',
]);
$media = $restock->getFirstMedia('photos');
return [
'id' => $restock->id,
'stock_type' => $restock->stock_type->value,
'notes' => $restock->notes,
'photo_key' => $media?->getCustomProperty('s3_key') ?? $media?->file_name,
'photo_url' => $media ? $this->s3Service->getTemporaryUrl($media->getPath()) : null,
'items' => $restock->restockItems->map(fn ($item) => [
'id' => $item->id,
'product_variant_id' => $item->product_variant_id,
'quantity' => $item->quantity,
'unit_price' => $item->unit_price,
]),
];
}
public function getItems(Restock $restock): \Illuminate\Support\Collection
{
return $restock->restockItems()
@ -66,7 +103,9 @@ public function getItems(Restock $restock): \Illuminate\Support\Collection
? $this->s3Service->getTemporaryUrl($media->getPath())
: null;
$item->productVariant->photo_conversion_url = $media
? $this->s3Service->getTemporaryUrl($media->getPath('thumb'))
? ($media->getGeneratedConversions()->contains('thumb')
? $this->s3Service->getTemporaryUrl($media->getPath('thumb'))
: $this->s3Service->getTemporaryUrl($media->getPath()))
: null;
});
}
@ -178,7 +217,7 @@ public function destroy(Restock $restock): bool
private function buildItemRows(array $items, string $stockType, $now, int &$total): array
{
$priceType = $stockType === ProductStockQuality::REJECT->value
? PriceType::REJECT
? PriceType::REJECT_CAPITAL
: PriceType::CAPITAL;
$variantIds = collect($items)->pluck('product_variant_id')->unique()->all();

View File

@ -0,0 +1,293 @@
<?php
namespace App\Services\Admin\Manage;
use App\Enums\ProductStockQuality;
use App\Enums\Role;
use App\Enums\StokOpnameStatus;
use App\Models\ProductVariant;
use App\Models\StokOpname;
use App\Models\StokOpnameItem;
use App\Services\Concerns\HasStockAdjustment;
use App\Services\NotificationService;
use App\Services\S3PresignedService;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class StokOpnameService
{
use HasStockAdjustment;
public function __construct(
private S3PresignedService $s3Service,
) {}
public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', ?int $highlight = null, ?string $status = null): LengthAwarePaginator
{
$itemsCountQuery = '(SELECT COUNT(DISTINCT product_variant_id) FROM stok_opname_items WHERE stok_opname_items.stok_opname_id = stok_opnames.id)';
$totalDifferenceQuery = '(SELECT IFNULL(SUM(difference), 0) FROM stok_opname_items WHERE stok_opname_items.stok_opname_id = stok_opnames.id)';
$productNamesQuery = '(SELECT GROUP_CONCAT(DISTINCT p.name ORDER BY p.name SEPARATOR \', \') FROM stok_opname_items soi JOIN product_variants pv ON pv.id = soi.product_variant_id JOIN products p ON p.id = pv.product_id WHERE soi.stok_opname_id = stok_opnames.id)';
return StokOpname::query()
->select(['id', 'created_by_id', 'verified_by_id', 'opname_date', 'status', 'notes', 'verification_notes', 'created_at'])
->with([
'createdBy:id',
'createdBy.userProfile:id,user_id,full_name',
'verifiedBy:id',
'verifiedBy.userProfile:id,user_id,full_name',
])
->selectRaw("{$itemsCountQuery} as items_count")
->selectRaw("{$totalDifferenceQuery} as total_difference")
->selectRaw("{$productNamesQuery} as product_names")
->when($highlight, fn ($q) => $q->where('id', $highlight))
->when($status, fn ($q) => $q->where('status', $status))
->when($search, function ($q) use ($search) {
$q->whereHas('stokOpnameItems.productVariant.product', fn ($sq) => $sq->where('name', 'like', "%{$search}%"))
->orWhere('notes', 'like', "%{$search}%");
})
->orderBy($sort, $direction)
->paginate($perPage);
}
public function store(array $data): StokOpname
{
return DB::transaction(function () use ($data) {
$stokOpname = StokOpname::create([
'created_by_id' => auth()->id(),
'opname_date' => $data['opname_date'],
'status' => StokOpnameStatus::DRAFT,
'notes' => $data['notes'] ?? null,
]);
$this->createItems($stokOpname, $data['items']);
NotificationService::notify(
roles: [Role::OWNER, Role::DEVELOPER],
title: 'Stok Opname Baru',
body: 'Stok opname baru berhasil dibuat oleh '.auth()->user()->full_name.'.',
url: route('admin.manage.stok-opnames.index', ['highlight' => $stokOpname->id]),
);
return $stokOpname;
});
}
public function update(StokOpname $stokOpname, array $data): StokOpname
{
$this->assertDraft($stokOpname);
return DB::transaction(function () use ($stokOpname, $data) {
$stokOpname->stokOpnameItems()->delete();
$stokOpname->update([
'opname_date' => $data['opname_date'],
'notes' => $data['notes'] ?? null,
]);
$this->createItems($stokOpname, $data['items']);
return $stokOpname;
});
}
public function destroy(StokOpname $stokOpname): void
{
$this->assertDraft($stokOpname);
DB::transaction(function () use ($stokOpname) {
$stokOpname->stokOpnameItems()->delete();
$stokOpname->delete();
});
}
public function submit(StokOpname $stokOpname): StokOpname
{
$this->assertDraft($stokOpname);
$stokOpname->update(['status' => StokOpnameStatus::IN_PROGRESS]);
NotificationService::notify(
roles: [Role::OWNER, Role::DEVELOPER],
title: 'Stok Opname Disubmit',
body: 'Stok opname berhasil disubmit oleh '.auth()->user()->full_name.'.',
url: route('admin.manage.stok-opnames.index', ['highlight' => $stokOpname->id]),
);
return $stokOpname;
}
public function verify(StokOpname $stokOpname, array $data): StokOpname
{
$this->assertInProgress($stokOpname);
return DB::transaction(function () use ($stokOpname, $data) {
$stokOpname->load('stokOpnameItems');
foreach ($stokOpname->stokOpnameItems as $item) {
if ($item->difference != 0) {
$this->adjustVariantStock(
$item->product_variant_id,
abs($item->difference),
$item->difference > 0 ? 1 : -1,
$item->stock_quality->value,
);
}
}
$stokOpname->update([
'status' => StokOpnameStatus::VERIFIED,
'verified_by_id' => auth()->id(),
'verification_notes' => $data['verification_notes'] ?? null,
]);
NotificationService::notify(
roles: [Role::STOK_OPNAME, Role::ADMIN_TOKO],
title: 'Stok Opname Diverifikasi',
body: 'Stok opname berhasil diverifikasi oleh '.auth()->user()->full_name.'.',
url: route('admin.manage.stok-opnames.index', ['highlight' => $stokOpname->id]),
);
return $stokOpname;
});
}
public function reject(StokOpname $stokOpname): StokOpname
{
$this->assertInProgress($stokOpname);
$stokOpname->update([
'status' => StokOpnameStatus::DRAFT,
'verification_notes' => null,
]);
NotificationService::notify(
roles: [Role::STOK_OPNAME],
title: 'Stok Opname Ditolak',
body: 'Stok opname ditolak oleh '.auth()->user()->full_name.'. Silakan periksa dan submit ulang.',
url: route('admin.manage.stok-opnames.index', ['highlight' => $stokOpname->id]),
);
return $stokOpname;
}
public function cancel(StokOpname $stokOpname): StokOpname
{
if ($stokOpname->status === StokOpnameStatus::VERIFIED) {
throw ValidationException::withMessages([
'status' => 'Stok opname yang sudah diverifikasi tidak dapat dibatalkan.',
]);
}
$stokOpname->update(['status' => StokOpnameStatus::CANCELLED]);
return $stokOpname;
}
public function getForEdit(StokOpname $stokOpname): array
{
$stokOpname->load([
'stokOpnameItems' => fn ($q) => $q
->select(['id', 'stok_opname_id', 'product_variant_id', 'stock_quality', 'system_stock', 'physical_stock', 'difference', 'notes']),
'stokOpnameItems.productVariant:id,product_id,name,stock,reject_stock,retail_stock',
'stokOpnameItems.productVariant.product:id,name',
]);
return [
'id' => $stokOpname->id,
'opname_date' => $stokOpname->opname_date->format('Y-m-d'),
'status' => $stokOpname->status->value,
'notes' => $stokOpname->notes,
'items' => $stokOpname->stokOpnameItems->map(fn ($item) => [
'id' => $item->id,
'product_variant_id' => $item->product_variant_id,
'stock_quality' => $item->stock_quality->value,
'system_stock' => $item->system_stock,
'physical_stock' => $item->physical_stock,
'difference' => $item->difference,
'notes' => $item->notes,
'variant_name' => $item->productVariant?->name,
'product_name' => $item->productVariant?->product?->name,
]),
];
}
public function getItems(StokOpname $stokOpname): \Illuminate\Support\Collection
{
return $stokOpname->stokOpnameItems()
->select(['id', 'stok_opname_id', 'product_variant_id', 'stock_quality', 'system_stock', 'physical_stock', 'difference', 'notes'])
->with(['productVariant:id,product_id,name', 'productVariant.product:id,name'])
->orderByRaw('(SELECT name FROM product_variants WHERE product_variants.id = stok_opname_items.product_variant_id)')
->get()
->map(function (StokOpnameItem $item) {
$media = $item->productVariant?->getFirstMedia('images');
$photoUrl = $media ? $this->s3Service->getTemporaryUrl($media->getPath()) : null;
$photoConversionUrl = $media
? ($media->getGeneratedConversions()->contains('thumb')
? $this->s3Service->getTemporaryUrl($media->getPath('thumb'))
: $this->s3Service->getTemporaryUrl($media->getPath()))
: null;
return [
'id' => $item->id,
'product_variant_id' => $item->product_variant_id,
'stock_quality' => $item->stock_quality,
'system_stock' => $item->system_stock,
'physical_stock' => $item->physical_stock,
'difference' => $item->difference,
'notes' => $item->notes,
'variant_name' => $item->productVariant?->name,
'product_name' => $item->productVariant?->product?->name,
'photo_url' => $photoUrl,
'photo_conversion_url' => $photoConversionUrl,
];
});
}
private function createItems(StokOpname $stokOpname, array $items): void
{
$now = now();
$rows = collect($items)->map(function ($item) use ($stokOpname, $now) {
$variant = ProductVariant::findOrFail($item['product_variant_id']);
$stockQuality = ProductStockQuality::from($item['stock_quality']);
$systemStock = match ($stockQuality) {
ProductStockQuality::GOOD => $variant->stock,
ProductStockQuality::REJECT => $variant->reject_stock,
ProductStockQuality::RETAIL => $variant->retail_stock,
};
return [
'stok_opname_id' => $stokOpname->id,
'product_variant_id' => $item['product_variant_id'],
'stock_quality' => $stockQuality->value,
'system_stock' => $systemStock,
'physical_stock' => (int) $item['physical_stock'],
'difference' => (int) $item['physical_stock'] - $systemStock,
'notes' => $item['notes'] ?? null,
'created_at' => $now,
'updated_at' => $now,
];
})->toArray();
DB::table('stok_opname_items')->insert($rows);
}
private function assertDraft(StokOpname $stokOpname): void
{
if ($stokOpname->status !== StokOpnameStatus::DRAFT) {
throw ValidationException::withMessages([
'status' => 'Hanya stok opname dengan status draft yang dapat diubah.',
]);
}
}
private function assertInProgress(StokOpname $stokOpname): void
{
if ($stokOpname->status !== StokOpnameStatus::IN_PROGRESS) {
throw ValidationException::withMessages([
'status' => 'Hanya stok opname dengan status dalam proses yang dapat diproses.',
]);
}
}
}

View File

@ -33,6 +33,7 @@ class TransactionService
PriceType::RETAIL->value => PriceType::RETAIL,
PriceType::TIKTOK->value => PriceType::TIKTOK,
PriceType::SHOPEE->value => PriceType::SHOPEE,
PriceType::REJECT_SELLING->value => PriceType::REJECT_SELLING,
];
public function __construct(
@ -86,7 +87,9 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
? $this->s3Service->getTemporaryUrl($orderMedia->getPath())
: null;
$order->photo_conversion_url = $orderMedia
? $this->s3Service->getTemporaryUrl($orderMedia->getPath('thumb'))
? ($orderMedia->getGeneratedConversions()->contains('thumb')
? $this->s3Service->getTemporaryUrl($orderMedia->getPath('thumb'))
: $this->s3Service->getTemporaryUrl($orderMedia->getPath()))
: null;
$order->profit = $order->total_amount - $order->cogs;
@ -149,39 +152,13 @@ public function getItems(Order $order): \Illuminate\Support\Collection
? $this->s3Service->getTemporaryUrl($media->getPath())
: null;
$item->productVariant->photo_conversion_url = $media
? $this->s3Service->getTemporaryUrl($media->getPath('thumb'))
? ($media->getGeneratedConversions()->contains('thumb')
? $this->s3Service->getTemporaryUrl($media->getPath('thumb'))
: $this->s3Service->getTemporaryUrl($media->getPath()))
: null;
});
}
public function getSummary(array $filters = [], ?User $user = null): array
{
$query = Order::query()
->selectRaw('COUNT(*) as total_orders')
->selectRaw('COALESCE(SUM(total_amount), 0) as total_amount')
->selectRaw('COALESCE(SUM(discount), 0) as total_discount')
->selectRaw('COALESCE(SUM(nego_price), 0) as total_deduction')
->selectRaw('COALESCE(SUM(cogs), 0) as total_cogs')
->when($user && $this->isMarketingUser($user), fn ($q) => $q->where('marketing_id', $user->id))
->when($filters['status'] ?? null, fn ($q, $status) => $q->where('status', $status))
->when($filters['channel'] ?? null, fn ($q, $channel) => $q->where('channel', $channel))
->when($filters['payment_type'] ?? null, fn ($q, $paymentType) => $q->where('payment_type', $paymentType))
->when($filters['customer_id'] ?? null, fn ($q, $customerId) => $q->where('customer_id', $customerId))
->when($filters['marketing_id'] ?? null, fn ($q, $marketingId) => $q->where('marketing_id', $marketingId))
->when($filters['created_by_id'] ?? null, fn ($q, $createdById) => $q->where('created_by_id', $createdById))
->when($filters['date_from'] ?? null, fn ($q, $dateFrom) => $q->whereDate('created_at', '>=', $dateFrom))
->when($filters['date_to'] ?? null, fn ($q, $dateTo) => $q->whereDate('created_at', '<=', $dateTo))
->first();
return [
'total_orders' => $query->total_orders,
'total_amount' => $query->total_amount,
'total_discount' => $query->total_discount,
'total_deduction' => $query->total_deduction,
'net_total' => $query->total_amount - $query->total_cogs,
];
}
public function getFilterOptions(): array
{
return [
@ -276,7 +253,7 @@ public function update(Order $order, array $data): Order
$order = DB::transaction(function () use ($order, $data) {
$order->load('orderItems');
$oldStockType = $order->orderItems->first()?->stock_type?->value ?? ProductStockQuality::GOOD->value;
$oldStockType = $order->orderItems->first()?->stock_quality?->value ?? ProductStockQuality::GOOD->value;
$order->orderItems->each(function (OrderItem $item) use ($oldStockType) {
$this->adjustVariantStock($item->product_variant_id, $item->quantity, 1, $oldStockType);
@ -351,11 +328,13 @@ public function destroy(Order $order): bool
$result = DB::transaction(function () use ($order) {
$order->load('orderItems');
$stockType = $order->orderItems->first()?->stock_type?->value ?? ProductStockQuality::GOOD->value;
if (! in_array($order->status, [OrderStatus::CANCELLED, OrderStatus::REFUNDED])) {
$stockType = $order->orderItems->first()?->stock_quality?->value ?? ProductStockQuality::GOOD->value;
$order->orderItems->each(function (OrderItem $item) use ($stockType) {
$this->adjustVariantStock($item->product_variant_id, $item->quantity, 1, $stockType);
});
$order->orderItems->each(function (OrderItem $item) use ($stockType) {
$this->adjustVariantStock($item->product_variant_id, $item->quantity, 1, $stockType);
});
}
$order->orderItems()->delete();
$order->delete();
@ -375,15 +354,26 @@ public function destroy(Order $order): bool
public function updateStatus(Order $order, string $status): Order
{
$oldStatus = $order->status->value;
$order->update(['status' => $status]);
if (in_array($status, [OrderStatus::CANCELLED->value, OrderStatus::REFUNDED->value]) && ! in_array($oldStatus, [OrderStatus::CANCELLED->value, OrderStatus::REFUNDED->value])) {
$order->load('orderItems');
$stockType = $order->orderItems->first()?->stock_quality?->value ?? ProductStockQuality::GOOD->value;
$order->orderItems->each(function (OrderItem $item) use ($stockType) {
$this->adjustVariantStock($item->product_variant_id, $item->quantity, 1, $stockType);
});
}
return $order;
}
private function buildItemRows(array $items, string $stockType, string $priceType, $now, int &$subtotal, int &$totalCost): array
{
$resolvedPriceType = $stockType === ProductStockQuality::REJECT->value
? PriceType::REJECT
? PriceType::REJECT_SELLING
: (self::SELLING_PRICE_MAP[$priceType] ?? PriceType::RETAIL);
$variantIds = collect($items)->pluck('product_variant_id')->unique()->all();
@ -403,9 +393,13 @@ private function buildItemRows(array $items, string $stockType, string $priceTyp
return [$variant->id => $price?->price ?? 0];
});
$capitalPrices = $variants->mapWithKeys(function (ProductVariant $variant) {
$capitalPriceType = $stockType === ProductStockQuality::REJECT->value
? PriceType::REJECT_CAPITAL
: PriceType::CAPITAL;
$capitalPrices = $variants->mapWithKeys(function (ProductVariant $variant) use ($capitalPriceType) {
$price = $variant->productPrices
->first(fn ($p) => $p->type === PriceType::CAPITAL);
->first(fn ($p) => $p->type === $capitalPriceType);
return [$variant->id => $price?->price ?? 0];
});

View File

@ -3,11 +3,14 @@
namespace App\Services\Admin\Master;
use App\Models\Category;
use App\Services\Concerns\LogsFormHistory;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Collection;
class CategoryService
{
use LogsFormHistory;
public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', array $filters = []): LengthAwarePaginator
{
return Category::query()
@ -24,18 +27,41 @@ public function getAll(): Collection
public function store(array $data): Category
{
return Category::create($data);
$category = Category::create($data);
$this->logCreated(
model: $category,
module: 'Kategori',
newValues: ['Nama' => $category->name],
);
return $category;
}
public function update(Category $category, array $data): Category
{
$oldValues = ['Nama' => $category->name];
$category->update($data);
$this->logUpdated(
model: $category,
module: 'Kategori',
oldValues: $oldValues,
newValues: ['Nama' => $category->name],
);
return $category;
}
public function destroy(Category $category): bool
{
$this->logDeleted(
model: $category,
module: 'Kategori',
oldValues: ['Nama' => $category->name],
);
return $category->delete();
}
}

View File

@ -7,6 +7,7 @@
use App\Enums\Role;
use App\Models\Product;
use App\Models\ProductVariant;
use App\Services\Concerns\LogsFormHistory;
use App\Services\NotificationService;
use App\Services\S3PresignedService;
use App\Services\StockMutationService;
@ -17,7 +18,7 @@
class ProductService
{
use HasRoleChecks;
use HasRoleChecks, LogsFormHistory;
public function __construct(
private ProductVariantService $variantService,
@ -74,7 +75,11 @@ public function getVariants(Product $product): Collection
->each(function (ProductVariant $variant) {
$media = $variant->getMedia('images');
$variant->photo_urls = $media->map(fn ($m) => $this->s3Service->getTemporaryUrl($m->getPath()))->toArray();
$variant->photo_conversion_urls = $media->map(fn ($m) => $this->s3Service->getTemporaryUrl($m->getPath('thumb')))->toArray();
$variant->photo_conversion_urls = $media->map(
fn ($m) => $m->getGeneratedConversions()->contains('thumb')
? $this->s3Service->getTemporaryUrl($m->getPath('thumb'))
: $this->s3Service->getTemporaryUrl($m->getPath())
)->toArray();
});
}
@ -161,6 +166,12 @@ public function store(array $data): Product
url: route('admin.master.products.index', ['highlight' => $product->id]),
);
$this->logCreated(
model: $product,
module: 'Produk',
newValues: $this->getProductLogValues($product),
);
return $product;
}
@ -206,6 +217,14 @@ public function update(Product $product, array $data): Product
{
$this->assertNotPending($product);
$oldValues = $this->getProductLogValues($product);
$oldVariants = $product->productVariants->map(fn ($v) => [
'Nama Varian' => $v->name,
'Stok' => $v->stock,
'Stok Reject' => $v->reject_stock,
'Stok Retail' => $v->retail_stock,
])->toArray();
$product = DB::transaction(function () use ($product, $data) {
// Auto-resubmit: non-verifier editing rejected product → status becomes pending
$newStatus = $data['status'] ?? $product->status;
@ -230,12 +249,10 @@ public function update(Product $product, array $data): Product
->filter()
->toArray();
// Delete removed variants (cascade prices + media)
// Delete removed variants (cascade prices only, preserve media)
$product->productVariants()
->whereNotIn('id', $existingVariantIds)
->each(function (ProductVariant $variant) {
$variant->productPrices()->delete();
$variant->clearMediaCollection('images');
$variant->delete();
});
@ -405,6 +422,13 @@ public function update(Product $product, array $data): Product
url: route('admin.master.products.index', ['highlight' => $product->id]),
);
$this->logUpdated(
model: $product,
module: 'Produk',
oldValues: $oldValues,
newValues: $this->getProductLogValues($product),
);
return $product;
}
@ -412,9 +436,10 @@ public function destroy(Product $product): bool
{
$this->assertNotPending($product);
$oldValues = $this->getProductLogValues($product);
$result = DB::transaction(function () use ($product) {
$product->productVariants->each(function (ProductVariant $variant) {
$variant->productPrices()->delete();
$variant->delete();
});
@ -430,6 +455,12 @@ public function destroy(Product $product): bool
url: route('admin.master.products.index'),
);
$this->logDeleted(
model: $product,
module: 'Produk',
oldValues: $oldValues,
);
return $result;
}
@ -523,4 +554,47 @@ private function assertNotPending(Product $product): void
]);
}
}
private function getProductLogValues(Product $product): array
{
$product->load(['categories:id,name', 'productVariants.productPrices']);
$priceTypeLabels = [
'distributor' => 'Harga Distributor',
'agent' => 'Harga Agen',
'sub_agent' => 'Harga Sub Agen',
'wholesale' => 'Harga Grosir',
'retail' => 'Harga Ecer',
'tiktok' => 'Harga TikTok',
'shopee' => 'Harga Shopee',
'capital' => 'Harga Modal',
'reject_capital' => 'Harga Reject Modal',
'reject_selling' => 'Harga Reject Jual',
];
$values = [
'Nama Produk' => $product->name,
'Status' => $product->status?->label(),
'Unggulan' => $this->formatBoolean($product->is_featured),
'Kategori' => $product->categories->pluck('name')->toArray(),
'Deskripsi' => $product->description,
'Varian' => $product->productVariants->map(function ($variant) use ($priceTypeLabels) {
$variantData = [
'Nama Varian' => $variant->name,
'Stok Bagus' => $variant->stock,
'Stok Reject' => $variant->reject_stock,
'Stok Retail' => $variant->retail_stock,
];
foreach ($variant->productPrices as $price) {
$label = $priceTypeLabels[$price->type->value] ?? $price->type->label();
$variantData[$label] = $this->formatCurrency($price->price);
}
return $variantData;
})->toArray(),
];
return $values;
}
}

View File

@ -14,6 +14,7 @@
use App\Services\StockMutationService;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
use Spatie\MediaLibrary\MediaCollections\Models\Media;
class ProductVariantService
{
@ -24,9 +25,43 @@ public function __construct(
private StockMutationService $stockMutationService,
) {}
public function getForStokOpname(): array
{
$products = Product::query()
->select(['id', 'name', 'status'])
->with([
'productVariants:id,product_id,name,stock,reject_stock,retail_stock',
])
->active()
->orderBy('name')
->get();
$allVariantIds = $products->pluck('productVariants.*.id')->flatten()->filter()->all();
if ($allVariantIds !== []) {
$mediaByVariant = Media::query()
->whereIn('model_id', $allVariantIds)
->where('model_type', ProductVariant::class)
->where('collection_name', 'images')
->get()
->groupBy('model_id');
} else {
$mediaByVariant = collect();
}
return $products->each(function (Product $product) use ($mediaByVariant) {
$product->productVariants->each(function (ProductVariant $variant) use ($mediaByVariant) {
$media = $mediaByVariant->get($variant->id, collect())->first();
$variant->photo_url = $media
? $this->s3Service->getTemporaryUrl($media->getPath())
: null;
});
})->toArray();
}
public function getForRestock(): array
{
return Product::query()
$products = Product::query()
->select(['id', 'name', 'status'])
->with([
'productVariants:id,product_id,name,stock,reject_stock',
@ -34,47 +69,75 @@ public function getForRestock(): array
])
->active()
->orderBy('name')
->get()
->each(function (Product $product) {
$product->productVariants->each(function (ProductVariant $variant) {
$media = $variant->getFirstMedia('images');
$variant->photo_url = $media
? $this->s3Service->getTemporaryUrl($media->getPath())
: null;
->get();
$capitalPrice = $variant->productPrices
->first(fn ($price) => $price->type === PriceType::CAPITAL);
$variant->capital_price = $capitalPrice?->price ?? 0;
$allVariantIds = $products->pluck('productVariants.*.id')->flatten()->filter()->all();
$rejectPrice = $variant->productPrices
->first(fn ($price) => $price->type === PriceType::REJECT);
$variant->reject_price = $rejectPrice?->price ?? 0;
});
})->toArray();
if ($allVariantIds !== []) {
$mediaByVariant = Media::query()
->whereIn('model_id', $allVariantIds)
->where('model_type', ProductVariant::class)
->where('collection_name', 'images')
->get()
->groupBy('model_id');
} else {
$mediaByVariant = collect();
}
return $products->each(function (Product $product) use ($mediaByVariant) {
$product->productVariants->each(function (ProductVariant $variant) use ($mediaByVariant) {
$media = $mediaByVariant->get($variant->id, collect())->first();
$variant->photo_url = $media
? $this->s3Service->getTemporaryUrl($media->getPath())
: null;
$capitalPrice = $variant->productPrices
->first(fn ($price) => $price->type === PriceType::CAPITAL);
$variant->capital_price = $capitalPrice?->price ?? 0;
$rejectPrice = $variant->productPrices
->first(fn ($price) => $price->type === PriceType::REJECT_CAPITAL);
$variant->reject_price = $rejectPrice?->price ?? 0;
});
})->toArray();
}
public function getForTransaction(): array
{
return Product::query()
$products = Product::query()
->select(['id', 'name', 'status'])
->with([
'productVariants:id,product_id,name,stock,reject_stock',
'productVariants:id,product_id,name,stock,reject_stock,retail_stock',
'productVariants.productPrices:id,variant_id,type,price',
])
->active()
->orderBy('name')
->get()
->each(function (Product $product) {
$product->productVariants->each(function (ProductVariant $variant) {
$media = $variant->getFirstMedia('images');
$variant->photo_url = $media
? $this->s3Service->getTemporaryUrl($media->getPath())
: null;
->get();
$prices = $variant->productPrices->mapWithKeys(fn ($p) => [$p->type->value => $p->price]);
$variant->prices = $prices;
});
})->toArray();
$allVariantIds = $products->pluck('productVariants.*.id')->flatten()->filter()->all();
if ($allVariantIds !== []) {
$mediaByVariant = Media::query()
->whereIn('model_id', $allVariantIds)
->where('model_type', ProductVariant::class)
->where('collection_name', 'images')
->get()
->groupBy('model_id');
} else {
$mediaByVariant = collect();
}
return $products->each(function (Product $product) use ($mediaByVariant) {
$product->productVariants->each(function (ProductVariant $variant) use ($mediaByVariant) {
$media = $mediaByVariant->get($variant->id, collect())->first();
$variant->photo_url = $media
? $this->s3Service->getTemporaryUrl($media->getPath())
: null;
$prices = $variant->productPrices->mapWithKeys(fn ($p) => [$p->type->value => $p->price]);
$variant->prices = $prices;
});
})->toArray();
}
public function getForEdit(ProductVariant $variant): array
@ -155,8 +218,6 @@ public function destroy(Product $product, ProductVariant $variant): bool
$this->assertNotPending($product);
$result = DB::transaction(function () use ($variant) {
$variant->productPrices()->delete();
return $variant->delete();
});

View File

@ -5,16 +5,18 @@
use App\Enums\Role;
use App\Models\RawMaterial;
use App\Models\RawMaterialPrice;
use App\Services\Concerns\LogsFormHistory;
use App\Services\Concerns\RegistersMedia;
use App\Services\NotificationService;
use App\Services\S3PresignedService;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class RawMaterialService
{
use RegistersMedia;
use LogsFormHistory, RegistersMedia;
public function __construct(
private S3PresignedService $s3Service,
@ -64,7 +66,9 @@ public function getVariants(RawMaterial $rawMaterial): Collection
$media = $price->getFirstMedia('images');
if ($media) {
$price->photo_url = $this->s3Service->getTemporaryUrl($media->getPath());
$price->photo_conversion_url = $this->s3Service->getTemporaryUrl($media->getPath('thumb'));
$price->photo_conversion_url = $media->getGeneratedConversions()->contains('thumb')
? $this->s3Service->getTemporaryUrl($media->getPath('thumb'))
: $this->s3Service->getTemporaryUrl($media->getPath());
} else {
$price->photo_url = null;
$price->photo_conversion_url = null;
@ -108,12 +112,14 @@ public function store(array $data): RawMaterial
});
NotificationService::notify(
roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO],
roles: [Role::OWNER, Role::DEVELOPER, Role::ADMIN_BAHAN_BAKU],
title: 'Bahan Baku Baru',
body: "Bahan baku \"{$rawMaterial->name}\" berhasil ditambahkan".' oleh '.auth()->user()->full_name.'.',
url: route('admin.master.raw-materials.index', ['highlight' => $rawMaterial->id]),
);
$this->logCreated($rawMaterial, 'Bahan Baku', $this->getRawMaterialLogValues($rawMaterial));
return $rawMaterial;
}
@ -149,6 +155,8 @@ public function getForEdit(RawMaterial $rawMaterial): array
public function update(RawMaterial $rawMaterial, array $data): RawMaterial
{
$oldValues = $this->getRawMaterialLogValues($rawMaterial);
$rawMaterial = DB::transaction(function () use ($rawMaterial, $data) {
$rawMaterial->update([
'name' => $data['name'],
@ -161,12 +169,29 @@ public function update(RawMaterial $rawMaterial, array $data): RawMaterial
->filter()
->toArray();
$rawMaterial->rawMaterialPrices()
$variantsToDelete = $rawMaterial->rawMaterialPrices()
->whereNotIn('id', $existingVariantIds)
->each(function (RawMaterialPrice $price) {
$price->clearMediaCollection('images');
$price->delete();
});
->get();
foreach ($variantsToDelete as $price) {
if ($price->stock > 0) {
throw ValidationException::withMessages([
'variants' => "Varian \"{$price->variant}\" tidak dapat dihapus karena masih memiliki stok.",
]);
}
$activeCuttingUsage = $price->cuttingMaterials()
->whereHas('cutting', fn ($q) => $q->inProgress())
->exists();
if ($activeCuttingUsage) {
throw ValidationException::withMessages([
'variants' => "Varian \"{$price->variant}\" tidak dapat dihapus karena masih digunakan di cutting yang sedang diproses.",
]);
}
$price->delete();
}
$existingPricesMap = RawMaterialPrice::whereIn('id', $existingVariantIds)
->with('media')
@ -228,17 +253,39 @@ public function update(RawMaterial $rawMaterial, array $data): RawMaterial
});
NotificationService::notify(
roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO],
roles: [Role::OWNER, Role::DEVELOPER, Role::ADMIN_BAHAN_BAKU],
title: 'Bahan Baku Diperbarui',
body: "Bahan baku \"{$rawMaterial->name}\" berhasil diperbarui".' oleh '.auth()->user()->full_name.'.',
url: route('admin.master.raw-materials.index', ['highlight' => $rawMaterial->id]),
);
$this->logUpdated($rawMaterial, 'Bahan Baku', $oldValues, $this->getRawMaterialLogValues($rawMaterial));
return $rawMaterial;
}
public function destroy(RawMaterial $rawMaterial): bool
{
$oldValues = $this->getRawMaterialLogValues($rawMaterial);
foreach ($rawMaterial->rawMaterialPrices as $price) {
if ($price->stock > 0) {
throw ValidationException::withMessages([
'raw_material' => "Bahan baku \"{$rawMaterial->name}\" tidak dapat dihapus karena varian \"{$price->variant}\" masih memiliki stok.",
]);
}
$activeCuttingUsage = $price->cuttingMaterials()
->whereHas('cutting', fn ($q) => $q->inProgress())
->exists();
if ($activeCuttingUsage) {
throw ValidationException::withMessages([
'raw_material' => "Bahan baku \"{$rawMaterial->name}\" tidak dapat dihapus karena varian \"{$price->variant}\" masih digunakan di cutting yang sedang diproses.",
]);
}
}
$result = DB::transaction(function () use ($rawMaterial) {
$rawMaterial->rawMaterialPrices->each(function (RawMaterialPrice $price) {
$price->delete();
@ -248,17 +295,21 @@ public function destroy(RawMaterial $rawMaterial): bool
});
NotificationService::notify(
roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO],
roles: [Role::OWNER, Role::DEVELOPER, Role::ADMIN_BAHAN_BAKU],
title: 'Bahan Baku Dihapus',
body: "Bahan baku \"{$rawMaterial->name}\" berhasil dihapus".' oleh '.auth()->user()->full_name.'.',
url: route('admin.master.raw-materials.index'),
);
$this->logDeleted($rawMaterial, 'Bahan Baku', $oldValues);
return $result;
}
public function toggleStatus(RawMaterial $rawMaterial): void
{
$oldValues = $this->getRawMaterialLogValues($rawMaterial);
$rawMaterial->update([
'is_active' => ! $rawMaterial->is_active,
]);
@ -266,10 +317,28 @@ public function toggleStatus(RawMaterial $rawMaterial): void
$status = $rawMaterial->is_active ? 'diaktifkan' : 'dinonaktifkan';
NotificationService::notify(
roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO],
roles: [Role::OWNER, Role::DEVELOPER, Role::ADMIN_BAHAN_BAKU],
title: 'Status Bahan Baku Diubah',
body: "Bahan baku \"{$rawMaterial->name}\" berhasil {$status}".' oleh '.auth()->user()->full_name.'.',
url: route('admin.master.raw-materials.index', ['highlight' => $rawMaterial->id]),
);
$this->logUpdated($rawMaterial, 'Bahan Baku', $oldValues, $this->getRawMaterialLogValues($rawMaterial));
}
private function getRawMaterialLogValues(RawMaterial $rawMaterial): array
{
$rawMaterial->load('rawMaterialPrices');
return [
'Nama Bahan Baku' => $rawMaterial->name,
'Satuan' => $rawMaterial->unit?->label(),
'Status' => $this->formatBoolean($rawMaterial->is_active),
'Varian' => $rawMaterial->rawMaterialPrices->map(fn ($price) => [
'Nama Varian' => $price->variant,
'Harga' => $this->formatCurrency($price->price),
'Stok' => number_format((float) $price->stock, 2, ',', '.'),
])->toArray(),
];
}
}

View File

@ -5,14 +5,16 @@
use App\Enums\Role;
use App\Models\RawMaterial;
use App\Models\RawMaterialPrice;
use App\Services\Concerns\LogsFormHistory;
use App\Services\Concerns\RegistersMedia;
use App\Services\NotificationService;
use App\Services\S3PresignedService;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class RawMaterialVariantService
{
use RegistersMedia;
use LogsFormHistory, RegistersMedia;
public function __construct(
private S3PresignedService $s3Service,
@ -24,10 +26,24 @@ public function getForCutting(): array
->select(['id', 'name', 'unit', 'is_active'])
->with([
'rawMaterialPrices:id,raw_material_id,variant,price,stock',
'rawMaterialPrices.media',
])
->active()
->orderBy('name')
->get()
->each(function (RawMaterial $rawMaterial) {
$rawMaterial->rawMaterialPrices->each(function (RawMaterialPrice $price) {
$media = $price->getFirstMedia('images');
$price->photo_url = $media
? $this->s3Service->getTemporaryUrl($media->getPath())
: null;
$price->photo_conversion_url = $media
? ($media->getGeneratedConversions()->contains('thumb')
? $this->s3Service->getTemporaryUrl($media->getPath('thumb'))
: $this->s3Service->getTemporaryUrl($media->getPath()))
: null;
});
})
->toArray();
}
@ -52,6 +68,9 @@ public function getForEdit(RawMaterialPrice $variant): array
public function update(RawMaterialPrice $variant, array $data): RawMaterialPrice
{
$rawMaterial = $variant->rawMaterial;
$oldValues = $this->getRawMaterialLogValues($rawMaterial);
DB::transaction(function () use ($variant, $data) {
$variant->update([
'variant' => $data['variant'],
@ -73,28 +92,67 @@ public function update(RawMaterialPrice $variant, array $data): RawMaterialPrice
});
NotificationService::notify(
roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO],
roles: [Role::OWNER, Role::DEVELOPER, Role::ADMIN_BAHAN_BAKU],
title: 'Varian Diperbarui',
body: "Varian \"{$variant->variant}\" berhasil diperbarui".' oleh '.auth()->user()->full_name.'.',
url: route('admin.master.raw-materials.index', ['highlight' => $variant->raw_material_id]),
);
$rawMaterial->refresh();
$this->logUpdated($rawMaterial, 'Bahan Baku', $oldValues, $this->getRawMaterialLogValues($rawMaterial));
return $variant->fresh();
}
public function destroy(RawMaterial $rawMaterial, RawMaterialPrice $variant): bool
{
$oldValues = $this->getRawMaterialLogValues($rawMaterial);
if ($variant->stock > 0) {
throw ValidationException::withMessages([
'variant' => 'Varian tidak dapat dihapus karena masih memiliki stok.',
]);
}
$activeCuttingUsage = $variant->cuttingMaterials()
->whereHas('cutting', fn ($q) => $q->inProgress())
->exists();
if ($activeCuttingUsage) {
throw ValidationException::withMessages([
'variant' => 'Varian tidak dapat dihapus karena masih digunakan di cutting yang sedang diproses.',
]);
}
$result = DB::transaction(function () use ($variant) {
return $variant->delete();
});
NotificationService::notify(
roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO],
roles: [Role::OWNER, Role::DEVELOPER, Role::ADMIN_BAHAN_BAKU],
title: 'Varian Dihapus',
body: "Varian \"{$variant->variant}\" dari bahan baku \"{$rawMaterial->name}\" berhasil dihapus".' oleh '.auth()->user()->full_name.'.',
url: route('admin.master.raw-materials.index'),
);
$this->logDeleted($rawMaterial, 'Bahan Baku', $oldValues);
return $result;
}
private function getRawMaterialLogValues(RawMaterial $rawMaterial): array
{
$rawMaterial->load('rawMaterialPrices');
return [
'Nama Bahan Baku' => $rawMaterial->name,
'Satuan' => $rawMaterial->unit?->label(),
'Status' => $this->formatBoolean($rawMaterial->is_active),
'Varian' => $rawMaterial->rawMaterialPrices->map(fn ($price) => [
'Nama Varian' => $price->variant,
'Harga' => $this->formatCurrency($price->price),
'Stok' => number_format((float) $price->stock, 2, ',', '.'),
])->toArray(),
];
}
}

View File

@ -0,0 +1,45 @@
<?php
namespace App\Services\Admin\System;
use App\Models\FormHistory;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
class FormHistoryService
{
public function paginated(
int $perPage = 25,
string $search = '',
string $sort = 'created_at',
string $direction = 'desc',
array $filters = [],
): LengthAwarePaginator {
return FormHistory::query()
->select(['id', 'causer_id', 'module', 'event', 'description', 'attribute_changes', 'created_at'])
->with(['causer.userProfile'])
->when($search, fn ($q) => $q->where('description', 'like', "%{$search}%"))
->when($filters['module'] ?? null, fn ($q, $module) => $q->where('module', $module))
->when($filters['event'] ?? null, fn ($q, $event) => $q->where('event', $event))
->orderBy($sort, $direction)
->paginate($perPage);
}
public function getModules(): array
{
return FormHistory::query()
->distinct()
->pluck('module')
->sort()
->values()
->toArray();
}
public function getEvents(): array
{
return [
'created' => 'Ditambahkan',
'updated' => 'Diperbarui',
'deleted' => 'Dihapus',
];
}
}

View File

@ -8,6 +8,7 @@
use App\Enums\OrderStatus;
use App\Enums\PaymentType;
use App\Enums\PayrollStatus;
use App\Enums\PriceType;
use App\Enums\RawMaterialUnit;
use App\Enums\Role;
use App\Models\Attendance;
@ -17,6 +18,7 @@
use App\Models\Expense;
use App\Models\LeaveRequest;
use App\Models\Order;
use App\Models\OrderItem;
use App\Models\Payroll;
use App\Models\ProductVariant;
use App\Models\Purchase;
@ -24,6 +26,7 @@
use App\Models\User;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
class AnalysisService
@ -84,14 +87,14 @@ public function getAttendanceStats(?string $startDate, ?string $endDate, ?User $
$employees = Employee::whereHas(
'user',
fn($q) => $q
fn ($q) => $q
->where('is_active', true)
->whereHas(
'roles',
fn($r) => $r
fn ($r) => $r
->whereHas(
'permissions',
fn($p) => $p
fn ($p) => $p
->where('name', 'attendances.create')
)
)
@ -207,10 +210,11 @@ public function getRawMaterialStock(): array
{
$items = RawMaterialPrice::query()
->join('raw_materials', 'raw_material_prices.raw_material_id', '=', 'raw_materials.id')
->select('raw_material_prices.stock', 'raw_materials.unit')
->select('raw_material_prices.stock', 'raw_material_prices.price', 'raw_materials.unit')
->get();
$totalQty = $items->sum('stock');
$totalPrice = $items->sum(fn ($i) => $i->stock * $i->price);
$byUnit = [
'yard' => $items->filter(fn ($i) => $i->unit === RawMaterialUnit::YARD->value)->sum('stock'),
@ -220,6 +224,7 @@ public function getRawMaterialStock(): array
return [
'total_stock' => $totalQty,
'total_price' => $totalPrice,
'by_unit' => $byUnit,
];
}
@ -227,15 +232,21 @@ public function getRawMaterialStock(): array
public function getProductStock(): array
{
$variants = ProductVariant::query()
->select('stock', 'reject_stock', 'retail_stock')
->leftJoin('product_prices', function ($join) {
$join->on('product_variants.id', '=', 'product_prices.variant_id')
->where('product_prices.type', '=', PriceType::RETAIL->value);
})
->select('product_variants.stock', 'product_variants.reject_stock', 'product_variants.retail_stock', 'product_prices.price')
->get();
$totalStock = $variants->sum('stock');
$totalRejectStock = $variants->sum('reject_stock');
$totalRetailStock = $variants->sum('retail_stock');
$totalPrice = $variants->sum(fn ($v) => ($v->stock + $v->reject_stock + $v->retail_stock) * ($v->price ?? 0));
return [
'total_stock' => $totalStock + $totalRejectStock + $totalRetailStock,
'total_price' => $totalPrice,
'by_type' => [
'stock' => $totalStock,
'reject_stock' => $totalRejectStock,
@ -244,6 +255,68 @@ public function getProductStock(): array
];
}
public function getRevenueByStockType(?string $startDate, ?string $endDate, ?User $user = null): array
{
$query = Order::where('orders.status', OrderStatus::COMPLETED);
$this->applyDateFilter($query, $startDate, $endDate, 'orders.created_at');
if ($user && $this->isMarketingUser($user)) {
$query->where('orders.marketing_id', $user->id);
}
if ($user && $this->isCashierUser($user)) {
$query->whereIn('orders.created_by_id', $this->getCashierUserIds());
}
$stockQualitySubquery = OrderItem::select('order_id')
->selectRaw('MIN(stock_quality) as stock_quality')
->groupBy('order_id');
$monthly = (clone $query)
->toBase()
->joinSub($stockQualitySubquery, 'oi', fn ($join) => $join->on('orders.id', '=', 'oi.order_id'))
->selectRaw("DATE_FORMAT(orders.created_at, '%b %Y') as month")
->selectRaw('oi.stock_quality')
->selectRaw('COALESCE(SUM(orders.total_amount), 0) as total_revenue')
->groupBy(DB::raw("DATE_FORMAT(orders.created_at, '%Y-%m')"), DB::raw("DATE_FORMAT(orders.created_at, '%b %Y')"), 'oi.stock_quality')
->orderBy(DB::raw("DATE_FORMAT(orders.created_at, '%Y-%m')"))
->get();
$allMonths = [];
$monthlyData = [];
foreach ($monthly as $row) {
$month = $row->month;
if (! array_key_exists($month, $allMonths)) {
$allMonths[$month] = $month;
$monthlyData[$month] = ['month' => $month, 'good' => 0, 'reject' => 0, 'retail' => 0];
}
$monthlyData[$month][$row->stock_quality] = (int) $row->total_revenue;
}
$result = [];
foreach ($allMonths as $month => $_) {
$result[] = $monthlyData[$month];
}
$totals = (clone $query)
->toBase()
->joinSub($stockQualitySubquery, 'oi', fn ($join) => $join->on('orders.id', '=', 'oi.order_id'))
->selectRaw('oi.stock_quality')
->selectRaw('COALESCE(SUM(orders.total_amount), 0) as total_revenue')
->groupBy('oi.stock_quality')
->get()
->keyBy('stock_quality');
return [
'monthly' => $result,
'totals' => [
'good' => (int) ($totals['good']->total_revenue ?? 0),
'reject' => (int) ($totals['reject']->total_revenue ?? 0),
'retail' => (int) ($totals['retail']->total_revenue ?? 0),
],
];
}
public function getRevenueSummary(?string $startDate, ?string $endDate, ?User $user = null): array
{
$query = Order::where('orders.status', OrderStatus::COMPLETED);
@ -253,6 +326,10 @@ public function getRevenueSummary(?string $startDate, ?string $endDate, ?User $u
$query->where('orders.marketing_id', $user->id);
}
if ($user && $this->isCashierUser($user)) {
$query->whereIn('orders.created_by_id', $this->getCashierUserIds());
}
$stats = (clone $query)
->selectRaw('COUNT(*) as total_orders')
->selectRaw('COALESCE(SUM(total_amount), 0) as total_revenue')
@ -297,6 +374,10 @@ public function getMonthlyRevenue(?string $startDate, ?string $endDate, ?User $u
$query->where('orders.marketing_id', $user->id);
}
if ($user && $this->isCashierUser($user)) {
$query->whereIn('orders.created_by_id', $this->getCashierUserIds());
}
$monthly = (clone $query)
->selectRaw("DATE_FORMAT(created_at, '%b %Y') as month")
->selectRaw('COALESCE(SUM(total_amount), 0) as total')
@ -373,6 +454,10 @@ public function getMonthlyRevenueByChannel(?string $startDate, ?string $endDate,
$query->where('orders.marketing_id', $user->id);
}
if ($user && $this->isCashierUser($user)) {
$query->whereIn('orders.created_by_id', $this->getCashierUserIds());
}
$monthly = (clone $query)
->selectRaw("DATE_FORMAT(created_at, '%b %Y') as month")
->selectRaw("COALESCE(SUM(CASE WHEN channel = 'store' THEN total_amount ELSE 0 END), 0) as store")
@ -381,7 +466,7 @@ public function getMonthlyRevenueByChannel(?string $startDate, ?string $endDate,
->groupBy(DB::raw("DATE_FORMAT(created_at, '%Y-%m')"), DB::raw("DATE_FORMAT(created_at, '%b %Y')"))
->orderBy(DB::raw("DATE_FORMAT(created_at, '%Y-%m')"))
->get()
->map(fn($item) => [
->map(fn ($item) => [
'month' => $item->month,
'store' => (int) $item->store,
'shopee' => (int) $item->shopee,
@ -400,12 +485,16 @@ public function getRevenueByPaymentType(?string $startDate, ?string $endDate, ?U
$query->where('orders.marketing_id', $user->id);
}
if ($user && $this->isCashierUser($user)) {
$query->whereIn('orders.created_by_id', $this->getCashierUserIds());
}
$data = (clone $query)
->select('payment_type')
->selectRaw('COALESCE(SUM(total_amount), 0) as total')
->groupBy('payment_type')
->get()
->map(fn($item) => [
->map(fn ($item) => [
'payment_type' => $item->payment_type,
'label' => $item->payment_type->label(),
'total' => (int) $item->total,
@ -444,6 +533,10 @@ public function getExpenseSummary(?string $startDate, ?string $endDate, ?User $u
$expenseQuery = Expense::query();
$this->applyDateFilter($expenseQuery, $startDate, $endDate, 'expenses.created_at');
if ($user && $this->isCashierUser($user)) {
$expenseQuery->whereIn('created_by_id', $this->getCashierUserIds());
}
$advanceQuery = EmployeeAdvance::where('status', EmployeeAdvanceStatus::REPAID);
$this->applyDateFilter($advanceQuery, $startDate, $endDate, 'employee_advances.created_at');
@ -519,6 +612,10 @@ public function getMonthlyExpense(?string $startDate, ?string $endDate, ?User $u
$expenseMonthly = Expense::query();
$this->applyDateFilter($expenseMonthly, $startDate, $endDate, 'expenses.created_at');
if ($user && $this->isCashierUser($user)) {
$expenseMonthly->whereIn('created_by_id', $this->getCashierUserIds());
}
$expenseByMonth = (clone $expenseMonthly)->toBase()
->selectRaw("DATE_FORMAT(created_at, '%b %Y') as month")
->selectRaw('COALESCE(SUM(amount), 0) as expense')
@ -580,6 +677,10 @@ public function getBusyHours(?string $startDate, ?string $endDate, ?User $user =
$query->where('orders.marketing_id', $user->id);
}
if ($user && $this->isCashierUser($user)) {
$query->whereIn('orders.created_by_id', $this->getCashierUserIds());
}
$hours = range(0, 23);
$hourCounts = (clone $query)
->selectRaw('HOUR(created_at) as hour')
@ -605,6 +706,10 @@ public function getProfitMetrics(?string $startDate, ?string $endDate, ?User $us
$query->where('orders.marketing_id', $user->id);
}
if ($user && $this->isCashierUser($user)) {
$query->whereIn('orders.created_by_id', $this->getCashierUserIds());
}
$stats = (clone $query)
->selectRaw('COUNT(*) as total_orders')
->selectRaw('COALESCE(SUM(total_amount), 0) as total_revenue')
@ -669,6 +774,10 @@ public function getTopCustomers(?string $startDate, ?string $endDate, ?User $use
$query->where('orders.marketing_id', $user->id);
}
if ($user && $this->isCashierUser($user)) {
$query->whereIn('orders.created_by_id', $this->getCashierUserIds());
}
return (clone $query)->toBase()
->join('customers', 'orders.customer_id', '=', 'customers.id')
->select('customers.name')
@ -690,6 +799,10 @@ public function getTopProducts(?string $startDate, ?string $endDate, ?User $user
$query->where('orders.marketing_id', $user->id);
}
if ($user && $this->isCashierUser($user)) {
$query->whereIn('orders.created_by_id', $this->getCashierUserIds());
}
return (clone $query)->toBase()
->join('order_items', 'orders.id', '=', 'order_items.order_id')
->join('product_variants', 'order_items.product_variant_id', '=', 'product_variants.id')
@ -713,6 +826,10 @@ public function getRevenueTrend(?string $startDate, ?string $endDate, ?User $use
$query->where('orders.marketing_id', $user->id);
}
if ($user && $this->isCashierUser($user)) {
$query->whereIn('orders.created_by_id', $this->getCashierUserIds());
}
return (clone $query)
->join('order_items', 'orders.id', '=', 'order_items.order_id')
->selectRaw('DATE(orders.created_at) as date')
@ -720,7 +837,7 @@ public function getRevenueTrend(?string $startDate, ?string $endDate, ?User $use
->groupBy(DB::raw('DATE(orders.created_at)'))
->orderBy(DB::raw('DATE(orders.created_at)'))
->get()
->map(fn($item) => [
->map(fn ($item) => [
'date' => $item->date,
'qty' => (int) $item->qty,
])
@ -737,6 +854,10 @@ public function getMarketingSales(?string $startDate, ?string $endDate, ?User $u
$query->where('orders.marketing_id', $user->id);
}
if ($user && $this->isCashierUser($user)) {
$query->whereIn('orders.created_by_id', $this->getCashierUserIds());
}
$orders = (clone $query)
->join('users', 'orders.marketing_id', '=', 'users.id')
->leftJoin('user_profiles', 'users.id', '=', 'user_profiles.user_id')
@ -745,6 +866,7 @@ public function getMarketingSales(?string $startDate, ?string $endDate, ?User $u
->selectRaw('COALESCE(SUM(orders.total_amount), 0) as total_revenue')
->selectRaw('COALESCE(SUM(orders.subtotal), 0) as total_subtotal')
->selectRaw('COALESCE(SUM(orders.discount), 0) as total_discount')
->selectRaw('COALESCE(SUM(orders.nego_price), 0) as total_nego_price')
->groupBy('orders.marketing_id', 'user_profiles.full_name')
->get();
@ -764,7 +886,7 @@ public function getMarketingSales(?string $startDate, ?string $endDate, ?User $u
'total_products_sold' => (int) ($productCounts[$item->marketing_id] ?? 0),
'total_revenue' => $totalRevenue,
'total_subtotal' => (int) $item->total_subtotal,
'total_discount' => (int) $item->total_discount,
'total_discount' => (int) $item->total_discount + (int) $item->total_nego_price,
'avg_order' => $totalOrders > 0 ? (int) ($totalRevenue / $totalOrders) : 0,
];
})->toArray();
@ -779,6 +901,10 @@ public function getOrderStats(?string $startDate, ?string $endDate, ?User $user
$baseQuery->where('orders.marketing_id', $user->id);
}
if ($user && $this->isCashierUser($user)) {
$baseQuery->whereIn('orders.created_by_id', $this->getCashierUserIds());
}
$byChannel = collect(OrderChannel::values())->map(function ($channel) use ($baseQuery) {
$count = (clone $baseQuery)->where('channel', $channel)->count();
$label = OrderChannel::from($channel)->label();
@ -811,7 +937,7 @@ public function getOrderStats(?string $startDate, ?string $endDate, ?User $user
->groupBy('marketing_id')
->with('marketing:id')
->get()
->map(fn($item) => [
->map(fn ($item) => [
'name' => $item->marketing?->userProfile->full_name ?? '-',
'count' => $item->count,
'total' => (int) $item->total,
@ -854,6 +980,16 @@ private function isMarketingUser(User $user): bool
]);
}
private function isCashierUser(User $user): bool
{
return $user->hasRole(Role::CASHIER->value);
}
private function getCashierUserIds(): Collection
{
return User::whereHas('roles', fn ($q) => $q->where('name', Role::CASHIER->value))->pluck('id');
}
private function isPurchaseVisible(User $user): bool
{
return $user->hasAnyRole([

View File

@ -13,6 +13,7 @@ trait HasStockAdjustment
private const QUALITY_STOCK_MAP = [
ProductStockQuality::GOOD->value => 'stock',
ProductStockQuality::REJECT->value => 'reject_stock',
ProductStockQuality::RETAIL->value => 'retail_stock',
];
private function adjustStock(Model $model, string $field, int $quantity, int $sign): void

View File

@ -0,0 +1,89 @@
<?php
namespace App\Services\Concerns;
use App\Models\FormHistory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Auth;
trait LogsFormHistory
{
private function logCreated(Model $model, string $module, array $newValues): void
{
$this->createFormHistory(
module: $module,
event: 'created',
description: "{$module} ditambahkan",
newValues: $newValues,
);
}
private function logUpdated(Model $model, string $module, array $oldValues, array $newValues): void
{
$this->createFormHistory(
module: $module,
event: 'updated',
description: "{$module} diperbarui",
newValues: $newValues,
oldValues: $oldValues,
);
}
private function logDeleted(Model $model, string $module, array $oldValues): void
{
$this->createFormHistory(
module: $module,
event: 'deleted',
description: "{$module} dihapus",
oldValues: $oldValues,
);
}
private function createFormHistory(
string $module,
string $event,
string $description,
array $newValues = [],
array $oldValues = [],
): void {
$attributeChanges = array_filter([
'new' => $newValues ?: null,
'old' => $oldValues ?: null,
]);
FormHistory::create([
'causer_id' => Auth::id(),
'module' => $module,
'event' => $event,
'description' => $description,
'attribute_changes' => $attributeChanges ?: null,
]);
}
private function formatCurrency(int $value): string
{
return 'Rp ' . number_format($value, 0, ',', '.');
}
private function formatDate(?string $date, string $format = 'l, d F Y'): ?string
{
return $date ? \Carbon\Carbon::parse($date)->translatedFormat($format) : null;
}
private function formatDateTime(?string $datetime): ?string
{
return $datetime ? \Carbon\Carbon::parse($datetime)->translatedFormat('l, d F Y H:i') : null;
}
private function formatBoolean(?bool $value): ?string
{
return $value === null ? null : ($value ? 'Ya' : 'Tidak');
}
private function resolveRelation(Model $model, string $relation, string $attribute): ?string
{
$related = $model->{$relation};
return $related?->{$attribute} ?? null;
}
}

View File

@ -17,6 +17,7 @@
use App\Models\User;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
class DashboardService
{
@ -129,6 +130,10 @@ public function getRevenueSummary(?User $user = null): array
$baseQuery->where('marketing_id', $user->id);
}
if ($user && $this->isCashierUser($user)) {
$baseQuery->whereIn('created_by_id', $this->getCashierUserIds());
}
$stats = (clone $baseQuery)
->selectRaw('COUNT(*) as total_orders')
->selectRaw('COALESCE(SUM(total_amount), 0) as total_revenue')
@ -194,12 +199,24 @@ public function getExpenseSummary(?User $user = null): array
];
}
$expenseTotal = Expense::whereDate('created_at', $today)
$expenseQuery = Expense::whereDate('created_at', $today);
if ($user && $this->isCashierUser($user)) {
$expenseQuery->whereIn('created_by_id', $this->getCashierUserIds());
}
$expenseTotal = $expenseQuery
->selectRaw('COALESCE(SUM(amount), 0) as total')
->first();
$advanceTotal = EmployeeAdvance::whereDate('created_at', $today)
->disbursed()
$advanceQuery = EmployeeAdvance::whereDate('created_at', $today)
->disbursed();
if ($user && $this->isCashierUser($user)) {
$advanceQuery->whereHas('employee.user', fn ($q) => $q->whereIn('id', $this->getCashierUserIds()));
}
$advanceTotal = $advanceQuery
->selectRaw('COALESCE(SUM(amount), 0) as total')
->first();
@ -219,6 +236,10 @@ public function getOrderStats(?User $user = null): array
$baseQuery->where('marketing_id', $user->id);
}
if ($user && $this->isCashierUser($user)) {
$baseQuery->whereIn('created_by_id', $this->getCashierUserIds());
}
$byChannel = collect(OrderChannel::values())->map(function ($channel) use ($baseQuery) {
$count = (clone $baseQuery)->where('channel', $channel)->count();
$label = OrderChannel::from($channel)->label();
@ -330,6 +351,16 @@ private function isMarketingUser(User $user): bool
]);
}
private function isCashierUser(User $user): bool
{
return $user->hasRole(Role::CASHIER->value);
}
private function getCashierUserIds(): Collection
{
return User::whereHas('roles', fn ($q) => $q->where('name', Role::CASHIER->value))->pluck('id');
}
private function applyMarketingFilter(Builder $query, User $user, string $column = 'marketing_id'): Builder
{
if ($this->isMarketingUser($user)) {

View File

@ -13,7 +13,7 @@ public function definition(): array
'raw_material_id' => RawMaterial::factory(),
'variant' => fake()->words(2, true),
'price' => fake()->numberBetween(1000, 500000),
'stock' => fake()->numberBetween(0, 1000),
'stock' => round(fake()->randomFloat(2, 0, 1000), 2),
];
}
}

View File

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('raw_material_prices', function (Blueprint $table) {
$table->decimal('stock', 10, 2)->default(0)->change();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('raw_material_prices', function (Blueprint $table) {
$table->unsignedInteger('stock')->default(0)->change();
});
}
};

View File

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('cutting_materials', function (Blueprint $table) {
$table->decimal('material_usage', 10, 2)->change();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('cutting_materials', function (Blueprint $table) {
$table->integer('material_usage')->change();
});
}
};

View File

@ -0,0 +1,62 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
public function up(): void
{
// 1. product_prices: rename 'reject' → 'reject_selling', then add 'reject_capital' rows
DB::table('product_prices')
->where('type', 'reject')
->update(['type' => 'reject_selling']);
// Insert reject_capital rows (price = 0) for each variant that now has reject_selling
$rejectSellingPrices = DB::table('product_prices')
->where('type', 'reject_selling')
->get();
foreach ($rejectSellingPrices as $row) {
DB::table('product_prices')->insert([
'variant_id' => $row->variant_id,
'type' => 'reject_capital',
'price' => 0,
'created_at' => $row->created_at,
'updated_at' => $row->updated_at,
]);
}
// 2. orders: rename 'reject' → 'reject_selling'
DB::table('orders')
->where('price_type', 'reject')
->update(['price_type' => 'reject_selling']);
// 3. Modify enum columns to reflect new values (MySQL only)
if (DB::getDriverName() === 'mysql') {
DB::statement("ALTER TABLE product_prices MODIFY COLUMN type ENUM('distributor','agent','sub_agent','wholesale','retail','tiktok','shopee','capital','reject_capital','reject_selling') NOT NULL");
DB::statement("ALTER TABLE orders MODIFY COLUMN price_type ENUM('distributor','agent','sub_agent','wholesale','retail','tiktok','shopee','capital','reject_capital','reject_selling') NOT NULL");
}
}
public function down(): void
{
// Reverse: rename back and clean up
DB::table('product_prices')
->where('type', 'reject_capital')
->delete();
DB::table('product_prices')
->where('type', 'reject_selling')
->update(['type' => 'reject']);
DB::table('orders')
->where('price_type', 'reject_selling')
->update(['price_type' => 'reject']);
if (DB::getDriverName() === 'mysql') {
DB::statement("ALTER TABLE product_prices MODIFY COLUMN type ENUM('distributor','agent','sub_agent','wholesale','retail','tiktok','shopee','capital','reject') NOT NULL");
DB::statement("ALTER TABLE orders MODIFY COLUMN price_type ENUM('distributor','agent','sub_agent','wholesale','retail','tiktok','shopee','capital','reject') NOT NULL");
}
}
};

View File

@ -0,0 +1,19 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
DB::statement("ALTER TABLE cash_transactions MODIFY COLUMN type ENUM('deposit', 'expense', 'withdrawal', 'employee_advance', 'salary') NOT NULL DEFAULT 'deposit'");
}
public function down(): void
{
DB::statement("ALTER TABLE cash_transactions MODIFY COLUMN type ENUM('deposit', 'expense', 'withdrawal', 'employee_advance') NOT NULL DEFAULT 'deposit'");
}
};

View File

@ -0,0 +1,26 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('form_histories', function (Blueprint $table) {
$table->id();
$table->foreignId('causer_id')->constrained('users');
$table->string('module');
$table->string('event');
$table->string('description');
$table->json('attribute_changes')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('form_histories');
}
};

View File

@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('form_histories', function (Blueprint $table) {
$table->text('attribute_changes')->nullable()->change();
});
}
public function down(): void
{
Schema::table('form_histories', function (Blueprint $table) {
$table->json('attribute_changes')->nullable()->change();
});
}
};

View File

@ -0,0 +1,49 @@
# Fix: Foto Bahan Baku Tidak Muncul di Cutting & Belanja
## Tujuan
Memperbaiki foto bahan baku (raw material) yang tidak muncul di halaman create/edit Cutting dan Belanja (Purchasing). Menampilkan foto conversion (thumbnail) bukan original.
## File yang Dibaca
- `app/Services/Admin/Master/RawMaterial/RawMaterialVariantService.php``getForCutting()` method (root cause backend)
- `resources/js/pages/admin/manage/cutting/columns.tsx``CuttingCreateData` type (root cause frontend)
- `resources/js/pages/admin/manage/purchase/columns.tsx``PurchaseCreateData` type (root cause frontend)
- `resources/js/pages/admin/master/raw-material/columns.tsx` → reference type pattern
## Root Cause
### Backend
`getForCutting()` tidak load media atau set `photo_url`/`photo_conversion_url` pada `RawMaterialPrice`.
### Frontend
Type `CuttingCreateData` dan `PurchaseCreateData` tidak include `photo_conversion_url` di `raw_material_prices` → TypeScript strip property → frontend tidak bisa akses.
## Perubahan
### 1. Backend — `RawMaterialVariantService::getForCutting()`
- Tambah eager load `rawMaterialPrices.media`
- Iterasi setiap `RawMaterialPrice` untuk set `photo_url` + `photo_conversion_url`
### 2. Frontend Types — `columns.tsx`
- `CuttingCreateData.raw_material_prices` → tambah `photo_conversion_url: string | null`
- `PurchaseCreateData.raw_material_prices` → tambah `photo_conversion_url: string | null`
### 3. Frontend Components — 4 files
- Tambah `photo_conversion_url` ke `MaterialState` type (cutting create/edit)
- Map `photo_conversion_url` dari backend data
- `<img src={price.photo_url}``<img src={price.photo_conversion_url ?? price.photo_url}`
- `photoUrl: price.photo_url``photoUrl: price.photo_conversion_url ?? price.photo_url` (purchase cart items)
## Files Changed
| File | Change |
|------|--------|
| `RawMaterialVariantService.php` | Load media + set photo_url/photo_conversion_url |
| `cutting/columns.tsx` | Add `photo_conversion_url` to type |
| `purchase/columns.tsx` | Add `photo_conversion_url` to type |
| `cutting/create.tsx` | Type + mapping + img tags |
| `cutting/edit.tsx` | Type + mapping + img tags |
| `purchase/create.tsx` | Cart photoUrl + img tag |
| `purchase/edit.tsx` | Cart photoUrl + img tag |
## Dampak
- Create/Edit Cutting → foto bahan baku muncul (conversion/thumbnail)
- Create/Edit Belanja → foto bahan baku muncul (conversion/thumbnail)

View File

@ -31,7 +31,7 @@ export function StatCard({ title, icon: Icon, mainLabel, mainValue, subLabel, de
{subLabel && <p className="text-xs text-muted-foreground">{subLabel}</p>}
{description && <p className="mt-1 text-[10px] text-muted-foreground italic">{description}</p>}
{items.length > 0 && (
<div className={cn('mt-3 grid gap-2', cols === 2 && 'grid-cols-2', cols === 3 && 'grid-cols-3', cols === 4 && 'grid-cols-4')}>
<div className={cn('mt-3 grid gap-2', cols === 2 && 'grid-cols-2', cols === 3 && 'grid-cols-2 sm:grid-cols-3', cols === 4 && 'grid-cols-2 sm:grid-cols-4')}>
{items.map((item, index) => (
<div key={index}>
<p className="text-xs text-muted-foreground">{item.label}</p>

View File

@ -37,7 +37,11 @@ export function FilterPopover({
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-64" align="end">
<PopoverContent
className="max-h-[calc(100vh-4rem)] w-64 overflow-y-auto"
align="end"
avoidCollisions={false}
>
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Filter</span>

View File

@ -1,4 +1,4 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { ConfirmDialog } from '@/components/dialogs';
type DeleteConfirmDialogProps<T> = {
@ -22,6 +22,10 @@ export function DeleteConfirmDialog<T>({
}: DeleteConfirmDialogProps<T>) {
const [loading, setLoading] = useState(false);
useEffect(() => {
setLoading(false);
}, [target]);
function handleConfirm() {
setLoading(true);
onConfirm();

View File

@ -50,6 +50,7 @@ import { index as leaveRequestsIndex } from '@/routes/admin/hr/leave-requests';
import { index as cuttingsIndex } from '@/routes/admin/manage/cuttings';
import { index as purchasesIndex } from '@/routes/admin/manage/purchases';
import { index as restocksIndex } from '@/routes/admin/manage/restocks';
import { index as stokOpnamesIndex } from '@/routes/admin/manage/stok-opnames';
import { index as transactionsIndex } from '@/routes/admin/manage/transactions';
import { index as categoriesIndex } from '@/routes/admin/master/categories';
import { index as customersIndex } from '@/routes/admin/master/customers';
@ -86,7 +87,7 @@ const kelolaItems: NavMenuItem[] = [
{ title: 'Cutting', href: cuttingsIndex.url(), icon: Scissors, permission: 'cuttings.view' },
{ title: 'Transaksi', href: transactionsIndex.url(), icon: ShoppingCart, permission: 'orders.view' },
{ title: 'Restock', href: restocksIndex.url(), icon: RefreshCw, permission: 'restocks.view' },
{ title: 'Stok Opname', href: '#', icon: ClipboardCheck, permission: 'stok_opnames.view' },
{ title: 'Stok Opname', href: stokOpnamesIndex.url(), icon: ClipboardCheck, permission: 'stok_opnames.view' },
];
const keuanganItems: NavMenuItem[] = [
@ -104,13 +105,14 @@ const hrItems: NavMenuItem[] = [
const sistemItems: NavMenuItem[] = [
{ title: 'Pengaturan', href: '/admin/settings', icon: Settings, permission: ['settings.view_system', 'settings.view_homepage', 'settings.view_social_media', 'settings.view_hr'] },
// { title: 'Role & Permission', href: rolesIndex.url(), icon: Shield, permission: 'roles.view' },
// { title: 'Log Aktivitas', href: '#', icon: Activity, permission: 'activity_logs.view' },
{ title: 'Role & Permission', href: rolesIndex.url(), icon: Shield, permission: 'roles.view' },
{ title: 'Log Aktivitas', href: '/admin/form-histories', icon: Activity, permission: 'activity_logs.view' },
];
function MenuGroup({ label, items }: { label: string; items: NavMenuItem[] }) {
const { isCurrentUrl } = useCurrentUrl();
const { can, canAny } = useCan();
const { isMobile, setOpenMobile } = useSidebar();
const filtered = items.filter((item) => {
if (!item.permission) {
@ -139,7 +141,7 @@ function MenuGroup({ label, items }: { label: string; items: NavMenuItem[] }) {
isActive={isCurrentUrl(item.href)}
tooltip={{ children: item.title }}
>
<Link href={item.href} prefetch>
<Link href={item.href} prefetch onClick={() => isMobile && setOpenMobile(false)}>
<item.icon />
<span>{item.title}</span>
</Link>
@ -174,7 +176,7 @@ export function AppSidebar() {
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton size="lg" asChild>
<Link href={dashboard.url()} prefetch>
<Link href={dashboard.url()} prefetch onClick={() => isMobile && setOpenMobile(false)}>
<AppLogo />
</Link>
</SidebarMenuButton>
@ -191,7 +193,7 @@ export function AppSidebar() {
isActive={isCurrentUrl(dasborItem.href)}
tooltip={{ children: dasborItem.title }}
>
<Link href={dasborItem.href} prefetch>
<Link href={dasborItem.href} prefetch onClick={() => isMobile && setOpenMobile(false)}>
<dasborItem.icon />
<span>{dasborItem.title}</span>
</Link>
@ -209,7 +211,7 @@ export function AppSidebar() {
isActive={isCurrentUrl(analisaItem.href)}
tooltip={{ children: analisaItem.title }}
>
<Link href={analisaItem.href} prefetch>
<Link href={analisaItem.href} prefetch onClick={() => isMobile && setOpenMobile(false)}>
<analisaItem.icon />
<span>{analisaItem.title}</span>
</Link>

View File

@ -1,12 +1,11 @@
import { router } from '@inertiajs/react';
import { Bell, Check, Trash2 } from 'lucide-react';
import { Bell, Check, CheckCheck, Loader2, Trash2 } from 'lucide-react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
@ -20,12 +19,23 @@ interface Notification {
created_at: string;
}
interface PaginatedResponse {
data: Notification[];
current_page: number;
last_page: number;
next_page_url: string | null;
}
export function NotificationBell() {
const [notifications, setNotifications] = useState<Notification[]>([]);
const [unreadCount, setUnreadCount] = useState(0);
const [isOpen, setIsOpen] = useState(false);
const [currentPage, setCurrentPage] = useState(1);
const [lastPage, setLastPage] = useState(1);
const [loadingMore, setLoadingMore] = useState(false);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const mountedRef = useRef(true);
const sentinelRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
mountedRef.current = true;
@ -52,23 +62,41 @@ export function NotificationBell() {
}
}, []);
const fetchNotifications = useCallback(async () => {
const fetchNotifications = useCallback(async (page: number = 1) => {
try {
const response = await fetch('/api/notifications', {
const response = await fetch(`/api/notifications?page=${page}`, {
headers: {
'X-Requested-With': 'XMLHttpRequest',
},
});
if (response.ok && mountedRef.current) {
const data = await response.json();
setNotifications(data);
const data: PaginatedResponse = await response.json();
if (page === 1) {
setNotifications(data.data);
} else {
setNotifications((prev) => [...prev, ...data.data]);
}
setCurrentPage(data.current_page);
setLastPage(data.last_page);
}
} catch {
// Silently fail
}
}, []);
const fetchNextPage = useCallback(async () => {
if (loadingMore || currentPage >= lastPage) {
return;
}
setLoadingMore(true);
await fetchNotifications(currentPage + 1);
setLoadingMore(false);
}, [loadingMore, currentPage, lastPage, fetchNotifications]);
const markAsRead = useCallback(async (id: number) => {
try {
await fetch(`/api/notifications/${id}/read`, {
@ -121,6 +149,22 @@ export function NotificationBell() {
}
}, []);
const deleteAll = useCallback(async () => {
try {
await fetch('/api/notifications', {
method: 'DELETE',
headers: {
'X-Requested-With': 'XMLHttpRequest',
},
});
setNotifications([]);
setUnreadCount(0);
} catch {
// Silently fail
}
}, []);
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- initial data fetch is safe
void fetchUnreadCount();
@ -137,11 +181,34 @@ export function NotificationBell() {
useEffect(() => {
if (isOpen) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- fetch on dropdown open is safe
void fetchNotifications();
// eslint-disable-next-line react-hooks/set-state-in-effect -- reset pagination on open is safe
setCurrentPage(1);
setLastPage(1);
void fetchNotifications(1);
} else {
setNotifications([]);
}
}, [isOpen, fetchNotifications]);
useEffect(() => {
if (!isOpen || !sentinelRef.current) {
return;
}
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && !loadingMore && currentPage < lastPage) {
void fetchNextPage();
}
},
{ rootMargin: '100px' },
);
observer.observe(sentinelRef.current);
return () => observer.disconnect();
}, [isOpen, loadingMore, currentPage, lastPage, fetchNextPage]);
return (
<DropdownMenu onOpenChange={setIsOpen}>
<DropdownMenuTrigger asChild>
@ -162,104 +229,140 @@ export function NotificationBell() {
<span className="sr-only">Notifikasi</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="max-h-[350px] w-80">
<DropdownMenuContent align="end" className="max-h-[350px] w-80 overflow-y-auto">
<div className="flex items-center justify-between border-b px-4 py-2">
<span className="text-sm font-semibold">Notifikasi</span>
{unreadCount > 0 && (
<button
onClick={() => {
void markAllAsRead();
}}
className="text-xs text-muted-foreground hover:text-foreground"
>
Tandai semua dibaca
</button>
)}
<div className="flex items-center gap-1">
{unreadCount > 0 && (
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
title="Tandai semua sudah dibaca"
onClick={() => {
void markAllAsRead();
}}
>
<CheckCheck className="h-3.5 w-3.5" />
</Button>
)}
{notifications.length > 0 && (
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-destructive hover:text-destructive"
title="Hapus semua notifikasi"
onClick={() => {
void deleteAll();
}}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
)}
</div>
</div>
{notifications.length === 0 ? (
<div className="px-4 py-6 text-center text-sm text-muted-foreground">
Tidak ada notifikasi
</div>
) : (
notifications.map((notification) => (
<div key={notification.id}>
<div
className={`flex items-start gap-2 px-4 py-3 ${
!notification.is_read
? 'border-l-2 border-l-primary bg-primary/5'
: 'opacity-60'
}`}
>
<>
{notifications.map((notification) => (
<div key={notification.id}>
<div
className="min-w-0 flex-1 cursor-pointer"
onClick={() => {
if (notification.url) {
if (!notification.is_read) {
void markAsRead(notification.id);
}
router.visit(notification.url);
}
}}
className={`flex items-start gap-2 px-4 py-3 ${
!notification.is_read
? 'border-l-2 border-l-primary bg-primary/5'
: 'opacity-60'
}`}
>
<span
className={`text-sm ${
!notification.is_read
? 'font-semibold'
: 'font-medium'
}`}
<div
className="min-w-0 flex-1 cursor-pointer"
onClick={() => {
if (notification.url) {
if (!notification.is_read) {
void markAsRead(notification.id);
}
router.visit(notification.url);
}
}}
>
{notification.title}
</span>
{notification.body && (
<span className="mt-0.5 line-clamp-2 block text-xs text-muted-foreground">
{notification.body}
<span
className={`text-sm ${
!notification.is_read
? 'font-semibold'
: 'font-medium'
}`}
>
{notification.title}
</span>
)}
<span className="mt-0.5 block text-xs text-muted-foreground">
{new Date(
notification.created_at,
).toLocaleDateString('id-ID', {
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit',
})}
</span>
</div>
<div className="flex shrink-0 flex-col gap-1 pt-0.5">
{!notification.is_read && (
{notification.body && (
<span className="mt-0.5 line-clamp-2 block text-xs text-muted-foreground">
{notification.body}
</span>
)}
<span className="mt-0.5 block text-xs text-muted-foreground">
{new Date(
notification.created_at,
).toLocaleDateString('id-ID', {
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit',
})}
</span>
</div>
<div className="flex shrink-0 flex-col gap-1 pt-0.5">
{!notification.is_read && (
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
title="Tandai sudah dibaca"
onClick={() => {
void markAsRead(
notification.id,
);
}}
>
<Check className="h-3 w-3" />
</Button>
)}
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
title="Tandai sudah dibaca"
className="h-6 w-6 text-destructive hover:text-destructive"
title="Hapus"
onClick={() => {
void markAsRead(
void deleteNotification(
notification.id,
);
}}
>
<Check className="h-3 w-3" />
<Trash2 className="h-3 w-3" />
</Button>
)}
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-destructive hover:text-destructive"
title="Hapus"
onClick={() => {
void deleteNotification(
notification.id,
);
}}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
<DropdownMenuSeparator />
</div>
<DropdownMenuSeparator />
))}
{/* Sentinel for infinite scroll */}
<div ref={sentinelRef} className="px-4 py-2">
{loadingMore && (
<div className="flex items-center justify-center gap-2 text-xs text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" />
<span>Memuat lainnya...</span>
</div>
)}
{!loadingMore && currentPage >= lastPage && notifications.length > 0 && (
<p className="text-center text-xs text-muted-foreground">
Semua notifikasi sudah dimuat
</p>
)}
</div>
))
</>
)}
</DropdownMenuContent>
</DropdownMenu>

View File

@ -10,21 +10,21 @@ export function NotificationPermissionPrompt() {
usePushNotification();
useEffect(() => {
if (!isSupported || hasRequested.current) {
return;
}
if (Notification.permission !== 'default') {
if (!isSupported || hasRequested.current || !vapidPublicKey) {
return;
}
hasRequested.current = true;
void requestPermission().then(async (result) => {
if (result === 'granted' && vapidPublicKey) {
await subscribe(vapidPublicKey);
}
});
if (Notification.permission === 'default') {
void requestPermission().then(async (result) => {
if (result === 'granted') {
await subscribe(vapidPublicKey);
}
});
} else if (Notification.permission === 'granted') {
void subscribe(vapidPublicKey);
}
}, [isSupported, requestPermission, subscribe, vapidPublicKey]);
return null;

View File

@ -278,9 +278,13 @@ function ChartLegendContent({
payload,
verticalAlign = "bottom",
nameKey,
onItemClick,
activeName,
}: React.ComponentProps<"div"> & {
hideIcon?: boolean
nameKey?: string
onItemClick?: (name: string) => void
activeName?: string
} & RechartsPrimitive.DefaultLegendContentProps) {
const { config } = useChart()
@ -301,13 +305,20 @@ function ChartLegendContent({
.map((item, index) => {
const key = `${nameKey ?? item.dataKey ?? "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const label = typeof itemConfig?.label === 'string' ? itemConfig.label : ''
const isInactive = activeName !== undefined && activeName !== label
return (
<div
key={index}
className={cn(
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground"
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground",
onItemClick && "cursor-pointer select-none hover:opacity-80",
isInactive && "opacity-40"
)}
onClick={() => {
if (label) onItemClick?.(label)
}}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />

View File

@ -1,26 +1,26 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cva, type VariantProps } from "class-variance-authority";
import { Slot } from "radix-ui";
import * as React from "react";
import { useIsMobile } from "@/hooks/use-mobile"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet"
import { Skeleton } from "@/components/ui/skeleton"
} from "@/components/ui/sheet";
import { Skeleton } from "@/components/ui/skeleton";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { PanelLeftIcon } from "lucide-react"
} from "@/components/ui/tooltip";
import { useIsMobile } from "@/hooks/use-mobile";
import { cn } from "@/lib/utils";
import { Menu } from "lucide-react";
const SIDEBAR_COOKIE_NAME = "sidebar_state"
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
@ -268,7 +268,7 @@ function SidebarTrigger({
}}
{...props}
>
<PanelLeftIcon />
<Menu />
<span className="sr-only">Toggle Sidebar</span>
</Button>
)
@ -559,7 +559,7 @@ function SidebarMenuAction({
className={cn(
"absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-[calc(var(--radius-sm)-2px)] p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
showOnHover &&
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 peer-data-active/menu-button:text-sidebar-accent-foreground aria-expanded:opacity-100 md:opacity-0",
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 peer-data-active/menu-button:text-sidebar-accent-foreground aria-expanded:opacity-100 md:opacity-0",
className
)}
{...props}
@ -702,5 +702,5 @@ export {
SidebarRail,
SidebarSeparator,
SidebarTrigger,
useSidebar,
}
useSidebar
};

View File

@ -18,8 +18,8 @@ type PageProps = {
function extractNames(items?: RoleOrPermission[]): string[] {
if (!items) {
return [];
}
return [];
}
return items.map((item) => (typeof item === 'string' ? item : item.name));
}
@ -33,40 +33,32 @@ export function useCan() {
function can(permission: string): boolean {
if (!user) {
return false;
}
if (roleNames.includes('developer') || roleNames.includes('owner')) {
return true;
}
return false;
}
return permissionNames.includes(permission);
}
function canAny(...permissions: string[]): boolean {
if (!user) {
return false;
}
if (roleNames.includes('developer') || roleNames.includes('owner')) {
return true;
}
return false;
}
return permissions.some((p) => permissionNames.includes(p));
}
function hasRole(role: string): boolean {
if (!user) {
return false;
}
return false;
}
return roleNames.includes(role);
}
function hasAnyRole(roles: string[]): boolean {
if (!user) {
return false;
}
return false;
}
return roles.some((role) => roleNames.includes(role));
}

View File

@ -33,6 +33,38 @@ function getIsSupported(): boolean {
);
}
async function sendSubscriptionToServer(
subscription: PushSubscription,
): Promise<boolean> {
const { endpoint } = subscription;
const key = subscription.getKey('p256dh');
const auth = subscription.getKey('auth');
const response = await fetch('/api/push/subscribe', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
'X-XSRF-TOKEN': decodeURIComponent(
document.cookie.match(/XSRF-TOKEN=([^;]+)/)?.[1] ?? '',
),
},
credentials: 'same-origin',
body: JSON.stringify({
endpoint,
public_key: key
? btoa(String.fromCharCode(...new Uint8Array(key)))
: null,
auth_token: auth
? btoa(String.fromCharCode(...new Uint8Array(auth)))
: null,
content_encoding: 'aes128gcm',
}),
});
return response.ok;
}
export function usePushNotification() {
const [permission, setPermission] =
useState<PermissionStatus>(getInitialPermission);
@ -54,40 +86,29 @@ export function usePushNotification() {
async (vapidPublicKey: string): Promise<boolean> => {
try {
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(
vapidPublicKey,
) as BufferSource,
});
const { endpoint } = subscription;
const key = subscription.getKey('p256dh');
const auth = subscription.getKey('auth');
const existingSubscription =
await registration.pushManager.getSubscription();
const response = await fetch('/api/push/subscribe', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
'X-XSRF-TOKEN': decodeURIComponent(
document.cookie.match(/XSRF-TOKEN=([^;]+)/)?.[1] ??
'',
),
},
body: JSON.stringify({
endpoint,
public_key: key
? btoa(String.fromCharCode(...new Uint8Array(key)))
: null,
auth_token: auth
? btoa(String.fromCharCode(...new Uint8Array(auth)))
: null,
content_encoding: 'aes128gcm',
}),
});
if (existingSubscription) {
const synced = await sendSubscriptionToServer(
existingSubscription,
);
return response.ok;
return synced;
}
const subscription =
await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(
vapidPublicKey,
) as BufferSource,
});
const saved = await sendSubscriptionToServer(subscription);
return saved;
} catch (error) {
console.error(
'Failed to subscribe to push notifications:',
@ -119,6 +140,7 @@ export function usePushNotification() {
document.cookie.match(/XSRF-TOKEN=([^;]+)/)?.[1] ?? '',
),
},
credentials: 'same-origin',
body: JSON.stringify({
endpoint: subscription.endpoint,
}),

View File

@ -0,0 +1,9 @@
import { createDraftHook } from '@/hooks/use-draft-save';
import { clearStokOpnameDraft, loadStokOpnameDraft, saveStokOpnameDraft } from '@/lib/stok-opname-draft';
import type { StokOpnameDraftData } from '@/lib/stok-opname-draft';
export const useStokOpnameDraftSave = createDraftHook<StokOpnameDraftData>({
save: saveStokOpnameDraft,
load: loadStokOpnameDraft,
clear: clearStokOpnameDraft,
});

View File

@ -0,0 +1,34 @@
import { createDraftStore } from '@/lib/draft-store';
export type StockType = 'good' | 'reject' | 'retail';
export type StokOpnameDraftData = {
selectedProductId: string;
physicalStocks: Record<string, Record<StockType, number>>;
notes: string;
};
export const stokOpnameDraftStore =
createDraftStore<StokOpnameDraftData>('stok-opname-draft');
export function saveStokOpnameDraft(
type: 'create' | 'edit',
data: StokOpnameDraftData,
userId?: number,
): boolean {
return stokOpnameDraftStore.save(type, data, userId);
}
export function loadStokOpnameDraft(
type: 'create' | 'edit',
userId?: number,
): StokOpnameDraftData | null {
return stokOpnameDraftStore.load(type, userId);
}
export function clearStokOpnameDraft(
type: 'create' | 'edit',
userId?: number,
): void {
stokOpnameDraftStore.clear(type, userId);
}

View File

@ -20,7 +20,6 @@ import {
Banknote,
Package,
ShoppingCart,
UserCheck,
} from 'lucide-react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
@ -51,13 +50,6 @@ type AnalysisProps = {
absent: number;
on_leave: number;
};
myAttendance: {
total_days: number;
present_days: number;
absent_days: number;
leave_days: number;
percentage: number;
} | null;
isManager: boolean;
cashOverview: {
total_balance: number;
@ -67,6 +59,7 @@ type AnalysisProps = {
};
rawMaterialStock: {
total_stock: number;
total_price: number;
by_unit: {
yard: number;
meter: number;
@ -75,12 +68,26 @@ type AnalysisProps = {
};
productStock: {
total_stock: number;
total_price: number;
by_type: {
stock?: number;
reject_stock?: number;
retail_stock?: number;
};
};
revenueByStockType: {
monthly: Array<{
month: string;
good: number;
reject: number;
retail: number;
}>;
totals: {
good: number;
reject: number;
retail: number;
};
};
revenueSummary: {
total_revenue: number;
total_discount: number;
@ -233,6 +240,17 @@ const revenueTrendChartConfig = (() => {
const revenueTrendKeys = ['qty'] as const;
const stockComparisonConfig = (() => {
const colors = generateRandomColors(3);
return {
good: { label: 'Bagus', color: colors[0] },
reject: { label: 'Reject', color: colors[1] },
retail: { label: 'Ecer', color: colors[2] },
} satisfies ChartConfig;
})();
const stockComparisonKeys = ['good', 'reject', 'retail'] as const;
const CHANNEL_COLORS: Record<string, string> = (() => {
const colors = generateRandomColors(3);
return {
@ -267,6 +285,8 @@ type DashboardPieChartProps = {
function DashboardPieChart({ title, data, dataKey, nameKey }: DashboardPieChartProps) {
const hasData = data.length > 0 && data.some((d) => d.count > 0);
const [activeName, setActiveName] = useState<string | undefined>(undefined);
const pieColors = useMemo(() => generateRandomColors(5), []);
const chartConfig = useMemo(() => {
@ -287,6 +307,17 @@ function DashboardPieChart({ title, data, dataKey, nameKey }: DashboardPieChartP
}));
}, [data, pieColors]);
const handlePieClick = (_: unknown, index: number) => {
const name = chartData[index]?.name;
if (name) {
setActiveName((prev) => (prev === name ? undefined : name));
}
};
const handleLegendClick = (name: string) => {
setActiveName((prev) => (prev === name ? undefined : name));
};
return (
<Card>
<CardHeader className="items-center pb-0">
@ -311,9 +342,24 @@ function DashboardPieChart({ title, data, dataKey, nameKey }: DashboardPieChartP
data={chartData}
dataKey={dataKey}
nameKey={nameKey}
/>
onClick={handlePieClick}
>
{chartData.map((item, index) => (
<Cell
key={`cell-${index}`}
opacity={activeName === undefined || activeName === item.name ? 1 : 0.3}
style={{ cursor: 'pointer' }}
/>
))}
</Pie>
<ChartLegend
content={<ChartLegendContent nameKey={nameKey} />}
content={
<ChartLegendContent
nameKey={nameKey}
onItemClick={handleLegendClick}
activeName={activeName}
/>
}
className="-translate-y-2 flex-wrap gap-2 *:basis-1/4 *:justify-center"
/>
</PieChart>
@ -385,11 +431,11 @@ function DonutTooltip({ active, payload }: TooltipProps) {
export default function Analysis({
filters: initialFilters,
attendance,
myAttendance,
isManager,
cashOverview,
rawMaterialStock,
productStock,
revenueByStockType,
revenueSummary,
monthlyRevenue,
monthlyRevenueByChannel,
@ -411,6 +457,7 @@ export default function Analysis({
const [selectedPreset, setSelectedPreset] = useState('');
const [activeRevenueKey, setActiveRevenueKey] = useState<keyof typeof revenueChartConfig>('total');
const [activeExpenseKey, setActiveExpenseKey] = useState<keyof typeof expenseChartConfig>('total');
const [activeStockKey, setActiveStockKey] = useState<'good' | 'reject' | 'retail'>('good');
const hasActiveFilters = !!startDate || !!endDate;
@ -481,6 +528,8 @@ export default function Analysis({
];
}, [monthlyRevenueByChannel]);
const stockComparisonData = useMemo(() => revenueByStockType.monthly, [revenueByStockType]);
const peakHour = useMemo(() => {
if (busyHours.length === 0) {
return { hour: '-', orders: 0 };
@ -506,6 +555,10 @@ export default function Analysis({
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 (hasRole('cashier')) {
return { statCards: 1, revenue: 2, revenueByChannel: 3, orderPie: 4, totalOrder: 5, topProducts: 6, topCustomers: 7 };
}
if (hasRole('marketing')) {
return { statCards: 1, revenue: 2, revenueByChannel: 3, orderPie: 4, totalOrder: 5, revenueTrend: 6, topProducts: 7, topCustomers: 8 };
}
@ -553,21 +606,6 @@ export default function Analysis({
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3" style={{ order: sectionOrder.statCards ?? 99 }}>
{can('analysis.attendance') && !isManager && myAttendance && (
<StatCard
title="Kehadiran Saya"
icon={UserCheck}
mainLabel="Hari Kerja"
mainValue={myAttendance.total_days}
subLabel={`${myAttendance.percentage}% hadir`}
items={[
{ label: 'Hadir', value: myAttendance.present_days },
{ label: 'Tidak Hadir', value: myAttendance.absent_days },
{ label: 'Cuti', value: myAttendance.leave_days },
]}
/>
)}
{can('analysis.cash') && (
<StatCard
title="Kas Toko"
@ -594,7 +632,9 @@ export default function Analysis({
{ label: 'Yard', value: rawMaterialStock.by_unit?.yard?.toLocaleString('id-ID') ?? '0' },
{ label: 'Meter', value: rawMaterialStock.by_unit?.meter?.toLocaleString('id-ID') ?? '0' },
{ label: 'Kg', value: rawMaterialStock.by_unit?.kilogram?.toLocaleString('id-ID') ?? '0' },
{ label: 'Total Harga', value: `Rp${formatRupiah(rawMaterialStock.total_price)}` },
]}
cols={4}
/>
)}
@ -609,11 +649,77 @@ export default function Analysis({
{ label: 'Bagus', value: (productStock.by_type?.stock ?? 0).toLocaleString('id-ID') },
{ label: 'Reject', value: (productStock.by_type?.reject_stock ?? 0).toLocaleString('id-ID') },
{ label: 'Ecer', value: (productStock.by_type?.retail_stock ?? 0).toLocaleString('id-ID') },
{ label: 'Total Harga', value: `Rp${formatRupiah(productStock.total_price)}` },
]}
cols={4}
/>
)}
</div>
{can('analysis.product_stock') && (
<Card className="py-0" style={{ order: 2 }}>
<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>Pendapatan per Jenis Stok</CardTitle>
</div>
<div className="flex flex-col sm:flex-row">
{(['good', 'reject', 'retail'] as const).map((key) => {
const value = revenueByStockType.totals[key];
return (
<button
key={key}
data-active={activeStockKey === key}
className="relative z-30 flex flex-1 flex-col justify-center gap-1 border-t px-4 py-2 text-left even:border-l data-[active=true]:bg-muted/30 sm:border-t-0 sm:border-l sm:px-5 sm:py-3"
onClick={() => setActiveStockKey(key)}
>
<span className="text-[10px] text-muted-foreground">
{stockComparisonConfig[key].label}
</span>
<span className="text-xs leading-none font-semibold sm:text-sm">
Rp{formatRupiah(value)}
</span>
</button>
);
})}
</div>
</CardHeader>
<CardContent className="px-2 sm:p-6">
{stockComparisonData.length > 0 ? (
<ChartContainer config={stockComparisonConfig} className="aspect-auto h-[250px] w-full">
<BarChart data={stockComparisonData} margin={{ left: 12, right: 12 }}>
<CartesianGrid vertical={false} />
<XAxis dataKey="month" tickLine={false} axisLine={false} tickMargin={8} />
<ChartTooltip
cursor={false}
content={
<ChartTooltipContent
className="w-[150px]"
nameKey="views"
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">{stockComparisonConfig[activeStockKey]?.label ?? name}</span>
<span className="ml-auto font-mono font-medium text-foreground tabular-nums">
Rp{Number(value).toLocaleString('id-ID')}
</span>
</>
)}
/>
}
/>
<Bar dataKey={activeStockKey} fill={`var(--color-${activeStockKey})`} radius={[4, 4, 0, 0]} />
</BarChart>
</ChartContainer>
) : (
<div className="flex h-[300px] items-center justify-center text-muted-foreground">Belum ada data pendapatan</div>
)}
</CardContent>
</Card>
)}
{can('analysis.revenue') && (
<Card className="py-0" style={{ order: sectionOrder.revenue ?? 99 }}>
<CardHeader className="flex flex-col items-stretch border-b p-0! sm:flex-row">
@ -986,7 +1092,7 @@ export default function Analysis({
<th className="px-4 py-3 text-right font-medium text-muted-foreground">Produk Terjual</th>
<th className="px-4 py-3 text-right font-medium text-muted-foreground">Total Pendapatan</th>
<th className="px-4 py-3 text-right font-medium text-muted-foreground">Total Subtotal</th>
<th className="px-4 py-3 text-right font-medium text-muted-foreground">Total Diskon</th>
<th className="px-4 py-3 text-right font-medium text-muted-foreground">Total Diskon & Potongan Nego</th>
<th className="px-4 py-3 text-right font-medium text-muted-foreground">Rata-rata Order</th>
</tr>
</thead>

View File

@ -32,6 +32,7 @@ function getTypeLabel(type: string): string {
withdrawal: 'Withdrawal',
expense: 'Pengeluaran',
employee_advance: 'Kasbon',
salary : 'Gaji'
};
return labels[type] ?? type;

View File

@ -1,3 +1,15 @@
import { Head, router } from '@inertiajs/react';
import { addMonths, format, subMonths } from 'date-fns';
import { id } from 'date-fns/locale';
import {
ChevronLeft,
ChevronRight,
Clock,
LogIn,
LogOut,
} from 'lucide-react';
import { useMemo, useState, useCallback } from 'react';
import { toast } from 'sonner';
import { TodayAttendanceAlert } from '@/components/card/today-attendance-alert';
import { CameraCapture, LocationMap } from '@/components/inputs';
import { Badge } from '@/components/ui/badge';
@ -10,22 +22,10 @@ import {
DialogTitle,
} from '@/components/ui/dialog';
import {
index as attendanceIndex,
byMonth as attendanceByMonth,
store,
update,
} from '@/routes/admin/hr/attendances';
import { Head, router } from '@inertiajs/react';
import { addMonths, format, subMonths } from 'date-fns';
import { id } from 'date-fns/locale';
import {
ChevronLeft,
ChevronRight,
Clock,
LogIn,
LogOut,
} from 'lucide-react';
import { useMemo, useState } from 'react';
import { toast } from 'sonner';
type Attendance = {
id: number;
@ -147,9 +147,9 @@ function formatMinutes(minutes: number | null): string {
}
export default function AttendanceIndex({
attendances,
leaves,
employees = [],
attendances: initialAttendances,
leaves: initialLeaves,
employees: initialEmployees = [],
todayAttendance,
currentYear,
currentMonth,
@ -174,10 +174,32 @@ export default function AttendanceIndex({
const [detailAttendance, setDetailAttendance] = useState<Attendance | null>(
null,
);
const [attendances, setAttendances] = useState<Attendance[]>(initialAttendances);
const [leaves, setLeaves] = useState<Leave[]>(initialLeaves);
const [employees, setEmployees] = useState<Employee[]>(initialEmployees);
const [loadingMonth, setLoadingMonth] = useState(false);
const viewYear = viewDate.getFullYear();
const viewMonth = viewDate.getMonth() + 1;
const fetchMonthData = useCallback(async (year: number, month: number) => {
setLoadingMonth(true);
try {
const url = attendanceByMonth.url({ query: { year, month } });
const res = await fetch(url);
if (res.ok) {
const data = await res.json();
setAttendances(data.attendances);
setLeaves(data.leaves);
setEmployees(data.employees);
}
} finally {
setLoadingMonth(false);
}
}, []);
const attendanceByDate = useMemo(() => {
const map = new Map<string, Attendance[]>();
attendances.forEach((att) => {
@ -195,6 +217,7 @@ export default function AttendanceIndex({
const start = new Date(leave.start_date);
const end = new Date(leave.end_date);
const current = new Date(start);
while (current <= end) {
const dateStr = format(current, 'yyyy-MM-dd');
const existing = map.get(dateStr) ?? [];
@ -254,47 +277,20 @@ export default function AttendanceIndex({
const handlePrevMonth = () => {
const newDate = subMonths(viewDate, 1);
setViewDate(newDate);
if (!isAdmin) {
router.get(
attendanceIndex.url(),
{
year: newDate.getFullYear(),
month: newDate.getMonth() + 1,
},
{ preserveState: true, preserveScroll: true },
);
}
fetchMonthData(newDate.getFullYear(), newDate.getMonth() + 1);
};
const handleNextMonth = () => {
const newDate = addMonths(viewDate, 1);
setViewDate(newDate);
if (!isAdmin) {
router.get(
attendanceIndex.url(),
{
year: newDate.getFullYear(),
month: newDate.getMonth() + 1,
},
{ preserveState: true, preserveScroll: true },
);
}
fetchMonthData(newDate.getFullYear(), newDate.getMonth() + 1);
};
const handleGoToToday = () => {
const now = new Date();
setViewDate(now);
setSelectedDate(now);
if (!isAdmin) {
router.get(
attendanceIndex.url(),
{ year: now.getFullYear(), month: now.getMonth() + 1 },
{ preserveState: true, preserveScroll: true },
);
}
fetchMonthData(now.getFullYear(), now.getMonth() + 1);
};
const handleCameraCapture = (dataUrl: string) => {
@ -374,17 +370,10 @@ export default function AttendanceIndex({
Menampilkan presensi dari notifikasi.
<button
onClick={() => {
router.get(
attendanceIndex.url(),
{
year: currentYear,
month: currentMonth,
},
{
replace: true,
preserveState: true,
},
);
const now = new Date();
setViewDate(now);
setSelectedDate(now);
fetchMonthData(currentYear, currentMonth);
}}
className="ml-1 text-primary underline underline-offset-4 hover:text-primary/80"
>
@ -463,6 +452,7 @@ export default function AttendanceIndex({
<div className="flex items-center gap-3 border-b px-6 py-2 text-xs text-muted-foreground">
<Badge variant="default" className="text-[10px]">Hadir</Badge>
<Badge variant="destructive" className="text-[10px]">Terlambat</Badge>
<Badge variant="outline" className="text-[10px] border-orange-300 bg-orange-50 text-orange-700">Belum Pulang</Badge>
<Badge className="text-[10px] bg-purple-100 text-purple-700 hover:bg-purple-100">Cuti</Badge>
</div>
)}
@ -495,8 +485,7 @@ export default function AttendanceIndex({
const todayMidnight = new Date();
todayMidnight.setHours(0, 0, 0, 0);
const [cYear, cMonth, cDay] = String(cell.date).split('-').map(Number);
const cellDate = new Date(cYear, cMonth - 1, cDay);
const cellDate = new Date(cell.date);
cellDate.setHours(0, 0, 0, 0);
const isPastDate = cellDate < todayMidnight;
@ -543,7 +532,7 @@ export default function AttendanceIndex({
<div className="mt-1 flex flex-col gap-0.5 overflow-hidden">
{isAdmin ? (
<>
{[...employees].sort((a, b) => a.name.localeCompare(b.name)).map((emp) => {
{cell.isCurrentMonth && !isFutureDate && [...employees].sort((a, b) => a.name.localeCompare(b.name)).map((emp) => {
const att = dayAttendances.find((a) => a.employee_id === emp.id);
const leave = dayLeaves.find((l) => l.employee_id === emp.id);
@ -553,11 +542,13 @@ export default function AttendanceIndex({
officeHour,
officeMinute,
);
const notCheckedOut = !att.check_out_at;
return (
<Badge
key={emp.id}
variant={late ? 'destructive' : 'default'}
className="w-full justify-center cursor-pointer truncate"
variant={notCheckedOut ? 'outline' : (late ? 'destructive' : 'default')}
className={`w-full justify-center cursor-pointer truncate ${notCheckedOut ? 'border-orange-300 bg-orange-50 text-orange-700 hover:bg-orange-50' : ''}`}
onClick={(e) => {
e.stopPropagation();
setDetailAttendance(att);
@ -618,6 +609,11 @@ export default function AttendanceIndex({
? 'Terlambat'
: 'Hadir'}
</span>
{!att.check_out_at && (
<span className="inline-flex items-center justify-center rounded-full bg-orange-100 px-1.5 py-0.5 text-[10px] font-semibold text-orange-700">
Belum Pulang
</span>
)}
<span className="text-[10px] text-muted-foreground">
Masuk :{' '}
{formatTime(
@ -626,9 +622,9 @@ export default function AttendanceIndex({
</span>
<span className="text-[10px] text-muted-foreground">
Pulang :{' '}
{formatTime(
att.check_out_at,
)}
{att.check_out_at
? formatTime(att.check_out_at)
: '-'}
</span>
{late && (
<span className="text-[10px] text-yellow-600">

View File

@ -18,6 +18,7 @@ export type Employee = {
} | null;
employee: {
join_date: string;
resign_date: string | null;
employment_status: string;
base_salary: number;
} | null;
@ -124,6 +125,67 @@ export function createEmployeeColumns(
);
},
},
{
accessorKey: 'employee.base_salary',
id: 'base_salary',
header: () => <span>Gaji</span>,
cell: ({ row }) => {
const employee = row.original;
const salary = employee.employee?.base_salary;
if (!salary) {
return <span>-</span>;
}
return (
<span className="font-medium">
{formatCurrency(salary)}
</span>
);
},
},
{
id: 'period',
header: () => <span>Masa Kerja</span>,
cell: ({ row }) => {
const employee = row.original;
const joinDate = employee.employee?.join_date;
const resignDate = employee.employee?.resign_date;
if (!joinDate) {
return <span>-</span>;
}
const formattedJoin = new Date(joinDate).toLocaleDateString('id-ID', {
day: 'numeric',
month: 'short',
year: 'numeric',
});
if (resignDate) {
const formattedResign = new Date(resignDate).toLocaleDateString('id-ID', {
day: 'numeric',
month: 'short',
year: 'numeric',
});
return (
<div className="flex flex-col">
<span className="text-xs text-muted-foreground">Masuk</span>
<span>{formattedJoin}</span>
<span className="text-xs text-muted-foreground">Keluar</span>
<span>{formattedResign}</span>
</div>
);
}
return (
<div className="flex flex-col">
<span className="text-xs text-muted-foreground">Masuk</span>
<span>{formattedJoin}</span>
</div>
);
},
},
...(canViewAll
? [
{

View File

@ -105,6 +105,7 @@ export type CuttingCreateData = {
price: number;
stock: number;
photo_url: string | null;
photo_conversion_url: string | null;
}[];
}[];
};

View File

@ -27,13 +27,14 @@ import type { CuttingCreateData } from './columns';
type MaterialState = {
raw_material_price_id: number;
material_usage: number;
material_usage: string;
material_result: number;
combination_id: number | null;
variant: string;
material_name: string;
unit: string;
photo_url: string | null;
photo_conversion_url: string | null;
};
type CombinationState = {
@ -54,13 +55,14 @@ export default function CuttingCreate({ rawMaterials }: Props) {
if (draft?.materials && draft.materials.length > 0) {
return draft.materials.map((m) => ({
raw_material_price_id: m.raw_material_price_id,
material_usage: m.material_usage,
material_usage: String(m.material_usage),
material_result: m.material_result ?? 0,
combination_id: m.combination_index ?? null,
variant: m.variant,
material_name: m.material_name,
unit: m.unit,
photo_url: m.photo_url,
photo_conversion_url: m.photo_conversion_url,
}));
}
@ -130,6 +132,7 @@ export default function CuttingCreate({ rawMaterials }: Props) {
material_name: m.material_name,
unit: m.unit,
photo_url: m.photo_url,
photo_conversion_url: m.photo_conversion_url,
})),
combinations: combinations.map((c) => ({
material_result: c.material_result,
@ -185,13 +188,14 @@ export default function CuttingCreate({ rawMaterials }: Props) {
...prev,
{
raw_material_price_id: price.id,
material_usage: 0,
material_usage: '0',
material_result: 0,
combination_id: null,
variant: price.variant,
material_name: rawMaterial.name,
unit: rawMaterial.unit,
photo_url: price.photo_url,
photo_conversion_url: price.photo_conversion_url,
},
];
});
@ -235,13 +239,14 @@ export default function CuttingCreate({ rawMaterials }: Props) {
return {
raw_material_price_id: priceId,
material_usage: 0,
material_usage: '0',
material_result: 0,
combination_id: comboIndex,
variant: foundPrice?.variant ?? '',
material_name: foundMaterial?.name ?? '',
unit: foundMaterial?.unit ?? '',
photo_url: foundPrice?.photo_url ?? null,
photo_conversion_url: foundPrice?.photo_conversion_url ?? null,
};
});
@ -284,7 +289,7 @@ export default function CuttingCreate({ rawMaterials }: Props) {
return materials.reduce((sum, m) => {
const price = priceMap.get(m.raw_material_price_id);
return sum + (price ? price.price * m.material_usage : 0);
return sum + (price ? price.price * (parseFloat(String(m.material_usage)) || 0) : 0);
}, 0);
}, [materials, priceMap]);
@ -304,7 +309,7 @@ export default function CuttingCreate({ rawMaterials }: Props) {
cutting_result: cuttingResult,
materials: materialsRef.current.map((m) => ({
raw_material_price_id: m.raw_material_price_id,
material_usage: m.material_usage,
material_usage: parseFloat(String(m.material_usage)) || 0,
material_result: m.material_result,
combination_index: m.combination_id,
})),
@ -393,8 +398,8 @@ export default function CuttingCreate({ rawMaterials }: Props) {
return (
<div key={price.id} className={`flex flex-wrap items-center justify-between gap-2 rounded-lg border p-3 ${isAdded ? 'border-primary' : ''}`}>
<div className="flex min-w-0 items-center gap-3">
{price.photo_url ? (
<img src={price.photo_url} alt={price.variant} className="h-10 w-10 shrink-0 rounded-md object-cover" />
{price.photo_conversion_url ?? price.photo_url ? (
<img src={price.photo_conversion_url ?? price.photo_url} alt={price.variant} className="h-10 w-10 shrink-0 rounded-md object-cover" />
) : (
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-md bg-muted text-xs text-muted-foreground">N/A</div>
)}
@ -439,8 +444,8 @@ export default function CuttingCreate({ rawMaterials }: Props) {
return (
<div key={price.id} className={`flex flex-wrap items-center justify-between gap-2 rounded-lg border p-3 ${isAdded ? 'border-primary' : ''}`}>
<div className="flex min-w-0 items-center gap-3">
{price.photo_url ? (
<img src={price.photo_url} alt={price.variant} className="h-10 w-10 shrink-0 rounded-md object-cover" />
{price.photo_conversion_url ?? price.photo_url ? (
<img src={price.photo_conversion_url ?? price.photo_url} alt={price.variant} className="h-10 w-10 shrink-0 rounded-md object-cover" />
) : (
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-md bg-muted text-xs text-muted-foreground">N/A</div>
)}
@ -623,9 +628,9 @@ export default function CuttingCreate({ rawMaterials }: Props) {
<div key={cartKey} className="space-y-2">
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-3">
{m.photo_url ? (
{m.photo_conversion_url ?? m.photo_url ? (
<button type="button" onClick={() => setPreviewKey(cartKey)} className="block h-8 w-8 shrink-0 overflow-hidden rounded-md border transition-opacity hover:opacity-80">
<img src={m.photo_url} alt={m.variant} className="h-full w-full object-cover" />
<img src={m.photo_conversion_url ?? m.photo_url} alt={m.variant} className="h-full w-full object-cover" />
</button>
) : (
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-muted text-xs text-muted-foreground">N/A</div>
@ -646,11 +651,13 @@ export default function CuttingCreate({ rawMaterials }: Props) {
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground whitespace-nowrap">Pemakaian <span className="text-destructive">*</span></span>
<NumberInput
className="flex-1"
value={m.material_usage}
onValueChange={(val) => updateMaterial(index, 'material_usage', val)}
/>
<Input
type="text"
inputMode="decimal"
className="flex-1"
value={m.material_usage}
onChange={(e) => updateMaterial(index, 'material_usage', e.target.value)}
/>
</div>
<InputError message={errors[`materials.${index}.material_usage` as keyof typeof errors]} />
{group.comboIndex === null && (
@ -675,7 +682,7 @@ export default function CuttingCreate({ rawMaterials }: Props) {
<SheetFooter>
<div className="flex items-center justify-between border-t pt-4">
<span className="text-sm">Total Pemakaian</span>
<span className="text-sm font-semibold">{formatQuantity(materials.reduce((sum, m) => sum + m.material_usage, 0))}</span>
<span className="text-sm font-semibold">{formatQuantity(materials.reduce((sum, m) => sum + (parseFloat(String(m.material_usage)) || 0), 0))}</span>
</div>
</SheetFooter>
</SheetContent>
@ -742,7 +749,7 @@ export default function CuttingCreate({ rawMaterials }: Props) {
if (p) {
variantName = p.variant;
materialName = rm.name;
photoUrl = p.photo_url;
photoUrl = p.photo_conversion_url ?? p.photo_url;
break;
}
}
@ -780,8 +787,8 @@ export default function CuttingCreate({ rawMaterials }: Props) {
return (
<div key={price.id} className={`flex items-center justify-between gap-3 rounded-lg border p-3 ${isSelected ? 'border-primary' : ''}`}>
<div className="flex min-w-0 items-center gap-3">
{price.photo_url ? (
<img src={price.photo_url} alt={price.variant} className="h-10 w-10 shrink-0 rounded-md object-cover" />
{price.photo_conversion_url ?? price.photo_url ? (
<img src={price.photo_conversion_url ?? price.photo_url} alt={price.variant} className="h-10 w-10 shrink-0 rounded-md object-cover" />
) : (
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-md bg-muted text-xs text-muted-foreground">N/A</div>
)}

View File

@ -20,8 +20,6 @@ function generateShareLink(cuttingId: number): string {
}
function generateWhatsappText(cutting: Cutting): string {
const result = cutting.cutting_results?.[0];
const items = cutting.cutting_materials ?? [];
const shareLink = generateShareLink(cutting.id);
let text = `*Cutting #${cutting.id}*\n`;
@ -33,25 +31,12 @@ function generateWhatsappText(cutting: Cutting): string {
text += `Deskripsi: ${cutting.description}\n`;
}
const productNames = new Set<string>();
(cutting.cutting_results ?? []).forEach((r) => {
if (r.product_name) {
productNames.add(r.product_name);
}
});
if (productNames.size > 0) {
text += `\nNama Produk: ${Array.from(productNames).join(', ')}\n`;
if (cutting.product_name) {
text += `\nNama Produk: ${cutting.product_name}\n`;
}
const totalUsage = items.reduce(
(sum, item) => sum + Number(item.material_usage),
0,
);
text += `Total Pemakaian: ${formatNumber(totalUsage)}\n`;
if (result?.cutting_result) {
text += `Total Hasil Cutting: ${formatNumber(result.cutting_result)} pcs\n`;
if (cutting.cutting_result) {
text += `Total Hasil Cutting: ${formatNumber(cutting.cutting_result)} pcs\n`;
}
text += `\nLihat detail lengkap:\n${shareLink}`;

View File

@ -26,13 +26,14 @@ import type { CuttingCreateData, CuttingForEdit } from './columns';
type MaterialState = {
id?: number;
raw_material_price_id: number;
material_usage: number;
material_usage: string;
material_result: number;
combination_id: number | null;
variant: string;
material_name: string;
unit: string;
photo_url: string | null;
photo_conversion_url: string | null;
};
type CombinationState = {
@ -63,7 +64,7 @@ export default function CuttingEdit({ cutting, rawMaterials }: Props) {
return {
id: m.id,
raw_material_price_id: m.raw_material_price_id,
material_usage: m.material_usage,
material_usage: String(m.material_usage),
material_result: m.material_result ?? 0,
combination_id:
m.combination_id !== null
@ -73,6 +74,7 @@ export default function CuttingEdit({ cutting, rawMaterials }: Props) {
material_name: rawMaterial?.name ?? '',
unit: rawMaterial?.unit ?? '',
photo_url: price?.photo_url ?? m.photo_url ?? null,
photo_conversion_url: price?.photo_conversion_url ?? m.photo_conversion_url ?? null,
};
});
});
@ -168,13 +170,14 @@ export default function CuttingEdit({ cutting, rawMaterials }: Props) {
...prev,
{
raw_material_price_id: price.id,
material_usage: 0,
material_usage: '0',
material_result: 0,
combination_id: null,
variant: price.variant,
material_name: rawMaterial.name,
unit: rawMaterial.unit,
photo_url: price.photo_url,
photo_conversion_url: price.photo_conversion_url,
},
];
});
@ -218,13 +221,14 @@ export default function CuttingEdit({ cutting, rawMaterials }: Props) {
return {
raw_material_price_id: priceId,
material_usage: 0,
material_usage: '0',
material_result: 0,
combination_id: comboIndex,
variant: foundPrice?.variant ?? '',
material_name: foundMaterial?.name ?? '',
unit: foundMaterial?.unit ?? '',
photo_url: foundPrice?.photo_url ?? null,
photo_conversion_url: foundPrice?.photo_conversion_url ?? null,
};
});
@ -262,7 +266,7 @@ export default function CuttingEdit({ cutting, rawMaterials }: Props) {
return materials.reduce((sum, m) => {
const price = priceMap.get(m.raw_material_price_id);
return sum + (price ? price.price * m.material_usage : 0);
return sum + (price ? price.price * (parseFloat(String(m.material_usage)) || 0) : 0);
}, 0);
}, [materials, priceMap]);
@ -282,7 +286,7 @@ export default function CuttingEdit({ cutting, rawMaterials }: Props) {
cutting_result: cuttingResult,
materials: materialsRef.current.map((m) => ({
raw_material_price_id: m.raw_material_price_id,
material_usage: m.material_usage,
material_usage: parseFloat(String(m.material_usage)) || 0,
material_result: m.material_result,
combination_index: m.combination_id,
})),
@ -371,8 +375,8 @@ export default function CuttingEdit({ cutting, rawMaterials }: Props) {
return (
<div key={price.id} className={`flex flex-wrap items-center justify-between gap-2 rounded-lg border p-3 ${isAdded ? 'border-primary' : ''}`}>
<div className="flex min-w-0 items-center gap-3">
{price.photo_url ? (
<img src={price.photo_url} alt={price.variant} className="h-10 w-10 shrink-0 rounded-md object-cover" />
{price.photo_conversion_url ?? price.photo_url ? (
<img src={price.photo_conversion_url ?? price.photo_url} alt={price.variant} className="h-10 w-10 shrink-0 rounded-md object-cover" />
) : (
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-md bg-muted text-xs text-muted-foreground">N/A</div>
)}
@ -417,8 +421,8 @@ export default function CuttingEdit({ cutting, rawMaterials }: Props) {
return (
<div key={price.id} className={`flex flex-wrap items-center justify-between gap-2 rounded-lg border p-3 ${isAdded ? 'border-primary' : ''}`}>
<div className="flex min-w-0 items-center gap-3">
{price.photo_url ? (
<img src={price.photo_url} alt={price.variant} className="h-10 w-10 shrink-0 rounded-md object-cover" />
{price.photo_conversion_url ?? price.photo_url ? (
<img src={price.photo_conversion_url ?? price.photo_url} alt={price.variant} className="h-10 w-10 shrink-0 rounded-md object-cover" />
) : (
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-md bg-muted text-xs text-muted-foreground">N/A</div>
)}
@ -601,9 +605,9 @@ export default function CuttingEdit({ cutting, rawMaterials }: Props) {
<div key={cartKey} className="space-y-2">
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-3">
{m.photo_url ? (
{m.photo_conversion_url ?? m.photo_url ? (
<button type="button" onClick={() => setPreviewKey(cartKey)} className="block h-8 w-8 shrink-0 overflow-hidden rounded-md border transition-opacity hover:opacity-80">
<img src={m.photo_url} alt={m.variant} className="h-full w-full object-cover" />
<img src={m.photo_conversion_url ?? m.photo_url} alt={m.variant} className="h-full w-full object-cover" />
</button>
) : (
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-muted text-xs text-muted-foreground">N/A</div>
@ -624,11 +628,13 @@ export default function CuttingEdit({ cutting, rawMaterials }: Props) {
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground whitespace-nowrap">Pemakaian <span className="text-destructive">*</span></span>
<NumberInput
className="flex-1"
value={m.material_usage}
onValueChange={(val) => updateMaterial(index, 'material_usage', val)}
/>
<Input
type="text"
inputMode="decimal"
className="flex-1"
value={m.material_usage}
onChange={(e) => updateMaterial(index, 'material_usage', e.target.value)}
/>
</div>
<InputError message={errors[`materials.${index}.material_usage` as keyof typeof errors]} />
{group.comboIndex === null && (
@ -653,7 +659,7 @@ export default function CuttingEdit({ cutting, rawMaterials }: Props) {
<SheetFooter>
<div className="flex items-center justify-between border-t pt-4">
<span className="text-sm">Total Pemakaian</span>
<span className="text-sm font-semibold">{formatQuantity(materials.reduce((sum, m) => sum + m.material_usage, 0))}</span>
<span className="text-sm font-semibold">{formatQuantity(materials.reduce((sum, m) => sum + (parseFloat(String(m.material_usage)) || 0), 0))}</span>
</div>
</SheetFooter>
</SheetContent>
@ -720,7 +726,7 @@ export default function CuttingEdit({ cutting, rawMaterials }: Props) {
if (p) {
variantName = p.variant;
materialName = rm.name;
photoUrl = p.photo_url;
photoUrl = p.photo_conversion_url ?? p.photo_url;
break;
}
}
@ -758,8 +764,8 @@ export default function CuttingEdit({ cutting, rawMaterials }: Props) {
return (
<div key={price.id} className={`flex items-center justify-between gap-3 rounded-lg border p-3 ${isSelected ? 'border-primary' : ''}`}>
<div className="flex min-w-0 items-center gap-3">
{price.photo_url ? (
<img src={price.photo_url} alt={price.variant} className="h-10 w-10 shrink-0 rounded-md object-cover" />
{price.photo_conversion_url ?? price.photo_url ? (
<img src={price.photo_conversion_url ?? price.photo_url} alt={price.variant} className="h-10 w-10 shrink-0 rounded-md object-cover" />
) : (
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-md bg-muted text-xs text-muted-foreground">N/A</div>
)}

View File

@ -98,6 +98,7 @@ export type PurchaseCreateData = {
price: number;
stock: number;
photo_url: string | null;
photo_conversion_url: string | null;
}[];
}[];
};

View File

@ -354,7 +354,7 @@ export default function PurchaseCreate({ suppliers, rawMaterials }: Props) {
if (price && material) {
lines.push({
key: `existing-${id}`,
photoUrl: price.photo_url,
photoUrl: price.photo_conversion_url ?? price.photo_url,
title: `${material.name}${price.variant}`,
subtitle: `${formatCurrency(price.price)} / ${material.unit}`,
price: price.price,
@ -876,10 +876,10 @@ export default function PurchaseCreate({ suppliers, rawMaterials }: Props) {
}
>
<div className="flex min-w-0 items-center gap-3">
{price.photo_url ? (
{price.photo_conversion_url ?? price.photo_url ? (
<img
src={
price.photo_url
price.photo_conversion_url ?? price.photo_url
}
alt={
price.variant

View File

@ -166,7 +166,7 @@ export default function PurchaseEdit({
if (price && material) {
lines.push({
key: `existing-${id}`,
photoUrl: price.photo_url,
photoUrl: price.photo_conversion_url ?? price.photo_url,
title: `${material.name}${price.variant}`,
subtitle: `${formatCurrency(price.price)} / ${material.unit}`,
price: price.price,
@ -319,11 +319,11 @@ export default function PurchaseEdit({
}
>
<div className="flex min-w-0 items-center gap-3">
{price.photo_url ? (
<img
src={
price.photo_url
}
{price.photo_conversion_url ?? price.photo_url ? (
<img
src={
price.photo_conversion_url ?? price.photo_url
}
alt={
price.variant
}

View File

@ -28,6 +28,8 @@ export type Restock = {
items_count: number;
total_qty: number;
product_names: string | null;
photo_urls: string[];
photo_conversion_urls: string[];
created_by: {
id: number;
user_profile: {

View File

@ -174,10 +174,11 @@ return 0;
if (variant) {
const unitPrice = stockType === 'reject' ? variant.reject_price : variant.capital_price;
const productName = productByVariantId.get(id) ?? '';
lines.push({
key: `variant-${id}`,
photoUrl: variant.photo_url,
title: variant.name,
title: `${productName}${variant.name}`,
subtitle: `${formatCurrency(unitPrice)} / pcs`,
price: unitPrice,
quantity,

View File

@ -96,6 +96,16 @@ export default function RestockEdit({ restock, products }: Props) {
[products],
);
const productByVariantId = useMemo(
() =>
new Map(
products.flatMap((p) =>
p.product_variants.map((v) => [v.id, p.name]),
),
),
[products],
);
const getUnitPrice = useCallback(
(variantId: number) => {
const variant = variantById.get(variantId);
@ -148,10 +158,11 @@ return 0;
if (variant) {
const unitPrice = stockType === 'reject' ? variant.reject_price : variant.capital_price;
const productName = productByVariantId.get(id) ?? '';
lines.push({
key: `variant-${id}`,
photoUrl: variant.photo_url,
title: variant.name,
title: `${productName}${variant.name}`,
subtitle: `${formatCurrency(unitPrice)} / pcs`,
price: unitPrice,
quantity,

View File

@ -1,4 +1,5 @@
import { ChevronDown, Pencil, Trash2 } from 'lucide-react';
import { ImagePreviewButton } from '@/components/dialogs';
import { RowActions } from '@/components/data-display';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
@ -118,6 +119,18 @@ export function RestockCardRow({
{formatCurrency(restock.total)}
</span>
</div>
{restock.photo_urls && restock.photo_urls.length > 0 && (
<div className="mt-2">
<ImagePreviewButton
srcs={restock.photo_conversion_urls?.length ? restock.photo_conversion_urls : restock.photo_urls}
modalSrcs={restock.photo_urls}
title={productNames.length > 0 ? productNames.join(', ') : 'Restock'}
description={formatDateTime(restock.created_at)}
className="h-16 w-16"
/>
</div>
)}
</div>
{(can('restocks.update') || can('restocks.delete')) && (

View File

@ -0,0 +1,79 @@
export type StokOpnameStatus = 'draft' | 'in_progress' | 'completed' | 'verified' | 'cancelled';
export type StockType = 'good' | 'reject' | 'retail';
export type StokOpnameItem = {
id: number;
product_variant_id: number;
stock_quality: StockType;
system_stock: number;
physical_stock: number;
difference: number;
notes: string | null;
variant_name: string;
product_name: string;
photo_url: string | null;
photo_conversion_url: string | null;
};
export type StokOpname = {
id: number;
created_by_id: number;
verified_by_id: number | null;
opname_date: string;
status: StokOpnameStatus;
notes: string | null;
verification_notes: string | null;
created_at: string;
items_count: number;
total_difference: number;
product_names: string | null;
created_by: {
id: number;
user_profile: {
full_name: string;
};
};
verified_by: {
id: number;
user_profile: {
full_name: string;
};
} | null;
};
export type StokOpnameForEdit = {
id: number;
opname_date: string;
status: StokOpnameStatus;
notes: string | null;
items: {
id: number;
product_variant_id: number;
stock_quality: StockType;
system_stock: number;
physical_stock: number;
difference: number;
notes: string | null;
variant_name: string;
product_name: string;
}[];
};
export type ProductForStokOpname = {
id: number;
name: string;
status: string;
product_variants: {
id: number;
name: string;
stock: number;
reject_stock: number;
retail_stock: number;
photo_url: string | null;
}[];
};
export type StokOpnameCreateData = {
products: ProductForStokOpname[];
};

View File

@ -0,0 +1,601 @@
'use no memo';
import { NumberInput } from '@/components/inputs';
import { InputError } from '@/components/ui';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxList,
} from '@/components/ui/combobox';
import { Label } from '@/components/ui/label';
import {
Sheet,
SheetContent,
SheetFooter,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet';
import { Textarea } from '@/components/ui/textarea';
import { useStokOpnameDraftSave } from '@/hooks/use-stok-opname-draft';
import { formatNumber } from '@/lib/format';
import { loadStokOpnameDraft } from '@/lib/stok-opname-draft';
import { index as stokOpnameIndex, store } from '@/routes/admin/manage/stok-opnames';
import { Form, Head, Link, usePage } from '@inertiajs/react';
import { ArrowLeft, Minus, Plus, ShoppingCart, Trash2 } from 'lucide-react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { toast } from 'sonner';
import type { ProductForStokOpname, StokOpnameCreateData } from './columns';
type StockType = 'good' | 'reject' | 'retail';
type CartLine = {
key: string;
photoUrl: string | null;
title: string;
subtitle: string;
quantity: number;
onAdjust: (delta: number) => void;
onSet: (value: number) => void;
onRemove: () => void;
};
type Props = {
products: StokOpnameCreateData['products'];
};
export default function StokOpnameCreate({ products }: Props) {
const { auth } = usePage().props as { auth: { user?: { id?: number } } };
const userId = auth.user?.id;
const draft = loadStokOpnameDraft('create', userId);
const [selectedProductId, setSelectedProductId] = useState(
draft?.selectedProductId ?? '',
);
const [physicalStocks, setPhysicalStocks] = useState<Record<string, Record<StockType, number>>>(() => {
if (draft?.physicalStocks) {
return draft.physicalStocks;
}
return {};
});
const [notes, setNotes] = useState(draft?.notes ?? '');
const [cartOpen, setCartOpen] = useState(false);
const draftData = useMemo(
() => ({
selectedProductId,
physicalStocks,
notes,
}),
[selectedProductId, physicalStocks, notes],
);
useStokOpnameDraftSave('create', draftData, userId);
const physicalStocksRef = useRef(physicalStocks);
useEffect(() => {
physicalStocksRef.current = physicalStocks;
}, [physicalStocks]);
const selectedProduct = useMemo(
() => products.find((p) => String(p.id) === selectedProductId) ?? null,
[products, selectedProductId],
);
const variantById = useMemo(
() =>
new Map(
products.flatMap((p: ProductForStokOpname) =>
p.product_variants.map((v) => [v.id, v]),
),
),
[products],
);
const productByVariantId = useMemo(
() =>
new Map(
products.flatMap((p: ProductForStokOpname) =>
p.product_variants.map((v) => [v.id, p.name]),
),
),
[products],
);
const updatePhysicalStock = useCallback((variantId: number, stockType: StockType, value: number) => {
setPhysicalStocks((prev) => ({
...prev,
[variantId]: {
...prev[variantId],
[stockType]: Math.max(0, value),
},
}));
}, []);
const hasAnyStock = useCallback((variantId: number) => {
const stocks = physicalStocks[variantId];
if (!stocks) return false;
return stocks.good > 0 || stocks.reject > 0 || stocks.retail > 0;
}, [physicalStocks]);
const cartItems: CartLine[] = (() => {
const lines: CartLine[] = [];
for (const [variantId, stocks] of Object.entries(physicalStocks)) {
const id = Number(variantId);
const variant = variantById.get(id);
if (!variant) continue;
for (const [stockType, quantity] of Object.entries(stocks)) {
if (quantity <= 0) continue;
const type = stockType as StockType;
const typeLabel = type === 'good' ? 'Bagus' : type === 'reject' ? 'Reject' : 'Ecer';
lines.push({
key: `variant-${id}-${type}`,
photoUrl: variant.photo_url,
title: `${productByVariantId.get(id) ?? ''}${variant.name}`,
subtitle: `${typeLabel}: ${formatNumber(quantity)}`,
quantity,
onAdjust: (delta) => updatePhysicalStock(id, type, quantity + delta),
onSet: (value) => updatePhysicalStock(id, type, value),
onRemove: () => updatePhysicalStock(id, type, 0),
});
}
}
return lines.sort((a, b) => a.title.localeCompare(b.title));
})();
function formatQuantity(value: number): string {
return formatNumber(value, { maximumFractionDigits: 4 });
}
function getPayload() {
const items: Array<{ product_variant_id: number; stock_quality: StockType; physical_stock: number }> = [];
for (const [variantId, stocks] of Object.entries(physicalStocksRef.current)) {
const variant = variantById.get(Number(variantId));
if (!variant) continue;
for (const [stockType, quantity] of Object.entries(stocks)) {
if (quantity <= 0) continue;
items.push({
product_variant_id: Number(variantId),
stock_quality: stockType as StockType,
physical_stock: quantity,
});
}
}
return {
opname_date: new Date().toISOString().split('T')[0],
items,
notes: notes || null,
};
}
return (
<>
<Head title="Tambah Stok Opname" />
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
<div className="flex items-center justify-between">
<h2 className="text-2xl font-semibold tracking-tight">
Tambah Stok Opname
</h2>
<Button asChild variant="outline">
<Link href={stokOpnameIndex.url()}>
<ArrowLeft className="h-4 w-4" />
Kembali
</Link>
</Button>
</div>
<Form
action={store()}
transform={(formData) => ({
...formData,
...getPayload(),
})}
onError={() => {
toast.error('Ada data yang belum sesuai, silakan periksa kembali input Anda.');
}}
>
{({ errors, processing }) => (
<div className="grid gap-6 md:grid-cols-3">
<div className="space-y-6 md:col-span-2">
<Card>
<CardHeader>
<CardTitle>Pilih Produk</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-2">
<Label>
Nama Produk{' '}
<span className="text-destructive">
*
</span>
</Label>
<Combobox
items={products}
itemToStringLabel={(p) =>
p.name
}
value={selectedProduct}
onValueChange={(value) =>
setSelectedProductId(
value
? String(value.id)
: '',
)
}
>
<ComboboxInput
placeholder="Cari produk..."
className="w-full"
/>
<ComboboxContent>
<ComboboxEmpty>
Tidak ada produk
ditemukan.
</ComboboxEmpty>
<ComboboxList>
{(p) => (
<ComboboxItem
key={p.id}
value={p}
>
{p.name}
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
<InputError
message={errors.items}
/>
</div>
{selectedProduct && (
<div className="space-y-4">
{selectedProduct.product_variants.map(
(variant) => {
const isActive = hasAnyStock(variant.id);
return (
<div
key={variant.id}
className={`rounded-lg border p-4 ${isActive ? 'border-primary bg-primary/5' : ''}`}
>
<div className="flex items-start gap-3">
{variant.photo_url ? (
<img
src={variant.photo_url}
alt={variant.name}
className="h-12 w-12 shrink-0 rounded-md object-cover"
/>
) : (
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-md bg-muted text-xs text-muted-foreground">
N/A
</div>
)}
<div className="min-w-0 flex-1">
<p className="font-medium">
{variant.name}
</p>
<div className="mt-2 flex gap-3">
<div className="flex-1 space-y-1 rounded-md border p-2">
<p className="text-xs text-muted-foreground">
Bagus: <span className="font-medium text-foreground">{formatQuantity(variant.stock)}</span>
</p>
<div className="flex items-center gap-1">
<Button
type="button"
variant="outline"
size="icon"
disabled={!physicalStocks[variant.id]?.good}
onClick={() =>
updatePhysicalStock(variant.id, 'good', (physicalStocks[variant.id]?.good ?? 0) - 1)
}
>
<Minus className="h-3 w-3" />
</Button>
<NumberInput
className="flex-1 text-center text-xs"
value={physicalStocks[variant.id]?.good ?? 0}
onValueChange={(val) =>
updatePhysicalStock(variant.id, 'good', val)
}
/>
<Button
type="button"
variant="outline"
size="icon"
onClick={() =>
updatePhysicalStock(variant.id, 'good', (physicalStocks[variant.id]?.good ?? 0) + 1)
}
>
<Plus className="h-3 w-3" />
</Button>
</div>
</div>
<div className="flex-1 space-y-1 rounded-md border p-2">
<p className="text-xs text-muted-foreground">
Reject: <span className="font-medium text-foreground">{formatQuantity(variant.reject_stock)}</span>
</p>
<div className="flex items-center gap-1">
<Button
type="button"
variant="outline"
size="icon"
disabled={!physicalStocks[variant.id]?.reject}
onClick={() =>
updatePhysicalStock(variant.id, 'reject', (physicalStocks[variant.id]?.reject ?? 0) - 1)
}
>
<Minus className="h-3 w-3" />
</Button>
<NumberInput
className="flex-1 text-center text-xs"
value={physicalStocks[variant.id]?.reject ?? 0}
onValueChange={(val) =>
updatePhysicalStock(variant.id, 'reject', val)
}
/>
<Button
type="button"
variant="outline"
size="icon"
onClick={() =>
updatePhysicalStock(variant.id, 'reject', (physicalStocks[variant.id]?.reject ?? 0) + 1)
}
>
<Plus className="h-3 w-3" />
</Button>
</div>
</div>
<div className="flex-1 space-y-1 rounded-md border p-2">
<p className="text-xs text-muted-foreground">
Ecer: <span className="font-medium text-foreground">{formatQuantity(variant.retail_stock)}</span>
</p>
<div className="flex items-center gap-1">
<Button
type="button"
variant="outline"
size="icon"
disabled={!physicalStocks[variant.id]?.retail}
onClick={() =>
updatePhysicalStock(variant.id, 'retail', (physicalStocks[variant.id]?.retail ?? 0) - 1)
}
>
<Minus className="h-3 w-3" />
</Button>
<NumberInput
className="flex-1 text-center text-xs"
value={physicalStocks[variant.id]?.retail ?? 0}
onValueChange={(val) =>
updatePhysicalStock(variant.id, 'retail', val)
}
/>
<Button
type="button"
variant="outline"
size="icon"
onClick={() =>
updatePhysicalStock(variant.id, 'retail', (physicalStocks[variant.id]?.retail ?? 0) + 1)
}
>
<Plus className="h-3 w-3" />
</Button>
</div>
</div>
</div>
</div>
</div>
</div>
);
},
)}
</div>
)}
</CardContent>
</Card>
</div>
<div className="space-y-6 md:col-span-1">
<Card className="sticky top-6">
<CardHeader>
<CardTitle>Ringkasan</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">
Jumlah Item
</span>
<span className="font-medium">
{cartItems.length}
</span>
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="notes">
Keterangan
</Label>
<Textarea
id="notes"
value={notes}
onChange={(e) =>
setNotes(e.target.value)
}
placeholder="Masukkan keterangan"
maxLength={100}
/>
<InputError
message={errors.notes}
/>
</div>
<Button
type="submit"
className="w-full"
disabled={
processing ||
cartItems.length === 0
}
>
{processing
? 'Menyimpan...'
: 'Simpan'}
</Button>
</CardContent>
</Card>
</div>
</div>
)}
</Form>
<Button
type="button"
onClick={() => setCartOpen(true)}
className="fixed top-1/2 right-4 z-50 h-14 w-14 -translate-y-1/2 rounded-full shadow-lg"
size="icon"
aria-label="Buka keranjang stok opname"
>
<ShoppingCart className="h-5 w-5" />
{cartItems.length > 0 && (
<span className="absolute -top-1 -right-1 flex h-5 min-w-5 items-center justify-center rounded-full bg-destructive px-1 text-xs font-semibold text-white">
{cartItems.length}
</span>
)}
</Button>
<Sheet open={cartOpen} onOpenChange={setCartOpen}>
<SheetContent side="right" className="w-full sm:max-w-md">
<SheetHeader>
<SheetTitle>Keranjang Stok Opname</SheetTitle>
</SheetHeader>
<div className="flex-1 space-y-4 overflow-y-auto px-6 pb-6">
{cartItems.length === 0 ? (
<p className="text-sm text-muted-foreground">
Keranjang kosong.
</p>
) : (
(() => {
const groupedByVariant: Record<string, { title: string; photoUrl: string | null; items: typeof cartItems }> = {};
for (const item of cartItems) {
const variantKey = item.key.split('-').slice(0, 2).join('-');
if (!groupedByVariant[variantKey]) {
groupedByVariant[variantKey] = {
title: item.title,
photoUrl: item.photoUrl,
items: [],
};
}
groupedByVariant[variantKey].items.push(item);
}
return Object.entries(groupedByVariant).map(([variantKey, group]) => (
<div key={variantKey} className="rounded-lg border p-3 space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
{group.photoUrl ? (
<div className="h-10 w-10 shrink-0 overflow-hidden rounded-md border">
<img
src={group.photoUrl}
alt={group.title}
className="h-full w-full object-cover"
/>
</div>
) : (
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-md bg-muted text-xs text-muted-foreground">
N/A
</div>
)}
<p className="font-medium">{group.title}</p>
</div>
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={() => {
for (const item of group.items) {
item.onRemove();
}
}}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
<div className="space-y-2">
{group.items.map((item) => {
const stockType = item.key.split('-').pop();
const typeLabel = stockType === 'good' ? 'Bagus' : stockType === 'reject' ? 'Reject' : 'Ecer';
return (
<div key={item.key} className="flex items-center justify-between gap-2">
<span className="text-xs text-muted-foreground w-14">{typeLabel}</span>
<div className="flex items-center gap-1">
<Button
type="button"
variant="outline"
size="icon-sm"
disabled={item.quantity <= 0}
onClick={() => item.onAdjust(-1)}
>
<Minus className="h-3 w-3" />
</Button>
<NumberInput
className="w-16 text-center text-xs"
value={item.quantity}
onValueChange={item.onSet}
/>
<Button
type="button"
variant="outline"
size="icon-sm"
onClick={() => item.onAdjust(1)}
>
<Plus className="h-3 w-3" />
</Button>
</div>
<span className="font-medium text-xs">
{formatQuantity(item.quantity)} pcs
</span>
</div>
);
})}
</div>
</div>
));
})()
)}
</div>
<SheetFooter>
<div className="flex items-center justify-between border-t pt-4">
<span className="text-sm">Total Item</span>
<span className="text-sm font-semibold">
{cartItems.length}
</span>
</div>
</SheetFooter>
</SheetContent>
</Sheet>
</div>
</>
);
}

View File

@ -0,0 +1,611 @@
'use no memo';
import { Form, Head, Link, usePage } from '@inertiajs/react';
import { ArrowLeft, Minus, Plus, ShoppingCart, Trash2 } from 'lucide-react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { toast } from 'sonner';
import { InputError } from '@/components/ui';
import { NumberInput } from '@/components/inputs';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxList,
} from '@/components/ui/combobox';
import { Label } from '@/components/ui/label';
import {
Sheet,
SheetContent,
SheetFooter,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet';
import { Textarea } from '@/components/ui/textarea';
import { useStokOpnameDraftSave } from '@/hooks/use-stok-opname-draft';
import { formatNumber } from '@/lib/format';
import { loadStokOpnameDraft } from '@/lib/stok-opname-draft';
import { index as stokOpnameIndex, update } from '@/routes/admin/manage/stok-opnames';
import type { ProductForStokOpname, StokOpnameForEdit } from './columns';
type StockType = 'good' | 'reject' | 'retail';
type CartLine = {
key: string;
photoUrl: string | null;
title: string;
subtitle: string;
quantity: number;
onAdjust: (delta: number) => void;
onSet: (value: number) => void;
onRemove: () => void;
};
type Props = {
stokOpname: StokOpnameForEdit;
products: ProductForStokOpname[];
};
export default function StokOpnameEdit({ stokOpname, products }: Props) {
const { auth } = usePage().props as { auth: { user?: { id?: number } } };
const userId = auth.user?.id;
const draft = loadStokOpnameDraft('edit', userId);
const [selectedProductId, setSelectedProductId] = useState(
draft?.selectedProductId ?? '',
);
const [physicalStocks, setPhysicalStocks] = useState<Record<string, Record<StockType, number>>>(() => {
if (draft?.physicalStocks && Object.keys(draft.physicalStocks).length > 0) {
return draft.physicalStocks;
}
const initial: Record<string, Record<StockType, number>> = {};
for (const item of stokOpname.items) {
const variantId = String(item.product_variant_id);
if (!initial[variantId]) {
initial[variantId] = { good: 0, reject: 0, retail: 0 };
}
initial[variantId][item.stock_quality] = item.physical_stock;
}
return initial;
});
const [notes, setNotes] = useState(draft?.notes ?? stokOpname.notes ?? '');
const [cartOpen, setCartOpen] = useState(false);
const draftData = useMemo(
() => ({
selectedProductId,
physicalStocks,
notes,
}),
[selectedProductId, physicalStocks, notes],
);
useStokOpnameDraftSave('edit', draftData, userId);
const physicalStocksRef = useRef(physicalStocks);
useEffect(() => {
physicalStocksRef.current = physicalStocks;
}, [physicalStocks]);
const selectedProduct = useMemo(
() => products.find((p) => String(p.id) === selectedProductId) ?? null,
[products, selectedProductId],
);
const variantById = useMemo(
() =>
new Map(
products.flatMap((p: ProductForStokOpname) =>
p.product_variants.map((v) => [v.id, v]),
),
),
[products],
);
const productByVariantId = useMemo(
() =>
new Map(
products.flatMap((p: ProductForStokOpname) =>
p.product_variants.map((v) => [v.id, p.name]),
),
),
[products],
);
const updatePhysicalStock = useCallback((variantId: number, stockType: StockType, value: number) => {
setPhysicalStocks((prev) => ({
...prev,
[variantId]: {
...prev[variantId],
[stockType]: Math.max(0, value),
},
}));
}, []);
const hasAnyStock = useCallback((variantId: number) => {
const stocks = physicalStocks[variantId];
if (!stocks) return false;
return stocks.good > 0 || stocks.reject > 0 || stocks.retail > 0;
}, [physicalStocks]);
const cartItems: CartLine[] = (() => {
const lines: CartLine[] = [];
for (const [variantId, stocks] of Object.entries(physicalStocks)) {
const id = Number(variantId);
const variant = variantById.get(id);
if (!variant) continue;
for (const [stockType, quantity] of Object.entries(stocks)) {
if (quantity <= 0) continue;
const type = stockType as StockType;
const typeLabel = type === 'good' ? 'Bagus' : type === 'reject' ? 'Reject' : 'Ecer';
lines.push({
key: `variant-${id}-${type}`,
photoUrl: variant.photo_url,
title: `${productByVariantId.get(id) ?? ''}${variant.name}`,
subtitle: `${typeLabel}: ${formatNumber(quantity)}`,
quantity,
onAdjust: (delta) => updatePhysicalStock(id, type, quantity + delta),
onSet: (value) => updatePhysicalStock(id, type, value),
onRemove: () => updatePhysicalStock(id, type, 0),
});
}
}
return lines.sort((a, b) => a.title.localeCompare(b.title));
})();
function formatQuantity(value: number): string {
return formatNumber(value, { maximumFractionDigits: 4 });
}
function getPayload() {
const items: Array<{ product_variant_id: number; stock_quality: StockType; physical_stock: number }> = [];
for (const [variantId, stocks] of Object.entries(physicalStocksRef.current)) {
const variant = variantById.get(Number(variantId));
if (!variant) continue;
for (const [stockType, quantity] of Object.entries(stocks)) {
if (quantity <= 0) continue;
items.push({
product_variant_id: Number(variantId),
stock_quality: stockType as StockType,
physical_stock: quantity,
});
}
}
return {
opname_date: stokOpname.opname_date,
items,
notes: notes || null,
};
}
return (
<>
<Head title="Edit Stok Opname" />
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
<div className="flex items-center justify-between">
<h2 className="text-2xl font-semibold tracking-tight">
Edit Stok Opname
</h2>
<Button asChild variant="outline">
<Link href={stokOpnameIndex.url()}>
<ArrowLeft className="h-4 w-4" />
Kembali
</Link>
</Button>
</div>
<Form
action={update(stokOpname.id)}
transform={(formData) => ({
...formData,
...getPayload(),
})}
onError={() => {
toast.error('Ada data yang belum sesuai, silakan periksa kembali input Anda.');
}}
>
{({ errors, processing }) => (
<div className="grid gap-6 md:grid-cols-3">
<div className="space-y-6 md:col-span-2">
<Card>
<CardHeader>
<CardTitle>Pilih Produk</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-2">
<Label>
Nama Produk{' '}
<span className="text-destructive">
*
</span>
</Label>
<Combobox
items={products}
itemToStringLabel={(p) =>
p.name
}
value={selectedProduct}
onValueChange={(value) =>
setSelectedProductId(
value
? String(value.id)
: '',
)
}
>
<ComboboxInput
placeholder="Cari produk..."
className="w-full"
/>
<ComboboxContent>
<ComboboxEmpty>
Tidak ada produk
ditemukan.
</ComboboxEmpty>
<ComboboxList>
{(p) => (
<ComboboxItem
key={p.id}
value={p}
>
{p.name}
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
<InputError
message={errors.items}
/>
</div>
{selectedProduct && (
<div className="space-y-4">
{selectedProduct.product_variants.map(
(variant) => {
const isActive = hasAnyStock(variant.id);
return (
<div
key={variant.id}
className={`rounded-lg border p-4 ${isActive ? 'border-primary bg-primary/5' : ''}`}
>
<div className="flex items-start gap-3">
{variant.photo_url ? (
<img
src={variant.photo_url}
alt={variant.name}
className="h-12 w-12 shrink-0 rounded-md object-cover"
/>
) : (
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-md bg-muted text-xs text-muted-foreground">
N/A
</div>
)}
<div className="min-w-0 flex-1">
<p className="font-medium">
{variant.name}
</p>
<div className="mt-2 flex gap-3">
<div className="flex-1 space-y-1 rounded-md border p-2">
<p className="text-xs text-muted-foreground">
Bagus: <span className="font-medium text-foreground">{formatQuantity(variant.stock)}</span>
</p>
<div className="flex items-center gap-1">
<Button
type="button"
variant="outline"
size="icon"
disabled={!physicalStocks[variant.id]?.good}
onClick={() =>
updatePhysicalStock(variant.id, 'good', (physicalStocks[variant.id]?.good ?? 0) - 1)
}
>
<Minus className="h-3 w-3" />
</Button>
<NumberInput
className="flex-1 text-center text-xs"
value={physicalStocks[variant.id]?.good ?? 0}
onValueChange={(val) =>
updatePhysicalStock(variant.id, 'good', val)
}
/>
<Button
type="button"
variant="outline"
size="icon"
onClick={() =>
updatePhysicalStock(variant.id, 'good', (physicalStocks[variant.id]?.good ?? 0) + 1)
}
>
<Plus className="h-3 w-3" />
</Button>
</div>
</div>
<div className="flex-1 space-y-1 rounded-md border p-2">
<p className="text-xs text-muted-foreground">
Reject: <span className="font-medium text-foreground">{formatQuantity(variant.reject_stock)}</span>
</p>
<div className="flex items-center gap-1">
<Button
type="button"
variant="outline"
size="icon"
disabled={!physicalStocks[variant.id]?.reject}
onClick={() =>
updatePhysicalStock(variant.id, 'reject', (physicalStocks[variant.id]?.reject ?? 0) - 1)
}
>
<Minus className="h-3 w-3" />
</Button>
<NumberInput
className="flex-1 text-center text-xs"
value={physicalStocks[variant.id]?.reject ?? 0}
onValueChange={(val) =>
updatePhysicalStock(variant.id, 'reject', val)
}
/>
<Button
type="button"
variant="outline"
size="icon"
onClick={() =>
updatePhysicalStock(variant.id, 'reject', (physicalStocks[variant.id]?.reject ?? 0) + 1)
}
>
<Plus className="h-3 w-3" />
</Button>
</div>
</div>
<div className="flex-1 space-y-1 rounded-md border p-2">
<p className="text-xs text-muted-foreground">
Ecer: <span className="font-medium text-foreground">{formatQuantity(variant.retail_stock)}</span>
</p>
<div className="flex items-center gap-1">
<Button
type="button"
variant="outline"
size="icon"
disabled={!physicalStocks[variant.id]?.retail}
onClick={() =>
updatePhysicalStock(variant.id, 'retail', (physicalStocks[variant.id]?.retail ?? 0) - 1)
}
>
<Minus className="h-3 w-3" />
</Button>
<NumberInput
className="flex-1 text-center text-xs"
value={physicalStocks[variant.id]?.retail ?? 0}
onValueChange={(val) =>
updatePhysicalStock(variant.id, 'retail', val)
}
/>
<Button
type="button"
variant="outline"
size="icon"
onClick={() =>
updatePhysicalStock(variant.id, 'retail', (physicalStocks[variant.id]?.retail ?? 0) + 1)
}
>
<Plus className="h-3 w-3" />
</Button>
</div>
</div>
</div>
</div>
</div>
</div>
);
},
)}
</div>
)}
</CardContent>
</Card>
</div>
<div className="space-y-6 md:col-span-1">
<Card className="sticky top-6">
<CardHeader>
<CardTitle>Ringkasan</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">
Jumlah Item
</span>
<span className="font-medium">
{cartItems.length}
</span>
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="notes">
Keterangan
</Label>
<Textarea
id="notes"
value={notes}
onChange={(e) =>
setNotes(e.target.value)
}
placeholder="Masukkan keterangan"
maxLength={100}
/>
<InputError
message={errors.notes}
/>
</div>
<Button
type="submit"
className="w-full"
disabled={
processing ||
cartItems.length === 0
}
>
{processing
? 'Menyimpan...'
: 'Simpan'}
</Button>
</CardContent>
</Card>
</div>
</div>
)}
</Form>
<Button
type="button"
onClick={() => setCartOpen(true)}
className="fixed top-1/2 right-4 z-50 h-14 w-14 -translate-y-1/2 rounded-full shadow-lg"
size="icon"
aria-label="Buka keranjang stok opname"
>
<ShoppingCart className="h-5 w-5" />
{cartItems.length > 0 && (
<span className="absolute -top-1 -right-1 flex h-5 min-w-5 items-center justify-center rounded-full bg-destructive px-1 text-xs font-semibold text-white">
{cartItems.length}
</span>
)}
</Button>
<Sheet open={cartOpen} onOpenChange={setCartOpen}>
<SheetContent side="right" className="w-full sm:max-w-md">
<SheetHeader>
<SheetTitle>Keranjang Stok Opname</SheetTitle>
</SheetHeader>
<div className="flex-1 space-y-4 overflow-y-auto px-6 pb-6">
{cartItems.length === 0 ? (
<p className="text-sm text-muted-foreground">
Keranjang kosong.
</p>
) : (
(() => {
const groupedByVariant: Record<string, { title: string; photoUrl: string | null; items: typeof cartItems }> = {};
for (const item of cartItems) {
const variantKey = item.key.split('-').slice(0, 2).join('-');
if (!groupedByVariant[variantKey]) {
groupedByVariant[variantKey] = {
title: item.title,
photoUrl: item.photoUrl,
items: [],
};
}
groupedByVariant[variantKey].items.push(item);
}
return Object.entries(groupedByVariant).map(([variantKey, group]) => (
<div key={variantKey} className="rounded-lg border p-3 space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
{group.photoUrl ? (
<div className="h-10 w-10 shrink-0 overflow-hidden rounded-md border">
<img
src={group.photoUrl}
alt={group.title}
className="h-full w-full object-cover"
/>
</div>
) : (
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-md bg-muted text-xs text-muted-foreground">
N/A
</div>
)}
<p className="font-medium">{group.title}</p>
</div>
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={() => {
for (const item of group.items) {
item.onRemove();
}
}}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
<div className="space-y-2">
{group.items.map((item) => {
const stockType = item.key.split('-').pop();
const typeLabel = stockType === 'good' ? 'Bagus' : stockType === 'reject' ? 'Reject' : 'Ecer';
return (
<div key={item.key} className="flex items-center justify-between gap-2">
<span className="text-xs text-muted-foreground w-14">{typeLabel}</span>
<div className="flex items-center gap-1">
<Button
type="button"
variant="outline"
size="icon-sm"
disabled={item.quantity <= 0}
onClick={() => item.onAdjust(-1)}
>
<Minus className="h-3 w-3" />
</Button>
<NumberInput
className="w-16 text-center text-xs"
value={item.quantity}
onValueChange={item.onSet}
/>
<Button
type="button"
variant="outline"
size="icon-sm"
onClick={() => item.onAdjust(1)}
>
<Plus className="h-3 w-3" />
</Button>
</div>
<span className="font-medium text-xs">
{formatQuantity(item.quantity)} pcs
</span>
</div>
);
})}
</div>
</div>
));
})()
)}
</div>
<SheetFooter>
<div className="flex items-center justify-between border-t pt-4">
<span className="text-sm">Total Item</span>
<span className="text-sm font-semibold">
{cartItems.length}
</span>
</div>
</SheetFooter>
</SheetContent>
</Sheet>
</div>
</>
);
}

View File

@ -0,0 +1,229 @@
import { Head, Link, router } from '@inertiajs/react';
import { ClipboardCheck, Plus, Send, XCircle } from 'lucide-react';
import { useCallback, useState } from 'react';
import { CardTable } from '@/components/data-display';
import { DeleteConfirmDialog } from '@/components/dialogs';
import { useCardTableExpand } from '@/components/hooks/use-card-table-expand';
import { PageHeader } from '@/components/layout';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { useCan } from '@/hooks/use-can';
import { useServerTable } from '@/hooks/use-server-table';
import {
destroy,
create as stokOpnameCreate,
index as stokOpnameIndex,
edit as stokOpnameEdit,
items as stokOpnameItems,
submit as stokOpnameSubmit,
verify as stokOpnameVerify,
reject as stokOpnameReject,
cancel as stokOpnameCancel,
} from '@/routes/admin/manage/stok-opnames';
import type { StokOpname, StokOpnameItem, StokOpnameStatus } from './columns';
import { StokOpnameCardRow } from './stok-opname-card';
import { StokOpnameItemSubRow } from './stok-opname-sub-row';
type Props = {
stokOpnames: {
data: StokOpname[];
current_page: number;
last_page: number;
per_page: number;
total: number;
};
highlight?: number;
};
const STATUS_CONFIG: Record<StokOpnameStatus, { label: string; className: string }> = {
draft: { label: 'Draft', className: 'bg-gray-100 text-gray-800 hover:bg-gray-100' },
in_progress: { label: 'Diajukan', className: 'bg-blue-100 text-blue-800 hover:bg-blue-100' },
completed: { label: 'Selesai', className: 'bg-yellow-100 text-yellow-800 hover:bg-yellow-100' },
verified: { label: 'Terverifikasi', className: 'bg-green-100 text-green-800 hover:bg-green-100' },
cancelled: { label: 'Dibatalkan', className: 'bg-red-100 text-red-800 hover:bg-red-100' },
};
export default function StokOpnameIndex({ stokOpnames, highlight }: Props) {
const { can } = useCan();
const [deleting, setDeleting] = useState<StokOpname | null>(null);
const [loadedItems, setLoadedItems] = useState<Record<number, StokOpnameItem[]>>({});
const [loadingItems, setLoadingItems] = useState<Record<number, boolean>>({});
const expand = useCardTableExpand(false);
const pagination = {
current_page: stokOpnames.current_page,
last_page: stokOpnames.last_page,
per_page: stokOpnames.per_page,
total: stokOpnames.total,
};
const {
search,
handlePageChange,
handlePerPageChange,
handleSearchChange,
} = useServerTable({
route: () => stokOpnameIndex.url(),
pagination,
});
const fetchItems = useCallback((stokOpname: StokOpname) => {
if (loadedItems[stokOpname.id] || loadingItems[stokOpname.id]) {
return;
}
setLoadingItems((prev) => ({ ...prev, [stokOpname.id]: true }));
fetch(stokOpnameItems.url(stokOpname.id))
.then((res) => res.json())
.then((data) => {
setLoadedItems((prev) => ({
...prev,
[stokOpname.id]: data.items ?? [],
}));
})
.catch(() => {
setLoadedItems((prev) => ({ ...prev, [stokOpname.id]: [] }));
})
.finally(() => {
setLoadingItems((prev) => ({ ...prev, [stokOpname.id]: false }));
});
}, [loadedItems, loadingItems]);
function handleDelete() {
if (!deleting) {
return;
}
router.delete(destroy.url(deleting.id), {
onSuccess: () => setDeleting(null),
});
}
function handleSubmit(stokOpname: StokOpname) {
router.patch(stokOpnameSubmit.url(stokOpname.id));
}
function handleVerify(stokOpname: StokOpname) {
router.patch(stokOpnameVerify.url(stokOpname.id), {
data: { verification_notes: '' },
});
}
function handleReject(stokOpname: StokOpname) {
router.patch(stokOpnameReject.url(stokOpname.id));
}
function handleCancel(stokOpname: StokOpname) {
router.patch(stokOpnameCancel.url(stokOpname.id));
}
return (
<>
<Head title="Stok Opname" />
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
<PageHeader
title="Stok Opname"
description={
highlight && (
<p className="mt-1 text-sm text-muted-foreground">
Menampilkan stok opname dari notifikasi.
<button
onClick={() => {
router.get(
stokOpnameIndex.url(),
{},
{
replace: true,
preserveState: true,
},
);
}}
className="ml-1 text-primary underline underline-offset-4 hover:text-primary/80"
>
Tampilkan semua
</button>
</p>
)
}
actions={
can('stok_opnames.create') ? (
<Button asChild>
<Link href={stokOpnameCreate.url()}>
<Plus className="h-4 w-4" />
Tambah
</Link>
</Button>
) : undefined
}
/>
<CardTable
data={stokOpnames.data}
getItemKey={(s) => s.id}
expandedKeys={expand.expandedKeys}
onToggleExpand={(key) => {
const s = stokOpnames.data.find((item) => item.id === key);
const isCurrentlyExpanded = expand.expandedKeys === 'all' || expand.expandedKeys.has(key);
if (s && !isCurrentlyExpanded) {
fetchItems(s);
}
expand.toggleExpand(key);
}}
searchValue={search}
onSearchChange={handleSearchChange}
pagination={pagination}
onPageChange={handlePageChange}
onPerPageChange={handlePerPageChange}
renderCard={({
item,
index,
isExpanded,
onToggleExpand,
}) => (
<StokOpnameCardRow
stokOpname={item}
index={
(pagination.current_page - 1) *
pagination.per_page +
index +
1
}
isExpanded={isExpanded}
onToggleExpand={onToggleExpand}
onEdit={(s) => {
router.visit(stokOpnameEdit.url(s.id));
}}
onDelete={(s) => setDeleting(s)}
onSubmit={handleSubmit}
onVerify={handleVerify}
onReject={handleReject}
onCancel={handleCancel}
/>
)}
renderSubContent={(stokOpname) => (
<StokOpnameItemSubRow
stokOpname={stokOpname}
items={loadedItems[stokOpname.id] ?? []}
isLoading={loadingItems[stokOpname.id] ?? false}
/>
)}
/>
<DeleteConfirmDialog
target={deleting}
onOpenChange={(open) => {
if (!open) {
setDeleting(null);
}
}}
title="Hapus Stok Opname"
description="Apakah Anda yakin ingin menghapus stok opname ini? Tindakan ini tidak dapat dibatalkan."
onConfirm={handleDelete}
/>
</div>
</>
);
}

View File

@ -0,0 +1,188 @@
import { ClipboardCheck, ChevronDown, Pencil, Send, Trash2, XCircle } from 'lucide-react';
import { RowActions } from '@/components/data-display';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { useCan } from '@/hooks/use-can';
import { formatDateTime, formatNumber } from '@/lib/format';
import type { StokOpname, StokOpnameStatus } from './columns';
const STATUS_CONFIG: Record<StokOpnameStatus, { label: string; className: string }> = {
draft: { label: 'Draft', className: 'bg-gray-100 text-gray-800 hover:bg-gray-100' },
in_progress: { label: 'Diajukan', className: 'bg-blue-100 text-blue-800 hover:bg-blue-100' },
completed: { label: 'Selesai', className: 'bg-yellow-100 text-yellow-800 hover:bg-yellow-100' },
verified: { label: 'Terverifikasi', className: 'bg-green-100 text-green-800 hover:bg-green-100' },
cancelled: { label: 'Dibatalkan', className: 'bg-red-100 text-red-800 hover:bg-red-100' },
};
export type StokOpnameCardRowParams = {
stokOpname: StokOpname;
index: number;
isExpanded: boolean;
onToggleExpand: () => void;
onEdit: (stokOpname: StokOpname) => void;
onDelete: (stokOpname: StokOpname) => void;
onSubmit: (stokOpname: StokOpname) => void;
onVerify: (stokOpname: StokOpname) => void;
onReject: (stokOpname: StokOpname) => void;
onCancel: (stokOpname: StokOpname) => void;
};
export function StokOpnameCardRow({
stokOpname,
index,
isExpanded,
onToggleExpand,
onEdit,
onDelete,
onSubmit,
onVerify,
onReject,
onCancel,
}: StokOpnameCardRowParams) {
const { can } = useCan();
const variantCount = stokOpname.items_count ?? 0;
const totalDifference = stokOpname.total_difference ?? 0;
const productNamesStr = stokOpname.product_names ?? '';
const productNames = productNamesStr ? productNamesStr.split(', ') : [];
const statusConfig = STATUS_CONFIG[stokOpname.status] ?? STATUS_CONFIG.draft;
return (
<Card className="overflow-hidden">
<CardContent className="p-0">
<div className="flex items-start gap-3 p-4">
<Button
variant="ghost"
size="icon"
className="mt-0.5 h-6 w-6 shrink-0"
onClick={onToggleExpand}
>
<ChevronDown
className={`h-4 w-4 transition-transform ${isExpanded ? 'rotate-180' : ''}`}
/>
</Button>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="text-xs text-muted-foreground">
{index}.
</span>
<h3>
{productNames.length > 0
? productNames.join(', ')
: '-'}
</h3>
<Badge
variant="secondary"
className={statusConfig.className}
>
{statusConfig.label}
</Badge>
</div>
<div className="mt-1 text-xs text-muted-foreground">
{variantCount > 0 && (
<span>({variantCount} varian)</span>
)}
</div>
<div className="mt-2 flex flex-wrap items-center gap-3 text-xs text-muted-foreground">
<span className="inline-flex items-center rounded-md bg-muted px-2 py-1 font-medium text-foreground">
{formatDateTime(stokOpname.opname_date)}
</span>
<span className="text-muted-foreground">
Oleh:{' '}
<span className="font-medium text-foreground">
{stokOpname.created_by?.user_profile
?.full_name ?? '-'}
</span>
</span>
{stokOpname.notes && (
<span className="max-w-[200px] truncate">
{stokOpname.notes}
</span>
)}
</div>
<div className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs">
<span>
<span className="text-muted-foreground">
Total Selisih:{' '}
</span>
<span className={totalDifference > 0 ? 'text-green-600 font-medium' : totalDifference < 0 ? 'text-red-600 font-medium' : ''}>
{totalDifference > 0 ? '+' : ''}{formatNumber(totalDifference)}
</span>
</span>
{stokOpname.verified_by && (
<span className="text-muted-foreground">
Diverifikasi oleh:{' '}
<span className="font-medium text-foreground">
{stokOpname.verified_by?.user_profile
?.full_name ?? '-'}
</span>
</span>
)}
</div>
</div>
<div className="flex shrink-0 items-center gap-1">
{can('stok_opnames.update') && stokOpname.status === 'draft' && (
<RowActions
actions={[
{
label: 'Edit',
icon: <Pencil className="h-4 w-4" />,
show: true,
onClick: () => onEdit(stokOpname),
},
{
label: 'Submit',
icon: <Send className="h-4 w-4" />,
show: can('stok_opnames.submit'),
onClick: () => onSubmit(stokOpname),
},
{
label: 'Hapus',
icon: <Trash2 className="h-4 w-4 text-destructive" />,
show: can('stok_opnames.delete'),
onClick: () => onDelete(stokOpname),
},
]}
/>
)}
{can('stok_opnames.submit') && stokOpname.status === 'in_progress' && (
<RowActions
actions={[
{
label: 'Batal',
icon: <XCircle className="h-4 w-4 text-destructive" />,
show: true,
onClick: () => onCancel(stokOpname),
},
]}
/>
)}
{can('stok_opnames.verify') && stokOpname.status === 'in_progress' && (
<RowActions
actions={[
{
label: 'Verifikasi',
icon: <ClipboardCheck className="h-4 w-4" />,
show: true,
onClick: () => onVerify(stokOpname),
},
{
label: 'Tolak',
icon: <XCircle className="h-4 w-4 text-destructive" />,
show: true,
onClick: () => onReject(stokOpname),
},
]}
/>
)}
</div>
</div>
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,171 @@
import { Fragment } from 'react';
import { ImagePreviewButton } from '@/components/dialogs';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { formatNumber } from '@/lib/format';
import type { StokOpname, StokOpnameItem } from './columns';
const STOCK_QUALITY_CONFIG: Record<string, { label: string; className: string }> = {
good: { label: 'Bagus', className: 'bg-green-100 text-green-800' },
reject: { label: 'Reject', className: 'bg-red-100 text-red-800' },
retail: { label: 'Ecer', className: 'bg-blue-100 text-blue-800' },
};
export function StokOpnameItemSubRow({
stokOpname,
items: loadedItems,
isLoading,
}: {
stokOpname: StokOpname;
items: StokOpnameItem[];
isLoading: boolean;
}) {
const items = loadedItems ?? [];
const groupedByProduct = items.reduce(
(acc, item) => {
const productName = item.product_name ?? 'Tanpa Produk';
if (!acc[productName]) {
acc[productName] = {};
}
const variantName = item.variant_name ?? '-';
if (!acc[productName][variantName]) {
acc[productName][variantName] = [];
}
acc[productName][variantName].push(item);
return acc;
},
{} as Record<string, Record<string, typeof items>>,
);
let counter = 0;
return (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[50px] text-center">
No
</TableHead>
<TableHead className="w-[60px]">Foto</TableHead>
<TableHead>Varian</TableHead>
<TableHead className="text-center">Jenis</TableHead>
<TableHead className="text-right">Stok Sistem</TableHead>
<TableHead className="text-right">Stok Fisik</TableHead>
<TableHead className="text-right">Selisih</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell
colSpan={7}
className="text-center text-muted-foreground"
>
Memuat item...
</TableCell>
</TableRow>
) : items.length === 0 ? (
<TableRow>
<TableCell
colSpan={7}
className="text-center text-muted-foreground"
>
Tidak ada item.
</TableCell>
</TableRow>
) : (
Object.entries(groupedByProduct).map(
([productName, variants]) => {
const variantEntries = Object.entries(variants);
return (
<Fragment key={productName}>
<TableRow>
<TableCell
colSpan={7}
className="font-semibold text-muted-foreground bg-muted/30"
>
{productName}
</TableCell>
</TableRow>
{variantEntries.map(([variantName, variantItems]) => {
const firstItem = variantItems[0];
return (
<Fragment key={`${productName}-${variantName}`}>
<TableRow>
<TableCell
colSpan={7}
className="font-medium text-muted-foreground"
>
<div className="flex items-center gap-2">
{firstItem?.photo_url ? (
<ImagePreviewButton
srcs={[
firstItem.photo_conversion_url ?? firstItem.photo_url,
]}
modalSrc={firstItem.photo_url}
title={variantName}
/>
) : (
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded bg-muted text-[10px] text-muted-foreground">
N/A
</div>
)}
{variantName}
</div>
</TableCell>
</TableRow>
{variantItems.map((item) => {
counter++;
const qualityConfig = STOCK_QUALITY_CONFIG[item.stock_quality] ?? STOCK_QUALITY_CONFIG.good;
return (
<TableRow key={item.id}>
<TableCell className="text-center">
{counter}
</TableCell>
<TableCell />
<TableCell />
<TableCell className="text-center">
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${qualityConfig.className}`}>
{qualityConfig.label}
</span>
</TableCell>
<TableCell className="text-right">
{formatNumber(item.system_stock)}
</TableCell>
<TableCell className="text-right font-medium">
{formatNumber(item.physical_stock)}
</TableCell>
<TableCell className={`text-right font-medium ${item.difference > 0 ? 'text-green-600' : item.difference < 0 ? 'text-red-600' : ''}`}>
{item.difference > 0 ? '+' : ''}{formatNumber(item.difference)}
</TableCell>
</TableRow>
);
})}
</Fragment>
);
})}
</Fragment>
);
},
)
)}
</TableBody>
</Table>
</div>
);
}

View File

@ -1,8 +1,8 @@
export type TransactionStockType = 'good' | 'reject';
export type TransactionStockType = 'good' | 'reject' | 'retail';
export type TransactionChannel = 'offline' | 'online' | 'whatsapp' | 'shopee' | 'tiktok';
export type TransactionPriceType = 'distributor' | 'agent' | 'sub_agent' | 'wholesale' | 'retail' | 'tiktok' | 'shopee';
export type TransactionPriceType = 'distributor' | 'agent' | 'sub_agent' | 'wholesale' | 'retail' | 'tiktok' | 'shopee' | 'reject_selling';
export type TransactionPaymentType = 'cash' | 'transfer' | 'marketplace' | 'qris';
@ -109,6 +109,7 @@ export type ProductForTransaction = {
name: string;
stock: number;
reject_stock: number;
retail_stock: number;
photo_url: string | null;
prices: Record<string, number>;
}[];

View File

@ -5,11 +5,11 @@ import { ArrowLeft, Minus, Plus, ShoppingCart, Trash2 } from 'lucide-react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { toast } from 'sonner';
import { ConfirmDialog } from '@/components/dialogs';
import { FileUpload } from '@/components/inputs';
import { ImagePreviewModal } from '@/components/dialogs';
import { InputError } from '@/components/ui';
import { FileUpload } from '@/components/inputs';
import { NumberInput } from '@/components/inputs';
import { RupiahInput } from '@/components/inputs';
import { InputError } from '@/components/ui';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
@ -39,6 +39,7 @@ import {
} from '@/components/ui/sheet';
import { Switch } from '@/components/ui/switch';
import { Textarea } from '@/components/ui/textarea';
import { useCan } from '@/hooks/use-can';
import { useTransactionDraftSave } from '@/hooks/use-transaction-draft';
import { formatNumber } from '@/lib/format';
import { loadTransactionDraft } from '@/lib/transaction-draft';
@ -68,7 +69,7 @@ type Props = {
priceTypeOptions: TransactionCreateData['priceTypeOptions'];
};
const SELLING_PRICE_TYPES = ['distributor', 'agent', 'sub_agent', 'wholesale', 'retail', 'tiktok', 'shopee'];
const SELLING_PRICE_TYPES = ['distributor', 'agent', 'sub_agent', 'wholesale', 'retail', 'tiktok', 'shopee', 'reject_selling'];
export default function TransactionCreate({
products,
@ -80,6 +81,8 @@ export default function TransactionCreate({
}: Props) {
const { auth } = usePage().props as { auth: { user?: { id?: number } } };
const userId = auth.user?.id;
const { hasRole } = useCan();
const isCashier = hasRole('cashier');
const draft = loadTransactionDraft('create', userId);
@ -87,7 +90,7 @@ export default function TransactionCreate({
draft?.stockType === 'reject' ? 'reject' : 'good',
);
const [channel, setChannel] = useState(draft?.channel ?? 'store');
const [priceType, setPriceType] = useState(draft?.priceType ?? 'retail');
const [priceType, setPriceType] = useState(draft?.priceType ?? (isCashier ? 'retail' : 'retail'));
const [paymentType, setPaymentType] = useState(draft?.paymentType ?? 'cash');
const [customerId, setCustomerId] = useState<number | null>(draft?.customerId ?? null);
const [marketingId, setMarketingId] = useState<number | null>(draft?.marketingId ?? null);
@ -169,6 +172,16 @@ export default function TransactionCreate({
[products],
);
const productByVariantId = useMemo(
() =>
new Map(
products.flatMap((p: ProductForTransaction) =>
p.product_variants.map((v) => [v.id, p.name]),
),
),
[products],
);
useEffect(() => {
if (channel === 'tiktok') {
setPriceType('tiktok');
@ -176,26 +189,34 @@ export default function TransactionCreate({
} else if (channel === 'shopee') {
setPriceType('shopee');
setPaymentType('marketplace');
} else if (isCashier) {
setPriceType('retail');
}
}, [channel]);
}, [channel, isCashier]);
useEffect(() => {
if (stockType === 'reject') {
setPriceType('reject');
} else if (priceType === 'reject') {
setPriceType('reject_selling');
} else if (isCashier) {
setPriceType('retail');
} else if (priceType === 'reject_selling') {
setPriceType('retail');
}
}, [stockType]);
}, [stockType, isCashier]);
const showPhoto = paymentType === 'transfer' || paymentType === 'qris';
const availablePriceTypes = useMemo(() => {
if (stockType === 'reject') {
return priceTypeOptions.filter((o) => o.value === 'reject');
return priceTypeOptions.filter((o) => o.value === 'reject_selling');
}
return priceTypeOptions.filter((o) => SELLING_PRICE_TYPES.includes(o.value));
}, [stockType, priceTypeOptions]);
if (isCashier) {
return priceTypeOptions.filter((o) => o.value === 'retail');
}
return priceTypeOptions.filter((o) => SELLING_PRICE_TYPES.includes(o.value) && o.value !== 'retail');
}, [stockType, priceTypeOptions, isCashier]);
const getUnitPrice = useCallback(
(variantId: number) => {
@ -206,12 +227,16 @@ export default function TransactionCreate({
}
if (stockType === 'reject') {
return variant.prices?.reject ?? 0;
return variant.prices?.reject_selling ?? 0;
}
if (isCashier) {
return variant.prices?.retail ?? 0;
}
return variant.prices?.[priceType] ?? 0;
},
[variantById, stockType, priceType],
[variantById, stockType, priceType, isCashier],
);
const subtotal = Object.entries(quantities).reduce(
@ -236,8 +261,10 @@ export default function TransactionCreate({
(variantId: number, amount: number) => {
if (amount > 0 && getUnitPrice(variantId) <= 0) {
toast.error('Harga produk ini belum diatur.');
return;
}
setQuantities((prev) => ({
...prev,
[variantId]: Math.max(0, (prev[variantId] ?? 0) + amount),
@ -259,10 +286,11 @@ export default function TransactionCreate({
if (variant) {
const unitPrice = getUnitPrice(id);
const productName = productByVariantId.get(id) ?? '';
lines.push({
key: `variant-${id}`,
photoUrl: variant.photo_url,
title: variant.name,
title: `${productName}${variant.name}`,
subtitle: `${formatCurrency(unitPrice)} / pcs`,
price: unitPrice,
quantity,
@ -282,9 +310,9 @@ export default function TransactionCreate({
function getPayload() {
return {
stock_type: stockType,
stock_type: stockType === 'good' && isCashier ? 'retail' : stockType,
channel,
price_type: stockType === 'reject' ? 'reject' : priceType,
price_type: stockType === 'reject' ? 'reject_selling' : (isCashier ? 'retail' : priceType),
payment_type: paymentType,
customer_id: customerId,
marketing_id: marketingId,
@ -391,9 +419,9 @@ export default function TransactionCreate({
{selectedProduct.product_variants.map(
(variant) => {
const currentStock =
stockType === 'good'
? variant.stock
: variant.reject_stock;
stockType === 'reject'
? variant.reject_stock
: (isCashier ? variant.retail_stock : variant.stock);
return (
<div
@ -443,8 +471,8 @@ export default function TransactionCreate({
) : (
formatCurrency(
stockType === 'reject'
? (variant.prices?.reject ?? 0)
: (variant.prices?.[priceType] ?? 0),
? (variant.prices?.reject_selling ?? 0)
: (isCashier ? (variant.prices?.retail ?? 0) : (variant.prices?.[priceType] ?? 0)),
)
)}
</p>
@ -629,33 +657,35 @@ export default function TransactionCreate({
</div>
)}
<div className="grid gap-2">
<Label>Tipe Harga <span className="text-destructive">*</span></Label>
<Select
value={priceType}
onValueChange={setPriceType}
disabled={channel === 'tiktok' || channel === 'shopee'}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Pilih tipe harga" />
</SelectTrigger>
<SelectContent>
{availablePriceTypes.map(
(opt) => (
<SelectItem
key={opt.value}
value={opt.value}
>
{opt.label}
</SelectItem>
),
)}
</SelectContent>
</Select>
<InputError
message={errors.price_type}
/>
</div>
{!isCashier && (
<div className="grid gap-2">
<Label>Tipe Harga <span className="text-destructive">*</span></Label>
<Select
value={priceType}
onValueChange={setPriceType}
disabled={channel === 'tiktok' || channel === 'shopee'}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Pilih tipe harga" />
</SelectTrigger>
<SelectContent>
{availablePriceTypes.map(
(opt) => (
<SelectItem
key={opt.value}
value={opt.value}
>
{opt.label}
</SelectItem>
),
)}
</SelectContent>
</Select>
<InputError
message={errors.price_type}
/>
</div>
)}
<div className="grid gap-2">
<Label>Tipe Pembayaran <span className="text-destructive">*</span></Label>

View File

@ -5,11 +5,11 @@ import { ArrowLeft, Minus, Plus, ShoppingCart, Trash2 } from 'lucide-react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { toast } from 'sonner';
import { ConfirmDialog } from '@/components/dialogs';
import { FileUpload } from '@/components/inputs';
import { ImagePreviewModal } from '@/components/dialogs';
import { InputError } from '@/components/ui';
import { FileUpload } from '@/components/inputs';
import { NumberInput } from '@/components/inputs';
import { RupiahInput } from '@/components/inputs';
import { InputError } from '@/components/ui';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
@ -39,6 +39,7 @@ import {
} from '@/components/ui/sheet';
import { Switch } from '@/components/ui/switch';
import { Textarea } from '@/components/ui/textarea';
import { useCan } from '@/hooks/use-can';
import { formatNumber } from '@/lib/format';
import { getTemporaryUrl } from '@/lib/upload';
import { formatCurrency } from '@/lib/utils';
@ -67,7 +68,7 @@ type Props = {
priceTypeOptions: TransactionCreateData['priceTypeOptions'];
};
const SELLING_PRICE_TYPES = ['distributor', 'agent', 'sub_agent', 'wholesale', 'retail', 'tiktok', 'shopee'];
const SELLING_PRICE_TYPES = ['distributor', 'agent', 'sub_agent', 'wholesale', 'retail', 'tiktok', 'shopee', 'reject_selling'];
export default function TransactionEdit({
transaction,
@ -79,6 +80,9 @@ export default function TransactionEdit({
priceTypeOptions,
}: Props) {
const { hasRole } = useCan();
const isCashier = hasRole('cashier');
const [stockType, setStockType] = useState<'good' | 'reject'>(
transaction.stock_type === 'reject' ? 'reject' : 'good',
);
@ -129,16 +133,20 @@ export default function TransactionEdit({
} else if (channel === 'shopee') {
setPriceType('shopee');
setPaymentType('marketplace');
} else if (isCashier) {
setPriceType('retail');
}
}, [channel]);
}, [channel, isCashier]);
useEffect(() => {
if (stockType === 'reject') {
setPriceType('reject');
} else if (priceType === 'reject') {
setPriceType('reject_selling');
} else if (isCashier) {
setPriceType('retail');
} else if (priceType === 'reject_selling') {
setPriceType('retail');
}
}, [stockType]);
}, [stockType, isCashier]);
const [tiktokOrderId, setTiktokOrderId] = useState(transaction.tiktok_order_id ?? '');
const [shopeeOrderId, setShopeeOrderId] = useState(transaction.shopee_order_id ?? '');
@ -158,15 +166,29 @@ export default function TransactionEdit({
[products],
);
const productByVariantId = useMemo(
() =>
new Map(
products.flatMap((p) =>
p.product_variants.map((v) => [v.id, p.name]),
),
),
[products],
);
const showPhoto = paymentType === 'transfer' || paymentType === 'qris';
const availablePriceTypes = useMemo(() => {
if (stockType === 'reject') {
return priceTypeOptions.filter((o) => o.value === 'reject');
return priceTypeOptions.filter((o) => o.value === 'reject_selling');
}
return priceTypeOptions.filter((o) => SELLING_PRICE_TYPES.includes(o.value));
}, [stockType, priceTypeOptions]);
if (isCashier) {
return priceTypeOptions.filter((o) => o.value === 'retail');
}
return priceTypeOptions.filter((o) => SELLING_PRICE_TYPES.includes(o.value) && o.value !== 'retail');
}, [stockType, priceTypeOptions, isCashier]);
const getUnitPrice = useCallback(
(variantId: number) => {
@ -177,12 +199,16 @@ export default function TransactionEdit({
}
if (stockType === 'reject') {
return variant.prices?.reject ?? 0;
return variant.prices?.reject_selling ?? 0;
}
if (isCashier) {
return variant.prices?.retail ?? 0;
}
return variant.prices?.[priceType] ?? 0;
},
[variantById, stockType, priceType],
[variantById, stockType, priceType, isCashier],
);
const subtotal = Object.entries(quantities).reduce(
@ -207,8 +233,10 @@ export default function TransactionEdit({
(variantId: number, amount: number) => {
if (amount > 0 && getUnitPrice(variantId) <= 0) {
toast.error('Harga produk ini belum diatur.');
return;
}
setQuantities((prev) => ({
...prev,
[variantId]: Math.max(0, (prev[variantId] ?? 0) + amount),
@ -230,10 +258,11 @@ export default function TransactionEdit({
if (variant) {
const unitPrice = getUnitPrice(id);
const productName = productByVariantId.get(id) ?? '';
lines.push({
key: `variant-${id}`,
photoUrl: variant.photo_url,
title: variant.name,
title: `${productName}${variant.name}`,
subtitle: `${formatCurrency(unitPrice)} / pcs`,
price: unitPrice,
quantity,
@ -253,9 +282,9 @@ export default function TransactionEdit({
function getPayload() {
return {
stock_type: stockType,
stock_type: stockType === 'good' && isCashier ? 'retail' : stockType,
channel,
price_type: stockType === 'reject' ? 'reject' : priceType,
price_type: stockType === 'reject' ? 'reject_selling' : (isCashier ? 'retail' : priceType),
payment_type: paymentType,
customer_id: customerId,
marketing_id: marketingId,
@ -371,9 +400,9 @@ export default function TransactionEdit({
{selectedProduct.product_variants.map(
(variant) => {
const currentStock =
stockType === 'good'
? variant.stock
: variant.reject_stock;
stockType === 'reject'
? variant.reject_stock
: (isCashier ? variant.retail_stock : variant.stock);
return (
<div
@ -423,8 +452,8 @@ export default function TransactionEdit({
) : (
formatCurrency(
stockType === 'reject'
? (variant.prices?.reject ?? 0)
: (variant.prices?.[priceType] ?? 0),
? (variant.prices?.reject_selling ?? 0)
: (isCashier ? (variant.prices?.retail ?? 0) : (variant.prices?.[priceType] ?? 0)),
)
)}
</p>
@ -609,33 +638,35 @@ export default function TransactionEdit({
</div>
)}
<div className="grid gap-2">
<Label>Tipe Harga <span className="text-destructive">*</span></Label>
<Select
value={priceType}
onValueChange={setPriceType}
disabled={channel === 'tiktok' || channel === 'shopee'}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Pilih tipe harga" />
</SelectTrigger>
<SelectContent>
{availablePriceTypes.map(
(opt) => (
<SelectItem
key={opt.value}
value={opt.value}
>
{opt.label}
</SelectItem>
),
)}
</SelectContent>
</Select>
<InputError
message={errors.price_type}
/>
</div>
{!isCashier && (
<div className="grid gap-2">
<Label>Tipe Harga <span className="text-destructive">*</span></Label>
<Select
value={priceType}
onValueChange={setPriceType}
disabled={channel === 'tiktok' || channel === 'shopee'}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Pilih tipe harga" />
</SelectTrigger>
<SelectContent>
{availablePriceTypes.map(
(opt) => (
<SelectItem
key={opt.value}
value={opt.value}
>
{opt.label}
</SelectItem>
),
)}
</SelectContent>
</Select>
<InputError
message={errors.price_type}
/>
</div>
)}
<div className="grid gap-2">
<Label>Tipe Pembayaran <span className="text-destructive">*</span></Label>

View File

@ -1,13 +1,7 @@
import { Head, Link, router, usePage } from '@inertiajs/react';
import { format } from 'date-fns';
import { Bluetooth, Cable, Plus, Printer, Unplug } from 'lucide-react';
import { useCallback, useMemo, useState } from 'react';
import { toast } from 'sonner';
import { CardTable } from '@/components/data-display';
import { DatePicker } from '@/components/inputs';
import { CardTable, FilterPopover } from '@/components/data-display';
import { DeleteConfirmDialog } from '@/components/dialogs';
import { FilterPopover } from '@/components/data-display';
import { useCardTableExpand } from '@/components/hooks/use-card-table-expand';
import { DatePicker } from '@/components/inputs';
import { PageHeader } from '@/components/layout';
import { Button } from '@/components/ui/button';
import {
@ -34,19 +28,23 @@ import {
} from '@/components/ui/select';
import { useCan } from '@/hooks/use-can';
import { useServerTable } from '@/hooks/use-server-table';
import { useThermalPrinter, encodeOrderReceipt } from '@/hooks/use-thermal-printer';
import { encodeOrderReceipt, useThermalPrinter } from '@/hooks/use-thermal-printer';
import {
destroy,
create as transactionCreate,
index as transactionIndex,
edit as transactionEdit,
updateStatus as transactionUpdateStatus,
index as transactionIndex,
items as transactionItems,
updateStatus as transactionUpdateStatus,
} from '@/routes/admin/manage/transactions';
import { Head, Link, router, usePage } from '@inertiajs/react';
import { format } from 'date-fns';
import { Bluetooth, Cable, Plus, Printer, Unplug } from 'lucide-react';
import { useCallback, useMemo, useState } from 'react';
import { toast } from 'sonner';
import type { Transaction, TransactionItem } from './columns';
import { TransactionCardRow } from './transaction-card';
import { TransactionItemSubRow } from './transaction-sub-row';
import { TransactionSummaryCard } from './transaction-summary-card';
type FilterOption = {
id: number;
@ -66,13 +64,6 @@ type Props = {
per_page: number;
total: number;
};
summary: {
total_orders: number;
total_amount: number;
total_discount: number;
total_deduction: number;
net_total: number;
};
filters: {
status?: string;
channel?: string;
@ -557,8 +548,6 @@ export default function TransactionIndex({
}
/>
<TransactionSummaryCard summary={summary} />
<CardTable
data={transactions.data}
getItemKey={(t) => t.id}
@ -588,7 +577,7 @@ export default function TransactionIndex({
transaction={item}
index={
(pagination.current_page - 1) *
pagination.per_page +
pagination.per_page +
index +
1
}

View File

@ -1,5 +1,6 @@
import { CheckCircle, ChevronDown, Pencil, Printer, Send, Trash2, XCircle } from 'lucide-react';
import { ImagePreviewButton } from '@/components/dialogs';
import { useState } from 'react';
import { Ban, CheckCircle, ChevronDown, Pencil, Printer, RotateCcw, Send, Trash2 } from 'lucide-react';
import { ConfirmDialog, ImagePreviewButton } from '@/components/dialogs';
import { RowActions } from '@/components/data-display';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
@ -47,6 +48,8 @@ export function TransactionCardRow({
onPrint,
}: TransactionCardRowParams) {
const { can } = useCan();
const [refundDialogOpen, setRefundDialogOpen] = useState(false);
const [cancelDialogOpen, setCancelDialogOpen] = useState(false);
const variantCount = transaction.items_count ?? 0;
const totalQty = transaction.total_qty ?? 0;
const productNamesStr = transaction.product_names ?? '';
@ -55,6 +58,7 @@ export function TransactionCardRow({
STATUS_BADGE_CLASSES[transaction.status] ?? STATUS_BADGE_CLASSES.pending;
return (
<>
<Card className="overflow-hidden">
<CardContent className="p-0">
<div className="flex items-start gap-3 p-4">
@ -256,8 +260,8 @@ export function TransactionCardRow({
},
{
label: 'Dibatalkan',
icon: <XCircle className="h-4 w-4 text-destructive" />,
onClick: () => onUpdateStatus(transaction, 'cancelled'),
icon: <Ban className="h-4 w-4 text-destructive" />,
onClick: () => setCancelDialogOpen(true),
},
]
: []),
@ -265,8 +269,8 @@ export function TransactionCardRow({
? [
{
label: 'Refund',
icon: <XCircle className="h-4 w-4 text-destructive" />,
onClick: () => onUpdateStatus(transaction, 'refunded'),
icon: <RotateCcw className="h-4 w-4 text-destructive" />,
onClick: () => setRefundDialogOpen(true),
},
]
: []),
@ -291,5 +295,32 @@ export function TransactionCardRow({
</div>
</CardContent>
</Card>
<ConfirmDialog
open={cancelDialogOpen}
onOpenChange={setCancelDialogOpen}
title="Batalkan Transaksi"
description={`Apakah Anda yakin ingin membatalkan transaksi ${transaction.order_number}? Tindakan ini tidak dapat dibatalkan.`}
confirmLabel="Batalkan"
variant="destructive"
onConfirm={() => {
onUpdateStatus(transaction, 'cancelled');
setCancelDialogOpen(false);
}}
/>
<ConfirmDialog
open={refundDialogOpen}
onOpenChange={setRefundDialogOpen}
title="Refund Transaksi"
description={`Apakah Anda yakin ingin melakukan refund untuk transaksi ${transaction.order_number}? Tindakan ini tidak dapat dibatalkan.`}
confirmLabel="Refund"
variant="destructive"
onConfirm={() => {
onUpdateStatus(transaction, 'refunded');
setRefundDialogOpen(false);
}}
/>
</>
);
}

View File

@ -1,91 +0,0 @@
import {
Banknote,
CircleDollarSign,
FileText,
Minus,
Percent,
TrendingUp,
} from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import { formatCurrency } from '@/lib/utils';
type Summary = {
total_orders: number;
total_amount: number;
total_discount: number;
total_deduction: number;
net_total: number;
};
type TransactionSummaryCardProps = {
summary: Summary;
};
const summaryItems = [
{
key: 'total_orders',
label: 'Total Pesanan',
icon: FileText,
color: 'bg-blue-100',
iconColor: 'text-blue-600',
format: (value: number) => value.toLocaleString('id-ID'),
},
{
key: 'total_amount',
label: 'Total',
icon: Banknote,
color: 'bg-emerald-100',
iconColor: 'text-emerald-600',
format: formatCurrency,
},
{
key: 'total_discount',
label: 'Diskon',
icon: Percent,
color: 'bg-amber-100',
iconColor: 'text-amber-600',
format: formatCurrency,
},
{
key: 'total_deduction',
label: 'Potongan',
icon: Minus,
color: 'bg-orange-100',
iconColor: 'text-orange-600',
format: formatCurrency,
},
{
key: 'net_total',
label: 'Total Bersih',
icon: TrendingUp,
color: 'bg-sky-100',
iconColor: 'text-sky-600',
format: formatCurrency,
},
] as const;
export function TransactionSummaryCard({ summary }: TransactionSummaryCardProps) {
return (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3 lg:grid-cols-5">
{summaryItems.map((item) => (
<Card key={item.key}>
<CardContent className="flex items-center gap-3 py-3">
<div
className={`flex h-10 w-10 shrink-0 items-center justify-center rounded-lg ${item.color}`}
>
<item.icon className={`h-5 w-5 ${item.iconColor}`} />
</div>
<div>
<p className="text-xs text-muted-foreground">
{item.label}
</p>
<p className="text-lg font-bold">
{item.format(summary[item.key] as number)}
</p>
</div>
</CardContent>
</Card>
))}
</div>
);
}

View File

@ -43,7 +43,8 @@ const PRICE_TYPES = [
{ key: 'tiktok', label: 'TikTok' },
{ key: 'shopee', label: 'Shopee' },
{ key: 'capital', label: 'Modal' },
{ key: 'reject', label: 'Reject' },
{ key: 'reject_capital', label: 'Reject Modal' },
{ key: 'reject_selling', label: 'Reject Jual' },
];
function createEmptyPrices(): Array<{ type: string; price: number }> {
@ -607,8 +608,8 @@ export default function ProductCreate({ categories }: Props) {
</Button>
</>
)}
{variantIndex >
0 && (
{variants.length >
1 && (
<Button
type="button"
variant="outline"

View File

@ -61,7 +61,8 @@ const PRICE_TYPES = [
{ key: 'tiktok', label: 'TikTok' },
{ key: 'shopee', label: 'Shopee' },
{ key: 'capital', label: 'Modal' },
{ key: 'reject', label: 'Reject' },
{ key: 'reject_capital', label: 'Reject Modal' },
{ key: 'reject_selling', label: 'Reject Jual' },
];
function createEmptyPrices(): Array<{ type: string; price: number }> {
@ -104,6 +105,13 @@ export default function ProductEdit({ product, categories }: Props) {
product.description ?? '',
);
function normalizePrices(serverPrices: Array<{ type: string; price: number }>): Array<{ type: string; price: number }> {
return PRICE_TYPES.map((pt) => {
const existing = serverPrices.find((p) => p.type === pt.key);
return { type: pt.key, price: existing?.price ?? 0 };
});
}
const serverVariants: VariantState[] = product.product_variants.map(
(v) => ({
id: v.id,
@ -116,7 +124,7 @@ export default function ProductEdit({ product, categories }: Props) {
url: v.photo_urls[i] ?? null,
})),
uploading: false,
prices: v.prices.length > 0 ? v.prices : createEmptyPrices(),
prices: normalizePrices(v.prices),
}),
);
@ -657,8 +665,8 @@ export default function ProductEdit({ product, categories }: Props) {
</Button>
</>
)}
{variantIndex >
0 && (
{variants.length >
1 && (
<Button
type="button"
variant="outline"

View File

@ -175,7 +175,15 @@ export default function ProductIndex({ products, categories, productNames, filte
variant: deletingVariant.variant.id,
}),
{
onSuccess: () => setDeletingVariant(null),
onSuccess: () => {
setDeletingVariant(null);
setLoadedVariants((prev) => ({
...prev,
[deletingVariant.product.id]: (prev[deletingVariant.product.id] ?? []).filter(
(v) => v.id !== deletingVariant.variant.id,
),
}));
},
},
);
}

View File

@ -35,7 +35,8 @@ const PRICE_TYPES = [
{ key: 'tiktok', label: 'TikTok' },
{ key: 'shopee', label: 'Shopee' },
{ key: 'capital', label: 'Modal' },
{ key: 'reject', label: 'Reject' },
{ key: 'reject_capital', label: 'Reject Modal' },
{ key: 'reject_selling', label: 'Reject Jual' },
];
export default function ProductVariantEdit({ variant }: Props) {
@ -53,14 +54,15 @@ export default function ProductVariantEdit({ variant }: Props) {
const [prices, setPrices] = useState<
Array<{ type: string; price: number }>
>(
variant.prices.length > 0
? variant.prices
: PRICE_TYPES.map((pt) => ({ type: pt.key, price: 0 })),
PRICE_TYPES.map((pt) => {
const existing = variant.prices.find((p) => p.type === pt.key);
return { type: pt.key, price: existing?.price ?? 0 };
}),
);
function updatePrice(priceIndex: number, value: number) {
function updatePrice(priceType: string, value: number) {
setPrices((prev) =>
prev.map((p, i) => (i === priceIndex ? { ...p, price: value } : p)),
prev.map((p) => (p.type === priceType ? { ...p, price: value } : p)),
);
}
@ -200,7 +202,9 @@ export default function ProductVariantEdit({ variant }: Props) {
<CardContent>
<div className="grid grid-cols-2 gap-4 md:grid-cols-3">
{PRICE_TYPES.map(
(priceType, priceIndex) => (
(priceType) => {
const priceEntry = prices.find((p) => p.type === priceType.key);
return (
<div
key={priceType.key}
className="grid gap-2"
@ -214,15 +218,13 @@ export default function ProductVariantEdit({ variant }: Props) {
</Label>
<RupiahInput
value={
prices[
priceIndex
]?.price ?? 0
priceEntry?.price ?? 0
}
onValueChange={(
val,
) =>
updatePrice(
priceIndex,
priceType.key,
val,
)
}
@ -230,12 +232,13 @@ export default function ProductVariantEdit({ variant }: Props) {
<InputError
message={
errors[
`prices.${priceIndex}.price`
`prices.${priceType.key}.price`
]
}
/>
</div>
),
);
},
)}
</div>
</CardContent>

View File

@ -12,7 +12,6 @@ import { toast } from 'sonner';
import { ConfirmDialog } from '@/components/dialogs';
import { FileUpload } from '@/components/inputs';
import { InputError } from '@/components/ui';
import { NumberInput } from '@/components/inputs';
import { RupiahInput } from '@/components/inputs';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
@ -31,7 +30,7 @@ import {
type VariantState = {
variant: string;
price: number;
stock: number;
stock: string;
photo: string | null;
photoUrl: string | null;
uploading: boolean;
@ -50,7 +49,7 @@ export default function RawMaterialCreate() {
return draft.variants.map((v) => ({
variant: v.variant,
price: v.price,
stock: v.stock,
stock: String(v.stock),
photo: v.photo ?? null,
photoUrl: v.photo ? getTemporaryUrl(v.photo) : null,
uploading: false,
@ -61,7 +60,7 @@ export default function RawMaterialCreate() {
{
variant: '',
price: 0,
stock: 0,
stock: '0',
photo: null,
photoUrl: null,
uploading: false,
@ -92,7 +91,7 @@ export default function RawMaterialCreate() {
{
variant: '',
price: 0,
stock: 0,
stock: '0',
photo: null,
photoUrl: null,
uploading: false,
@ -178,7 +177,7 @@ export default function RawMaterialCreate() {
variants: variantsRef.current.map((v) => ({
variant: v.variant,
price: Number(v.price),
stock: Number(v.stock),
stock: parseFloat(String(v.stock)) || 0,
photo_key: v.photo,
})),
};
@ -422,17 +421,19 @@ export default function RawMaterialCreate() {
*
</span>
</Label>
<NumberInput
<Input
type="text"
inputMode="decimal"
value={
variant.stock
}
onValueChange={(
val,
onChange={(
e,
) =>
updateVariant(
variantIndex,
'stock',
val,
e.target.value,
)
}
/>

View File

@ -7,12 +7,11 @@ import {
Plus,
Trash2,
} from 'lucide-react';
import { useCallback, useRef, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { toast } from 'sonner';
import { ConfirmDialog } from '@/components/dialogs';
import { FileUpload } from '@/components/inputs';
import { InputError } from '@/components/ui';
import { NumberInput } from '@/components/inputs';
import { RupiahInput } from '@/components/inputs';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
@ -37,7 +36,7 @@ type VariantState = {
id: number | null;
variant: string;
price: number;
stock: number;
stock: string;
photo: string | null;
photoUrl: string | null;
uploading: boolean;
@ -58,7 +57,7 @@ export default function RawMaterialEdit({ rawMaterial }: Props) {
id: v.id,
variant: v.variant,
price: v.price,
stock: v.stock,
stock: String(v.stock),
photo: v.photo_key,
photoUrl: v.photo_url,
uploading: false,
@ -73,7 +72,7 @@ export default function RawMaterialEdit({ rawMaterial }: Props) {
id: null,
variant: '',
price: 0,
stock: 0,
stock: '0',
photo: null,
photoUrl: null,
uploading: false,
@ -106,7 +105,7 @@ export default function RawMaterialEdit({ rawMaterial }: Props) {
id: null,
variant: '',
price: 0,
stock: 0,
stock: '0',
photo: null,
photoUrl: null,
uploading: false,
@ -135,6 +134,23 @@ export default function RawMaterialEdit({ rawMaterial }: Props) {
const [deleteVariantIndex, setDeleteVariantIndex] = useState<number | null>(
null,
);
const [removedVariants, setRemovedVariants] = useState<VariantState[]>([]);
const removedVariantsRef = useRef(removedVariants);
removedVariantsRef.current = removedVariants;
const { errors: pageErrors } = usePage<{ errors?: Record<string, string[]> }>().props;
useEffect(() => {
if (pageErrors && Object.keys(pageErrors).length > 0) {
const removed = removedVariantsRef.current;
if (removed.length > 0) {
setVariants((prev) => [...prev, ...removed]);
setRemovedVariants([]);
}
const firstError = Object.values(pageErrors).flat().find(Boolean);
toast.error(firstError ?? 'Ada data yang belum sesuai, silakan periksa kembali input Anda.');
}
}, [pageErrors]);
const confirmRemoveVariant = useCallback((index: number) => {
setDeleteVariantIndex(index);
@ -193,7 +209,7 @@ export default function RawMaterialEdit({ rawMaterial }: Props) {
id: v.id,
variant: v.variant,
price: Number(v.price),
stock: Number(v.stock),
stock: parseFloat(String(v.stock)) || 0,
photo_key: v.photo,
})),
};
@ -223,9 +239,6 @@ export default function RawMaterialEdit({ rawMaterial }: Props) {
...data,
...getPayload(),
})}
onError={() => {
toast.error('Ada data yang belum sesuai, silakan periksa kembali input Anda.');
}}
>
{({ errors, processing }) => (
<>
@ -438,19 +451,19 @@ export default function RawMaterialEdit({ rawMaterial }: Props) {
*
</span>
</Label>
<NumberInput
<Input
type="text"
inputMode="decimal"
value={
Number(
variant.stock,
) || 0
variant.stock
}
onValueChange={(
val,
onChange={(
e,
) =>
updateVariant(
variantIndex,
'stock',
val,
e.target.value,
)
}
/>
@ -556,6 +569,10 @@ export default function RawMaterialEdit({ rawMaterial }: Props) {
confirmLabel="Hapus"
onConfirm={() => {
if (deleteVariantIndex !== null) {
const removed = variants[deleteVariantIndex];
if (removed.id !== null) {
setRemovedVariants((prev) => [...prev, removed]);
}
removeVariant(deleteVariantIndex);
}

View File

@ -141,6 +141,7 @@ export default function RawMaterialIndex({ rawMaterials, rawMaterialNames, filte
router.delete(destroy.url(deleting.id), {
onSuccess: () => setDeleting(null),
onError: () => setDeleting(null),
});
}
@ -155,7 +156,16 @@ export default function RawMaterialIndex({ rawMaterials, rawMaterialNames, filte
variant: deletingVariant.variant.id,
}),
{
onSuccess: () => setDeletingVariant(null),
onSuccess: () => {
setDeletingVariant(null);
setLoadedVariants((prev) => ({
...prev,
[deletingVariant.rawMaterial.id]: (prev[deletingVariant.rawMaterial.id] ?? []).filter(
(v) => v.id !== deletingVariant.variant.id,
),
}));
},
onError: () => setDeletingVariant(null),
},
);
}
@ -325,6 +335,14 @@ export default function RawMaterialIndex({ rawMaterials, rawMaterialNames, filte
variants={loadedVariants[rawMaterial.id] ?? []}
isLoading={loadingVariants[rawMaterial.id] ?? false}
allPreviewItems={allPreviewItems}
onDeleteSuccess={(variantId: number) => {
setLoadedVariants((prev) => ({
...prev,
[rawMaterial.id]: (prev[rawMaterial.id] ?? []).filter(
(v) => v.id !== variantId,
),
}));
}}
/>
)}
/>

View File

@ -4,7 +4,6 @@ import { useState } from 'react';
import { toast } from 'sonner';
import { FileUpload } from '@/components/inputs';
import { InputError } from '@/components/ui';
import { NumberInput } from '@/components/inputs';
import { RupiahInput } from '@/components/inputs';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
@ -28,7 +27,7 @@ type Props = {
export default function RawMaterialVariantEdit({ variant }: Props) {
const [variantName, setVariantName] = useState(variant.variant);
const [price, setPrice] = useState(variant.price);
const [stock, setStock] = useState(Number(variant.stock) || 0);
const [stock, setStock] = useState(String(variant.stock) || '0');
const [photo, setPhoto] = useState<string | null>(variant.photo_key);
const [photoUrl, setPhotoUrl] = useState<string | null>(variant.photo_url);
const [uploading, setUploading] = useState(false);
@ -37,7 +36,7 @@ export default function RawMaterialVariantEdit({ variant }: Props) {
return {
variant: variantName,
price: Number(price),
stock: Number(stock),
stock: parseFloat(String(stock)) || 0,
photo_key: photo,
};
}
@ -101,9 +100,11 @@ export default function RawMaterialVariantEdit({ variant }: Props) {
<Label>
Stok <span className="text-destructive">*</span>
</Label>
<NumberInput
<Input
type="text"
inputMode="decimal"
value={stock}
onValueChange={setStock}
onChange={(e) => setStock(e.target.value)}
/>
<InputError message={errors.stock} />
</div>

View File

@ -27,11 +27,13 @@ export function RawMaterialVariantSubRow({
variants: loadedVariants,
isLoading,
allPreviewItems,
onDeleteSuccess,
}: {
rawMaterial: RawMaterial;
variants: RawMaterialVariant[];
isLoading: boolean;
allPreviewItems: ImagePreviewItem[];
onDeleteSuccess?: (variantId: number) => void;
}) {
const { can } = useCan();
const [deletingVariant, setDeletingVariant] =
@ -48,7 +50,11 @@ export function RawMaterialVariantSubRow({
variant: deletingVariant.id,
}),
{
onSuccess: () => setDeletingVariant(null),
onSuccess: () => {
const deletedId = deletingVariant.id;
setDeletingVariant(null);
onDeleteSuccess?.(deletedId);
},
},
);
}

View File

@ -0,0 +1,169 @@
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Badge } from '@/components/ui/badge';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import type { FormHistory } from './columns';
type AttributeChangesDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
item: FormHistory | null;
};
const eventBadgeVariant: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
created: 'default',
updated: 'secondary',
deleted: 'destructive',
};
const eventLabel: Record<string, string> = {
created: 'Ditambahkan',
updated: 'Diperbarui',
deleted: 'Dihapus',
};
function renderValue(key: string, value: unknown): React.ReactNode {
if (value === null || value === undefined) {
return <span className="text-muted-foreground">-</span>;
}
if (typeof value === 'boolean') {
return value ? 'Ya' : 'Tidak';
}
if (Array.isArray(value)) {
if (value.length === 0) {
return <span className="text-muted-foreground">-</span>;
}
if (typeof value[0] === 'object' && value[0] !== null) {
return <RenderObjectArray items={value} />;
}
return value.join(', ');
}
if (typeof value === 'object') {
return JSON.stringify(value);
}
return String(value);
}
function RenderObjectArray({ items }: { items: Record<string, unknown>[] }) {
if (!items.length) {
return <span className="text-muted-foreground">-</span>;
}
const keys = Object.keys(items[0]);
return (
<div className="rounded-md border">
<div className="max-h-[300px] overflow-auto">
<Table>
<TableHeader>
<TableRow>
{keys.map((key) => (
<TableHead key={key} className="h-8 text-xs">
{key}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{items.map((item, index) => (
<TableRow key={index}>
{keys.map((key) => (
<TableCell key={key} className="py-1.5 text-xs">
{renderValue(key, item[key])}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
);
}
export function AttributeChangesDialog({ open, onOpenChange, item }: AttributeChangesDialogProps) {
if (!item) {
return null;
}
const changes = item.attribute_changes;
const hasNew = changes?.new && Object.keys(changes.new).length > 0;
const hasOld = changes?.old && Object.keys(changes.old).length > 0;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-3xl overflow-hidden">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<span>{item.description}</span>
<Badge variant={eventBadgeVariant[item.event] ?? 'outline'}>
{eventLabel[item.event] ?? item.event}
</Badge>
</DialogTitle>
</DialogHeader>
<div className="space-y-4 overflow-y-auto max-h-[calc(85vh-8rem)]">
<div className="text-sm text-muted-foreground space-y-1">
<div>Oleh: <span className="font-medium text-foreground">{item.causer?.full_name ?? item.causer?.username ?? '-'}</span></div>
<div>{item.formatted_created_at}</div>
</div>
{hasNew && (
<div className="space-y-2">
<h4 className="text-sm font-medium">Nilai Baru</h4>
<div className="rounded-md border p-3 bg-muted/50">
<dl className="space-y-2">
{Object.entries(changes!.new!).map(([key, value]) => (
<div key={key} className="flex flex-col">
<dt className="text-xs text-muted-foreground">{key}</dt>
<dd className="text-sm font-medium">{renderValue(key, value)}</dd>
</div>
))}
</dl>
</div>
</div>
)}
{hasOld && (
<div className="space-y-2">
<h4 className="text-sm font-medium">Nilai Lama</h4>
<div className="rounded-md border p-3 bg-muted/50">
<dl className="space-y-2">
{Object.entries(changes!.old!).map(([key, value]) => (
<div key={key} className="flex flex-col">
<dt className="text-xs text-muted-foreground">{key}</dt>
<dd className="text-sm font-medium">{renderValue(key, value)}</dd>
</div>
))}
</dl>
</div>
</div>
)}
{!hasNew && !hasOld && (
<p className="text-sm text-muted-foreground text-center py-4">
Tidak ada perubahan data.
</p>
)}
</div>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,106 @@
import type { ColumnDef } from '@tanstack/react-table';
import { Eye } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
export type FormHistory = {
id: number;
causer_id: number;
module: string;
event: string;
description: string;
attribute_changes: {
new?: Record<string, unknown>;
old?: Record<string, unknown>;
} | null;
created_at: string;
formatted_created_at: string;
causer: {
id: number;
username: string;
full_name: string;
};
};
const eventBadgeVariant: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
created: 'default',
updated: 'secondary',
deleted: 'destructive',
};
const eventLabel: Record<string, string> = {
created: 'Ditambahkan',
updated: 'Diperbarui',
deleted: 'Dihapus',
};
type CreateColumnsParams = {
handleDetail: (item: FormHistory) => void;
};
export function createFormHistoryColumns({ handleDetail }: CreateColumnsParams): ColumnDef<FormHistory>[] {
return [
{
accessorKey: 'formatted_created_at',
header: () => <span>Waktu</span>,
cell: ({ row }) => (
<span className="text-sm text-muted-foreground">
{row.original.formatted_created_at}
</span>
),
},
{
accessorKey: 'causer',
header: () => <span>User</span>,
cell: ({ row }) => (
<span className="font-medium">
{row.original.causer?.full_name ?? row.original.causer?.username ?? '-'}
</span>
),
},
{
accessorKey: 'module',
header: () => <span>Module</span>,
cell: ({ row }) => (
<span>{row.getValue('module') as string}</span>
),
},
{
accessorKey: 'event',
header: () => <span>Aksi</span>,
cell: ({ row }) => {
const event = row.getValue('event') as string;
return (
<Badge variant={eventBadgeVariant[event] ?? 'outline'}>
{eventLabel[event] ?? event}
</Badge>
);
},
},
{
accessorKey: 'description',
header: () => <span>Deskripsi</span>,
cell: ({ row }) => (
<span>{row.getValue('description') as string}</span>
),
},
{
id: 'actions',
header: () => <span className="block text-center">Aksi</span>,
meta: {
className: 'w-[80px] text-center',
headerClassName: 'w-[80px] text-center',
},
cell: ({ row }) => (
<Button
variant="ghost"
size="icon"
onClick={() => handleDetail(row.original)}
>
<Eye className="h-4 w-4" />
</Button>
),
},
];
}

View File

@ -0,0 +1,78 @@
import { Head } from '@inertiajs/react';
import { useState } from 'react';
import type { PaginationState } from '@/components/data-display';
import { DataTable } from '@/components/data-display';
import { PageHeader } from '@/components/layout';
import { useServerTable } from '@/hooks/use-server-table';
import type { FormHistory } from './columns';
import { createFormHistoryColumns } from './columns';
import { AttributeChangesDialog } from './attribute-changes-dialog';
type Props = {
formHistories: {
data: FormHistory[];
current_page: number;
last_page: number;
per_page: number;
total: number;
};
modules: string[];
events: Record<string, string>;
};
export default function FormHistoryIndex({ formHistories, modules, events }: Props) {
const [detailItem, setDetailItem] = useState<FormHistory | null>(null);
const pagination: PaginationState = {
current_page: formHistories.current_page,
last_page: formHistories.last_page,
per_page: formHistories.per_page,
total: formHistories.total,
};
const {
search,
handlePageChange,
handlePerPageChange,
handleSearchChange,
} = useServerTable({
route: () => route('admin.form-histories.index'),
pagination,
});
const columns = createFormHistoryColumns({
handleDetail: (item) => setDetailItem(item),
});
return (
<>
<Head title="Log Aktivitas" />
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
<PageHeader title="Log Aktivitas" />
<DataTable
columns={columns}
data={formHistories.data}
searchKey="description"
emptyText="Belum ada log aktivitas."
pagination={pagination}
onPageChange={handlePageChange}
onPerPageChange={handlePerPageChange}
onSearchChange={handleSearchChange}
searchValue={search}
/>
<AttributeChangesDialog
open={detailItem !== null}
onOpenChange={(open) => {
if (!open) {
setDetailItem(null);
}
}}
item={detailItem}
/>
</div>
</>
);
}

Some files were not shown because too many files have changed in this diff Show More