summaryrefslogtreecommitdiff
path: root/src/modules/stock-opname
diff options
context:
space:
mode:
authorRafi Zadanly <zadanlyr@gmail.com>2023-11-09 15:40:16 +0700
committerRafi Zadanly <zadanlyr@gmail.com>2023-11-09 15:40:16 +0700
commitbe0f537dc4fe384eef09436833c6407e6482c16d (patch)
tree194b1ad3f34396cb8149075bbbd38b854aedf361 /src/modules/stock-opname
parent5d5401ae36e7e0c8eb38ccd943c1aa44a9573d35 (diff)
Initial commit
Diffstat (limited to 'src/modules/stock-opname')
-rw-r--r--src/modules/stock-opname/index.tsx261
-rw-r--r--src/modules/stock-opname/stock-opname.module.css15
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;
+}