diff options
Diffstat (limited to 'src/modules/result/components')
| -rw-r--r-- | src/modules/result/components/DetailRow.tsx | 81 | ||||
| -rw-r--r-- | src/modules/result/components/Filter.tsx | 74 | ||||
| -rw-r--r-- | src/modules/result/components/ImportModal.tsx | 62 | ||||
| -rw-r--r-- | src/modules/result/components/MoreMenu.tsx | 36 | ||||
| -rw-r--r-- | src/modules/result/components/ProductModal.tsx | 98 | ||||
| -rw-r--r-- | src/modules/result/components/Table.tsx | 106 | ||||
| -rw-r--r-- | src/modules/result/components/filter.module.css | 3 | ||||
| -rw-r--r-- | src/modules/result/components/table.module.css | 23 |
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; +} |
