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\PaymentType;
use App\Enums\PriceType;
use App\Enums\Role;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\Manage\TransactionRequest;
use App\Http\Requests\PaginatedRequest;
@ -45,13 +46,20 @@ public function index(PaginatedRequest $request): Response
public function create(): Response
{
$user = auth()->user();
$isCashier = $user->hasRole(Role::CASHIER);
return Inertia::render('admin/manage/transaction/create', [
'products' => $this->productVariantService->getForTransaction(),
'customers' => $this->customerService->getAll(),
'employees' => $this->getEmployees(),
'channelOptions' => OrderChannel::toSelect(),
'paymentTypeOptions' => PaymentType::toSelect(),
'priceTypeOptions' => PriceType::toSelect()->filter(fn ($p) => ! 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
{
$user = auth()->user();
$isCashier = $user->hasRole(Role::CASHIER);
return Inertia::render('admin/manage/transaction/edit', [
'transaction' => $this->service->getForEdit($transaction),
'products' => $this->productVariantService->getForTransaction(),
@ -74,7 +85,11 @@ public function edit(Order $transaction): Response
'employees' => $this->getEmployees(),
'channelOptions' => OrderChannel::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->load('orderItems');
$oldStockType = $order->orderItems->first()?->stock_type?->value ?? ProductStockQuality::GOOD->value;
$oldStockType = $order->orderItems->first()?->stock_quality?->value ?? ProductStockQuality::GOOD->value;
$order->orderItems->each(function (OrderItem $item) use ($oldStockType) {
$this->adjustVariantStock($item->product_variant_id, $item->quantity, 1, $oldStockType);
@ -328,11 +328,13 @@ public function destroy(Order $order): bool
$result = DB::transaction(function () use ($order) {
$order->load('orderItems');
$stockType = $order->orderItems->first()?->stock_type?->value ?? ProductStockQuality::GOOD->value;
if (! in_array($order->status, [OrderStatus::CANCELLED, OrderStatus::REFUNDED])) {
$stockType = $order->orderItems->first()?->stock_quality?->value ?? ProductStockQuality::GOOD->value;
$order->orderItems->each(function (OrderItem $item) use ($stockType) {
$this->adjustVariantStock($item->product_variant_id, $item->quantity, 1, $stockType);
});
$order->orderItems->each(function (OrderItem $item) use ($stockType) {
$this->adjustVariantStock($item->product_variant_id, $item->quantity, 1, $stockType);
});
}
$order->orderItems()->delete();
$order->delete();
@ -352,8 +354,19 @@ public function destroy(Order $order): bool
public function updateStatus(Order $order, string $status): Order
{
$oldStatus = $order->status->value;
$order->update(['status' => $status]);
if (in_array($status, [OrderStatus::CANCELLED->value, OrderStatus::REFUNDED->value]) && ! in_array($oldStatus, [OrderStatus::CANCELLED->value, OrderStatus::REFUNDED->value])) {
$order->load('orderItems');
$stockType = $order->orderItems->first()?->stock_quality?->value ?? ProductStockQuality::GOOD->value;
$order->orderItems->each(function (OrderItem $item) use ($stockType) {
$this->adjustVariantStock($item->product_variant_id, $item->quantity, 1, $stockType);
});
}
return $order;
}

View File

@ -73,7 +73,7 @@ public function getForTransaction(): array
$products = Product::query()
->select(['id', 'name', 'status'])
->with([
'productVariants:id,product_id,name,stock,reject_stock',
'productVariants:id,product_id,name,stock,reject_stock,retail_stock',
'productVariants.productPrices:id,variant_id,type,price',
])
->active()

View File

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

View File

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

View File

@ -5,11 +5,11 @@ import { ArrowLeft, Minus, Plus, ShoppingCart, Trash2 } from 'lucide-react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { toast } from 'sonner';
import { ConfirmDialog } from '@/components/dialogs';
import { FileUpload } from '@/components/inputs';
import { ImagePreviewModal } from '@/components/dialogs';
import { InputError } from '@/components/ui';
import { FileUpload } from '@/components/inputs';
import { NumberInput } from '@/components/inputs';
import { RupiahInput } from '@/components/inputs';
import { InputError } from '@/components/ui';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
@ -39,6 +39,7 @@ import {
} from '@/components/ui/sheet';
import { Switch } from '@/components/ui/switch';
import { Textarea } from '@/components/ui/textarea';
import { useCan } from '@/hooks/use-can';
import { useTransactionDraftSave } from '@/hooks/use-transaction-draft';
import { formatNumber } from '@/lib/format';
import { loadTransactionDraft } from '@/lib/transaction-draft';
@ -80,6 +81,8 @@ export default function TransactionCreate({
}: Props) {
const { auth } = usePage().props as { auth: { user?: { id?: number } } };
const userId = auth.user?.id;
const { hasRole } = useCan();
const isCashier = hasRole('cashier');
const draft = loadTransactionDraft('create', userId);
@ -87,7 +90,7 @@ export default function TransactionCreate({
draft?.stockType === 'reject' ? 'reject' : 'good',
);
const [channel, setChannel] = useState(draft?.channel ?? 'store');
const [priceType, setPriceType] = useState(draft?.priceType ?? 'retail');
const [priceType, setPriceType] = useState(draft?.priceType ?? (isCashier ? 'retail' : 'retail'));
const [paymentType, setPaymentType] = useState(draft?.paymentType ?? 'cash');
const [customerId, setCustomerId] = useState<number | null>(draft?.customerId ?? null);
const [marketingId, setMarketingId] = useState<number | null>(draft?.marketingId ?? null);
@ -176,16 +179,20 @@ export default function TransactionCreate({
} else if (channel === 'shopee') {
setPriceType('shopee');
setPaymentType('marketplace');
} else if (isCashier) {
setPriceType('retail');
}
}, [channel]);
}, [channel, isCashier]);
useEffect(() => {
if (stockType === 'reject') {
setPriceType('reject_selling');
} else if (isCashier) {
setPriceType('retail');
} else if (priceType === 'reject_selling') {
setPriceType('retail');
}
}, [stockType]);
}, [stockType, isCashier]);
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) => SELLING_PRICE_TYPES.includes(o.value));
}, [stockType, priceTypeOptions]);
if (isCashier) {
return priceTypeOptions.filter((o) => o.value === 'retail');
}
return priceTypeOptions.filter((o) => SELLING_PRICE_TYPES.includes(o.value) && o.value !== 'retail');
}, [stockType, priceTypeOptions, isCashier]);
const getUnitPrice = useCallback(
(variantId: number) => {
@ -209,9 +220,13 @@ export default function TransactionCreate({
return variant.prices?.reject_selling ?? 0;
}
if (isCashier) {
return variant.prices?.retail ?? 0;
}
return variant.prices?.[priceType] ?? 0;
},
[variantById, stockType, priceType],
[variantById, stockType, priceType, isCashier],
);
const subtotal = Object.entries(quantities).reduce(
@ -236,8 +251,10 @@ export default function TransactionCreate({
(variantId: number, amount: number) => {
if (amount > 0 && getUnitPrice(variantId) <= 0) {
toast.error('Harga produk ini belum diatur.');
return;
}
setQuantities((prev) => ({
...prev,
[variantId]: Math.max(0, (prev[variantId] ?? 0) + amount),
@ -282,9 +299,9 @@ export default function TransactionCreate({
function getPayload() {
return {
stock_type: stockType,
stock_type: stockType === 'good' && isCashier ? 'retail' : stockType,
channel,
price_type: stockType === 'reject' ? 'reject_selling' : priceType,
price_type: stockType === 'reject' ? 'reject_selling' : (isCashier ? 'retail' : priceType),
payment_type: paymentType,
customer_id: customerId,
marketing_id: marketingId,
@ -391,9 +408,9 @@ export default function TransactionCreate({
{selectedProduct.product_variants.map(
(variant) => {
const currentStock =
stockType === 'good'
? variant.stock
: variant.reject_stock;
stockType === 'reject'
? variant.reject_stock
: (isCashier ? variant.retail_stock : variant.stock);
return (
<div
@ -444,7 +461,7 @@ export default function TransactionCreate({
formatCurrency(
stockType === 'reject'
? (variant.prices?.reject_selling ?? 0)
: (variant.prices?.[priceType] ?? 0),
: (isCashier ? (variant.prices?.retail ?? 0) : (variant.prices?.[priceType] ?? 0)),
)
)}
</p>
@ -629,33 +646,35 @@ export default function TransactionCreate({
</div>
)}
<div className="grid gap-2">
<Label>Tipe Harga <span className="text-destructive">*</span></Label>
<Select
value={priceType}
onValueChange={setPriceType}
disabled={channel === 'tiktok' || channel === 'shopee'}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Pilih tipe harga" />
</SelectTrigger>
<SelectContent>
{availablePriceTypes.map(
(opt) => (
<SelectItem
key={opt.value}
value={opt.value}
>
{opt.label}
</SelectItem>
),
)}
</SelectContent>
</Select>
<InputError
message={errors.price_type}
/>
</div>
{!isCashier && (
<div className="grid gap-2">
<Label>Tipe Harga <span className="text-destructive">*</span></Label>
<Select
value={priceType}
onValueChange={setPriceType}
disabled={channel === 'tiktok' || channel === 'shopee'}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Pilih tipe harga" />
</SelectTrigger>
<SelectContent>
{availablePriceTypes.map(
(opt) => (
<SelectItem
key={opt.value}
value={opt.value}
>
{opt.label}
</SelectItem>
),
)}
</SelectContent>
</Select>
<InputError
message={errors.price_type}
/>
</div>
)}
<div className="grid gap-2">
<Label>Tipe Pembayaran <span className="text-destructive">*</span></Label>

View File

@ -5,11 +5,11 @@ import { ArrowLeft, Minus, Plus, ShoppingCart, Trash2 } from 'lucide-react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { toast } from 'sonner';
import { ConfirmDialog } from '@/components/dialogs';
import { FileUpload } from '@/components/inputs';
import { ImagePreviewModal } from '@/components/dialogs';
import { InputError } from '@/components/ui';
import { FileUpload } from '@/components/inputs';
import { NumberInput } from '@/components/inputs';
import { RupiahInput } from '@/components/inputs';
import { InputError } from '@/components/ui';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
@ -39,6 +39,7 @@ import {
} from '@/components/ui/sheet';
import { Switch } from '@/components/ui/switch';
import { Textarea } from '@/components/ui/textarea';
import { useCan } from '@/hooks/use-can';
import { formatNumber } from '@/lib/format';
import { getTemporaryUrl } from '@/lib/upload';
import { formatCurrency } from '@/lib/utils';
@ -79,6 +80,9 @@ export default function TransactionEdit({
priceTypeOptions,
}: Props) {
const { hasRole } = useCan();
const isCashier = hasRole('cashier');
const [stockType, setStockType] = useState<'good' | 'reject'>(
transaction.stock_type === 'reject' ? 'reject' : 'good',
);
@ -129,16 +133,20 @@ export default function TransactionEdit({
} else if (channel === 'shopee') {
setPriceType('shopee');
setPaymentType('marketplace');
} else if (isCashier) {
setPriceType('retail');
}
}, [channel]);
}, [channel, isCashier]);
useEffect(() => {
if (stockType === 'reject') {
setPriceType('reject_selling');
} else if (isCashier) {
setPriceType('retail');
} else if (priceType === 'reject_selling') {
setPriceType('retail');
}
}, [stockType]);
}, [stockType, isCashier]);
const [tiktokOrderId, setTiktokOrderId] = useState(transaction.tiktok_order_id ?? '');
const [shopeeOrderId, setShopeeOrderId] = useState(transaction.shopee_order_id ?? '');
@ -165,8 +173,12 @@ export default function TransactionEdit({
return priceTypeOptions.filter((o) => o.value === 'reject_selling');
}
return priceTypeOptions.filter((o) => SELLING_PRICE_TYPES.includes(o.value));
}, [stockType, priceTypeOptions]);
if (isCashier) {
return priceTypeOptions.filter((o) => o.value === 'retail');
}
return priceTypeOptions.filter((o) => SELLING_PRICE_TYPES.includes(o.value) && o.value !== 'retail');
}, [stockType, priceTypeOptions, isCashier]);
const getUnitPrice = useCallback(
(variantId: number) => {
@ -180,9 +192,13 @@ export default function TransactionEdit({
return variant.prices?.reject_selling ?? 0;
}
if (isCashier) {
return variant.prices?.retail ?? 0;
}
return variant.prices?.[priceType] ?? 0;
},
[variantById, stockType, priceType],
[variantById, stockType, priceType, isCashier],
);
const subtotal = Object.entries(quantities).reduce(
@ -207,8 +223,10 @@ export default function TransactionEdit({
(variantId: number, amount: number) => {
if (amount > 0 && getUnitPrice(variantId) <= 0) {
toast.error('Harga produk ini belum diatur.');
return;
}
setQuantities((prev) => ({
...prev,
[variantId]: Math.max(0, (prev[variantId] ?? 0) + amount),
@ -253,9 +271,9 @@ export default function TransactionEdit({
function getPayload() {
return {
stock_type: stockType,
stock_type: stockType === 'good' && isCashier ? 'retail' : stockType,
channel,
price_type: stockType === 'reject' ? 'reject_selling' : priceType,
price_type: stockType === 'reject' ? 'reject_selling' : (isCashier ? 'retail' : priceType),
payment_type: paymentType,
customer_id: customerId,
marketing_id: marketingId,
@ -371,9 +389,9 @@ export default function TransactionEdit({
{selectedProduct.product_variants.map(
(variant) => {
const currentStock =
stockType === 'good'
? variant.stock
: variant.reject_stock;
stockType === 'reject'
? variant.reject_stock
: (isCashier ? variant.retail_stock : variant.stock);
return (
<div
@ -424,7 +442,7 @@ export default function TransactionEdit({
formatCurrency(
stockType === 'reject'
? (variant.prices?.reject_selling ?? 0)
: (variant.prices?.[priceType] ?? 0),
: (isCashier ? (variant.prices?.retail ?? 0) : (variant.prices?.[priceType] ?? 0)),
)
)}
</p>
@ -609,33 +627,35 @@ export default function TransactionEdit({
</div>
)}
<div className="grid gap-2">
<Label>Tipe Harga <span className="text-destructive">*</span></Label>
<Select
value={priceType}
onValueChange={setPriceType}
disabled={channel === 'tiktok' || channel === 'shopee'}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Pilih tipe harga" />
</SelectTrigger>
<SelectContent>
{availablePriceTypes.map(
(opt) => (
<SelectItem
key={opt.value}
value={opt.value}
>
{opt.label}
</SelectItem>
),
)}
</SelectContent>
</Select>
<InputError
message={errors.price_type}
/>
</div>
{!isCashier && (
<div className="grid gap-2">
<Label>Tipe Harga <span className="text-destructive">*</span></Label>
<Select
value={priceType}
onValueChange={setPriceType}
disabled={channel === 'tiktok' || channel === 'shopee'}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Pilih tipe harga" />
</SelectTrigger>
<SelectContent>
{availablePriceTypes.map(
(opt) => (
<SelectItem
key={opt.value}
value={opt.value}
>
{opt.label}
</SelectItem>
),
)}
</SelectContent>
</Select>
<InputError
message={errors.price_type}
/>
</div>
)}
<div className="grid gap-2">
<Label>Tipe Pembayaran <span className="text-destructive">*</span></Label>