summaryrefslogtreecommitdiff
path: root/src/modules/result/components
diff options
context:
space:
mode:
Diffstat (limited to 'src/modules/result/components')
-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
8 files changed, 483 insertions, 0 deletions
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;
+}