Compare commits
39 Commits
22c9a4cf2a
...
919e8228d2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
919e8228d2 | ||
|
|
581761398f | ||
|
|
aaa85a45ed | ||
|
|
097b7f7192 | ||
|
|
05707aad7d | ||
|
|
28f88bb69c | ||
|
|
731c8aa6da | ||
|
|
85ba5631b3 | ||
|
|
953a395f24 | ||
|
|
078d4e2461 | ||
|
|
b0cb117e60 | ||
|
|
8ea9378c78 | ||
|
|
50ece97933 | ||
|
|
3535480266 | ||
|
|
0998c81803 | ||
|
|
0947cf3737 | ||
|
|
c415493ee1 | ||
|
|
cc3cd03e41 | ||
|
|
6fa9224983 | ||
|
|
ef136bd1e7 | ||
|
|
25a316ccf0 | ||
|
|
dd3a2aa1eb | ||
|
|
cff231097a | ||
|
|
a07cb4bb45 | ||
|
|
a10519ab9d | ||
|
|
bac9f79087 | ||
|
|
17273c9479 | ||
|
|
de18ac5ce8 | ||
|
|
00ff74bab6 | ||
|
|
7371a9e517 | ||
|
|
41a32b08f2 | ||
|
|
0d1e6ba1fe | ||
|
|
4758b19d2a | ||
|
|
86cc8637bd | ||
|
|
bdfb75f2f7 | ||
|
|
e8c46ec18b | ||
|
|
fd6793af50 | ||
|
|
10f277a268 | ||
|
|
ae376342af |
@ -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` |
|
||||
|
||||
@ -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 |
|
||||
|
||||
@ -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 = [
|
||||
|
||||
@ -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',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -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',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
121
app/Http/Controllers/Admin/Manage/StokOpnameController.php
Normal file
121
app/Http/Controllers/Admin/Manage/StokOpnameController.php
Normal 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),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@ -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(),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
25
app/Http/Controllers/Admin/System/FormHistoryController.php
Normal file
25
app/Http/Controllers/Admin/System/FormHistoryController.php
Normal 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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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.']);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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'],
|
||||
|
||||
40
app/Http/Requests/Admin/Manage/StokOpnameRequest.php
Normal file
40
app/Http/Requests/Admin/Manage/StokOpnameRequest.php
Normal 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',
|
||||
];
|
||||
}
|
||||
}
|
||||
27
app/Http/Requests/Admin/Manage/StokOpnameVerifyRequest.php
Normal file
27
app/Http/Requests/Admin/Manage/StokOpnameVerifyRequest.php
Normal 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',
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -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')],
|
||||
|
||||
@ -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'],
|
||||
];
|
||||
}
|
||||
|
||||
@ -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'],
|
||||
];
|
||||
}
|
||||
|
||||
@ -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, ',', '.'),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
36
app/Models/FormHistory.php
Normal file
36
app/Models/FormHistory.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -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, ',', '.'),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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([
|
||||
|
||||
@ -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 ?? '-',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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',
|
||||
])
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
293
app/Services/Admin/Manage/StokOpnameService.php
Normal file
293
app/Services/Admin/Manage/StokOpnameService.php
Normal 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.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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];
|
||||
});
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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();
|
||||
});
|
||||
|
||||
|
||||
@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
45
app/Services/Admin/System/FormHistoryService.php
Normal file
45
app/Services/Admin/System/FormHistoryService.php
Normal 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',
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -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([
|
||||
|
||||
@ -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
|
||||
|
||||
89
app/Services/Concerns/LogsFormHistory.php
Normal file
89
app/Services/Concerns/LogsFormHistory.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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)) {
|
||||
|
||||
@ -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),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -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();
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -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();
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
};
|
||||
@ -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'");
|
||||
}
|
||||
};
|
||||
@ -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');
|
||||
}
|
||||
};
|
||||
@ -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();
|
||||
});
|
||||
}
|
||||
};
|
||||
49
docs/2026-08-15-raw-material-photo-fix.md
Normal file
49
docs/2026-08-15-raw-material-photo-fix.md
Normal 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)
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 />
|
||||
|
||||
@ -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
|
||||
};
|
||||
|
||||
@ -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));
|
||||
}
|
||||
|
||||
@ -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,
|
||||
}),
|
||||
|
||||
9
resources/js/hooks/use-stok-opname-draft.ts
Normal file
9
resources/js/hooks/use-stok-opname-draft.ts
Normal 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,
|
||||
});
|
||||
34
resources/js/lib/stok-opname-draft.ts
Normal file
34
resources/js/lib/stok-opname-draft.ts
Normal 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);
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -32,6 +32,7 @@ function getTypeLabel(type: string): string {
|
||||
withdrawal: 'Withdrawal',
|
||||
expense: 'Pengeluaran',
|
||||
employee_advance: 'Kasbon',
|
||||
salary : 'Gaji'
|
||||
};
|
||||
|
||||
return labels[type] ?? type;
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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
|
||||
? [
|
||||
{
|
||||
|
||||
@ -105,6 +105,7 @@ export type CuttingCreateData = {
|
||||
price: number;
|
||||
stock: number;
|
||||
photo_url: string | null;
|
||||
photo_conversion_url: string | null;
|
||||
}[];
|
||||
}[];
|
||||
};
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
@ -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}`;
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
@ -98,6 +98,7 @@ export type PurchaseCreateData = {
|
||||
price: number;
|
||||
stock: number;
|
||||
photo_url: string | null;
|
||||
photo_conversion_url: string | null;
|
||||
}[];
|
||||
}[];
|
||||
};
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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')) && (
|
||||
|
||||
79
resources/js/pages/admin/manage/stok-opname/columns.tsx
Normal file
79
resources/js/pages/admin/manage/stok-opname/columns.tsx
Normal 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[];
|
||||
};
|
||||
601
resources/js/pages/admin/manage/stok-opname/create.tsx
Normal file
601
resources/js/pages/admin/manage/stok-opname/create.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
611
resources/js/pages/admin/manage/stok-opname/edit.tsx
Normal file
611
resources/js/pages/admin/manage/stok-opname/edit.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
229
resources/js/pages/admin/manage/stok-opname/index.tsx
Normal file
229
resources/js/pages/admin/manage/stok-opname/index.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
188
resources/js/pages/admin/manage/stok-opname/stok-opname-card.tsx
Normal file
188
resources/js/pages/admin/manage/stok-opname/stok-opname-card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>;
|
||||
}[];
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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"
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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,
|
||||
),
|
||||
}));
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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,
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
),
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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);
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
106
resources/js/pages/admin/system/form-histories/columns.tsx
Normal file
106
resources/js/pages/admin/system/form-histories/columns.tsx
Normal 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>
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
78
resources/js/pages/admin/system/form-histories/index.tsx
Normal file
78
resources/js/pages/admin/system/form-histories/index.tsx
Normal 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
Loading…
Reference in New Issue
Block a user