From be0f537dc4fe384eef09436833c6407e6482c16d Mon Sep 17 00:00:00 2001 From: Rafi Zadanly Date: Thu, 9 Nov 2023 15:40:16 +0700 Subject: Initial commit --- src/modules/login/components/Form.tsx | 81 +++++++ src/modules/login/index.tsx | 20 ++ src/modules/login/login.module.css | 15 ++ src/modules/profile-card/components/Dropdown.tsx | 36 ++++ src/modules/profile-card/index.tsx | 37 ++++ src/modules/profile-card/profile-card.module.css | 11 + src/modules/result/components/DetailRow.tsx | 81 +++++++ src/modules/result/components/Filter.tsx | 74 +++++++ src/modules/result/components/ImportModal.tsx | 62 ++++++ src/modules/result/components/MoreMenu.tsx | 36 ++++ src/modules/result/components/ProductModal.tsx | 98 +++++++++ src/modules/result/components/Table.tsx | 106 +++++++++ src/modules/result/components/filter.module.css | 3 + src/modules/result/components/table.module.css | 23 ++ src/modules/result/index.tsx | 22 ++ src/modules/result/result.module.css | 7 + src/modules/stock-opname/index.tsx | 261 +++++++++++++++++++++++ src/modules/stock-opname/stock-opname.module.css | 15 ++ 18 files changed, 988 insertions(+) create mode 100644 src/modules/login/components/Form.tsx create mode 100644 src/modules/login/index.tsx create mode 100644 src/modules/login/login.module.css create mode 100644 src/modules/profile-card/components/Dropdown.tsx create mode 100644 src/modules/profile-card/index.tsx create mode 100644 src/modules/profile-card/profile-card.module.css create mode 100644 src/modules/result/components/DetailRow.tsx create mode 100644 src/modules/result/components/Filter.tsx create mode 100644 src/modules/result/components/ImportModal.tsx create mode 100644 src/modules/result/components/MoreMenu.tsx create mode 100644 src/modules/result/components/ProductModal.tsx create mode 100644 src/modules/result/components/Table.tsx create mode 100644 src/modules/result/components/filter.module.css create mode 100644 src/modules/result/components/table.module.css create mode 100644 src/modules/result/index.tsx create mode 100644 src/modules/result/result.module.css create mode 100644 src/modules/stock-opname/index.tsx create mode 100644 src/modules/stock-opname/stock-opname.module.css (limited to 'src/modules') 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) => { + updateForm(e.target.name, e.target.value) + } + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + mutation.mutate(form) + } + + const isValid = useMemo(() => { + return form.username && form.password + }, [form]) + + return ( +
+ + + +
+ ) +} + +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 ( +
+
+

Stock Opname

+ +

Masuk terlebih dahulu untuk melanjutkan

+
+ + +
+
+ ) +} + +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 ( + + + + + + + Logout + + + + ) +} + +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 && ( + + + } size='sm' isBordered color='primary' classNames={{ icon: 'text-white' }} /> +
+
{credential.name}
+
+ + {credential.company.name} + + · + + Tim {teamAliases[credential.team].name} + +
+
+ +
+ +
+
+
+ ) +} + +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 ( + + +
+ + +
+ + + ) + } + + return ( + <> + {detailLocation.data?.map((location: StockOpnameLocationRes) => ( + + + +
+ + {location.name} +
+ + + + + + + + + + + + + + ))} + + ) +} + +const QuantityColumn = ({ data }: { data: { quantity?: number | undefined, user?: User } }) => ( +
+ {typeof data?.quantity !== 'number' && '-'} + {data.quantity !== null && ( + <> + {data.quantity} +
+ {data.user?.name} +
+ + )} +
+) + +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([]) + + 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) => { + const { name, value } = e.target + updateFilter(name, value) + } + + const handleSelectChange = (e: React.ChangeEvent) => { + const { name, value } = e.target + updateFilter(name, value) + } + + return ( +
+ + +
+ ) +} + +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() + + const handleFileChange = (e: ChangeEvent) => { + 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) => { + e.preventDefault() + importMutation.mutate() + } + + return ( + + + Import Product + + + + + + + + + ) +} + +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 ( + <> + + + + + + + Product List + + + Import Product + + + + + + + + ) +} + +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 ( + + + Product List + + setSearch(e.target.value)} /> + {!data && ( + + )} + + {!!data && ( + + + NAME + ITEM CODE + BARCODE + ON-HAND QTY + DIFFERENCE QTY + COMPANY + + + {(product) => ( + + {product.name} + {product.itemCode} + {product.barcode} + {product.onhandQty} + {product.differenceQty} + {product.company.name} + + )} + +
+ )} + + setPage(page)} + className='mt-2' + /> + + +
+
+
+ ) +} + +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 ( + <> +
+ + + + + + + + + + + + {stockOpnames.data?.result.map((stockOpname: StockOpnameRes['result']) => ( + <> + + + + + + + + + + + + + ))} + + {stockOpnames.data?.result.length === 0 && ( + + + + )} + +
STATUSNAMA PRODUKTIM HITUNG 1TIM HITUNG 2TIM VERIFIKASION-HAND QTYGUDANG SELISIH
+
+ {stockOpname.isDifferent ? 'Tidak Sesuai' : 'Sesuai'} +
+
+ {stockOpname.itemCode ? `[${stockOpname.itemCode}] ` : ''} + {stockOpname.name} + {stockOpname.barcode ? ` [${stockOpname.barcode}]` : ''} + + {stockOpname.quantity.COUNT1 || '-'} + + {stockOpname.quantity.COUNT2 || '-'} + + {stockOpname.quantity.VERIFICATION || '-'} + + {stockOpname.onhandQty} + + {stockOpname.differenceQty} +
Belum ada data untuk ditampilkan
+ + + router.push(`?page=${page}`)} + /> +
+ + ) +} + +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 ( + <> +
+
Stock Opname Result
+ +
+ + + + + + ) +} + +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, action: ActionMeta) => { + updateForm(action.name as keyof typeof form, val) + } + + const handleInputChange = (e: React.ChangeEvent) => { + 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) => { + e.preventDefault() + saveMutation.mutate(form) + } + + const [activeScanner, setActiveScanner] = useState(null) + const [scannerOptions, setScannerOptions] = useState() + const [scannerOptionLoading, setScannerOptionLoading] = useState(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 ( + <> +
+ Stock Opname +
+ + + +
+
+ +
+ + +
+
+ +
+ +
+ + +
+
+ +
+ + + {oldOpname && ( +
+ Jumlah terakhir adalah {' '} + {oldOpname.quantity} + {' '} diisi oleh {' '} + {oldOpname.user.name}. + {' '} + +
+ )} +
+ + + + + + + Scan {activeScanner} + + {!scannerOptions && !scannerOptionLoading && ( + + )} + + {!scannerOptions && scannerOptionLoading && } + + {!!scannerOptions && ( + <> +
+ {scannerOptions.map((option) => ( + handleScannerOptionPress(option)}> + {option.label} + + ))} +
+ {scannerOptions.length === 0 && ( +
+ + Tidak ada opsi untuk ditampilkan +
+ )} + + + + )} +
+
+
+ + ) +} + +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; +} -- cgit v1.2.3