- Integrated toast notifications to display error messages when form submissions fail across various components, enhancing user feedback and experience. - Updated components including DeleteUser, FormDialog, ManageTwoFactor, TwoFactorRecoveryCodes, TwoFactorSetupModal, PayrollPeriodShow, EmployeeCreate, EmployeeEdit, CuttingCreate, CuttingEdit, PurchaseCreate, PurchaseEdit, RestockCreate, RestockEdit, TransactionCreate, TransactionEdit, Category management, Product management, Raw Material management, Role management, Settings, and Authentication pages.
1052 lines
58 KiB
TypeScript
1052 lines
58 KiB
TypeScript
'use no memo';
|
|
|
|
import { ConfirmDialog } from '@/components/confirm-dialog';
|
|
import { FileUpload } from '@/components/file-upload';
|
|
import { ImagePreviewModal } from '@/components/image-preview-modal';
|
|
import InputError from '@/components/input-error';
|
|
import { NumberInput } from '@/components/number-input';
|
|
import { RupiahInput } from '@/components/rupiah-input';
|
|
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 { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from '@/components/ui/select';
|
|
import {
|
|
Sheet,
|
|
SheetContent,
|
|
SheetFooter,
|
|
SheetHeader,
|
|
SheetTitle,
|
|
} from '@/components/ui/sheet';
|
|
import { Switch } from '@/components/ui/switch';
|
|
import { Textarea } from '@/components/ui/textarea';
|
|
import { useTransactionDraftSave } from '@/hooks/use-transaction-draft';
|
|
import { formatNumber } from '@/lib/format';
|
|
import { loadTransactionDraft } from '@/lib/transaction-draft';
|
|
import { getTemporaryUrl } from '@/lib/upload';
|
|
import { formatCurrency } from '@/lib/utils';
|
|
import { store, index as transactionIndex } from '@/routes/admin/manage/transactions';
|
|
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 { ProductForTransaction, TransactionCreateData } from './columns';
|
|
import { FieldDescription } from '@/components/ui/field';
|
|
|
|
type CartLine = {
|
|
key: string;
|
|
photoUrl: string | null;
|
|
title: string;
|
|
subtitle: string;
|
|
price: number;
|
|
quantity: number;
|
|
onAdjust: (delta: number) => void;
|
|
onSet: (value: number) => void;
|
|
onRemove: () => void;
|
|
};
|
|
|
|
type Props = {
|
|
data: TransactionCreateData;
|
|
};
|
|
|
|
const SELLING_PRICE_TYPES = ['distributor', 'agent', 'sub_agent', 'wholesale', 'retail', 'tiktok', 'shopee'];
|
|
|
|
export default function TransactionCreate({ data }: Props) {
|
|
const { products, customers, employees, channelOptions, paymentTypeOptions, priceTypeOptions } = data;
|
|
const { auth } = usePage().props as { auth: { user?: { id?: number } } };
|
|
const userId = auth.user?.id;
|
|
|
|
const draft = loadTransactionDraft('create', userId);
|
|
|
|
const [stockType, setStockType] = useState<'good' | 'reject'>(
|
|
draft?.stockType === 'reject' ? 'reject' : 'good',
|
|
);
|
|
const [channel, setChannel] = useState(draft?.channel ?? 'store');
|
|
const [priceType, setPriceType] = useState(draft?.priceType ?? '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);
|
|
const [discount, setDiscount] = useState(draft?.discount ?? 0);
|
|
const [negoPrice, setNegoPrice] = useState<number | null>(draft?.negoPrice ?? null);
|
|
const [isCompleted, setIsCompleted] = useState(draft?.isCompleted ?? false);
|
|
const [isAffiliate, setIsAffiliate] = useState(draft?.isAffiliate ?? false);
|
|
const [notes, setNotes] = useState(draft?.notes ?? '');
|
|
const [photo, setPhoto] = useState<string | null>(draft?.photo ?? null);
|
|
const [photoUrl, setPhotoUrl] = useState<string | null>(
|
|
draft?.photo ? getTemporaryUrl(draft.photo) : null,
|
|
);
|
|
const [uploading, setUploading] = useState(false);
|
|
const [selectedProductId, setSelectedProductId] = useState(draft?.selectedProductId ?? '');
|
|
const [quantities, setQuantities] = useState<Record<number, number>>(() =>
|
|
Object.fromEntries(
|
|
Object.entries(draft?.quantities ?? {}).map(([id, qty]) => [
|
|
Number(id),
|
|
qty,
|
|
]),
|
|
),
|
|
);
|
|
const [cartOpen, setCartOpen] = useState(false);
|
|
const [previewKey, setPreviewKey] = useState<string | null>(null);
|
|
const [cartRemoveKey, setCartRemoveKey] = useState<string | null>(null);
|
|
const [tiktokOrderId, setTiktokOrderId] = useState(draft?.tiktokOrderId ?? '');
|
|
const [shopeeOrderId, setShopeeOrderId] = useState(draft?.shopeeOrderId ?? '');
|
|
|
|
const quantitiesRef = useRef(quantities);
|
|
|
|
useEffect(() => {
|
|
quantitiesRef.current = quantities;
|
|
}, [quantities]);
|
|
|
|
const draftData = useMemo(
|
|
() => ({
|
|
stockType,
|
|
channel,
|
|
priceType,
|
|
paymentType,
|
|
customerId,
|
|
marketingId,
|
|
discount,
|
|
negoPrice,
|
|
isCompleted,
|
|
isAffiliate,
|
|
tiktokOrderId: channel === 'tiktok' ? tiktokOrderId : '',
|
|
shopeeOrderId: channel === 'shopee' ? shopeeOrderId : '',
|
|
selectedProductId,
|
|
quantities: Object.fromEntries(
|
|
Object.entries(quantities).map(([id, qty]) => [
|
|
String(id),
|
|
qty,
|
|
]),
|
|
),
|
|
notes,
|
|
photo: photo ?? undefined,
|
|
}),
|
|
[
|
|
stockType, channel, priceType, paymentType,
|
|
customerId, marketingId, discount, negoPrice,
|
|
isCompleted, isAffiliate, tiktokOrderId, shopeeOrderId,
|
|
selectedProductId, quantities, notes, photo,
|
|
],
|
|
);
|
|
|
|
useTransactionDraftSave('create', draftData, userId);
|
|
|
|
const selectedProduct = useMemo(
|
|
() => products.find((p) => String(p.id) === selectedProductId) ?? null,
|
|
[products, selectedProductId],
|
|
);
|
|
|
|
const variantById = useMemo(
|
|
() =>
|
|
new Map(
|
|
products.flatMap((p: ProductForTransaction) =>
|
|
p.product_variants.map((v) => [v.id, v]),
|
|
),
|
|
),
|
|
[products],
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (channel === 'tiktok') {
|
|
setPriceType('tiktok');
|
|
setPaymentType('marketplace');
|
|
} else if (channel === 'shopee') {
|
|
setPriceType('shopee');
|
|
setPaymentType('marketplace');
|
|
}
|
|
}, [channel]);
|
|
|
|
useEffect(() => {
|
|
if (stockType === 'reject') {
|
|
setPriceType('reject');
|
|
} else if (priceType === 'reject') {
|
|
setPriceType('retail');
|
|
}
|
|
}, [stockType]);
|
|
|
|
const showPhoto = paymentType === 'transfer' || paymentType === 'qris';
|
|
|
|
const availablePriceTypes = useMemo(() => {
|
|
if (stockType === 'reject') {
|
|
return priceTypeOptions.filter((o) => o.value === 'reject');
|
|
}
|
|
|
|
return priceTypeOptions.filter((o) => SELLING_PRICE_TYPES.includes(o.value));
|
|
}, [stockType, priceTypeOptions]);
|
|
|
|
const getUnitPrice = useCallback(
|
|
(variantId: number) => {
|
|
const variant = variantById.get(variantId);
|
|
|
|
if (!variant) {
|
|
return 0;
|
|
}
|
|
|
|
if (stockType === 'reject') {
|
|
return variant.prices?.reject ?? 0;
|
|
}
|
|
|
|
return variant.prices?.[priceType] ?? 0;
|
|
},
|
|
[variantById, stockType, priceType],
|
|
);
|
|
|
|
const subtotal = Object.entries(quantities).reduce(
|
|
(sum, [variantId, quantity]) => {
|
|
const unitPrice = getUnitPrice(Number(variantId));
|
|
|
|
return sum + unitPrice * quantity;
|
|
},
|
|
0,
|
|
);
|
|
|
|
const total = subtotal - discount + (negoPrice ?? 0);
|
|
|
|
const updateQuantity = useCallback((variantId: number, value: number) => {
|
|
setQuantities((prev) => ({
|
|
...prev,
|
|
[variantId]: Math.max(0, value),
|
|
}));
|
|
}, []);
|
|
|
|
const incrementQuantity = useCallback(
|
|
(variantId: number, amount: number) => {
|
|
setQuantities((prev) => ({
|
|
...prev,
|
|
[variantId]: Math.max(0, (prev[variantId] ?? 0) + amount),
|
|
}));
|
|
},
|
|
[],
|
|
);
|
|
|
|
const cartItems: CartLine[] = (() => {
|
|
const lines: CartLine[] = [];
|
|
|
|
for (const [variantId, quantity] of Object.entries(quantities)) {
|
|
if (quantity <= 0) {
|
|
continue;
|
|
}
|
|
|
|
const id = Number(variantId);
|
|
const variant = variantById.get(id);
|
|
|
|
if (variant) {
|
|
const unitPrice = getUnitPrice(id);
|
|
lines.push({
|
|
key: `variant-${id}`,
|
|
photoUrl: variant.photo_url,
|
|
title: variant.name,
|
|
subtitle: `${formatCurrency(unitPrice)} / pcs`,
|
|
price: unitPrice,
|
|
quantity,
|
|
onAdjust: (delta) => incrementQuantity(id, delta),
|
|
onSet: (value) => updateQuantity(id, value),
|
|
onRemove: () => updateQuantity(id, 0),
|
|
});
|
|
}
|
|
}
|
|
|
|
return lines;
|
|
})();
|
|
|
|
function formatQuantity(value: number): string {
|
|
return formatNumber(value, { maximumFractionDigits: 4 });
|
|
}
|
|
|
|
function getPayload() {
|
|
return {
|
|
stock_type: stockType,
|
|
channel,
|
|
price_type: stockType === 'reject' ? 'reject' : priceType,
|
|
payment_type: paymentType,
|
|
customer_id: customerId,
|
|
marketing_id: marketingId,
|
|
discount,
|
|
nego_price: negoPrice,
|
|
is_completed: isCompleted,
|
|
is_affiliate: isAffiliate,
|
|
tiktok_order_id: channel === 'tiktok' ? tiktokOrderId || null : null,
|
|
shopee_order_id: channel === 'shopee' ? shopeeOrderId || null : null,
|
|
items: Object.entries(quantitiesRef.current)
|
|
.map(([variantId, quantity]) => ({
|
|
product_variant_id: Number(variantId),
|
|
quantity: Number(quantity),
|
|
}))
|
|
.filter((item) => item.quantity > 0),
|
|
notes: notes || null,
|
|
photo_key: showPhoto ? photo : null,
|
|
};
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<Head title="Tambah Transaksi" />
|
|
|
|
<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 Transaksi
|
|
</h2>
|
|
<Button asChild variant="outline">
|
|
<Link href={transactionIndex.url()}>
|
|
<ArrowLeft className="h-4 w-4" />
|
|
Kembali
|
|
</Link>
|
|
</Button>
|
|
</div>
|
|
|
|
<Form
|
|
action={store()}
|
|
transform={(formData) => ({
|
|
...formData,
|
|
...getPayload(),
|
|
})}
|
|
onError={() => {
|
|
toast.error('Terjadi kesalahan saat menyimpan data. 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-2">
|
|
{selectedProduct.product_variants.map(
|
|
(variant) => {
|
|
const currentStock =
|
|
stockType === 'good'
|
|
? variant.stock
|
|
: variant.reject_stock;
|
|
|
|
return (
|
|
<div
|
|
key={variant.id}
|
|
className={
|
|
(quantities[
|
|
variant
|
|
.id
|
|
] ?? 0) > 0
|
|
? 'flex items-center justify-between gap-3 rounded-lg border border-primary p-3'
|
|
: 'flex items-center justify-between gap-3 rounded-lg border p-3'
|
|
}
|
|
>
|
|
<div className="flex min-w-0 items-center gap-3">
|
|
{variant.photo_url ? (
|
|
<img
|
|
src={
|
|
variant.photo_url
|
|
}
|
|
alt={
|
|
variant.name
|
|
}
|
|
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="min-w-0">
|
|
<p className="truncate font-medium">
|
|
{
|
|
variant.name
|
|
}
|
|
</p>
|
|
<p className="text-xs text-muted-foreground">
|
|
Stok:{' '}
|
|
{formatQuantity(
|
|
Number(
|
|
currentStock,
|
|
),
|
|
)}{' '}
|
|
pcs
|
|
·{' '}
|
|
{formatCurrency(
|
|
stockType === 'reject'
|
|
? (variant.prices?.reject ?? 0)
|
|
: (variant.prices?.[priceType] ?? 0),
|
|
)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex shrink-0 items-center gap-2">
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="icon"
|
|
disabled={
|
|
!(
|
|
quantities[
|
|
variant
|
|
.id
|
|
] ??
|
|
0
|
|
)
|
|
}
|
|
onClick={() =>
|
|
incrementQuantity(
|
|
variant.id,
|
|
-1,
|
|
)
|
|
}
|
|
>
|
|
<Minus className="h-4 w-4" />
|
|
</Button>
|
|
<NumberInput
|
|
className="w-24 text-center"
|
|
value={
|
|
quantities[
|
|
variant
|
|
.id
|
|
] ??
|
|
0
|
|
}
|
|
onValueChange={(
|
|
val,
|
|
) =>
|
|
updateQuantity(
|
|
variant.id,
|
|
val,
|
|
)
|
|
}
|
|
/>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="icon"
|
|
onClick={() =>
|
|
incrementQuantity(
|
|
variant.id,
|
|
1,
|
|
)
|
|
}
|
|
>
|
|
<Plus className="h-4 w-4" />
|
|
</Button>
|
|
</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="grid gap-2">
|
|
<Label>Tipe Stok <span className="text-destructive">*</span></Label>
|
|
<RadioGroup
|
|
value={stockType}
|
|
onValueChange={(value) =>
|
|
setStockType(
|
|
value as
|
|
'good' | 'reject',
|
|
)
|
|
}
|
|
className="flex flex-wrap gap-4"
|
|
>
|
|
<div className="flex items-center space-x-2">
|
|
<RadioGroupItem
|
|
value="good"
|
|
id="stock-type-good"
|
|
/>
|
|
<Label
|
|
htmlFor="stock-type-good"
|
|
className="font-normal"
|
|
>
|
|
Bagus
|
|
</Label>
|
|
</div>
|
|
<div className="flex items-center space-x-2">
|
|
<RadioGroupItem
|
|
value="reject"
|
|
id="stock-type-reject"
|
|
/>
|
|
<Label
|
|
htmlFor="stock-type-reject"
|
|
className="font-normal"
|
|
>
|
|
Reject
|
|
</Label>
|
|
</div>
|
|
</RadioGroup>
|
|
<InputError
|
|
message={errors.stock_type}
|
|
/>
|
|
</div>
|
|
|
|
<div className="grid gap-2">
|
|
<Label>Channel <span className="text-destructive">*</span></Label>
|
|
<Select
|
|
value={channel}
|
|
onValueChange={setChannel}
|
|
>
|
|
<SelectTrigger className="w-full">
|
|
<SelectValue placeholder="Pilih channel" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{channelOptions.map(
|
|
(opt) => (
|
|
<SelectItem
|
|
key={opt.value}
|
|
value={opt.value}
|
|
>
|
|
{opt.label}
|
|
</SelectItem>
|
|
),
|
|
)}
|
|
</SelectContent>
|
|
</Select>
|
|
<InputError
|
|
message={errors.channel}
|
|
/>
|
|
</div>
|
|
|
|
{channel === 'tiktok' && (
|
|
<div className="grid gap-2">
|
|
<Label htmlFor="tiktok_order_id">
|
|
ID Pesanan TikTok
|
|
</Label>
|
|
<input
|
|
id="tiktok_order_id"
|
|
type="text"
|
|
value={tiktokOrderId}
|
|
onChange={(e) => setTiktokOrderId(e.target.value)}
|
|
placeholder="Masukkan ID pesanan TikTok"
|
|
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50"
|
|
/>
|
|
<InputError
|
|
message={errors.tiktok_order_id}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{channel === 'shopee' && (
|
|
<div className="grid gap-2">
|
|
<Label htmlFor="shopee_order_id">
|
|
ID Pesanan Shopee
|
|
</Label>
|
|
<input
|
|
id="shopee_order_id"
|
|
type="text"
|
|
value={shopeeOrderId}
|
|
onChange={(e) => setShopeeOrderId(e.target.value)}
|
|
placeholder="Masukkan ID pesanan Shopee"
|
|
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50"
|
|
/>
|
|
<InputError
|
|
message={errors.shopee_order_id}
|
|
/>
|
|
</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>
|
|
|
|
<div className="grid gap-2">
|
|
<Label>Tipe Pembayaran <span className="text-destructive">*</span></Label>
|
|
<Select
|
|
value={paymentType}
|
|
onValueChange={setPaymentType}
|
|
disabled={channel === 'tiktok' || channel === 'shopee'}
|
|
>
|
|
<SelectTrigger className="w-full">
|
|
<SelectValue placeholder="Pilih tipe pembayaran" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{paymentTypeOptions.map(
|
|
(opt) => (
|
|
<SelectItem
|
|
key={opt.value}
|
|
value={opt.value}
|
|
>
|
|
{opt.label}
|
|
</SelectItem>
|
|
),
|
|
)}
|
|
</SelectContent>
|
|
</Select>
|
|
<InputError
|
|
message={errors.payment_type}
|
|
/>
|
|
</div>
|
|
|
|
{showPhoto && (
|
|
<div className="grid gap-2">
|
|
<Label>Foto <span className="text-destructive">*</span></Label>
|
|
<FileUpload
|
|
value={photo}
|
|
onChange={(key) => {
|
|
setPhoto(key);
|
|
setPhotoUrl(
|
|
key
|
|
? getTemporaryUrl(
|
|
key,
|
|
)
|
|
: null,
|
|
);
|
|
}}
|
|
folder="transaction"
|
|
existingUrl={photoUrl}
|
|
onUploadingChange={setUploading}
|
|
/>
|
|
<InputError
|
|
message={errors.photo_key}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
<div className="grid gap-2">
|
|
<Label>Pelanggan</Label>
|
|
<Combobox
|
|
items={customers}
|
|
itemToStringLabel={(c) => c.name}
|
|
value={customers.find((c) => c.id === customerId) ?? null}
|
|
onValueChange={(value) =>
|
|
setCustomerId(value ? value.id : null)
|
|
}
|
|
>
|
|
<ComboboxInput
|
|
placeholder="Pilih pelanggan..."
|
|
className="w-full"
|
|
/>
|
|
<ComboboxContent>
|
|
<ComboboxEmpty>
|
|
Tidak ada pelanggan.
|
|
</ComboboxEmpty>
|
|
<ComboboxList>
|
|
{(c) => (
|
|
<ComboboxItem
|
|
key={c.id}
|
|
value={c}
|
|
>
|
|
{c.name}
|
|
</ComboboxItem>
|
|
)}
|
|
</ComboboxList>
|
|
</ComboboxContent>
|
|
</Combobox>
|
|
<InputError
|
|
message={errors.customer_id}
|
|
/>
|
|
</div>
|
|
|
|
<div className="grid gap-2">
|
|
<Label>Marketing</Label>
|
|
<Combobox
|
|
items={employees}
|
|
itemToStringLabel={(e) =>
|
|
e.user_profile?.full_name ?? '-'
|
|
}
|
|
value={employees.find((e) => e.id === marketingId) ?? null}
|
|
onValueChange={(value) =>
|
|
setMarketingId(value ? value.id : null)
|
|
}
|
|
>
|
|
<ComboboxInput
|
|
placeholder="Pilih marketing..."
|
|
className="w-full"
|
|
/>
|
|
<ComboboxContent>
|
|
<ComboboxEmpty>
|
|
Tidak ada karyawan.
|
|
</ComboboxEmpty>
|
|
<ComboboxList>
|
|
{(e) => (
|
|
<ComboboxItem
|
|
key={e.id}
|
|
value={e}
|
|
>
|
|
{e.user_profile?.full_name ?? '-'}
|
|
</ComboboxItem>
|
|
)}
|
|
</ComboboxList>
|
|
</ComboboxContent>
|
|
</Combobox>
|
|
<InputError
|
|
message={errors.marketing_id}
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<div className="flex items-center justify-between text-sm">
|
|
<span className="text-muted-foreground">
|
|
Subtotal
|
|
</span>
|
|
<span className="font-medium">
|
|
{formatCurrency(subtotal)}
|
|
</span>
|
|
</div>
|
|
|
|
<div className="grid gap-2">
|
|
<Label htmlFor="discount">
|
|
Diskon
|
|
</Label>
|
|
<RupiahInput
|
|
id="discount"
|
|
value={discount}
|
|
onValueChange={setDiscount}
|
|
placeholder="0"
|
|
/>
|
|
<InputError
|
|
message={errors.discount}
|
|
/>
|
|
</div>
|
|
|
|
<div className="grid gap-2">
|
|
<Label htmlFor="nego_price">
|
|
Harga Nego
|
|
</Label>
|
|
<RupiahInput
|
|
id="nego_price"
|
|
value={negoPrice ?? 0}
|
|
onValueChange={(val) =>
|
|
setNegoPrice(val || null)
|
|
}
|
|
placeholder="0"
|
|
/>
|
|
<FieldDescription>
|
|
Jika diisi, harga nego menjadi total akhir.
|
|
</FieldDescription>
|
|
<InputError
|
|
message={errors.nego_price}
|
|
/>
|
|
</div>
|
|
|
|
<div className="border-t pt-2">
|
|
<div className="flex items-center justify-between text-sm font-semibold">
|
|
<span>Total</span>
|
|
<span>
|
|
{formatCurrency(
|
|
total,
|
|
)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between">
|
|
<Label htmlFor="is_completed">
|
|
Pesanan Selesai
|
|
</Label>
|
|
<Switch
|
|
id="is_completed"
|
|
checked={isCompleted}
|
|
onCheckedChange={setIsCompleted}
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between">
|
|
<Label htmlFor="is_affiliate">
|
|
Affiliasi
|
|
</Label>
|
|
<Switch
|
|
id="is_affiliate"
|
|
checked={isAffiliate}
|
|
onCheckedChange={setIsAffiliate}
|
|
/>
|
|
</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 ||
|
|
uploading ||
|
|
!selectedProductId ||
|
|
Object.values(quantities).every(
|
|
(q) => q <= 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 transaksi"
|
|
>
|
|
<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 Transaksi</SheetTitle>
|
|
</SheetHeader>
|
|
|
|
<div className="flex-1 space-y-3 overflow-y-auto px-6 pb-6">
|
|
{cartItems.length === 0 ? (
|
|
<p className="text-sm text-muted-foreground">
|
|
Keranjang kosong.
|
|
</p>
|
|
) : (
|
|
cartItems.map((item) => (
|
|
<div
|
|
key={item.key}
|
|
className="space-y-3 rounded-lg border p-3"
|
|
>
|
|
<div className="flex items-start justify-between gap-2">
|
|
<div className="flex items-center gap-3">
|
|
{item.photoUrl ? (
|
|
<button
|
|
type="button"
|
|
onClick={() =>
|
|
setPreviewKey(
|
|
item.key,
|
|
)
|
|
}
|
|
className="block h-10 w-10 shrink-0 overflow-hidden rounded-md border transition-opacity hover:opacity-80"
|
|
>
|
|
<img
|
|
src={item.photoUrl}
|
|
alt={item.title}
|
|
className="h-full w-full object-cover"
|
|
/>
|
|
</button>
|
|
) : (
|
|
<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>
|
|
<p className="font-medium">
|
|
{item.title}
|
|
</p>
|
|
<p className="text-xs text-muted-foreground">
|
|
{item.subtitle}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon-sm"
|
|
onClick={() =>
|
|
setCartRemoveKey(item.key)
|
|
}
|
|
>
|
|
<Trash2 className="h-4 w-4 text-destructive" />
|
|
</Button>
|
|
</div>
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="icon-sm"
|
|
disabled={
|
|
item.quantity <= 0
|
|
}
|
|
onClick={() =>
|
|
item.onAdjust(-1)
|
|
}
|
|
>
|
|
<Minus className="h-4 w-4" />
|
|
</Button>
|
|
<NumberInput
|
|
className="w-20 text-center"
|
|
value={item.quantity}
|
|
onValueChange={item.onSet}
|
|
/>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="icon-sm"
|
|
onClick={() =>
|
|
item.onAdjust(1)
|
|
}
|
|
>
|
|
<Plus className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
<span className="font-medium">
|
|
{formatCurrency(
|
|
item.price * item.quantity,
|
|
)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
|
|
<SheetFooter>
|
|
<div className="flex items-center justify-between border-t pt-4">
|
|
<span className="text-sm">Subtotal</span>
|
|
<span className="text-sm font-semibold">
|
|
{formatCurrency(subtotal)}
|
|
</span>
|
|
</div>
|
|
</SheetFooter>
|
|
</SheetContent>
|
|
</Sheet>
|
|
|
|
<ImagePreviewModal
|
|
open={previewKey !== null}
|
|
onOpenChange={(open) => {
|
|
if (!open) {
|
|
setPreviewKey(null);
|
|
}
|
|
}}
|
|
src={
|
|
cartItems.find((i) => i.key === previewKey)?.photoUrl ??
|
|
null
|
|
}
|
|
title={cartItems.find((i) => i.key === previewKey)?.title}
|
|
/>
|
|
|
|
<ConfirmDialog
|
|
open={cartRemoveKey !== null}
|
|
onOpenChange={(open) => {
|
|
if (!open) {
|
|
setCartRemoveKey(null);
|
|
}
|
|
}}
|
|
title="Hapus Item Keranjang"
|
|
description="Apakah Anda yakin ingin menghapus item ini dari keranjang?"
|
|
confirmLabel="Hapus"
|
|
variant="destructive"
|
|
onConfirm={() => {
|
|
cartItems
|
|
.find((i) => i.key === cartRemoveKey)
|
|
?.onRemove();
|
|
setCartRemoveKey(null);
|
|
}}
|
|
/>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|