diff options
| author | Rafi Zadanly <zadanlyr@gmail.com> | 2023-11-09 15:40:16 +0700 |
|---|---|---|
| committer | Rafi Zadanly <zadanlyr@gmail.com> | 2023-11-09 15:40:16 +0700 |
| commit | be0f537dc4fe384eef09436833c6407e6482c16d (patch) | |
| tree | 194b1ad3f34396cb8149075bbbd38b854aedf361 /src/modules/stock-opname | |
| parent | 5d5401ae36e7e0c8eb38ccd943c1aa44a9573d35 (diff) | |
Initial commit
Diffstat (limited to 'src/modules/stock-opname')
| -rw-r--r-- | src/modules/stock-opname/index.tsx | 261 | ||||
| -rw-r--r-- | src/modules/stock-opname/stock-opname.module.css | 15 |
2 files changed, 276 insertions, 0 deletions
diff --git a/src/modules/stock-opname/index.tsx b/src/modules/stock-opname/index.tsx new file mode 100644 index 0000000..0a5c848 --- /dev/null +++ b/src/modules/stock-opname/index.tsx @@ -0,0 +1,261 @@ +"use client"; +import { Location, Product } from "@prisma/client"; +import AsyncSelect from "react-select/async" +import styles from "./stock-opname.module.css" +import { Button, Card, CardBody, Input, Modal, ModalBody, ModalContent, ModalHeader, Spacer, Spinner } from "@nextui-org/react"; +import { SelectOption } from "@/common/types/select"; +import { useStockOpnameStore } from "@/common/stores/useStockOpnameStore"; +import { ActionMeta, SingleValue } from "react-select"; +import { useMutation } from "@tanstack/react-query"; +import { useEffect, useState } from "react"; +import toast from "@/common/libs/toast"; +import Scanner from "@/common/components/Scanner"; +import { AlertCircleIcon, ScanIcon, ScanLineIcon } from "lucide-react"; + +type ActiveScanner = "product" | "location" | null +type ScannerOption = { value: number, label: string } + +const StockOpname = () => { + const { + form, updateForm, + oldOpname, setOldOpname, + resetForm + } = useStockOpnameStore() + + useEffect(() => { + const productId = form.product?.value + const locationId = form.location?.value + + if ( + typeof productId === 'number' && + typeof locationId === 'number' + ) { + loadOldQuantity(productId, locationId) + .then((data) => setOldOpname(data)) + } + + }, [form.product, form.location, setOldOpname]) + + const handleSelectChange = (val: SingleValue<SelectOption>, action: ActionMeta<SelectOption>) => { + updateForm(action.name as keyof typeof form, val) + } + + const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => { + updateForm(e.target.name as keyof typeof form, e.target.value) + } + + const saveMutation = useMutation({ + mutationKey: ['stock-opname'], + mutationFn: async (data: typeof form) => await fetch("/api/stock-opname", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + location: data.location?.value, + product: data.product?.value, + quantity: parseInt(data.quantity), + }), + }), + onSuccess(data) { + if (data.status === 200) { + toast('Data berhasil disimpan') + resetForm() + } + }, + }) + + const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => { + e.preventDefault() + saveMutation.mutate(form) + } + + const [activeScanner, setActiveScanner] = useState<ActiveScanner>(null) + const [scannerOptions, setScannerOptions] = useState<ScannerOption[]>() + const [scannerOptionLoading, setScannerOptionLoading] = useState<boolean>(false) + + const handleOnScan = async (data: string) => { + if (!activeScanner) return + const loadFunc = activeScanner === 'product' ? loadProduct : loadLocation + setScannerOptionLoading(true) + const response = await loadFunc(data) + setScannerOptionLoading(false) + setScannerOptions(response) + } + + const handleScannerOptionPress = (option: ScannerOption) => { + toast(`${activeScanner} berhasil diubah`) + updateForm(activeScanner as keyof typeof form, option) + closeModal() + } + + const closeModal = () => { + setActiveScanner(null) + setScannerOptions(undefined) + } + + return ( + <> + <div className="font-semibold text-xl"> + Stock Opname + </div> + + <Spacer y={4} /> + + <form className={styles.form} onSubmit={handleSubmit}> + <div> + <label htmlFor="location" className={styles.label}>Lokasi Rak</label> + <div className={styles.inputGroup}> + <AsyncSelect + cacheOptions + defaultOptions + loadOptions={loadLocation} + classNamePrefix="react-select" + id="location" + name="location" + placeholder="Pilih lokasi..." + value={form.location} + onChange={handleSelectChange} + className="flex-1" + /> + <Button type="button" className={styles.scanButton} variant="flat" onClick={() => setActiveScanner('location')}> + <ScanLineIcon size={20} /> + </Button> + </div> + </div> + + <div> + <label htmlFor="product" className={styles.label}>Produk</label> + <div className={styles.inputGroup}> + <AsyncSelect + cacheOptions + defaultOptions + loadOptions={loadProduct} + classNamePrefix="react-select" + id="product" + name="product" + placeholder="Pilih barang..." + value={form.product} + onChange={handleSelectChange} + className="flex-1" + /> + <Button type="button" className={styles.scanButton} variant="flat" onClick={() => setActiveScanner('product')}> + <ScanLineIcon size={20} /> + </Button> + </div> + </div> + + <div> + <label htmlFor="quantity" className={styles.label}>Jumlah</label> + <Input + type="number" + id="quantity" + name="quantity" + value={form.quantity} + onChange={handleInputChange} + placeholder="Masukan jumlah barang..." + /> + {oldOpname && ( + <div className="text-sm mt-2 text-neutral-600"> + Jumlah terakhir adalah {' '} + <span className="font-medium">{oldOpname.quantity}</span> + {' '} diisi oleh {' '} + <span className="font-medium">{oldOpname.user.name}</span>. + {' '} + <button + type="button" + className="text-primary-500 underline" + onClick={() => updateForm('quantity', oldOpname.quantity.toString())} + > + Gunakan + </button> + </div> + )} + </div> + + <Button + className="mt-4" + color="primary" + type="submit" + isDisabled={!form.quantity || !form.location || !form.product || saveMutation.isPending} + > + {saveMutation.isPending ? <Spinner color="white" size="sm" /> : 'Simpan'} + </Button> + </form> + + <Modal isOpen={!!activeScanner} onOpenChange={closeModal}> + <ModalContent> + <ModalHeader>Scan {activeScanner}</ModalHeader> + <ModalBody className="pb-6"> + {!scannerOptions && !scannerOptionLoading && ( + <Scanner paused={!activeScanner} onScan={handleOnScan} /> + )} + + {!scannerOptions && scannerOptionLoading && <Spinner />} + + {!!scannerOptions && ( + <> + <div className="max-h-[60vh] overflow-auto grid grid-cols-1 gap-y-4"> + {scannerOptions.map((option) => ( + <Card key={option.value} isPressable shadow="none" className="border border-default-300" onPress={() => handleScannerOptionPress(option)}> + <CardBody>{option.label}</CardBody> + </Card> + ))} + </div> + {scannerOptions.length === 0 && ( + <div className="flex flex-col justify-center items-center gap-y-4 text-default-700"> + <AlertCircleIcon size={36} /> + Tidak ada opsi untuk ditampilkan + </div> + )} + <Spacer y={2} /> + <Button variant="flat" onPress={() => setScannerOptions(undefined)}> + Scan Ulang + </Button> + </> + )} + </ModalBody> + </ModalContent> + </Modal> + </> + ) +} + +const loadOldQuantity = async (productId: number, locationId: number) => { + const queryParams = new URLSearchParams({ + productId: productId.toString(), + locationId: locationId.toString() + }) + + const response = await fetch(`/api/stock-opname/quantity?${queryParams}`) + const data = await response.json() + + return data +} + +const loadLocation = async (inputValue: string) => { + const response = await fetch(`/api/location?search=${inputValue}`) + const data: Location[] = await response.json() || [] + + return data.map((location) => ({ + value: location.id, + label: location.name + })) +} + +const loadProduct = async (inputValue: string) => { + const response = await fetch(`/api/product?search=${inputValue}`) + const data: { products: Product[] } = await response.json() || [] + + return data?.products.map((product) => { + let label = '' + if (product.itemCode) label += `[${product.itemCode}]` + label += ` ${product.name}` + if (product.barcode) label += ` [${product.barcode}]` + + return { + value: product.id, + label + } + }) +} + +export default StockOpname
\ No newline at end of file diff --git a/src/modules/stock-opname/stock-opname.module.css b/src/modules/stock-opname/stock-opname.module.css new file mode 100644 index 0000000..b020549 --- /dev/null +++ b/src/modules/stock-opname/stock-opname.module.css @@ -0,0 +1,15 @@ +.label { + @apply block mb-2; +} + +.form { + @apply grid grid-cols-1 gap-y-4; +} + +.inputGroup { + @apply flex gap-x-2; +} + +.scanButton { + @apply min-w-fit px-4 text-default-700; +} |
