feat: enhance transaction management by adding cashier role checks and updating price type logic

This commit is contained in:
Yoga Pangestu 2026-08-16 16:20:51 +07:00
parent 85ba5631b3
commit 731c8aa6da
7 changed files with 160 additions and 91 deletions

View File

@ -5,6 +5,7 @@
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;
@ -45,13 +46,20 @@ 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()->filter(fn ($p) => ! in_array($p['value'], [PriceType::CAPITAL->value, PriceType::REJECT_CAPITAL->value]))->values(), 'priceTypeOptions' => PriceType::toSelect()
->filter(fn ($p) => ! in_array($p['value'], [PriceType::CAPITAL->value, PriceType::REJECT_CAPITAL->value]))
->when($isCashier, fn ($q) => $q->filter(fn ($p) => in_array($p['value'], [PriceType::RETAIL->value, PriceType::REJECT_SELLING->value])))
->when(! $isCashier, fn ($q) => $q->filter(fn ($p) => $p['value'] !== PriceType::RETAIL->value))
->values(),
]); ]);
} }
@ -67,6 +75,9 @@ 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(),
@ -74,7 +85,11 @@ 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()->filter(fn ($p) => ! in_array($p['value'], [PriceType::CAPITAL->value, PriceType::REJECT_CAPITAL->value]))->values(), 'priceTypeOptions' => PriceType::toSelect()
->filter(fn ($p) => ! in_array($p['value'], [PriceType::CAPITAL->value, PriceType::REJECT_CAPITAL->value]))
->when($isCashier, fn ($q) => $q->filter(fn ($p) => in_array($p['value'], [PriceType::RETAIL->value, PriceType::REJECT_SELLING->value])))
->when(! $isCashier, fn ($q) => $q->filter(fn ($p) => $p['value'] !== PriceType::RETAIL->value))
->values(),
]); ]);
} }

View File

@ -253,7 +253,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_type?->value ?? ProductStockQuality::GOOD->value; $oldStockType = $order->orderItems->first()?->stock_quality?->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,11 +328,13 @@ public function destroy(Order $order): bool
$result = DB::transaction(function () use ($order) { $result = DB::transaction(function () use ($order) {
$order->load('orderItems'); $order->load('orderItems');
$stockType = $order->orderItems->first()?->stock_type?->value ?? ProductStockQuality::GOOD->value; if (! in_array($order->status, [OrderStatus::CANCELLED, OrderStatus::REFUNDED])) {
$stockType = $order->orderItems->first()?->stock_quality?->value ?? ProductStockQuality::GOOD->value;
$order->orderItems->each(function (OrderItem $item) use ($stockType) { $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();
@ -352,8 +354,19 @@ 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;
} }

View File

@ -73,7 +73,7 @@ public function getForTransaction(): array
$products = Product::query() $products = 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,retail_stock',
'productVariants.productPrices:id,variant_id,type,price', 'productVariants.productPrices:id,variant_id,type,price',
]) ])
->active() ->active()

View File

@ -13,6 +13,7 @@ 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,8 +1,8 @@
export type TransactionStockType = 'good' | 'reject'; export type TransactionStockType = 'good' | 'reject' | 'retail';
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'; export type TransactionPriceType = 'distributor' | 'agent' | 'sub_agent' | 'wholesale' | 'retail' | 'tiktok' | 'shopee' | 'reject_selling';
export type TransactionPaymentType = 'cash' | 'transfer' | 'marketplace' | 'qris'; export type TransactionPaymentType = 'cash' | 'transfer' | 'marketplace' | 'qris';
@ -109,6 +109,7 @@ 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 { FileUpload } from '@/components/inputs';
import { ImagePreviewModal } from '@/components/dialogs'; import { ImagePreviewModal } from '@/components/dialogs';
import { InputError } from '@/components/ui'; import { FileUpload } from '@/components/inputs';
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,6 +39,7 @@ 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';
@ -80,6 +81,8 @@ 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);
@ -87,7 +90,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 ?? 'retail'); const [priceType, setPriceType] = useState(draft?.priceType ?? (isCashier ? 'retail' : '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);
@ -176,16 +179,20 @@ 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]); }, [channel, isCashier]);
useEffect(() => { useEffect(() => {
if (stockType === 'reject') { if (stockType === 'reject') {
setPriceType('reject_selling'); setPriceType('reject_selling');
} else if (isCashier) {
setPriceType('retail');
} else if (priceType === 'reject_selling') { } else if (priceType === 'reject_selling') {
setPriceType('retail'); setPriceType('retail');
} }
}, [stockType]); }, [stockType, isCashier]);
const showPhoto = paymentType === 'transfer' || paymentType === 'qris'; const showPhoto = paymentType === 'transfer' || paymentType === 'qris';
@ -194,8 +201,12 @@ export default function TransactionCreate({
return priceTypeOptions.filter((o) => o.value === 'reject_selling'); return priceTypeOptions.filter((o) => o.value === 'reject_selling');
} }
return priceTypeOptions.filter((o) => SELLING_PRICE_TYPES.includes(o.value)); if (isCashier) {
}, [stockType, priceTypeOptions]); return priceTypeOptions.filter((o) => o.value === 'retail');
}
return priceTypeOptions.filter((o) => SELLING_PRICE_TYPES.includes(o.value) && o.value !== 'retail');
}, [stockType, priceTypeOptions, isCashier]);
const getUnitPrice = useCallback( const getUnitPrice = useCallback(
(variantId: number) => { (variantId: number) => {
@ -209,9 +220,13 @@ export default function TransactionCreate({
return variant.prices?.reject_selling ?? 0; return variant.prices?.reject_selling ?? 0;
} }
if (isCashier) {
return variant.prices?.retail ?? 0;
}
return variant.prices?.[priceType] ?? 0; return variant.prices?.[priceType] ?? 0;
}, },
[variantById, stockType, priceType], [variantById, stockType, priceType, isCashier],
); );
const subtotal = Object.entries(quantities).reduce( const subtotal = Object.entries(quantities).reduce(
@ -236,8 +251,10 @@ 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),
@ -282,9 +299,9 @@ export default function TransactionCreate({
function getPayload() { function getPayload() {
return { return {
stock_type: stockType, stock_type: stockType === 'good' && isCashier ? 'retail' : stockType,
channel, channel,
price_type: stockType === 'reject' ? 'reject_selling' : priceType, price_type: stockType === 'reject' ? 'reject_selling' : (isCashier ? 'retail' : priceType),
payment_type: paymentType, payment_type: paymentType,
customer_id: customerId, customer_id: customerId,
marketing_id: marketingId, marketing_id: marketingId,
@ -391,9 +408,9 @@ export default function TransactionCreate({
{selectedProduct.product_variants.map( {selectedProduct.product_variants.map(
(variant) => { (variant) => {
const currentStock = const currentStock =
stockType === 'good' stockType === 'reject'
? variant.stock ? variant.reject_stock
: variant.reject_stock; : (isCashier ? variant.retail_stock : variant.stock);
return ( return (
<div <div
@ -444,7 +461,7 @@ export default function TransactionCreate({
formatCurrency( formatCurrency(
stockType === 'reject' stockType === 'reject'
? (variant.prices?.reject_selling ?? 0) ? (variant.prices?.reject_selling ?? 0)
: (variant.prices?.[priceType] ?? 0), : (isCashier ? (variant.prices?.retail ?? 0) : (variant.prices?.[priceType] ?? 0)),
) )
)} )}
</p> </p>
@ -629,6 +646,7 @@ 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
@ -656,6 +674,7 @@ export default function TransactionCreate({
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 { FileUpload } from '@/components/inputs';
import { ImagePreviewModal } from '@/components/dialogs'; import { ImagePreviewModal } from '@/components/dialogs';
import { InputError } from '@/components/ui'; import { FileUpload } from '@/components/inputs';
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,6 +39,7 @@ 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';
@ -79,6 +80,9 @@ 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',
); );
@ -129,16 +133,20 @@ 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]); }, [channel, isCashier]);
useEffect(() => { useEffect(() => {
if (stockType === 'reject') { if (stockType === 'reject') {
setPriceType('reject_selling'); setPriceType('reject_selling');
} else if (isCashier) {
setPriceType('retail');
} else if (priceType === 'reject_selling') { } else if (priceType === 'reject_selling') {
setPriceType('retail'); setPriceType('retail');
} }
}, [stockType]); }, [stockType, isCashier]);
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 ?? '');
@ -165,8 +173,12 @@ export default function TransactionEdit({
return priceTypeOptions.filter((o) => o.value === 'reject_selling'); return priceTypeOptions.filter((o) => o.value === 'reject_selling');
} }
return priceTypeOptions.filter((o) => SELLING_PRICE_TYPES.includes(o.value)); if (isCashier) {
}, [stockType, priceTypeOptions]); return priceTypeOptions.filter((o) => o.value === 'retail');
}
return priceTypeOptions.filter((o) => SELLING_PRICE_TYPES.includes(o.value) && o.value !== 'retail');
}, [stockType, priceTypeOptions, isCashier]);
const getUnitPrice = useCallback( const getUnitPrice = useCallback(
(variantId: number) => { (variantId: number) => {
@ -180,9 +192,13 @@ export default function TransactionEdit({
return variant.prices?.reject_selling ?? 0; return variant.prices?.reject_selling ?? 0;
} }
if (isCashier) {
return variant.prices?.retail ?? 0;
}
return variant.prices?.[priceType] ?? 0; return variant.prices?.[priceType] ?? 0;
}, },
[variantById, stockType, priceType], [variantById, stockType, priceType, isCashier],
); );
const subtotal = Object.entries(quantities).reduce( const subtotal = Object.entries(quantities).reduce(
@ -207,8 +223,10 @@ 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),
@ -253,9 +271,9 @@ export default function TransactionEdit({
function getPayload() { function getPayload() {
return { return {
stock_type: stockType, stock_type: stockType === 'good' && isCashier ? 'retail' : stockType,
channel, channel,
price_type: stockType === 'reject' ? 'reject_selling' : priceType, price_type: stockType === 'reject' ? 'reject_selling' : (isCashier ? 'retail' : priceType),
payment_type: paymentType, payment_type: paymentType,
customer_id: customerId, customer_id: customerId,
marketing_id: marketingId, marketing_id: marketingId,
@ -371,9 +389,9 @@ export default function TransactionEdit({
{selectedProduct.product_variants.map( {selectedProduct.product_variants.map(
(variant) => { (variant) => {
const currentStock = const currentStock =
stockType === 'good' stockType === 'reject'
? variant.stock ? variant.reject_stock
: variant.reject_stock; : (isCashier ? variant.retail_stock : variant.stock);
return ( return (
<div <div
@ -424,7 +442,7 @@ export default function TransactionEdit({
formatCurrency( formatCurrency(
stockType === 'reject' stockType === 'reject'
? (variant.prices?.reject_selling ?? 0) ? (variant.prices?.reject_selling ?? 0)
: (variant.prices?.[priceType] ?? 0), : (isCashier ? (variant.prices?.retail ?? 0) : (variant.prices?.[priceType] ?? 0)),
) )
)} )}
</p> </p>
@ -609,6 +627,7 @@ 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
@ -636,6 +655,7 @@ export default function TransactionEdit({
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>