Compare commits

..

No commits in common. "919e8228d2b971d183d5d373564bb17995d1565c" and "22c9a4cf2a68cc3af8a72fde456c9ac60fcc0658" have entirely different histories.

105 changed files with 781 additions and 4977 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -165,9 +165,10 @@ private function formatExpense(Expense $expense): array
return $this->s3Service->getTemporaryUrl($s3Key); return $this->s3Service->getTemporaryUrl($s3Key);
}); });
$receiptConversionUrl = $media->getGeneratedConversions()->contains('thumb') $conversionCacheKey = "expense_receipt_conversion_{$media->id}";
? $this->s3Service->getTemporaryUrl($media->getPath('thumb')) $receiptConversionUrl = Cache::remember($conversionCacheKey, now()->addMinutes(55), function () use ($media) {
: $receiptUrl; return $this->s3Service->getTemporaryUrl($media->getPath('thumb'));
});
return $expense->toArray() + [ return $expense->toArray() + [
'receipt_key' => $s3Key, 'receipt_key' => $s3Key,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -46,46 +46,9 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
->orderBy($sort, $direction) ->orderBy($sort, $direction)
->paginate($perPage); ->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; 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 public function getItems(Restock $restock): \Illuminate\Support\Collection
{ {
return $restock->restockItems() return $restock->restockItems()
@ -103,9 +66,7 @@ public function getItems(Restock $restock): \Illuminate\Support\Collection
? $this->s3Service->getTemporaryUrl($media->getPath()) ? $this->s3Service->getTemporaryUrl($media->getPath())
: null; : null;
$item->productVariant->photo_conversion_url = $media $item->productVariant->photo_conversion_url = $media
? ($media->getGeneratedConversions()->contains('thumb') ? $this->s3Service->getTemporaryUrl($media->getPath('thumb'))
? $this->s3Service->getTemporaryUrl($media->getPath('thumb'))
: $this->s3Service->getTemporaryUrl($media->getPath()))
: null; : null;
}); });
} }
@ -217,7 +178,7 @@ public function destroy(Restock $restock): bool
private function buildItemRows(array $items, string $stockType, $now, int &$total): array private function buildItemRows(array $items, string $stockType, $now, int &$total): array
{ {
$priceType = $stockType === ProductStockQuality::REJECT->value $priceType = $stockType === ProductStockQuality::REJECT->value
? PriceType::REJECT_CAPITAL ? PriceType::REJECT
: PriceType::CAPITAL; : PriceType::CAPITAL;
$variantIds = collect($items)->pluck('product_variant_id')->unique()->all(); $variantIds = collect($items)->pluck('product_variant_id')->unique()->all();

View File

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

View File

@ -33,7 +33,6 @@ class TransactionService
PriceType::RETAIL->value => PriceType::RETAIL, PriceType::RETAIL->value => PriceType::RETAIL,
PriceType::TIKTOK->value => PriceType::TIKTOK, PriceType::TIKTOK->value => PriceType::TIKTOK,
PriceType::SHOPEE->value => PriceType::SHOPEE, PriceType::SHOPEE->value => PriceType::SHOPEE,
PriceType::REJECT_SELLING->value => PriceType::REJECT_SELLING,
]; ];
public function __construct( public function __construct(
@ -87,9 +86,7 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
? $this->s3Service->getTemporaryUrl($orderMedia->getPath()) ? $this->s3Service->getTemporaryUrl($orderMedia->getPath())
: null; : null;
$order->photo_conversion_url = $orderMedia $order->photo_conversion_url = $orderMedia
? ($orderMedia->getGeneratedConversions()->contains('thumb') ? $this->s3Service->getTemporaryUrl($orderMedia->getPath('thumb'))
? $this->s3Service->getTemporaryUrl($orderMedia->getPath('thumb'))
: $this->s3Service->getTemporaryUrl($orderMedia->getPath()))
: null; : null;
$order->profit = $order->total_amount - $order->cogs; $order->profit = $order->total_amount - $order->cogs;
@ -152,13 +149,39 @@ public function getItems(Order $order): \Illuminate\Support\Collection
? $this->s3Service->getTemporaryUrl($media->getPath()) ? $this->s3Service->getTemporaryUrl($media->getPath())
: null; : null;
$item->productVariant->photo_conversion_url = $media $item->productVariant->photo_conversion_url = $media
? ($media->getGeneratedConversions()->contains('thumb') ? $this->s3Service->getTemporaryUrl($media->getPath('thumb'))
? $this->s3Service->getTemporaryUrl($media->getPath('thumb'))
: $this->s3Service->getTemporaryUrl($media->getPath()))
: null; : 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 public function getFilterOptions(): array
{ {
return [ return [
@ -253,7 +276,7 @@ public function update(Order $order, array $data): Order
$order = DB::transaction(function () use ($order, $data) { $order = DB::transaction(function () use ($order, $data) {
$order->load('orderItems'); $order->load('orderItems');
$oldStockType = $order->orderItems->first()?->stock_quality?->value ?? ProductStockQuality::GOOD->value; $oldStockType = $order->orderItems->first()?->stock_type?->value ?? ProductStockQuality::GOOD->value;
$order->orderItems->each(function (OrderItem $item) use ($oldStockType) { $order->orderItems->each(function (OrderItem $item) use ($oldStockType) {
$this->adjustVariantStock($item->product_variant_id, $item->quantity, 1, $oldStockType); $this->adjustVariantStock($item->product_variant_id, $item->quantity, 1, $oldStockType);
@ -328,13 +351,11 @@ public function destroy(Order $order): bool
$result = DB::transaction(function () use ($order) { $result = DB::transaction(function () use ($order) {
$order->load('orderItems'); $order->load('orderItems');
if (! in_array($order->status, [OrderStatus::CANCELLED, OrderStatus::REFUNDED])) { $stockType = $order->orderItems->first()?->stock_type?->value ?? ProductStockQuality::GOOD->value;
$stockType = $order->orderItems->first()?->stock_quality?->value ?? ProductStockQuality::GOOD->value;
$order->orderItems->each(function (OrderItem $item) use ($stockType) { $order->orderItems->each(function (OrderItem $item) use ($stockType) {
$this->adjustVariantStock($item->product_variant_id, $item->quantity, 1, $stockType); $this->adjustVariantStock($item->product_variant_id, $item->quantity, 1, $stockType);
}); });
}
$order->orderItems()->delete(); $order->orderItems()->delete();
$order->delete(); $order->delete();
@ -354,26 +375,15 @@ public function destroy(Order $order): bool
public function updateStatus(Order $order, string $status): Order public function updateStatus(Order $order, string $status): Order
{ {
$oldStatus = $order->status->value;
$order->update(['status' => $status]); $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; return $order;
} }
private function buildItemRows(array $items, string $stockType, string $priceType, $now, int &$subtotal, int &$totalCost): array private function buildItemRows(array $items, string $stockType, string $priceType, $now, int &$subtotal, int &$totalCost): array
{ {
$resolvedPriceType = $stockType === ProductStockQuality::REJECT->value $resolvedPriceType = $stockType === ProductStockQuality::REJECT->value
? PriceType::REJECT_SELLING ? PriceType::REJECT
: (self::SELLING_PRICE_MAP[$priceType] ?? PriceType::RETAIL); : (self::SELLING_PRICE_MAP[$priceType] ?? PriceType::RETAIL);
$variantIds = collect($items)->pluck('product_variant_id')->unique()->all(); $variantIds = collect($items)->pluck('product_variant_id')->unique()->all();
@ -393,13 +403,9 @@ private function buildItemRows(array $items, string $stockType, string $priceTyp
return [$variant->id => $price?->price ?? 0]; return [$variant->id => $price?->price ?? 0];
}); });
$capitalPriceType = $stockType === ProductStockQuality::REJECT->value $capitalPrices = $variants->mapWithKeys(function (ProductVariant $variant) {
? PriceType::REJECT_CAPITAL
: PriceType::CAPITAL;
$capitalPrices = $variants->mapWithKeys(function (ProductVariant $variant) use ($capitalPriceType) {
$price = $variant->productPrices $price = $variant->productPrices
->first(fn ($p) => $p->type === $capitalPriceType); ->first(fn ($p) => $p->type === PriceType::CAPITAL);
return [$variant->id => $price?->price ?? 0]; return [$variant->id => $price?->price ?? 0];
}); });

View File

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

View File

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

View File

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

View File

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

View File

@ -5,16 +5,14 @@
use App\Enums\Role; use App\Enums\Role;
use App\Models\RawMaterial; use App\Models\RawMaterial;
use App\Models\RawMaterialPrice; use App\Models\RawMaterialPrice;
use App\Services\Concerns\LogsFormHistory;
use App\Services\Concerns\RegistersMedia; use App\Services\Concerns\RegistersMedia;
use App\Services\NotificationService; use App\Services\NotificationService;
use App\Services\S3PresignedService; use App\Services\S3PresignedService;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class RawMaterialVariantService class RawMaterialVariantService
{ {
use LogsFormHistory, RegistersMedia; use RegistersMedia;
public function __construct( public function __construct(
private S3PresignedService $s3Service, private S3PresignedService $s3Service,
@ -26,24 +24,10 @@ public function getForCutting(): array
->select(['id', 'name', 'unit', 'is_active']) ->select(['id', 'name', 'unit', 'is_active'])
->with([ ->with([
'rawMaterialPrices:id,raw_material_id,variant,price,stock', 'rawMaterialPrices:id,raw_material_id,variant,price,stock',
'rawMaterialPrices.media',
]) ])
->active() ->active()
->orderBy('name') ->orderBy('name')
->get() ->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(); ->toArray();
} }
@ -68,9 +52,6 @@ public function getForEdit(RawMaterialPrice $variant): array
public function update(RawMaterialPrice $variant, array $data): RawMaterialPrice public function update(RawMaterialPrice $variant, array $data): RawMaterialPrice
{ {
$rawMaterial = $variant->rawMaterial;
$oldValues = $this->getRawMaterialLogValues($rawMaterial);
DB::transaction(function () use ($variant, $data) { DB::transaction(function () use ($variant, $data) {
$variant->update([ $variant->update([
'variant' => $data['variant'], 'variant' => $data['variant'],
@ -92,67 +73,28 @@ public function update(RawMaterialPrice $variant, array $data): RawMaterialPrice
}); });
NotificationService::notify( NotificationService::notify(
roles: [Role::OWNER, Role::DEVELOPER, Role::ADMIN_BAHAN_BAKU], roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO],
title: 'Varian Diperbarui', title: 'Varian Diperbarui',
body: "Varian \"{$variant->variant}\" berhasil diperbarui".' oleh '.auth()->user()->full_name.'.', body: "Varian \"{$variant->variant}\" berhasil diperbarui".' oleh '.auth()->user()->full_name.'.',
url: route('admin.master.raw-materials.index', ['highlight' => $variant->raw_material_id]), 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(); return $variant->fresh();
} }
public function destroy(RawMaterial $rawMaterial, RawMaterialPrice $variant): bool 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) { $result = DB::transaction(function () use ($variant) {
return $variant->delete(); return $variant->delete();
}); });
NotificationService::notify( NotificationService::notify(
roles: [Role::OWNER, Role::DEVELOPER, Role::ADMIN_BAHAN_BAKU], roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO],
title: 'Varian Dihapus', title: 'Varian Dihapus',
body: "Varian \"{$variant->variant}\" dari bahan baku \"{$rawMaterial->name}\" berhasil dihapus".' oleh '.auth()->user()->full_name.'.', body: "Varian \"{$variant->variant}\" dari bahan baku \"{$rawMaterial->name}\" berhasil dihapus".' oleh '.auth()->user()->full_name.'.',
url: route('admin.master.raw-materials.index'), url: route('admin.master.raw-materials.index'),
); );
$this->logDeleted($rawMaterial, 'Bahan Baku', $oldValues);
return $result; return $result;
} }
private function getRawMaterialLogValues(RawMaterial $rawMaterial): array
{
$rawMaterial->load('rawMaterialPrices');
return [
'Nama Bahan Baku' => $rawMaterial->name,
'Satuan' => $rawMaterial->unit?->label(),
'Status' => $this->formatBoolean($rawMaterial->is_active),
'Varian' => $rawMaterial->rawMaterialPrices->map(fn ($price) => [
'Nama Varian' => $price->variant,
'Harga' => $this->formatCurrency($price->price),
'Stok' => number_format((float) $price->stock, 2, ',', '.'),
])->toArray(),
];
}
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -37,11 +37,7 @@ export function FilterPopover({
)} )}
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent <PopoverContent className="w-64" align="end">
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 flex-col gap-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-sm font-medium">Filter</span> <span className="text-sm font-medium">Filter</span>

View File

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

View File

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

View File

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

View File

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

View File

@ -278,13 +278,9 @@ function ChartLegendContent({
payload, payload,
verticalAlign = "bottom", verticalAlign = "bottom",
nameKey, nameKey,
onItemClick,
activeName,
}: React.ComponentProps<"div"> & { }: React.ComponentProps<"div"> & {
hideIcon?: boolean hideIcon?: boolean
nameKey?: string nameKey?: string
onItemClick?: (name: string) => void
activeName?: string
} & RechartsPrimitive.DefaultLegendContentProps) { } & RechartsPrimitive.DefaultLegendContentProps) {
const { config } = useChart() const { config } = useChart()
@ -305,20 +301,13 @@ function ChartLegendContent({
.map((item, index) => { .map((item, index) => {
const key = `${nameKey ?? item.dataKey ?? "value"}` const key = `${nameKey ?? item.dataKey ?? "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key) const itemConfig = getPayloadConfigFromPayload(config, item, key)
const label = typeof itemConfig?.label === 'string' ? itemConfig.label : ''
const isInactive = activeName !== undefined && activeName !== label
return ( return (
<div <div
key={index} key={index}
className={cn( 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 && !hideIcon ? (
<itemConfig.icon /> <itemConfig.icon />

View File

@ -1,26 +1,26 @@
import { cva, type VariantProps } from "class-variance-authority"; import * as React from "react"
import { Slot } from "radix-ui"; import { cva, type VariantProps } from "class-variance-authority"
import * as React from "react"; import { Slot } from "radix-ui"
import { Button } from "@/components/ui/button"; import { useIsMobile } from "@/hooks/use-mobile"
import { Input } from "@/components/ui/input"; import { cn } from "@/lib/utils"
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 { import {
Sheet, Sheet,
SheetContent, SheetContent,
SheetDescription, SheetDescription,
SheetHeader, SheetHeader,
SheetTitle, SheetTitle,
} from "@/components/ui/sheet"; } from "@/components/ui/sheet"
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton"
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip"
import { useIsMobile } from "@/hooks/use-mobile"; import { PanelLeftIcon } from "lucide-react"
import { cn } from "@/lib/utils";
import { Menu } from "lucide-react";
const SIDEBAR_COOKIE_NAME = "sidebar_state" const SIDEBAR_COOKIE_NAME = "sidebar_state"
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7 const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
@ -268,7 +268,7 @@ function SidebarTrigger({
}} }}
{...props} {...props}
> >
<Menu /> <PanelLeftIcon />
<span className="sr-only">Toggle Sidebar</span> <span className="sr-only">Toggle Sidebar</span>
</Button> </Button>
) )
@ -559,7 +559,7 @@ function SidebarMenuAction({
className={cn( 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", "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 && 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 className
)} )}
{...props} {...props}
@ -702,5 +702,5 @@ export {
SidebarRail, SidebarRail,
SidebarSeparator, SidebarSeparator,
SidebarTrigger, SidebarTrigger,
useSidebar useSidebar,
}; }

View File

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

View File

@ -33,38 +33,6 @@ 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() { export function usePushNotification() {
const [permission, setPermission] = const [permission, setPermission] =
useState<PermissionStatus>(getInitialPermission); useState<PermissionStatus>(getInitialPermission);
@ -86,29 +54,40 @@ export function usePushNotification() {
async (vapidPublicKey: string): Promise<boolean> => { async (vapidPublicKey: string): Promise<boolean> => {
try { try {
const registration = await navigator.serviceWorker.ready; const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(
vapidPublicKey,
) as BufferSource,
});
const existingSubscription = const { endpoint } = subscription;
await registration.pushManager.getSubscription(); const key = subscription.getKey('p256dh');
const auth = subscription.getKey('auth');
if (existingSubscription) { const response = await fetch('/api/push/subscribe', {
const synced = await sendSubscriptionToServer( method: 'POST',
existingSubscription, 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',
}),
});
return synced; return response.ok;
}
const subscription =
await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(
vapidPublicKey,
) as BufferSource,
});
const saved = await sendSubscriptionToServer(subscription);
return saved;
} catch (error) { } catch (error) {
console.error( console.error(
'Failed to subscribe to push notifications:', 'Failed to subscribe to push notifications:',
@ -140,7 +119,6 @@ export function usePushNotification() {
document.cookie.match(/XSRF-TOKEN=([^;]+)/)?.[1] ?? '', document.cookie.match(/XSRF-TOKEN=([^;]+)/)?.[1] ?? '',
), ),
}, },
credentials: 'same-origin',
body: JSON.stringify({ body: JSON.stringify({
endpoint: subscription.endpoint, endpoint: subscription.endpoint,
}), }),

View File

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

View File

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

View File

@ -20,6 +20,7 @@ import {
Banknote, Banknote,
Package, Package,
ShoppingCart, ShoppingCart,
UserCheck,
} from 'lucide-react'; } from 'lucide-react';
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { import {
@ -50,6 +51,13 @@ type AnalysisProps = {
absent: number; absent: number;
on_leave: number; on_leave: number;
}; };
myAttendance: {
total_days: number;
present_days: number;
absent_days: number;
leave_days: number;
percentage: number;
} | null;
isManager: boolean; isManager: boolean;
cashOverview: { cashOverview: {
total_balance: number; total_balance: number;
@ -59,7 +67,6 @@ type AnalysisProps = {
}; };
rawMaterialStock: { rawMaterialStock: {
total_stock: number; total_stock: number;
total_price: number;
by_unit: { by_unit: {
yard: number; yard: number;
meter: number; meter: number;
@ -68,26 +75,12 @@ type AnalysisProps = {
}; };
productStock: { productStock: {
total_stock: number; total_stock: number;
total_price: number;
by_type: { by_type: {
stock?: number; stock?: number;
reject_stock?: number; reject_stock?: number;
retail_stock?: number; retail_stock?: number;
}; };
}; };
revenueByStockType: {
monthly: Array<{
month: string;
good: number;
reject: number;
retail: number;
}>;
totals: {
good: number;
reject: number;
retail: number;
};
};
revenueSummary: { revenueSummary: {
total_revenue: number; total_revenue: number;
total_discount: number; total_discount: number;
@ -240,17 +233,6 @@ const revenueTrendChartConfig = (() => {
const revenueTrendKeys = ['qty'] as const; 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 CHANNEL_COLORS: Record<string, string> = (() => {
const colors = generateRandomColors(3); const colors = generateRandomColors(3);
return { return {
@ -285,8 +267,6 @@ type DashboardPieChartProps = {
function DashboardPieChart({ title, data, dataKey, nameKey }: DashboardPieChartProps) { function DashboardPieChart({ title, data, dataKey, nameKey }: DashboardPieChartProps) {
const hasData = data.length > 0 && data.some((d) => d.count > 0); const hasData = data.length > 0 && data.some((d) => d.count > 0);
const [activeName, setActiveName] = useState<string | undefined>(undefined);
const pieColors = useMemo(() => generateRandomColors(5), []); const pieColors = useMemo(() => generateRandomColors(5), []);
const chartConfig = useMemo(() => { const chartConfig = useMemo(() => {
@ -307,17 +287,6 @@ function DashboardPieChart({ title, data, dataKey, nameKey }: DashboardPieChartP
})); }));
}, [data, pieColors]); }, [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 ( return (
<Card> <Card>
<CardHeader className="items-center pb-0"> <CardHeader className="items-center pb-0">
@ -342,24 +311,9 @@ function DashboardPieChart({ title, data, dataKey, nameKey }: DashboardPieChartP
data={chartData} data={chartData}
dataKey={dataKey} dataKey={dataKey}
nameKey={nameKey} 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 <ChartLegend
content={ content={<ChartLegendContent nameKey={nameKey} />}
<ChartLegendContent
nameKey={nameKey}
onItemClick={handleLegendClick}
activeName={activeName}
/>
}
className="-translate-y-2 flex-wrap gap-2 *:basis-1/4 *:justify-center" className="-translate-y-2 flex-wrap gap-2 *:basis-1/4 *:justify-center"
/> />
</PieChart> </PieChart>
@ -431,11 +385,11 @@ function DonutTooltip({ active, payload }: TooltipProps) {
export default function Analysis({ export default function Analysis({
filters: initialFilters, filters: initialFilters,
attendance, attendance,
myAttendance,
isManager, isManager,
cashOverview, cashOverview,
rawMaterialStock, rawMaterialStock,
productStock, productStock,
revenueByStockType,
revenueSummary, revenueSummary,
monthlyRevenue, monthlyRevenue,
monthlyRevenueByChannel, monthlyRevenueByChannel,
@ -457,7 +411,6 @@ export default function Analysis({
const [selectedPreset, setSelectedPreset] = useState(''); const [selectedPreset, setSelectedPreset] = useState('');
const [activeRevenueKey, setActiveRevenueKey] = useState<keyof typeof revenueChartConfig>('total'); const [activeRevenueKey, setActiveRevenueKey] = useState<keyof typeof revenueChartConfig>('total');
const [activeExpenseKey, setActiveExpenseKey] = useState<keyof typeof expenseChartConfig>('total'); const [activeExpenseKey, setActiveExpenseKey] = useState<keyof typeof expenseChartConfig>('total');
const [activeStockKey, setActiveStockKey] = useState<'good' | 'reject' | 'retail'>('good');
const hasActiveFilters = !!startDate || !!endDate; const hasActiveFilters = !!startDate || !!endDate;
@ -528,8 +481,6 @@ export default function Analysis({
]; ];
}, [monthlyRevenueByChannel]); }, [monthlyRevenueByChannel]);
const stockComparisonData = useMemo(() => revenueByStockType.monthly, [revenueByStockType]);
const peakHour = useMemo(() => { const peakHour = useMemo(() => {
if (busyHours.length === 0) { if (busyHours.length === 0) {
return { hour: '-', orders: 0 }; return { hour: '-', orders: 0 };
@ -555,10 +506,6 @@ 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 }; 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')) { if (hasRole('marketing')) {
return { statCards: 1, revenue: 2, revenueByChannel: 3, orderPie: 4, totalOrder: 5, revenueTrend: 6, topProducts: 7, topCustomers: 8 }; return { statCards: 1, revenue: 2, revenueByChannel: 3, orderPie: 4, totalOrder: 5, revenueTrend: 6, topProducts: 7, topCustomers: 8 };
} }
@ -606,6 +553,21 @@ export default function Analysis({
</div> </div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3" style={{ order: sectionOrder.statCards ?? 99 }}> <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') && ( {can('analysis.cash') && (
<StatCard <StatCard
title="Kas Toko" title="Kas Toko"
@ -632,9 +594,7 @@ export default function Analysis({
{ label: 'Yard', value: rawMaterialStock.by_unit?.yard?.toLocaleString('id-ID') ?? '0' }, { label: 'Yard', value: rawMaterialStock.by_unit?.yard?.toLocaleString('id-ID') ?? '0' },
{ label: 'Meter', value: rawMaterialStock.by_unit?.meter?.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: 'Kg', value: rawMaterialStock.by_unit?.kilogram?.toLocaleString('id-ID') ?? '0' },
{ label: 'Total Harga', value: `Rp${formatRupiah(rawMaterialStock.total_price)}` },
]} ]}
cols={4}
/> />
)} )}
@ -649,77 +609,11 @@ export default function Analysis({
{ label: 'Bagus', value: (productStock.by_type?.stock ?? 0).toLocaleString('id-ID') }, { label: 'Bagus', value: (productStock.by_type?.stock ?? 0).toLocaleString('id-ID') },
{ label: 'Reject', value: (productStock.by_type?.reject_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: 'Ecer', value: (productStock.by_type?.retail_stock ?? 0).toLocaleString('id-ID') },
{ label: 'Total Harga', value: `Rp${formatRupiah(productStock.total_price)}` },
]} ]}
cols={4}
/> />
)} )}
</div> </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') && ( {can('analysis.revenue') && (
<Card className="py-0" style={{ order: sectionOrder.revenue ?? 99 }}> <Card className="py-0" style={{ order: sectionOrder.revenue ?? 99 }}>
<CardHeader className="flex flex-col items-stretch border-b p-0! sm:flex-row"> <CardHeader className="flex flex-col items-stretch border-b p-0! sm:flex-row">
@ -1092,7 +986,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">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 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 Subtotal</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">Total Diskon</th>
<th className="px-4 py-3 text-right font-medium text-muted-foreground">Rata-rata Order</th> <th className="px-4 py-3 text-right font-medium text-muted-foreground">Rata-rata Order</th>
</tr> </tr>
</thead> </thead>

View File

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

View File

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

View File

@ -18,7 +18,6 @@ export type Employee = {
} | null; } | null;
employee: { employee: {
join_date: string; join_date: string;
resign_date: string | null;
employment_status: string; employment_status: string;
base_salary: number; base_salary: number;
} | null; } | null;
@ -125,67 +124,6 @@ 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 ...(canViewAll
? [ ? [
{ {

View File

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

View File

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

View File

@ -20,6 +20,8 @@ function generateShareLink(cuttingId: number): string {
} }
function generateWhatsappText(cutting: Cutting): string { function generateWhatsappText(cutting: Cutting): string {
const result = cutting.cutting_results?.[0];
const items = cutting.cutting_materials ?? [];
const shareLink = generateShareLink(cutting.id); const shareLink = generateShareLink(cutting.id);
let text = `*Cutting #${cutting.id}*\n`; let text = `*Cutting #${cutting.id}*\n`;
@ -31,12 +33,25 @@ function generateWhatsappText(cutting: Cutting): string {
text += `Deskripsi: ${cutting.description}\n`; text += `Deskripsi: ${cutting.description}\n`;
} }
if (cutting.product_name) { const productNames = new Set<string>();
text += `\nNama Produk: ${cutting.product_name}\n`; (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.cutting_result) { const totalUsage = items.reduce(
text += `Total Hasil Cutting: ${formatNumber(cutting.cutting_result)} pcs\n`; (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`;
} }
text += `\nLihat detail lengkap:\n${shareLink}`; text += `\nLihat detail lengkap:\n${shareLink}`;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,5 +1,4 @@
import { ChevronDown, Pencil, Trash2 } from 'lucide-react'; import { ChevronDown, Pencil, Trash2 } from 'lucide-react';
import { ImagePreviewButton } from '@/components/dialogs';
import { RowActions } from '@/components/data-display'; import { RowActions } from '@/components/data-display';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@ -119,18 +118,6 @@ export function RestockCardRow({
{formatCurrency(restock.total)} {formatCurrency(restock.total)}
</span> </span>
</div> </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> </div>
{(can('restocks.update') || can('restocks.delete')) && ( {(can('restocks.update') || can('restocks.delete')) && (

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,78 +0,0 @@
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