diff options
| author | Rafi Zadanly <zadanlyr@gmail.com> | 2023-11-09 15:40:16 +0700 |
|---|---|---|
| committer | Rafi Zadanly <zadanlyr@gmail.com> | 2023-11-09 15:40:16 +0700 |
| commit | be0f537dc4fe384eef09436833c6407e6482c16d (patch) | |
| tree | 194b1ad3f34396cb8149075bbbd38b854aedf361 /src | |
| parent | 5d5401ae36e7e0c8eb38ccd943c1aa44a9573d35 (diff) | |
Initial commit
Diffstat (limited to 'src')
55 files changed, 1905 insertions, 147 deletions
diff --git a/src/app/(desktop-screen)/result/page.tsx b/src/app/(desktop-screen)/result/page.tsx new file mode 100644 index 0000000..8c96272 --- /dev/null +++ b/src/app/(desktop-screen)/result/page.tsx @@ -0,0 +1,14 @@ +import Authenticated from "@/common/components/Authenticated" +import Result from "@/modules/result" + +const ResultPage = () => { + return ( + <Authenticated> + <main className="px-4 pt-6 bg-white"> + <Result /> + </main> + </Authenticated> + ) +} + +export default ResultPage
\ No newline at end of file diff --git a/src/app/(mobile-screen)/layout.tsx b/src/app/(mobile-screen)/layout.tsx new file mode 100644 index 0000000..f44be2c --- /dev/null +++ b/src/app/(mobile-screen)/layout.tsx @@ -0,0 +1,12 @@ +import ScreenContainer from '@/common/components/ScreenContainer' +import React from 'react' + +const layout = ({ children }: { children: React.ReactNode }) => { + return ( + <ScreenContainer> + {children} + </ScreenContainer> + ) +} + +export default layout
\ No newline at end of file diff --git a/src/app/(mobile-screen)/login/page.tsx b/src/app/(mobile-screen)/login/page.tsx new file mode 100644 index 0000000..d518e2d --- /dev/null +++ b/src/app/(mobile-screen)/login/page.tsx @@ -0,0 +1,8 @@ +import Login from '@/modules/login' +import React from 'react' + +const LoginPage = () => { + return <Login /> +} + +export default LoginPage
\ No newline at end of file diff --git a/src/app/(mobile-screen)/page.tsx b/src/app/(mobile-screen)/page.tsx new file mode 100644 index 0000000..430a195 --- /dev/null +++ b/src/app/(mobile-screen)/page.tsx @@ -0,0 +1,16 @@ +import Authenticated from "@/common/components/Authenticated"; +import ProfileCard from "@/modules/profile-card"; +import StockOpname from "@/modules/stock-opname"; +import { Spacer } from "@nextui-org/react"; + +export default function HomePage() { + return ( + <Authenticated> + <main className="px-4 pt-10"> + <ProfileCard /> + <Spacer y={6} /> + <StockOpname /> + </main> + </Authenticated> + ) +} diff --git a/src/app/api/auth/login/route.tsx b/src/app/api/auth/login/route.tsx new file mode 100644 index 0000000..d4da662 --- /dev/null +++ b/src/app/api/auth/login/route.tsx @@ -0,0 +1,36 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "prisma/client"; +import { cookies } from "next/headers" +import { Credential } from "@/common/types/auth" +import bcrypt from "bcrypt"; +import jwt from "jsonwebtoken"; + +const JWT_SECRET = process.env.JWT_SECRET as string + +export async function POST(request: NextRequest) { + const body = await request.json() + + const user = await prisma.user.findUnique({ + where: { username: body.username }, + include: { + company: true + } + }) + + if (!user) { + return NextResponse.json({ error: 'User not found' }, { status: 404 }) + } + + if (!await bcrypt.compare(body.password, user.password)) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const credential: Credential = { + ...user, + token: jwt.sign(user, JWT_SECRET, { expiresIn: '10y' }) + } + + cookies().set('credential', JSON.stringify(credential)) + + return NextResponse.json(credential) +}
\ No newline at end of file diff --git a/src/app/api/company/route.tsx b/src/app/api/company/route.tsx new file mode 100644 index 0000000..c970108 --- /dev/null +++ b/src/app/api/company/route.tsx @@ -0,0 +1,8 @@ +import { NextResponse } from "next/server"; +import { prisma } from "prisma/client"; + +export async function GET() { + const companies = await prisma.company.findMany() + + return NextResponse.json(companies) +}
\ No newline at end of file diff --git a/src/app/api/hash/route.tsx b/src/app/api/hash/route.tsx new file mode 100644 index 0000000..2727e1f --- /dev/null +++ b/src/app/api/hash/route.tsx @@ -0,0 +1,10 @@ +import { NextRequest, NextResponse } from "next/server"; +import bcrypt from "bcrypt" + +export async function GET(request: NextRequest) { + const searchParams = request.nextUrl.searchParams; + const password = searchParams.get('password') ?? ''; + const hash = await bcrypt.hash(password, 10); + + return NextResponse.json({ hash }); +}
\ No newline at end of file diff --git a/src/app/api/location/route.tsx b/src/app/api/location/route.tsx new file mode 100644 index 0000000..452a85d --- /dev/null +++ b/src/app/api/location/route.tsx @@ -0,0 +1,27 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "prisma/client"; +import { Credential } from "@/common/types/auth" + +export async function GET(request: NextRequest) { + const searchParams = request.nextUrl.searchParams; + const search = searchParams.get('search'); + + const credentialStr = request.cookies.get('credential')?.value + const credential: Credential | null = credentialStr ? JSON.parse(credentialStr) : null + + if (!credential) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { companyId } = credential + + const locations = await prisma.location.findMany({ + where: { + companyId, + name: { contains: search ?? '' } + }, + take: 20 + }) + + return NextResponse.json(locations) +}
\ No newline at end of file diff --git a/src/app/api/product/import/route.tsx b/src/app/api/product/import/route.tsx new file mode 100644 index 0000000..7358205 --- /dev/null +++ b/src/app/api/product/import/route.tsx @@ -0,0 +1,27 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "prisma/client"; +import * as XLSX from "xlsx"; + +export async function POST(request: NextRequest) { + const body = await request.arrayBuffer(); + const workbook = XLSX.read(body, { type: 'buffer' }) + const worksheetName = workbook.SheetNames[0] + const worksheet = workbook.Sheets[worksheetName] + const fileData: any[] = XLSX.utils.sheet_to_json(worksheet, { header: 1 }) + fileData.shift(); + + const newProducts = fileData.map(row => ({ + id: undefined, + isDifferent: false, + name: row[0], + barcode: row[1], + itemCode: row[2], + onhandQty: row[3], + differenceQty: row[4], + companyId: row[5], + })); + + await prisma.product.createMany({ data: newProducts }) + + return NextResponse.json(true) +}
\ No newline at end of file diff --git a/src/app/api/product/route.tsx b/src/app/api/product/route.tsx new file mode 100644 index 0000000..1161a4e --- /dev/null +++ b/src/app/api/product/route.tsx @@ -0,0 +1,48 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "prisma/client"; +import { Credential } from "@/common/types/auth" + +export async function GET(request: NextRequest) { + const PAGE_SIZE = 30; + + const searchParams = request.nextUrl.searchParams; + const type = searchParams.get('type') + const search = searchParams.get('search'); + const page = searchParams.get('page'); + const intPage: number = page ? parseInt(page) : 1; + + const credentialStr = request.cookies.get('credential')?.value + const credential: Credential | null = credentialStr ? JSON.parse(credentialStr) : null + + if (!credential) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { companyId } = credential + + const where = { + AND: { + OR: [ + { name: { contains: search ?? '' } }, + { barcode: { contains: search ?? '' } }, + { itemCode: { contains: search ?? '' } }, + ], + companyId: type == 'all' ? undefined : companyId + } + } + + const products = await prisma.product.findMany({ + where, + include: { company: true }, + take: PAGE_SIZE, + skip: (intPage - 1) * PAGE_SIZE + }) + + const count = await prisma.product.count({ where }) + const pagination = { + page: intPage, + totalPage: Math.ceil(count / PAGE_SIZE), + } + + return NextResponse.json({ products, ...pagination }) +}
\ No newline at end of file diff --git a/src/app/api/stock-opname/location/route.tsx b/src/app/api/stock-opname/location/route.tsx new file mode 100644 index 0000000..1009486 --- /dev/null +++ b/src/app/api/stock-opname/location/route.tsx @@ -0,0 +1,57 @@ +import { DetailTeam, StockOpnameLocationRes } from "@/common/types/stockOpname"; +import { Location, Team } from "@prisma/client"; +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "prisma/client"; + + + +const getOpnameQuantity = async (where: { locationId: number, productId: number }) => { + const detailTeam: DetailTeam = { + COUNT1: { quantity: undefined, user: undefined }, + COUNT2: { quantity: undefined, user: undefined }, + VERIFICATION: { quantity: undefined, user: undefined }, + } + for (const team of Object.keys(Team)) { + const opname = await prisma.stockOpname.findFirst({ + where: { ...where, team: team as Team }, + select: { quantity: true, user: true }, + }) + if (!opname) continue + detailTeam[team as Team]['quantity'] = opname?.quantity + detailTeam[team as Team]['user'] = opname?.user + } + return detailTeam +} + +export async function GET(request: NextRequest) { + const searchParams = request.nextUrl.searchParams; + const productId = searchParams.get('productId') ?? ''; + const companyId = searchParams.get('companyId'); + const intProductId = parseInt(productId); + + if (!companyId) { + return NextResponse.json({ error: 'Bad Request. Missing companyId' }, { status: 400 }) + } + + const intCompanyId = parseInt(companyId); + + const opnameLocByProduct = await prisma.stockOpname.groupBy({ + by: ['locationId'], + where: { productId: intProductId, companyId: intCompanyId }, + }) + + const locationIds = opnameLocByProduct.map((opname) => opname.locationId) + + const result: StockOpnameLocationRes[] = [] + + for (const locationId of locationIds) { + const detail = await getOpnameQuantity({ locationId, productId: intProductId }) + const location = await prisma.location.findFirst({ + where: { id: locationId, companyId: intCompanyId } + }) + if (!location) continue + result.push({ ...location, ...detail }) + } + + return NextResponse.json(result) +}
\ No newline at end of file diff --git a/src/app/api/stock-opname/quantity/route.tsx b/src/app/api/stock-opname/quantity/route.tsx new file mode 100644 index 0000000..621297f --- /dev/null +++ b/src/app/api/stock-opname/quantity/route.tsx @@ -0,0 +1,34 @@ +import { Credential } from "@/common/types/auth"; +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "prisma/client"; + +export async function GET(request: NextRequest) { + const credentialStr = request.cookies.get('credential')?.value + const credential: Credential | null = credentialStr ? JSON.parse(credentialStr) : null + + if (!credential) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + + const searchParams = request.nextUrl.searchParams; + let locationId = searchParams.get('locationId'); + let productId = searchParams.get('productId'); + + if (!locationId || !productId) return NextResponse.json({ error: 'Bad Request. Missing locationId and productId' }, { status: 400 }) + let intLocationId = parseInt(locationId) + let intProductId = parseInt(productId) + + const { companyId, team } = credential + + const query = { + locationId: intLocationId, + productId: intProductId, + companyId, + team + } + + const stockOpname = await prisma.stockOpname.findFirst({ + where: query, + select: { id: true, quantity: true, user: true } + }) + + return NextResponse.json(stockOpname) +}
\ No newline at end of file diff --git a/src/app/api/stock-opname/route.tsx b/src/app/api/stock-opname/route.tsx new file mode 100644 index 0000000..50feacd --- /dev/null +++ b/src/app/api/stock-opname/route.tsx @@ -0,0 +1,218 @@ +import { Credential } from "@/common/types/auth"; +import { StockOpnameLocationRes, StockOpnameRequest } from "@/common/types/stockOpname"; +import { Team } from "@prisma/client"; +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "prisma/client"; + +type Quantity = { + [key in keyof typeof Team]: number | null +} + +const calculateOpnameQuantity = async ( + where: { + productId: number, + companyId: number + } +): Promise<Quantity> => { + const quantity: Quantity = { + COUNT1: null, + COUNT2: null, + VERIFICATION: null, + } + for (const team of Object.values(Team)) { + const opnameQty = await prisma.stockOpname.groupBy({ + by: ['productId', 'team'], + _sum: { quantity: true }, + where: { team, ...where } + }) + if (opnameQty.length === 0) continue + quantity[team] = opnameQty[0]._sum.quantity + } + return quantity +} + +export async function GET(request: NextRequest) { + const PAGE_SIZE = 30; + + const params = request.nextUrl.searchParams + const companyId = params.get('companyId') + const search = params.get('search') + const page = params.get('page') ?? null; + const intPage = page ? parseInt(page) : 1; + + if (!companyId) { + return NextResponse.json({ error: 'Bad Request. Missing companyId' }, { status: 400 }) + } + + const where = { + AND: { + stockOpnames: { some: {} }, + companyId: parseInt(companyId), + OR: [ + { name: { contains: search ?? '' } }, + { itemCode: { contains: search ?? '' } }, + { barcode: { contains: search ?? '' } }, + ] + } + } + + const products = await prisma.product.findMany({ + skip: (intPage - 1) * PAGE_SIZE, + take: PAGE_SIZE, + where, + select: { + id: true, + name: true, + itemCode: true, + barcode: true, + onhandQty: true, + differenceQty: true, + isDifferent: true + } + }) + + const productCount = await prisma.product.count({ where }) + + const pagination = { + page: intPage, + totalPage: Math.ceil(productCount / PAGE_SIZE), + } + + type ProductWithSum = typeof products[0] & { quantity: Quantity } + + const productsWithSum: ProductWithSum[] = [] + + for (const product of products) { + const quantity = await calculateOpnameQuantity({ + productId: product.id, + companyId: parseInt(companyId) + }) + productsWithSum.push({ ...product, quantity }) + } + + return NextResponse.json({ + result: productsWithSum, + ...pagination + }) +} + +export async function POST(request: NextRequest) { + const credentialStr = request.cookies.get('credential')?.value + const credential: Credential | null = credentialStr ? JSON.parse(credentialStr) : null + + if (!credential) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + + const body: StockOpnameRequest = await request.json() + + const { companyId, team } = credential + + const query = { + locationId: body.location, + productId: body.product, + companyId, + team + } + + const stockOpname = await prisma.stockOpname.findFirst({ + where: query + }) + + const data = { + ...query, + userId: credential.id, + quantity: body.quantity, + isDifferent: false + } + + let newStockOpname = null + + if (!stockOpname) { + newStockOpname = await prisma.stockOpname.create({ data }) + } else { + newStockOpname = await prisma.stockOpname.update({ + where: { id: stockOpname.id }, + data + }) + } + + computeIsDifferent({ productId: body.product, companyId: companyId }) + + return NextResponse.json(newStockOpname) +} + +const SELF_HOST = process.env.SELF_HOST as string + +const computeIsDifferent = async ({ + companyId, + productId +}: { + companyId: number, + productId: number +}) => { + const totalQty: { [key in keyof typeof Team]: number | null } = { + COUNT1: null, + COUNT2: null, + VERIFICATION: null + } + + const searchParams = new URLSearchParams({ + companyId: companyId.toString(), + productId: productId.toString() + }) + + const stockOpnamesFetch = await fetch(`${SELF_HOST}/api/stock-opname/location?${searchParams}`) + const stockOpnames: StockOpnameLocationRes[] = await stockOpnamesFetch.json() + + const count2Count = await prisma.stockOpname.count({ + where: { companyId, productId, team: 'COUNT2' } + }) + + const isCount2Counted: boolean = count2Count > 0 + + let isDifferent: boolean = false + + for (const opname of stockOpnames) { + let { COUNT1, COUNT2, VERIFICATION } = opname + + if (!totalQty['COUNT1'] && COUNT1.quantity) totalQty['COUNT1'] = 0 + if (!totalQty['COUNT2'] && COUNT2.quantity) totalQty['COUNT2'] = 0 + if (!totalQty['VERIFICATION'] && VERIFICATION.quantity) totalQty['VERIFICATION'] = 0 + + if (totalQty['COUNT1'] !== null && COUNT1.quantity) totalQty['COUNT1'] += COUNT1.quantity + if (totalQty['COUNT2'] !== null && COUNT2.quantity) totalQty['COUNT2'] += COUNT2.quantity + if (totalQty['VERIFICATION'] !== null && VERIFICATION.quantity) totalQty['VERIFICATION'] += VERIFICATION.quantity + + if (isCount2Counted && COUNT1.quantity != COUNT2.quantity) { + isDifferent = true + } + } + + const product = await prisma.product.findFirst({ + where: { id: productId } + }) + if (!product) return + + const onhandQty = product?.onhandQty || 0 + + if (!isDifferent) { + if ( + (typeof totalQty['VERIFICATION'] === 'number' && totalQty['VERIFICATION'] > 0) || + totalQty['COUNT2'] == onhandQty || + totalQty['COUNT1'] == onhandQty || + totalQty['COUNT1'] == totalQty['COUNT2'] + ) { + isDifferent = false + } else { + isDifferent = true + } + + if (isCount2Counted && totalQty['COUNT1'] != onhandQty) { + isDifferent = true + } + } + + await prisma.product.update({ + where: { id: product.id }, + data: { isDifferent } + }) +}
\ No newline at end of file diff --git a/src/app/globals.css b/src/app/globals.css deleted file mode 100644 index fd81e88..0000000 --- a/src/app/globals.css +++ /dev/null @@ -1,27 +0,0 @@ -@tailwind base; -@tailwind components; -@tailwind utilities; - -:root { - --foreground-rgb: 0, 0, 0; - --background-start-rgb: 214, 219, 220; - --background-end-rgb: 255, 255, 255; -} - -@media (prefers-color-scheme: dark) { - :root { - --foreground-rgb: 255, 255, 255; - --background-start-rgb: 0, 0, 0; - --background-end-rgb: 0, 0, 0; - } -} - -body { - color: rgb(var(--foreground-rgb)); - background: linear-gradient( - to bottom, - transparent, - rgb(var(--background-end-rgb)) - ) - rgb(var(--background-start-rgb)); -} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 40e027f..933e308 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,12 +1,15 @@ import type { Metadata } from 'next' -import { Inter } from 'next/font/google' -import './globals.css' - -const inter = Inter({ subsets: ['latin'] }) +import '@/common/styles/globals.css' +import clsxm from '@/common/libs/clsxm' +import { inter } from '@/common/styles/fonts' +import UIProvider from '@/common/contexts/UIProvider' +import QueryProvider from '@/common/contexts/QueryProvider' +import { Toaster } from 'react-hot-toast' export const metadata: Metadata = { - title: 'Create Next App', - description: 'Generated by create next app', + title: 'FIN Stock Opname', + description: 'FIN Stock Opname', + viewport: 'width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0', } export default function RootLayout({ @@ -16,7 +19,14 @@ export default function RootLayout({ }) { return ( <html lang="en"> - <body className={inter.className}>{children}</body> + <body className={clsxm(inter.variable)}> + <Toaster /> + <UIProvider> + <QueryProvider> + {children} + </QueryProvider> + </UIProvider> + </body> </html> ) } diff --git a/src/app/page.tsx b/src/app/page.tsx deleted file mode 100644 index e38c626..0000000 --- a/src/app/page.tsx +++ /dev/null @@ -1,113 +0,0 @@ -import Image from 'next/image' - -export default function Home() { - return ( - <main className="flex min-h-screen flex-col items-center justify-between p-24"> - <div className="z-10 max-w-5xl w-full items-center justify-between font-mono text-sm lg:flex"> - <p className="fixed left-0 top-0 flex w-full justify-center border-b border-gray-300 bg-gradient-to-b from-zinc-200 pb-6 pt-8 backdrop-blur-2xl dark:border-neutral-800 dark:bg-zinc-800/30 dark:from-inherit lg:static lg:w-auto lg:rounded-xl lg:border lg:bg-gray-200 lg:p-4 lg:dark:bg-zinc-800/30"> - Get started by editing - <code className="font-mono font-bold">src/app/page.tsx</code> - </p> - <div className="fixed bottom-0 left-0 flex h-48 w-full items-end justify-center bg-gradient-to-t from-white via-white dark:from-black dark:via-black lg:static lg:h-auto lg:w-auto lg:bg-none"> - <a - className="pointer-events-none flex place-items-center gap-2 p-8 lg:pointer-events-auto lg:p-0" - href="https://vercel.com?utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app" - target="_blank" - rel="noopener noreferrer" - > - By{' '} - <Image - src="/vercel.svg" - alt="Vercel Logo" - className="dark:invert" - width={100} - height={24} - priority - /> - </a> - </div> - </div> - - <div className="relative flex place-items-center before:absolute before:h-[300px] before:w-[480px] before:-translate-x-1/2 before:rounded-full before:bg-gradient-radial before:from-white before:to-transparent before:blur-2xl before:content-[''] after:absolute after:-z-20 after:h-[180px] after:w-[240px] after:translate-x-1/3 after:bg-gradient-conic after:from-sky-200 after:via-blue-200 after:blur-2xl after:content-[''] before:dark:bg-gradient-to-br before:dark:from-transparent before:dark:to-blue-700 before:dark:opacity-10 after:dark:from-sky-900 after:dark:via-[#0141ff] after:dark:opacity-40 before:lg:h-[360px] z-[-1]"> - <Image - className="relative dark:drop-shadow-[0_0_0.3rem_#ffffff70] dark:invert" - src="/next.svg" - alt="Next.js Logo" - width={180} - height={37} - priority - /> - </div> - - <div className="mb-32 grid text-center lg:max-w-5xl lg:w-full lg:mb-0 lg:grid-cols-4 lg:text-left"> - <a - href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app" - className="group rounded-lg border border-transparent px-5 py-4 transition-colors hover:border-gray-300 hover:bg-gray-100 hover:dark:border-neutral-700 hover:dark:bg-neutral-800/30" - target="_blank" - rel="noopener noreferrer" - > - <h2 className={`mb-3 text-2xl font-semibold`}> - Docs{' '} - <span className="inline-block transition-transform group-hover:translate-x-1 motion-reduce:transform-none"> - -> - </span> - </h2> - <p className={`m-0 max-w-[30ch] text-sm opacity-50`}> - Find in-depth information about Next.js features and API. - </p> - </a> - - <a - href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app" - className="group rounded-lg border border-transparent px-5 py-4 transition-colors hover:border-gray-300 hover:bg-gray-100 hover:dark:border-neutral-700 hover:dark:bg-neutral-800/30" - target="_blank" - rel="noopener noreferrer" - > - <h2 className={`mb-3 text-2xl font-semibold`}> - Learn{' '} - <span className="inline-block transition-transform group-hover:translate-x-1 motion-reduce:transform-none"> - -> - </span> - </h2> - <p className={`m-0 max-w-[30ch] text-sm opacity-50`}> - Learn about Next.js in an interactive course with quizzes! - </p> - </a> - - <a - href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app" - className="group rounded-lg border border-transparent px-5 py-4 transition-colors hover:border-gray-300 hover:bg-gray-100 hover:dark:border-neutral-700 hover:dark:bg-neutral-800/30" - target="_blank" - rel="noopener noreferrer" - > - <h2 className={`mb-3 text-2xl font-semibold`}> - Templates{' '} - <span className="inline-block transition-transform group-hover:translate-x-1 motion-reduce:transform-none"> - -> - </span> - </h2> - <p className={`m-0 max-w-[30ch] text-sm opacity-50`}> - Explore the Next.js 13 playground. - </p> - </a> - - <a - href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app" - className="group rounded-lg border border-transparent px-5 py-4 transition-colors hover:border-gray-300 hover:bg-gray-100 hover:dark:border-neutral-700 hover:dark:bg-neutral-800/30" - target="_blank" - rel="noopener noreferrer" - > - <h2 className={`mb-3 text-2xl font-semibold`}> - Deploy{' '} - <span className="inline-block transition-transform group-hover:translate-x-1 motion-reduce:transform-none"> - -> - </span> - </h2> - <p className={`m-0 max-w-[30ch] text-sm opacity-50`}> - Instantly deploy your Next.js site to a shareable URL with Vercel. - </p> - </a> - </div> - </main> - ) -} diff --git a/src/common/components/Authenticated/index.tsx b/src/common/components/Authenticated/index.tsx new file mode 100644 index 0000000..cf7086e --- /dev/null +++ b/src/common/components/Authenticated/index.tsx @@ -0,0 +1,14 @@ +import { cookies } from 'next/headers' +import { redirect } from 'next/navigation' +import React from 'react' + +const Authenticated = ({ children }: { children: React.ReactNode }) => { + const credentialStr = cookies().get('credential')?.value + const credential: Credential | null = credentialStr ? JSON.parse(credentialStr) : null + + if (!credential) redirect('/login') + + return children +} + +export default Authenticated
\ No newline at end of file diff --git a/src/common/components/Scanner/index.tsx b/src/common/components/Scanner/index.tsx new file mode 100644 index 0000000..56d2495 --- /dev/null +++ b/src/common/components/Scanner/index.tsx @@ -0,0 +1,39 @@ +import { useZxing } from "react-zxing"; +import styles from "./scanner.module.css" + +type Props = { + paused: boolean, + onScan: (string: string) => void +} + +const Scanner = (props: Props) => { + const { ref } = useZxing({ + constraints: { + video: { + facingMode: 'environment', + width: { ideal: 1280 }, + height: { ideal: 720 }, + frameRate: { ideal: 30, max: 60 } + } + }, + timeBetweenDecodingAttempts: 100, + paused: props.paused, + onDecodeResult(result) { + props.onScan(result.getText()); + }, + }) + + const restartCam = () => { + ref.current?.pause() + ref.current?.play() + } + + return ( + <div className={styles.wrapper}> + <video ref={ref} onClick={restartCam} className={styles.video} /> + <div className={styles.videoFrame} /> + </div> + ) +} + +export default Scanner
\ No newline at end of file diff --git a/src/common/components/Scanner/scanner.module.css b/src/common/components/Scanner/scanner.module.css new file mode 100644 index 0000000..3528172 --- /dev/null +++ b/src/common/components/Scanner/scanner.module.css @@ -0,0 +1,16 @@ +.wrapper { + @apply relative w-auto h-auto rounded-lg border border-default-300; +} + +.video { + @apply rounded-lg; +} + +.videoFrame { + @apply absolute + border-dashed border-2 border-default-50 + w-3/5 h-1/5 + top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 + pointer-events-none + rounded-md; +} diff --git a/src/common/components/ScreenContainer/index.tsx b/src/common/components/ScreenContainer/index.tsx new file mode 100644 index 0000000..60676ea --- /dev/null +++ b/src/common/components/ScreenContainer/index.tsx @@ -0,0 +1,15 @@ +import styles from "./screen-container.module.css" + +interface Props { + children: React.ReactNode +} + +const ScreenContainer = ({ children }: Props) => { + return ( + <div className={styles.screen}> + {children} + </div> + ) +} + +export default ScreenContainer
\ No newline at end of file diff --git a/src/common/components/ScreenContainer/screen-container.module.css b/src/common/components/ScreenContainer/screen-container.module.css new file mode 100644 index 0000000..0d055d3 --- /dev/null +++ b/src/common/components/ScreenContainer/screen-container.module.css @@ -0,0 +1,3 @@ +.screen { + @apply container max-w-[480px] h-screen bg-white; +}
\ No newline at end of file diff --git a/src/common/constants/team.ts b/src/common/constants/team.ts new file mode 100644 index 0000000..cfb895b --- /dev/null +++ b/src/common/constants/team.ts @@ -0,0 +1,13 @@ +import { TeamAliases } from "../types/team"; + +export const teamAliases: TeamAliases = { + COUNT1: { + name: "Hitung 1", + }, + COUNT2: { + name: "Hitung 2", + }, + VERIFICATION: { + name: "Verifikasi", + }, +}; diff --git a/src/common/contexts/QueryProvider.tsx b/src/common/contexts/QueryProvider.tsx new file mode 100644 index 0000000..1d6a948 --- /dev/null +++ b/src/common/contexts/QueryProvider.tsx @@ -0,0 +1,20 @@ +"use client"; + +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import React from 'react' + +type Props = { + children: React.ReactNode +} + +const queryClient = new QueryClient() + +const QueryProvider = ({ children }: Props) => { + return ( + <QueryClientProvider client={queryClient}> + {children} + </QueryClientProvider> + ) +} + +export default QueryProvider
\ No newline at end of file diff --git a/src/common/contexts/UIProvider.tsx b/src/common/contexts/UIProvider.tsx new file mode 100644 index 0000000..683e39e --- /dev/null +++ b/src/common/contexts/UIProvider.tsx @@ -0,0 +1,17 @@ +"use client"; + +import { NextUIProvider } from "@nextui-org/react"; + +type Props = { + children: React.ReactNode +} + +const UIProvider = ({ children }: Props) => { + return ( + <NextUIProvider> + {children} + </NextUIProvider> + ) +} + +export default UIProvider
\ No newline at end of file diff --git a/src/common/libs/authenticate.ts b/src/common/libs/authenticate.ts new file mode 100644 index 0000000..48d0314 --- /dev/null +++ b/src/common/libs/authenticate.ts @@ -0,0 +1,22 @@ +const authenticate = async ({ + username, + password, +}: { + username: string; + password: string; +}) => { + const res = await fetch("/api/authenticate", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + username, + password, + }), + }); + + return res; +}; + +export default authenticate; diff --git a/src/common/libs/clsxm.ts b/src/common/libs/clsxm.ts new file mode 100644 index 0000000..0aeffa4 --- /dev/null +++ b/src/common/libs/clsxm.ts @@ -0,0 +1,6 @@ +import clsx, { ClassValue } from "clsx"; +import { twMerge } from "tw-merge"; + +export default function clsxm(...classes: ClassValue[]) { + return twMerge(clsx(...classes)); +} diff --git a/src/common/libs/toast.tsx b/src/common/libs/toast.tsx new file mode 100644 index 0000000..2047a27 --- /dev/null +++ b/src/common/libs/toast.tsx @@ -0,0 +1,23 @@ +import ReactHotToast, { Toast } from "react-hot-toast" +import clsxm from "./clsxm" +import { ReactNode } from "react" +import { XIcon } from "lucide-react" + +type Options = Partial<Pick<Toast, "style" | "className" | "id" | "icon" | "duration" | "ariaProps" | "position" | "iconTheme">> | undefined + +const toast = (children: ReactNode, options: Options = undefined) => { + return ReactHotToast.custom((t) => ( + <div className={clsxm("bg-neutral-100 border border-neutral-200 text-neutral-800 text-sm rounded-lg flex", { + "animate-appearance-in": t.visible, + "animate-appearance-out": !t.visible + })}> + <span className="py-2 px-3">{children}</span> + <div className="w-[1px] h-full bg-neutral-300" /> + <button type="button" className="px-2 text-neutral-800" onClick={() => ReactHotToast.dismiss(t.id)}> + <XIcon size={18} /> + </button> + </div> + ), options) +} + +export default toast
\ No newline at end of file diff --git a/src/common/stores/useAuthStore.ts b/src/common/stores/useAuthStore.ts new file mode 100644 index 0000000..b88cf0b --- /dev/null +++ b/src/common/stores/useAuthStore.ts @@ -0,0 +1,15 @@ +import { create } from "zustand"; +import { Credential } from "../types/auth"; + +type State = { + credentials: Credential | null | undefined; +}; + +type Action = { + setCredentials: (credentials: Credential | null) => void; +}; + +export const useAuthStore = create<State & Action>((set) => ({ + credentials: null, + setCredentials: (credentials) => set({ credentials }), +})); diff --git a/src/common/stores/useLoginStore.ts b/src/common/stores/useLoginStore.ts new file mode 100644 index 0000000..7b4551c --- /dev/null +++ b/src/common/stores/useLoginStore.ts @@ -0,0 +1,26 @@ +import { create } from "zustand"; + +type State = { + form: { + username: string; + password: string; + }; +}; + +type Action = { + updateForm: (name: string, value: string) => void; +}; + +export const useLoginStore = create<State & Action>((set) => ({ + form: { + username: "", + password: "", + }, + updateForm: (name, value) => + set((state) => ({ + form: { + ...state.form, + [name]: value, + }, + })), +})); diff --git a/src/common/stores/useResultStore.ts b/src/common/stores/useResultStore.ts new file mode 100644 index 0000000..d8da56c --- /dev/null +++ b/src/common/stores/useResultStore.ts @@ -0,0 +1,26 @@ +import { create } from "zustand"; + +type State = { + filter: { + search: string; + company: string; + }; +}; + +type Action = { + updateFilter: (name: string, value: string) => void; +}; + +export const useResultStore = create<State & Action>((set) => ({ + filter: { + search: "", + company: "", + }, + updateFilter: (name, value) => + set((state) => ({ + filter: { + ...state.filter, + [name]: value, + }, + })), +})); diff --git a/src/common/stores/useStockOpnameStore.ts b/src/common/stores/useStockOpnameStore.ts new file mode 100644 index 0000000..8aeff77 --- /dev/null +++ b/src/common/stores/useStockOpnameStore.ts @@ -0,0 +1,48 @@ +import { create } from "zustand"; +import { SelectOption } from "../types/select"; +import { SingleValue } from "react-select"; +import { User } from "@prisma/client"; + +type State = { + form: { + location: SingleValue<SelectOption> | null; + product: SingleValue<SelectOption> | null; + quantity: string; + }; + oldOpname: { + id: number; + quantity: number; + user: User; + } | null; +}; + +type Action = { + updateForm: ( + name: keyof State["form"], + value: SingleValue<SelectOption> | string + ) => void; + setOldOpname: (value: State["oldOpname"]) => void; + resetForm: () => void; +}; + +export const useStockOpnameStore = create<State & Action>((set) => ({ + form: { + location: null, + product: null, + quantity: "", + }, + oldOpname: null, + updateForm: (name, value) => + set((state) => ({ + form: { + ...state.form, + [name]: value, + }, + })), + setOldOpname: (value) => set(() => ({ oldOpname: value })), + resetForm: () => + set((state) => ({ + form: { ...state.form, product: null, quantity: "" }, + oldOpname: null, + })), +})); diff --git a/src/common/styles/fonts.ts b/src/common/styles/fonts.ts new file mode 100644 index 0000000..bf78bf7 --- /dev/null +++ b/src/common/styles/fonts.ts @@ -0,0 +1,8 @@ +import { Inter } from "next/font/google"; + +export const inter = Inter({ + subsets: ["latin"], + display: "fallback", + weight: ["400", "500", "600", "700"], + variable: "--font-inter", +}); diff --git a/src/common/styles/globals.css b/src/common/styles/globals.css new file mode 100644 index 0000000..861f1c0 --- /dev/null +++ b/src/common/styles/globals.css @@ -0,0 +1,24 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +html, +body { + @apply bg-neutral-100 font-primary; +} + +.react-select__control { + @apply h-auto !py-0.5 !rounded-medium !bg-default-100 !border-transparent; +} + +.react-select__single-value { + @apply !whitespace-normal text-sm leading-6; +} + +.react-select__menu { + @apply text-sm; +} + +.react-select__placeholder { + @apply text-sm; +} diff --git a/src/common/types/auth.ts b/src/common/types/auth.ts new file mode 100644 index 0000000..50d176f --- /dev/null +++ b/src/common/types/auth.ts @@ -0,0 +1,6 @@ +import { Company, User } from "@prisma/client"; + +export type Credential = User & { + company: Company; + token: string; +}; diff --git a/src/common/types/select.ts b/src/common/types/select.ts new file mode 100644 index 0000000..f61dc08 --- /dev/null +++ b/src/common/types/select.ts @@ -0,0 +1,4 @@ +export type SelectOption = { + value: string | number; + label: string; +}; diff --git a/src/common/types/stockOpname.ts b/src/common/types/stockOpname.ts new file mode 100644 index 0000000..762722a --- /dev/null +++ b/src/common/types/stockOpname.ts @@ -0,0 +1,33 @@ +import { Location, Team, User } from "@prisma/client"; + +export type DetailTeam = { + [key in keyof typeof Team]: { + quantity?: number; + user?: User; + }; +}; + +export type StockOpnameRequest = { + location: number; + product: number; + quantity: number; +}; + +export type StockOpnameRes = { + result: { + id: number; + name: string; + itemCode: string; + barcode: string; + onhandQty: number; + differenceQty: number; + isDifferent: boolean; + quantity: { + [key in keyof typeof Team]: number | null; + }; + }; + page: number; + totalPage: number; +}; + +export type StockOpnameLocationRes = Location & DetailTeam; diff --git a/src/common/types/team.ts b/src/common/types/team.ts new file mode 100644 index 0000000..0989337 --- /dev/null +++ b/src/common/types/team.ts @@ -0,0 +1,7 @@ +import { Team } from "@prisma/client"; + +export type TeamAliases = { + [key in keyof typeof Team]: { + name: string; + }; +}; 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> + · + <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; +} |
