summaryrefslogtreecommitdiff
path: root/src/modules
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
parent5d5401ae36e7e0c8eb38ccd943c1aa44a9573d35 (diff)
Initial commit
Diffstat (limited to 'src/modules')
-rw-r--r--src/modules/login/components/Form.tsx81
-rw-r--r--src/modules/login/index.tsx20
-rw-r--r--src/modules/login/login.module.css15
-rw-r--r--src/modules/profile-card/components/Dropdown.tsx36
-rw-r--r--src/modules/profile-card/index.tsx37
-rw-r--r--src/modules/profile-card/profile-card.module.css11
-rw-r--r--src/modules/result/components/DetailRow.tsx81
-rw-r--r--src/modules/result/components/Filter.tsx74
-rw-r--r--src/modules/result/components/ImportModal.tsx62
-rw-r--r--src/modules/result/components/MoreMenu.tsx36
-rw-r--r--src/modules/result/components/ProductModal.tsx98
-rw-r--r--src/modules/result/components/Table.tsx106
-rw-r--r--src/modules/result/components/filter.module.css3
-rw-r--r--src/modules/result/components/table.module.css23
-rw-r--r--src/modules/result/index.tsx22
-rw-r--r--src/modules/result/result.module.css7
-rw-r--r--src/modules/stock-opname/index.tsx261
-rw-r--r--src/modules/stock-opname/stock-opname.module.css15
18 files changed, 988 insertions, 0 deletions
diff --git a/src/modules/login/components/Form.tsx b/src/modules/login/components/Form.tsx
new file mode 100644
index 0000000..941dab3
--- /dev/null
+++ b/src/modules/login/components/Form.tsx
@@ -0,0 +1,81 @@
+"use client";
+import toast from "@/common/libs/toast";
+import { useLoginStore } from "@/common/stores/useLoginStore"
+import { Button, Input, Spinner } from "@nextui-org/react"
+import { useMutation } from "@tanstack/react-query";
+import { useRouter } from "next/navigation";
+import { useMemo } from "react";
+
+const Form = () => {
+ const { form, updateForm } = useLoginStore()
+ const router = useRouter()
+
+ const errorMessage = {
+ 401: 'Username atau password tidak sesuai',
+ 404: 'Akun dengan username tersebut tidak ditemukan'
+ }
+
+ const mutation = useMutation({
+ mutationKey: ['login'],
+ mutationFn: async (data: typeof form) => await fetch("/api/auth/login", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify(data),
+ }),
+ onError() {
+ toast('Mohon maaf terjadi kesalahan')
+ },
+ async onSuccess(data) {
+ if (data.status !== 200) {
+ return toast(errorMessage[data.status as keyof typeof errorMessage])
+ }
+ router.push('/')
+ },
+ })
+
+ const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
+ updateForm(e.target.name, e.target.value)
+ }
+
+ const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
+ e.preventDefault()
+ mutation.mutate(form)
+ }
+
+ const isValid = useMemo(() => {
+ return form.username && form.password
+ }, [form])
+
+ return (
+ <form className="grid grid-cols-1 gap-y-4" onSubmit={handleSubmit}>
+ <Input
+ isRequired
+ type="text"
+ label="Nama Pengguna"
+ name="username"
+ onChange={handleInputChange}
+ autoFocus
+ />
+ <Input
+ isRequired
+ type="password"
+ label="Kata Sandi"
+ name="password"
+ onChange={handleInputChange}
+ />
+ <Button
+ variant="solid"
+ color="primary"
+ isDisabled={!isValid || mutation.isPending}
+ className="mt-2"
+ type="submit"
+ >
+ {mutation.isPending ? <Spinner color="white" size="sm" /> : 'Masuk'}
+ </Button>
+ </form>
+ )
+}
+
+export default Form \ No newline at end of file
diff --git a/src/modules/login/index.tsx b/src/modules/login/index.tsx
new file mode 100644
index 0000000..7247af4
--- /dev/null
+++ b/src/modules/login/index.tsx
@@ -0,0 +1,20 @@
+import { Spacer } from "@nextui-org/react"
+import Form from "./components/Form"
+import styles from "./login.module.css"
+
+const Login = () => {
+ return (
+ <div className={styles.wrapper}>
+ <div className={styles.header}>
+ <h1 className={styles.title}>Stock Opname</h1>
+ <Spacer y={1} />
+ <h2 className={styles.subtitle}>Masuk terlebih dahulu untuk melanjutkan</h2>
+ </div>
+
+ <Spacer y={10} />
+ <Form />
+ </div>
+ )
+}
+
+export default Login \ No newline at end of file
diff --git a/src/modules/login/login.module.css b/src/modules/login/login.module.css
new file mode 100644
index 0000000..99575d5
--- /dev/null
+++ b/src/modules/login/login.module.css
@@ -0,0 +1,15 @@
+.wrapper {
+ @apply pt-20 px-6;
+}
+
+.header {
+ @apply text-center;
+}
+
+.title {
+ @apply text-2xl text-blue-600 font-semibold;
+}
+
+.subtitle {
+ @apply font-medium text-neutral-600;
+}
diff --git a/src/modules/profile-card/components/Dropdown.tsx b/src/modules/profile-card/components/Dropdown.tsx
new file mode 100644
index 0000000..f6f58c9
--- /dev/null
+++ b/src/modules/profile-card/components/Dropdown.tsx
@@ -0,0 +1,36 @@
+"use client";
+import { DropdownItem, DropdownMenu, DropdownTrigger, Dropdown as UIDropdown } from "@nextui-org/react"
+import { MoreVerticalIcon } from "lucide-react"
+import { deleteCookie } from "cookies-next"
+import { useRouter } from "next/navigation";
+
+const Dropdown = () => {
+ const router = useRouter()
+
+ const logout = () => {
+ deleteCookie('credential')
+ router.push('/login')
+ }
+
+ return (
+ <UIDropdown>
+ <DropdownTrigger>
+ <button type="button" className="p-1">
+ <MoreVerticalIcon size={20} />
+ </button>
+ </DropdownTrigger>
+ <DropdownMenu>
+ <DropdownItem
+ key="logout"
+ className="text-danger-600"
+ color="danger"
+ onPress={logout}
+ >
+ Logout
+ </DropdownItem>
+ </DropdownMenu>
+ </UIDropdown>
+ )
+}
+
+export default Dropdown \ No newline at end of file
diff --git a/src/modules/profile-card/index.tsx b/src/modules/profile-card/index.tsx
new file mode 100644
index 0000000..08c4478
--- /dev/null
+++ b/src/modules/profile-card/index.tsx
@@ -0,0 +1,37 @@
+import { Credential } from "@/common/types/auth"
+import { Avatar, AvatarIcon, Card, CardBody } from '@nextui-org/react';
+import { teamAliases } from '@/common/constants/team';
+import styles from "./profile-card.module.css"
+import Dropdown from './components/Dropdown';
+import { cookies } from 'next/headers';
+
+const ProfileCard = () => {
+ const credentialStr = cookies().get('credential')?.value
+ const credential: Credential | null = credentialStr ? JSON.parse(credentialStr) : null
+
+ return credential && (
+ <Card shadow='sm'>
+ <CardBody className={styles.cardBody}>
+ <Avatar icon={<AvatarIcon />} size='sm' isBordered color='primary' classNames={{ icon: 'text-white' }} />
+ <div>
+ <div className={styles.name}>{credential.name}</div>
+ <div className={styles.description}>
+ <span>
+ {credential.company.name}
+ </span>
+ &#183;
+ <span>
+ Tim {teamAliases[credential.team].name}
+ </span>
+ </div>
+ </div>
+
+ <div className="ml-auto">
+ <Dropdown />
+ </div>
+ </CardBody>
+ </Card>
+ )
+}
+
+export default ProfileCard \ No newline at end of file
diff --git a/src/modules/profile-card/profile-card.module.css b/src/modules/profile-card/profile-card.module.css
new file mode 100644
index 0000000..b0b05ef
--- /dev/null
+++ b/src/modules/profile-card/profile-card.module.css
@@ -0,0 +1,11 @@
+.cardBody {
+ @apply flex flex-row items-center gap-x-4;
+}
+
+.name {
+ @apply font-medium;
+}
+
+.description {
+ @apply text-sm text-neutral-500 flex gap-x-1;
+}
diff --git a/src/modules/result/components/DetailRow.tsx b/src/modules/result/components/DetailRow.tsx
new file mode 100644
index 0000000..99ccb01
--- /dev/null
+++ b/src/modules/result/components/DetailRow.tsx
@@ -0,0 +1,81 @@
+"use client";
+import { useResultStore } from '@/common/stores/useResultStore';
+import { StockOpnameLocationRes } from '@/common/types/stockOpname';
+import { Skeleton } from '@nextui-org/react';
+import { useQuery } from '@tanstack/react-query'
+import styles from './table.module.css'
+import { CornerDownRightIcon } from 'lucide-react';
+import { User } from '@prisma/client';
+import clsxm from '@/common/libs/clsxm';
+
+const DetailRow = ({ productId }: { productId: number }) => {
+ const { filter } = useResultStore()
+
+ const detailLocation = useQuery({
+ queryKey: ['detailLocation', productId, filter.company],
+ queryFn: async () => {
+ const searchParams = new URLSearchParams()
+ if (!filter?.company) return null
+ searchParams.set('companyId', filter.company)
+ searchParams.set('productId', productId.toString())
+ return await fetch(`/api/stock-opname/location?${searchParams}`)
+ .then(res => res.json())
+ }
+ })
+
+ if (detailLocation.isLoading) {
+ return (
+ <tr>
+ <td colSpan={7}>
+ <div className='grid grid-cols-1 gap-y-2 w-full'>
+ <Skeleton className='w-full h-8' />
+ <Skeleton className='w-full h-8' />
+ </div>
+ </td>
+ </tr>
+ )
+ }
+
+ return (
+ <>
+ {detailLocation.data?.map((location: StockOpnameLocationRes) => (
+ <tr key={location.id}>
+ <td />
+ <td className={clsxm(styles.td, 'min-w-[250px]')}>
+ <div className="flex gap-x-2">
+ <CornerDownRightIcon size={16} />
+ {location.name}
+ </div>
+ </td>
+ <td className={styles.td}>
+ <QuantityColumn data={location.COUNT1} />
+ </td>
+ <td className={styles.td}>
+ <QuantityColumn data={location.COUNT2} />
+ </td>
+ <td className={styles.td}>
+ <QuantityColumn data={location.VERIFICATION} />
+ </td>
+ <td className={styles.td} />
+ <td className={styles.td} />
+ </tr>
+ ))}
+ </>
+ )
+}
+
+const QuantityColumn = ({ data }: { data: { quantity?: number | undefined, user?: User } }) => (
+ <div className='grid grid-cols-1'>
+ {typeof data?.quantity !== 'number' && '-'}
+ {data.quantity !== null && (
+ <>
+ <span>{data.quantity}</span>
+ <div className='text-xs'>
+ {data.user?.name}
+ </div>
+ </>
+ )}
+ </div>
+)
+
+export default DetailRow \ No newline at end of file
diff --git a/src/modules/result/components/Filter.tsx b/src/modules/result/components/Filter.tsx
new file mode 100644
index 0000000..f8bc7b5
--- /dev/null
+++ b/src/modules/result/components/Filter.tsx
@@ -0,0 +1,74 @@
+"use client";
+import { Input, Select, SelectItem } from "@nextui-org/react"
+import styles from "./filter.module.css"
+import { Company } from "@prisma/client"
+import { useEffect, useState } from "react"
+import { SelectOption } from "@/common/types/select"
+import { useResultStore } from "@/common/stores/useResultStore";
+import { getCookie } from "cookies-next";
+import { Credential } from "@/common/types/auth";
+
+const Filter = () => {
+ const { filter, updateFilter } = useResultStore()
+ const [companies, setCompanies] = useState<SelectOption[]>([])
+
+ const credentialStr = getCookie('credential')
+ const credential: Credential | null = credentialStr ? JSON.parse(credentialStr) : null
+
+ useEffect(() => {
+ if (credential && !filter.company)
+ updateFilter("company", credential.companyId.toString())
+ }, [credential, updateFilter, filter])
+
+ useEffect(() => {
+ loadCompany().then((data: SelectOption[]) => {
+ setCompanies(data)
+ })
+ }, [updateFilter])
+
+ const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
+ const { name, value } = e.target
+ updateFilter(name, value)
+ }
+
+ const handleSelectChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
+ const { name, value } = e.target
+ updateFilter(name, value)
+ }
+
+ return (
+ <div className={styles.wrapper}>
+ <Input
+ className="w-7/12 md:w-10/12"
+ type="text"
+ label="Produk"
+ name="search"
+ onChange={handleInputChange}
+ value={filter.search}
+ />
+ <Select
+ className="w-5/12 md:w-2/12"
+ label="Perusahaan"
+ selectedKeys={filter.company}
+ name="company"
+ onChange={handleSelectChange}
+ >
+ {companies.map((company) => (
+ <SelectItem key={company.value} value={company.value}>{company.label}</SelectItem>
+ ))}
+ </Select>
+ </div>
+ )
+}
+
+const loadCompany = async () => {
+ const response = await fetch(`/api/company`)
+ const data: Company[] = await response.json() || []
+
+ return data.map((company) => ({
+ value: company.id,
+ label: company.name
+ }))
+}
+
+export default Filter \ No newline at end of file
diff --git a/src/modules/result/components/ImportModal.tsx b/src/modules/result/components/ImportModal.tsx
new file mode 100644
index 0000000..85e4a97
--- /dev/null
+++ b/src/modules/result/components/ImportModal.tsx
@@ -0,0 +1,62 @@
+import toast from '@/common/libs/toast'
+import { Button, Modal, ModalBody, ModalContent, ModalHeader } from '@nextui-org/react'
+import { useMutation } from '@tanstack/react-query'
+import React, { ChangeEvent, FormEvent, useState } from 'react'
+
+type Props = {
+ modal: {
+ isOpen: boolean,
+ onOpenChange: () => void
+ }
+}
+
+const ImportModal = ({ modal }: Props) => {
+ const [file, setFile] = useState<File>()
+
+ const handleFileChange = (e: ChangeEvent<HTMLInputElement>) => {
+ if (e.target.files) setFile(e.target.files[0])
+ }
+
+ const importMutation = useMutation({
+ mutationKey: ['import-product'],
+ mutationFn: async () => {
+ if (!file) return
+ return await fetch('/api/product/import', {
+ method: 'POST',
+ body: file,
+ headers: { 'content-type': file.type, 'content-length': `${file.size}` }
+ })
+ },
+ onSuccess(data) {
+ if (data?.status === 200) {
+ toast('Berhasil import product')
+ setFile(undefined)
+ } else {
+ toast('Gagal import product')
+ }
+ },
+ })
+
+ const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
+ e.preventDefault()
+ importMutation.mutate()
+ }
+
+ return (
+ <Modal isOpen={modal.isOpen} onOpenChange={modal.onOpenChange}>
+ <ModalContent>
+ <ModalHeader>Import Product</ModalHeader>
+ <ModalBody>
+ <form className='pb-6' onSubmit={handleSubmit}>
+ <input type='file' onChange={handleFileChange} accept='.xls, .xlsx' />
+ <Button type='submit' color='primary' className='mt-4 w-full' isDisabled={!file || importMutation.isPending}>
+ {importMutation.isPending ? 'Loading...' : 'Submit'}
+ </Button>
+ </form>
+ </ModalBody>
+ </ModalContent>
+ </Modal>
+ )
+}
+
+export default ImportModal \ No newline at end of file
diff --git a/src/modules/result/components/MoreMenu.tsx b/src/modules/result/components/MoreMenu.tsx
new file mode 100644
index 0000000..a7380f4
--- /dev/null
+++ b/src/modules/result/components/MoreMenu.tsx
@@ -0,0 +1,36 @@
+"use client";
+import { Button, Dropdown, DropdownItem, DropdownMenu, DropdownTrigger, useDisclosure } from '@nextui-org/react'
+import { MoreVerticalIcon } from 'lucide-react'
+import React from 'react'
+import ImportModal from './ImportModal';
+import ProductModal from './ProductModal';
+
+const MoreMenu = () => {
+ const importModal = useDisclosure();
+ const productModal = useDisclosure();
+
+ return (
+ <>
+ <Dropdown>
+ <DropdownTrigger>
+ <Button variant="flat" className="px-2.5 min-w-fit">
+ <MoreVerticalIcon size={20} />
+ </Button>
+ </DropdownTrigger>
+ <DropdownMenu>
+ <DropdownItem key="product" onPress={productModal.onOpen}>
+ Product List
+ </DropdownItem>
+ <DropdownItem key="import" onPress={importModal.onOpen}>
+ Import Product
+ </DropdownItem>
+ </DropdownMenu>
+ </Dropdown>
+
+ <ProductModal modal={productModal} />
+ <ImportModal modal={importModal} />
+ </>
+ )
+}
+
+export default MoreMenu \ No newline at end of file
diff --git a/src/modules/result/components/ProductModal.tsx b/src/modules/result/components/ProductModal.tsx
new file mode 100644
index 0000000..a4ef49e
--- /dev/null
+++ b/src/modules/result/components/ProductModal.tsx
@@ -0,0 +1,98 @@
+import { Input, Modal, ModalBody, ModalContent, ModalHeader, Pagination, Skeleton, Table, TableBody, TableCell, TableColumn, TableHeader, TableRow } from '@nextui-org/react'
+import { Product } from '@prisma/client'
+import { useQuery } from '@tanstack/react-query'
+import React, { useEffect, useMemo, useState } from 'react'
+import { useDebounce } from 'usehooks-ts'
+
+type Props = {
+ modal: {
+ isOpen: boolean,
+ onOpenChange: () => void
+ }
+}
+
+const ProductModal = ({ modal }: Props) => {
+ const [page, setPage] = useState(1)
+ const [search, setSearch] = useState("")
+ const debouncedSearch = useDebounce(search, 500)
+
+ useEffect(() => {
+ setPage(1)
+ }, [debouncedSearch])
+
+ const { data } = useQuery({
+ queryKey: ['product', page, debouncedSearch],
+ queryFn: async () => {
+ const searchParams = new URLSearchParams({
+ page: page.toString(),
+ search: debouncedSearch,
+ type: 'all'
+ })
+ const response = await fetch(`/api/product?${searchParams}`)
+ const data: {
+ products: (Product & { company: { id: number, name: string } })[],
+ page: number,
+ totalPage: number
+ } = await response.json()
+
+ return data
+ }
+ })
+
+ const [totalPage, setTotalPage] = useState(1)
+
+ useEffect(() => {
+ if (data?.totalPage) setTotalPage(data?.totalPage)
+ }, [data?.totalPage])
+
+ return (
+ <Modal isOpen={modal.isOpen} onOpenChange={modal.onOpenChange} size='5xl'>
+ <ModalContent>
+ <ModalHeader>Product List</ModalHeader>
+ <ModalBody className='pb-6'>
+ <Input type='text' label="Search" value={search} onChange={(e) => setSearch(e.target.value)} />
+ {!data && (
+ <Skeleton isLoaded={!!data} className='rounded-lg h-[600px]' />
+ )}
+
+ {!!data && (
+ <Table className='max-h-[600px]' isHeaderSticky>
+ <TableHeader>
+ <TableColumn>NAME</TableColumn>
+ <TableColumn>ITEM CODE</TableColumn>
+ <TableColumn>BARCODE</TableColumn>
+ <TableColumn>ON-HAND QTY</TableColumn>
+ <TableColumn>DIFFERENCE QTY</TableColumn>
+ <TableColumn>COMPANY</TableColumn>
+ </TableHeader>
+ <TableBody items={data?.products || []}>
+ {(product) => (
+ <TableRow key={product.id}>
+ <TableCell>{product.name}</TableCell>
+ <TableCell>{product.itemCode}</TableCell>
+ <TableCell>{product.barcode}</TableCell>
+ <TableCell>{product.onhandQty}</TableCell>
+ <TableCell>{product.differenceQty}</TableCell>
+ <TableCell>{product.company.name}</TableCell>
+ </TableRow>
+ )}
+ </TableBody>
+ </Table>
+ )}
+
+ <Pagination
+ initialPage={1}
+ page={page}
+ total={totalPage}
+ onChange={(page) => setPage(page)}
+ className='mt-2'
+ />
+
+
+ </ModalBody>
+ </ModalContent>
+ </Modal>
+ )
+}
+
+export default ProductModal \ No newline at end of file
diff --git a/src/modules/result/components/Table.tsx b/src/modules/result/components/Table.tsx
new file mode 100644
index 0000000..d2e5af4
--- /dev/null
+++ b/src/modules/result/components/Table.tsx
@@ -0,0 +1,106 @@
+"use client";
+import { useResultStore } from "@/common/stores/useResultStore";
+import { StockOpnameRes } from "@/common/types/stockOpname";
+import { Badge, Pagination, Spacer } from "@nextui-org/react"
+import { useQuery } from "@tanstack/react-query";
+import { useSearchParams } from "next/navigation";
+import { useRouter } from "next/navigation";
+import styles from "./table.module.css"
+import clsxm from "@/common/libs/clsxm";
+import DetailRow from "./DetailRow";
+import { useDebounce } from "usehooks-ts";
+
+const Table = () => {
+ const params = useSearchParams()
+ const router = useRouter()
+ const page = params.get('page') ?? '1'
+
+ const { filter: { company, search } } = useResultStore()
+ const debouncedSearch = useDebounce(search, 500)
+
+ const stockOpnames = useQuery({
+ queryKey: ['stockOpnames', company, debouncedSearch, page],
+ queryFn: async () => {
+ const searchParams = new URLSearchParams()
+ if (!company) return null
+ searchParams.set('companyId', company)
+ searchParams.set('page', page);
+ if (debouncedSearch) searchParams.set('search', debouncedSearch)
+
+ return await fetch(`/api/stock-opname?${searchParams}`)
+ .then(res => res.json())
+ },
+ })
+
+ return (
+ <>
+ <div className="w-full overflow-auto pb-4">
+ <table className="w-full">
+ <thead className={styles.thead}>
+ <th className={styles.th}>STATUS</th>
+ <th className={clsxm(styles.th, '!text-left')}>NAMA PRODUK</th>
+ <th className={styles.th}>TIM HITUNG 1</th>
+ <th className={styles.th}>TIM HITUNG 2</th>
+ <th className={styles.th}>TIM VERIFIKASI</th>
+ <th className={styles.th}>ON-HAND QTY</th>
+ <th className={styles.th}>GUDANG SELISIH</th>
+ </thead>
+ <tbody className={styles.tbody}>
+ {stockOpnames.data?.result.map((stockOpname: StockOpnameRes['result']) => (
+ <>
+ <tr key={stockOpname.id} className="border-t border-neutral-200">
+ <td className={styles.td}>
+ <div className={clsxm("w-full rounded-lg mr-1 mt-1.5 p-1 text-xs text-white whitespace-nowrap", {
+ "bg-danger-600": stockOpname.isDifferent,
+ "bg-success-600": !stockOpname.isDifferent,
+ })}>
+ {stockOpname.isDifferent ? 'Tidak Sesuai' : 'Sesuai'}
+ </div>
+ </td>
+ <td className={clsxm(styles.td, '!text-left flex min-w-[250px]')}>
+ {stockOpname.itemCode ? `[${stockOpname.itemCode}] ` : ''}
+ {stockOpname.name}
+ {stockOpname.barcode ? ` [${stockOpname.barcode}]` : ''}
+ </td>
+ <td className={styles.td}>
+ {stockOpname.quantity.COUNT1 || '-'}
+ </td>
+ <td className={styles.td}>
+ {stockOpname.quantity.COUNT2 || '-'}
+ </td>
+ <td className={styles.td}>
+ {stockOpname.quantity.VERIFICATION || '-'}
+ </td>
+ <td className={styles.td}>
+ {stockOpname.onhandQty}
+ </td>
+ <td className={styles.td}>
+ {stockOpname.differenceQty}
+ </td>
+ </tr>
+
+ <DetailRow productId={stockOpname.id} />
+ </>
+ ))}
+
+ {stockOpnames.data?.result.length === 0 && (
+ <tr>
+ <td colSpan={7} className="text-center text-neutral-600 py-4">Belum ada data untuk ditampilkan</td>
+ </tr>
+ )}
+ </tbody>
+ </table>
+
+ <Spacer y={4} />
+ <Pagination
+ isCompact
+ page={stockOpnames.data?.page || 1}
+ total={stockOpnames.data?.totalPage || 1}
+ onChange={(page) => router.push(`?page=${page}`)}
+ />
+ </div>
+ </>
+ )
+}
+
+export default Table \ No newline at end of file
diff --git a/src/modules/result/components/filter.module.css b/src/modules/result/components/filter.module.css
new file mode 100644
index 0000000..7142d3e
--- /dev/null
+++ b/src/modules/result/components/filter.module.css
@@ -0,0 +1,3 @@
+.wrapper {
+ @apply flex gap-x-2;
+}
diff --git a/src/modules/result/components/table.module.css b/src/modules/result/components/table.module.css
new file mode 100644
index 0000000..c888070
--- /dev/null
+++ b/src/modules/result/components/table.module.css
@@ -0,0 +1,23 @@
+.thead {
+ @apply text-xs;
+}
+
+.tbody {
+ @apply text-sm;
+}
+
+.th,
+.td,
+.tdChild {
+ @apply py-2 px-2 text-center;
+}
+
+.th {
+ @apply whitespace-nowrap font-medium py-3 bg-neutral-100
+ first:rounded-md
+ last:rounded-md;
+}
+
+.td {
+ @apply text-neutral-800;
+}
diff --git a/src/modules/result/index.tsx b/src/modules/result/index.tsx
new file mode 100644
index 0000000..73e613a
--- /dev/null
+++ b/src/modules/result/index.tsx
@@ -0,0 +1,22 @@
+import { Spacer } from "@nextui-org/react"
+import Filter from "./components/Filter"
+import styles from "./result.module.css"
+import Table from "./components/Table"
+import MoreMenu from "./components/MoreMenu"
+
+const Result = () => {
+ return (
+ <>
+ <div className={styles.wrapper}>
+ <div className={styles.title}>Stock Opname Result</div>
+ <MoreMenu />
+ </div>
+ <Spacer y={6} />
+ <Filter />
+ <Spacer y={4} />
+ <Table />
+ </>
+ )
+}
+
+export default Result \ No newline at end of file
diff --git a/src/modules/result/result.module.css b/src/modules/result/result.module.css
new file mode 100644
index 0000000..4bcb649
--- /dev/null
+++ b/src/modules/result/result.module.css
@@ -0,0 +1,7 @@
+.wrapper {
+ @apply flex justify-between items-center;
+}
+
+.title {
+ @apply font-semibold text-xl;
+}
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;
+}