summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorRafi Zadanly <zadanlyr@gmail.com>2023-11-09 15:40:16 +0700
committerRafi Zadanly <zadanlyr@gmail.com>2023-11-09 15:40:16 +0700
commitbe0f537dc4fe384eef09436833c6407e6482c16d (patch)
tree194b1ad3f34396cb8149075bbbd38b854aedf361 /src
parent5d5401ae36e7e0c8eb38ccd943c1aa44a9573d35 (diff)
Initial commit
Diffstat (limited to 'src')
-rw-r--r--src/app/(desktop-screen)/result/page.tsx14
-rw-r--r--src/app/(mobile-screen)/layout.tsx12
-rw-r--r--src/app/(mobile-screen)/login/page.tsx8
-rw-r--r--src/app/(mobile-screen)/page.tsx16
-rw-r--r--src/app/api/auth/login/route.tsx36
-rw-r--r--src/app/api/company/route.tsx8
-rw-r--r--src/app/api/hash/route.tsx10
-rw-r--r--src/app/api/location/route.tsx27
-rw-r--r--src/app/api/product/import/route.tsx27
-rw-r--r--src/app/api/product/route.tsx48
-rw-r--r--src/app/api/stock-opname/location/route.tsx57
-rw-r--r--src/app/api/stock-opname/quantity/route.tsx34
-rw-r--r--src/app/api/stock-opname/route.tsx218
-rw-r--r--src/app/globals.css27
-rw-r--r--src/app/layout.tsx24
-rw-r--r--src/app/page.tsx113
-rw-r--r--src/common/components/Authenticated/index.tsx14
-rw-r--r--src/common/components/Scanner/index.tsx39
-rw-r--r--src/common/components/Scanner/scanner.module.css16
-rw-r--r--src/common/components/ScreenContainer/index.tsx15
-rw-r--r--src/common/components/ScreenContainer/screen-container.module.css3
-rw-r--r--src/common/constants/team.ts13
-rw-r--r--src/common/contexts/QueryProvider.tsx20
-rw-r--r--src/common/contexts/UIProvider.tsx17
-rw-r--r--src/common/libs/authenticate.ts22
-rw-r--r--src/common/libs/clsxm.ts6
-rw-r--r--src/common/libs/toast.tsx23
-rw-r--r--src/common/stores/useAuthStore.ts15
-rw-r--r--src/common/stores/useLoginStore.ts26
-rw-r--r--src/common/stores/useResultStore.ts26
-rw-r--r--src/common/stores/useStockOpnameStore.ts48
-rw-r--r--src/common/styles/fonts.ts8
-rw-r--r--src/common/styles/globals.css24
-rw-r--r--src/common/types/auth.ts6
-rw-r--r--src/common/types/select.ts4
-rw-r--r--src/common/types/stockOpname.ts33
-rw-r--r--src/common/types/team.ts7
-rw-r--r--src/modules/login/components/Form.tsx81
-rw-r--r--src/modules/login/index.tsx20
-rw-r--r--src/modules/login/login.module.css15
-rw-r--r--src/modules/profile-card/components/Dropdown.tsx36
-rw-r--r--src/modules/profile-card/index.tsx37
-rw-r--r--src/modules/profile-card/profile-card.module.css11
-rw-r--r--src/modules/result/components/DetailRow.tsx81
-rw-r--r--src/modules/result/components/Filter.tsx74
-rw-r--r--src/modules/result/components/ImportModal.tsx62
-rw-r--r--src/modules/result/components/MoreMenu.tsx36
-rw-r--r--src/modules/result/components/ProductModal.tsx98
-rw-r--r--src/modules/result/components/Table.tsx106
-rw-r--r--src/modules/result/components/filter.module.css3
-rw-r--r--src/modules/result/components/table.module.css23
-rw-r--r--src/modules/result/index.tsx22
-rw-r--r--src/modules/result/result.module.css7
-rw-r--r--src/modules/stock-opname/index.tsx261
-rw-r--r--src/modules/stock-opname/stock-opname.module.css15
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&nbsp;
- <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">
- -&gt;
- </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">
- -&gt;
- </span>
- </h2>
- <p className={`m-0 max-w-[30ch] text-sm opacity-50`}>
- Learn about Next.js in an interactive course with&nbsp;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">
- -&gt;
- </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">
- -&gt;
- </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>
+ &#183;
+ <span>
+ Tim {teamAliases[credential.team].name}
+ </span>
+ </div>
+ </div>
+
+ <div className="ml-auto">
+ <Dropdown />
+ </div>
+ </CardBody>
+ </Card>
+ )
+}
+
+export default ProfileCard \ No newline at end of file
diff --git a/src/modules/profile-card/profile-card.module.css b/src/modules/profile-card/profile-card.module.css
new file mode 100644
index 0000000..b0b05ef
--- /dev/null
+++ b/src/modules/profile-card/profile-card.module.css
@@ -0,0 +1,11 @@
+.cardBody {
+ @apply flex flex-row items-center gap-x-4;
+}
+
+.name {
+ @apply font-medium;
+}
+
+.description {
+ @apply text-sm text-neutral-500 flex gap-x-1;
+}
diff --git a/src/modules/result/components/DetailRow.tsx b/src/modules/result/components/DetailRow.tsx
new file mode 100644
index 0000000..99ccb01
--- /dev/null
+++ b/src/modules/result/components/DetailRow.tsx
@@ -0,0 +1,81 @@
+"use client";
+import { useResultStore } from '@/common/stores/useResultStore';
+import { StockOpnameLocationRes } from '@/common/types/stockOpname';
+import { Skeleton } from '@nextui-org/react';
+import { useQuery } from '@tanstack/react-query'
+import styles from './table.module.css'
+import { CornerDownRightIcon } from 'lucide-react';
+import { User } from '@prisma/client';
+import clsxm from '@/common/libs/clsxm';
+
+const DetailRow = ({ productId }: { productId: number }) => {
+ const { filter } = useResultStore()
+
+ const detailLocation = useQuery({
+ queryKey: ['detailLocation', productId, filter.company],
+ queryFn: async () => {
+ const searchParams = new URLSearchParams()
+ if (!filter?.company) return null
+ searchParams.set('companyId', filter.company)
+ searchParams.set('productId', productId.toString())
+ return await fetch(`/api/stock-opname/location?${searchParams}`)
+ .then(res => res.json())
+ }
+ })
+
+ if (detailLocation.isLoading) {
+ return (
+ <tr>
+ <td colSpan={7}>
+ <div className='grid grid-cols-1 gap-y-2 w-full'>
+ <Skeleton className='w-full h-8' />
+ <Skeleton className='w-full h-8' />
+ </div>
+ </td>
+ </tr>
+ )
+ }
+
+ return (
+ <>
+ {detailLocation.data?.map((location: StockOpnameLocationRes) => (
+ <tr key={location.id}>
+ <td />
+ <td className={clsxm(styles.td, 'min-w-[250px]')}>
+ <div className="flex gap-x-2">
+ <CornerDownRightIcon size={16} />
+ {location.name}
+ </div>
+ </td>
+ <td className={styles.td}>
+ <QuantityColumn data={location.COUNT1} />
+ </td>
+ <td className={styles.td}>
+ <QuantityColumn data={location.COUNT2} />
+ </td>
+ <td className={styles.td}>
+ <QuantityColumn data={location.VERIFICATION} />
+ </td>
+ <td className={styles.td} />
+ <td className={styles.td} />
+ </tr>
+ ))}
+ </>
+ )
+}
+
+const QuantityColumn = ({ data }: { data: { quantity?: number | undefined, user?: User } }) => (
+ <div className='grid grid-cols-1'>
+ {typeof data?.quantity !== 'number' && '-'}
+ {data.quantity !== null && (
+ <>
+ <span>{data.quantity}</span>
+ <div className='text-xs'>
+ {data.user?.name}
+ </div>
+ </>
+ )}
+ </div>
+)
+
+export default DetailRow \ No newline at end of file
diff --git a/src/modules/result/components/Filter.tsx b/src/modules/result/components/Filter.tsx
new file mode 100644
index 0000000..f8bc7b5
--- /dev/null
+++ b/src/modules/result/components/Filter.tsx
@@ -0,0 +1,74 @@
+"use client";
+import { Input, Select, SelectItem } from "@nextui-org/react"
+import styles from "./filter.module.css"
+import { Company } from "@prisma/client"
+import { useEffect, useState } from "react"
+import { SelectOption } from "@/common/types/select"
+import { useResultStore } from "@/common/stores/useResultStore";
+import { getCookie } from "cookies-next";
+import { Credential } from "@/common/types/auth";
+
+const Filter = () => {
+ const { filter, updateFilter } = useResultStore()
+ const [companies, setCompanies] = useState<SelectOption[]>([])
+
+ const credentialStr = getCookie('credential')
+ const credential: Credential | null = credentialStr ? JSON.parse(credentialStr) : null
+
+ useEffect(() => {
+ if (credential && !filter.company)
+ updateFilter("company", credential.companyId.toString())
+ }, [credential, updateFilter, filter])
+
+ useEffect(() => {
+ loadCompany().then((data: SelectOption[]) => {
+ setCompanies(data)
+ })
+ }, [updateFilter])
+
+ const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
+ const { name, value } = e.target
+ updateFilter(name, value)
+ }
+
+ const handleSelectChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
+ const { name, value } = e.target
+ updateFilter(name, value)
+ }
+
+ return (
+ <div className={styles.wrapper}>
+ <Input
+ className="w-7/12 md:w-10/12"
+ type="text"
+ label="Produk"
+ name="search"
+ onChange={handleInputChange}
+ value={filter.search}
+ />
+ <Select
+ className="w-5/12 md:w-2/12"
+ label="Perusahaan"
+ selectedKeys={filter.company}
+ name="company"
+ onChange={handleSelectChange}
+ >
+ {companies.map((company) => (
+ <SelectItem key={company.value} value={company.value}>{company.label}</SelectItem>
+ ))}
+ </Select>
+ </div>
+ )
+}
+
+const loadCompany = async () => {
+ const response = await fetch(`/api/company`)
+ const data: Company[] = await response.json() || []
+
+ return data.map((company) => ({
+ value: company.id,
+ label: company.name
+ }))
+}
+
+export default Filter \ No newline at end of file
diff --git a/src/modules/result/components/ImportModal.tsx b/src/modules/result/components/ImportModal.tsx
new file mode 100644
index 0000000..85e4a97
--- /dev/null
+++ b/src/modules/result/components/ImportModal.tsx
@@ -0,0 +1,62 @@
+import toast from '@/common/libs/toast'
+import { Button, Modal, ModalBody, ModalContent, ModalHeader } from '@nextui-org/react'
+import { useMutation } from '@tanstack/react-query'
+import React, { ChangeEvent, FormEvent, useState } from 'react'
+
+type Props = {
+ modal: {
+ isOpen: boolean,
+ onOpenChange: () => void
+ }
+}
+
+const ImportModal = ({ modal }: Props) => {
+ const [file, setFile] = useState<File>()
+
+ const handleFileChange = (e: ChangeEvent<HTMLInputElement>) => {
+ if (e.target.files) setFile(e.target.files[0])
+ }
+
+ const importMutation = useMutation({
+ mutationKey: ['import-product'],
+ mutationFn: async () => {
+ if (!file) return
+ return await fetch('/api/product/import', {
+ method: 'POST',
+ body: file,
+ headers: { 'content-type': file.type, 'content-length': `${file.size}` }
+ })
+ },
+ onSuccess(data) {
+ if (data?.status === 200) {
+ toast('Berhasil import product')
+ setFile(undefined)
+ } else {
+ toast('Gagal import product')
+ }
+ },
+ })
+
+ const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
+ e.preventDefault()
+ importMutation.mutate()
+ }
+
+ return (
+ <Modal isOpen={modal.isOpen} onOpenChange={modal.onOpenChange}>
+ <ModalContent>
+ <ModalHeader>Import Product</ModalHeader>
+ <ModalBody>
+ <form className='pb-6' onSubmit={handleSubmit}>
+ <input type='file' onChange={handleFileChange} accept='.xls, .xlsx' />
+ <Button type='submit' color='primary' className='mt-4 w-full' isDisabled={!file || importMutation.isPending}>
+ {importMutation.isPending ? 'Loading...' : 'Submit'}
+ </Button>
+ </form>
+ </ModalBody>
+ </ModalContent>
+ </Modal>
+ )
+}
+
+export default ImportModal \ No newline at end of file
diff --git a/src/modules/result/components/MoreMenu.tsx b/src/modules/result/components/MoreMenu.tsx
new file mode 100644
index 0000000..a7380f4
--- /dev/null
+++ b/src/modules/result/components/MoreMenu.tsx
@@ -0,0 +1,36 @@
+"use client";
+import { Button, Dropdown, DropdownItem, DropdownMenu, DropdownTrigger, useDisclosure } from '@nextui-org/react'
+import { MoreVerticalIcon } from 'lucide-react'
+import React from 'react'
+import ImportModal from './ImportModal';
+import ProductModal from './ProductModal';
+
+const MoreMenu = () => {
+ const importModal = useDisclosure();
+ const productModal = useDisclosure();
+
+ return (
+ <>
+ <Dropdown>
+ <DropdownTrigger>
+ <Button variant="flat" className="px-2.5 min-w-fit">
+ <MoreVerticalIcon size={20} />
+ </Button>
+ </DropdownTrigger>
+ <DropdownMenu>
+ <DropdownItem key="product" onPress={productModal.onOpen}>
+ Product List
+ </DropdownItem>
+ <DropdownItem key="import" onPress={importModal.onOpen}>
+ Import Product
+ </DropdownItem>
+ </DropdownMenu>
+ </Dropdown>
+
+ <ProductModal modal={productModal} />
+ <ImportModal modal={importModal} />
+ </>
+ )
+}
+
+export default MoreMenu \ No newline at end of file
diff --git a/src/modules/result/components/ProductModal.tsx b/src/modules/result/components/ProductModal.tsx
new file mode 100644
index 0000000..a4ef49e
--- /dev/null
+++ b/src/modules/result/components/ProductModal.tsx
@@ -0,0 +1,98 @@
+import { Input, Modal, ModalBody, ModalContent, ModalHeader, Pagination, Skeleton, Table, TableBody, TableCell, TableColumn, TableHeader, TableRow } from '@nextui-org/react'
+import { Product } from '@prisma/client'
+import { useQuery } from '@tanstack/react-query'
+import React, { useEffect, useMemo, useState } from 'react'
+import { useDebounce } from 'usehooks-ts'
+
+type Props = {
+ modal: {
+ isOpen: boolean,
+ onOpenChange: () => void
+ }
+}
+
+const ProductModal = ({ modal }: Props) => {
+ const [page, setPage] = useState(1)
+ const [search, setSearch] = useState("")
+ const debouncedSearch = useDebounce(search, 500)
+
+ useEffect(() => {
+ setPage(1)
+ }, [debouncedSearch])
+
+ const { data } = useQuery({
+ queryKey: ['product', page, debouncedSearch],
+ queryFn: async () => {
+ const searchParams = new URLSearchParams({
+ page: page.toString(),
+ search: debouncedSearch,
+ type: 'all'
+ })
+ const response = await fetch(`/api/product?${searchParams}`)
+ const data: {
+ products: (Product & { company: { id: number, name: string } })[],
+ page: number,
+ totalPage: number
+ } = await response.json()
+
+ return data
+ }
+ })
+
+ const [totalPage, setTotalPage] = useState(1)
+
+ useEffect(() => {
+ if (data?.totalPage) setTotalPage(data?.totalPage)
+ }, [data?.totalPage])
+
+ return (
+ <Modal isOpen={modal.isOpen} onOpenChange={modal.onOpenChange} size='5xl'>
+ <ModalContent>
+ <ModalHeader>Product List</ModalHeader>
+ <ModalBody className='pb-6'>
+ <Input type='text' label="Search" value={search} onChange={(e) => setSearch(e.target.value)} />
+ {!data && (
+ <Skeleton isLoaded={!!data} className='rounded-lg h-[600px]' />
+ )}
+
+ {!!data && (
+ <Table className='max-h-[600px]' isHeaderSticky>
+ <TableHeader>
+ <TableColumn>NAME</TableColumn>
+ <TableColumn>ITEM CODE</TableColumn>
+ <TableColumn>BARCODE</TableColumn>
+ <TableColumn>ON-HAND QTY</TableColumn>
+ <TableColumn>DIFFERENCE QTY</TableColumn>
+ <TableColumn>COMPANY</TableColumn>
+ </TableHeader>
+ <TableBody items={data?.products || []}>
+ {(product) => (
+ <TableRow key={product.id}>
+ <TableCell>{product.name}</TableCell>
+ <TableCell>{product.itemCode}</TableCell>
+ <TableCell>{product.barcode}</TableCell>
+ <TableCell>{product.onhandQty}</TableCell>
+ <TableCell>{product.differenceQty}</TableCell>
+ <TableCell>{product.company.name}</TableCell>
+ </TableRow>
+ )}
+ </TableBody>
+ </Table>
+ )}
+
+ <Pagination
+ initialPage={1}
+ page={page}
+ total={totalPage}
+ onChange={(page) => setPage(page)}
+ className='mt-2'
+ />
+
+
+ </ModalBody>
+ </ModalContent>
+ </Modal>
+ )
+}
+
+export default ProductModal \ No newline at end of file
diff --git a/src/modules/result/components/Table.tsx b/src/modules/result/components/Table.tsx
new file mode 100644
index 0000000..d2e5af4
--- /dev/null
+++ b/src/modules/result/components/Table.tsx
@@ -0,0 +1,106 @@
+"use client";
+import { useResultStore } from "@/common/stores/useResultStore";
+import { StockOpnameRes } from "@/common/types/stockOpname";
+import { Badge, Pagination, Spacer } from "@nextui-org/react"
+import { useQuery } from "@tanstack/react-query";
+import { useSearchParams } from "next/navigation";
+import { useRouter } from "next/navigation";
+import styles from "./table.module.css"
+import clsxm from "@/common/libs/clsxm";
+import DetailRow from "./DetailRow";
+import { useDebounce } from "usehooks-ts";
+
+const Table = () => {
+ const params = useSearchParams()
+ const router = useRouter()
+ const page = params.get('page') ?? '1'
+
+ const { filter: { company, search } } = useResultStore()
+ const debouncedSearch = useDebounce(search, 500)
+
+ const stockOpnames = useQuery({
+ queryKey: ['stockOpnames', company, debouncedSearch, page],
+ queryFn: async () => {
+ const searchParams = new URLSearchParams()
+ if (!company) return null
+ searchParams.set('companyId', company)
+ searchParams.set('page', page);
+ if (debouncedSearch) searchParams.set('search', debouncedSearch)
+
+ return await fetch(`/api/stock-opname?${searchParams}`)
+ .then(res => res.json())
+ },
+ })
+
+ return (
+ <>
+ <div className="w-full overflow-auto pb-4">
+ <table className="w-full">
+ <thead className={styles.thead}>
+ <th className={styles.th}>STATUS</th>
+ <th className={clsxm(styles.th, '!text-left')}>NAMA PRODUK</th>
+ <th className={styles.th}>TIM HITUNG 1</th>
+ <th className={styles.th}>TIM HITUNG 2</th>
+ <th className={styles.th}>TIM VERIFIKASI</th>
+ <th className={styles.th}>ON-HAND QTY</th>
+ <th className={styles.th}>GUDANG SELISIH</th>
+ </thead>
+ <tbody className={styles.tbody}>
+ {stockOpnames.data?.result.map((stockOpname: StockOpnameRes['result']) => (
+ <>
+ <tr key={stockOpname.id} className="border-t border-neutral-200">
+ <td className={styles.td}>
+ <div className={clsxm("w-full rounded-lg mr-1 mt-1.5 p-1 text-xs text-white whitespace-nowrap", {
+ "bg-danger-600": stockOpname.isDifferent,
+ "bg-success-600": !stockOpname.isDifferent,
+ })}>
+ {stockOpname.isDifferent ? 'Tidak Sesuai' : 'Sesuai'}
+ </div>
+ </td>
+ <td className={clsxm(styles.td, '!text-left flex min-w-[250px]')}>
+ {stockOpname.itemCode ? `[${stockOpname.itemCode}] ` : ''}
+ {stockOpname.name}
+ {stockOpname.barcode ? ` [${stockOpname.barcode}]` : ''}
+ </td>
+ <td className={styles.td}>
+ {stockOpname.quantity.COUNT1 || '-'}
+ </td>
+ <td className={styles.td}>
+ {stockOpname.quantity.COUNT2 || '-'}
+ </td>
+ <td className={styles.td}>
+ {stockOpname.quantity.VERIFICATION || '-'}
+ </td>
+ <td className={styles.td}>
+ {stockOpname.onhandQty}
+ </td>
+ <td className={styles.td}>
+ {stockOpname.differenceQty}
+ </td>
+ </tr>
+
+ <DetailRow productId={stockOpname.id} />
+ </>
+ ))}
+
+ {stockOpnames.data?.result.length === 0 && (
+ <tr>
+ <td colSpan={7} className="text-center text-neutral-600 py-4">Belum ada data untuk ditampilkan</td>
+ </tr>
+ )}
+ </tbody>
+ </table>
+
+ <Spacer y={4} />
+ <Pagination
+ isCompact
+ page={stockOpnames.data?.page || 1}
+ total={stockOpnames.data?.totalPage || 1}
+ onChange={(page) => router.push(`?page=${page}`)}
+ />
+ </div>
+ </>
+ )
+}
+
+export default Table \ No newline at end of file
diff --git a/src/modules/result/components/filter.module.css b/src/modules/result/components/filter.module.css
new file mode 100644
index 0000000..7142d3e
--- /dev/null
+++ b/src/modules/result/components/filter.module.css
@@ -0,0 +1,3 @@
+.wrapper {
+ @apply flex gap-x-2;
+}
diff --git a/src/modules/result/components/table.module.css b/src/modules/result/components/table.module.css
new file mode 100644
index 0000000..c888070
--- /dev/null
+++ b/src/modules/result/components/table.module.css
@@ -0,0 +1,23 @@
+.thead {
+ @apply text-xs;
+}
+
+.tbody {
+ @apply text-sm;
+}
+
+.th,
+.td,
+.tdChild {
+ @apply py-2 px-2 text-center;
+}
+
+.th {
+ @apply whitespace-nowrap font-medium py-3 bg-neutral-100
+ first:rounded-md
+ last:rounded-md;
+}
+
+.td {
+ @apply text-neutral-800;
+}
diff --git a/src/modules/result/index.tsx b/src/modules/result/index.tsx
new file mode 100644
index 0000000..73e613a
--- /dev/null
+++ b/src/modules/result/index.tsx
@@ -0,0 +1,22 @@
+import { Spacer } from "@nextui-org/react"
+import Filter from "./components/Filter"
+import styles from "./result.module.css"
+import Table from "./components/Table"
+import MoreMenu from "./components/MoreMenu"
+
+const Result = () => {
+ return (
+ <>
+ <div className={styles.wrapper}>
+ <div className={styles.title}>Stock Opname Result</div>
+ <MoreMenu />
+ </div>
+ <Spacer y={6} />
+ <Filter />
+ <Spacer y={4} />
+ <Table />
+ </>
+ )
+}
+
+export default Result \ No newline at end of file
diff --git a/src/modules/result/result.module.css b/src/modules/result/result.module.css
new file mode 100644
index 0000000..4bcb649
--- /dev/null
+++ b/src/modules/result/result.module.css
@@ -0,0 +1,7 @@
+.wrapper {
+ @apply flex justify-between items-center;
+}
+
+.title {
+ @apply font-semibold text-xl;
+}
diff --git a/src/modules/stock-opname/index.tsx b/src/modules/stock-opname/index.tsx
new file mode 100644
index 0000000..0a5c848
--- /dev/null
+++ b/src/modules/stock-opname/index.tsx
@@ -0,0 +1,261 @@
+"use client";
+import { Location, Product } from "@prisma/client";
+import AsyncSelect from "react-select/async"
+import styles from "./stock-opname.module.css"
+import { Button, Card, CardBody, Input, Modal, ModalBody, ModalContent, ModalHeader, Spacer, Spinner } from "@nextui-org/react";
+import { SelectOption } from "@/common/types/select";
+import { useStockOpnameStore } from "@/common/stores/useStockOpnameStore";
+import { ActionMeta, SingleValue } from "react-select";
+import { useMutation } from "@tanstack/react-query";
+import { useEffect, useState } from "react";
+import toast from "@/common/libs/toast";
+import Scanner from "@/common/components/Scanner";
+import { AlertCircleIcon, ScanIcon, ScanLineIcon } from "lucide-react";
+
+type ActiveScanner = "product" | "location" | null
+type ScannerOption = { value: number, label: string }
+
+const StockOpname = () => {
+ const {
+ form, updateForm,
+ oldOpname, setOldOpname,
+ resetForm
+ } = useStockOpnameStore()
+
+ useEffect(() => {
+ const productId = form.product?.value
+ const locationId = form.location?.value
+
+ if (
+ typeof productId === 'number' &&
+ typeof locationId === 'number'
+ ) {
+ loadOldQuantity(productId, locationId)
+ .then((data) => setOldOpname(data))
+ }
+
+ }, [form.product, form.location, setOldOpname])
+
+ const handleSelectChange = (val: SingleValue<SelectOption>, action: ActionMeta<SelectOption>) => {
+ updateForm(action.name as keyof typeof form, val)
+ }
+
+ const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
+ updateForm(e.target.name as keyof typeof form, e.target.value)
+ }
+
+ const saveMutation = useMutation({
+ mutationKey: ['stock-opname'],
+ mutationFn: async (data: typeof form) => await fetch("/api/stock-opname", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ location: data.location?.value,
+ product: data.product?.value,
+ quantity: parseInt(data.quantity),
+ }),
+ }),
+ onSuccess(data) {
+ if (data.status === 200) {
+ toast('Data berhasil disimpan')
+ resetForm()
+ }
+ },
+ })
+
+ const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
+ e.preventDefault()
+ saveMutation.mutate(form)
+ }
+
+ const [activeScanner, setActiveScanner] = useState<ActiveScanner>(null)
+ const [scannerOptions, setScannerOptions] = useState<ScannerOption[]>()
+ const [scannerOptionLoading, setScannerOptionLoading] = useState<boolean>(false)
+
+ const handleOnScan = async (data: string) => {
+ if (!activeScanner) return
+ const loadFunc = activeScanner === 'product' ? loadProduct : loadLocation
+ setScannerOptionLoading(true)
+ const response = await loadFunc(data)
+ setScannerOptionLoading(false)
+ setScannerOptions(response)
+ }
+
+ const handleScannerOptionPress = (option: ScannerOption) => {
+ toast(`${activeScanner} berhasil diubah`)
+ updateForm(activeScanner as keyof typeof form, option)
+ closeModal()
+ }
+
+ const closeModal = () => {
+ setActiveScanner(null)
+ setScannerOptions(undefined)
+ }
+
+ return (
+ <>
+ <div className="font-semibold text-xl">
+ Stock Opname
+ </div>
+
+ <Spacer y={4} />
+
+ <form className={styles.form} onSubmit={handleSubmit}>
+ <div>
+ <label htmlFor="location" className={styles.label}>Lokasi Rak</label>
+ <div className={styles.inputGroup}>
+ <AsyncSelect
+ cacheOptions
+ defaultOptions
+ loadOptions={loadLocation}
+ classNamePrefix="react-select"
+ id="location"
+ name="location"
+ placeholder="Pilih lokasi..."
+ value={form.location}
+ onChange={handleSelectChange}
+ className="flex-1"
+ />
+ <Button type="button" className={styles.scanButton} variant="flat" onClick={() => setActiveScanner('location')}>
+ <ScanLineIcon size={20} />
+ </Button>
+ </div>
+ </div>
+
+ <div>
+ <label htmlFor="product" className={styles.label}>Produk</label>
+ <div className={styles.inputGroup}>
+ <AsyncSelect
+ cacheOptions
+ defaultOptions
+ loadOptions={loadProduct}
+ classNamePrefix="react-select"
+ id="product"
+ name="product"
+ placeholder="Pilih barang..."
+ value={form.product}
+ onChange={handleSelectChange}
+ className="flex-1"
+ />
+ <Button type="button" className={styles.scanButton} variant="flat" onClick={() => setActiveScanner('product')}>
+ <ScanLineIcon size={20} />
+ </Button>
+ </div>
+ </div>
+
+ <div>
+ <label htmlFor="quantity" className={styles.label}>Jumlah</label>
+ <Input
+ type="number"
+ id="quantity"
+ name="quantity"
+ value={form.quantity}
+ onChange={handleInputChange}
+ placeholder="Masukan jumlah barang..."
+ />
+ {oldOpname && (
+ <div className="text-sm mt-2 text-neutral-600">
+ Jumlah terakhir adalah {' '}
+ <span className="font-medium">{oldOpname.quantity}</span>
+ {' '} diisi oleh {' '}
+ <span className="font-medium">{oldOpname.user.name}</span>.
+ {' '}
+ <button
+ type="button"
+ className="text-primary-500 underline"
+ onClick={() => updateForm('quantity', oldOpname.quantity.toString())}
+ >
+ Gunakan
+ </button>
+ </div>
+ )}
+ </div>
+
+ <Button
+ className="mt-4"
+ color="primary"
+ type="submit"
+ isDisabled={!form.quantity || !form.location || !form.product || saveMutation.isPending}
+ >
+ {saveMutation.isPending ? <Spinner color="white" size="sm" /> : 'Simpan'}
+ </Button>
+ </form>
+
+ <Modal isOpen={!!activeScanner} onOpenChange={closeModal}>
+ <ModalContent>
+ <ModalHeader>Scan {activeScanner}</ModalHeader>
+ <ModalBody className="pb-6">
+ {!scannerOptions && !scannerOptionLoading && (
+ <Scanner paused={!activeScanner} onScan={handleOnScan} />
+ )}
+
+ {!scannerOptions && scannerOptionLoading && <Spinner />}
+
+ {!!scannerOptions && (
+ <>
+ <div className="max-h-[60vh] overflow-auto grid grid-cols-1 gap-y-4">
+ {scannerOptions.map((option) => (
+ <Card key={option.value} isPressable shadow="none" className="border border-default-300" onPress={() => handleScannerOptionPress(option)}>
+ <CardBody>{option.label}</CardBody>
+ </Card>
+ ))}
+ </div>
+ {scannerOptions.length === 0 && (
+ <div className="flex flex-col justify-center items-center gap-y-4 text-default-700">
+ <AlertCircleIcon size={36} />
+ Tidak ada opsi untuk ditampilkan
+ </div>
+ )}
+ <Spacer y={2} />
+ <Button variant="flat" onPress={() => setScannerOptions(undefined)}>
+ Scan Ulang
+ </Button>
+ </>
+ )}
+ </ModalBody>
+ </ModalContent>
+ </Modal>
+ </>
+ )
+}
+
+const loadOldQuantity = async (productId: number, locationId: number) => {
+ const queryParams = new URLSearchParams({
+ productId: productId.toString(),
+ locationId: locationId.toString()
+ })
+
+ const response = await fetch(`/api/stock-opname/quantity?${queryParams}`)
+ const data = await response.json()
+
+ return data
+}
+
+const loadLocation = async (inputValue: string) => {
+ const response = await fetch(`/api/location?search=${inputValue}`)
+ const data: Location[] = await response.json() || []
+
+ return data.map((location) => ({
+ value: location.id,
+ label: location.name
+ }))
+}
+
+const loadProduct = async (inputValue: string) => {
+ const response = await fetch(`/api/product?search=${inputValue}`)
+ const data: { products: Product[] } = await response.json() || []
+
+ return data?.products.map((product) => {
+ let label = ''
+ if (product.itemCode) label += `[${product.itemCode}]`
+ label += ` ${product.name}`
+ if (product.barcode) label += ` [${product.barcode}]`
+
+ return {
+ value: product.id,
+ label
+ }
+ })
+}
+
+export default StockOpname \ No newline at end of file
diff --git a/src/modules/stock-opname/stock-opname.module.css b/src/modules/stock-opname/stock-opname.module.css
new file mode 100644
index 0000000..b020549
--- /dev/null
+++ b/src/modules/stock-opname/stock-opname.module.css
@@ -0,0 +1,15 @@
+.label {
+ @apply block mb-2;
+}
+
+.form {
+ @apply grid grid-cols-1 gap-y-4;
+}
+
+.inputGroup {
+ @apply flex gap-x-2;
+}
+
+.scanButton {
+ @apply min-w-fit px-4 text-default-700;
+}