├── .eslintrc.json
├── app
├── favicon.ico
├── login
│ └── page.tsx
├── signup
│ └── page.tsx
├── not-found.tsx
├── loading.tsx
├── add
│ ├── loading.tsx
│ └── page.tsx
├── categories
│ ├── loading.tsx
│ ├── page.tsx
│ ├── page.module.css
│ └── categoriesPage.tsx
├── part
│ └── [partId]
│ │ ├── page.module.css
│ │ ├── loading.tsx
│ │ ├── page.tsx
│ │ └── partPage.tsx
├── globals.css
├── api
│ ├── parts
│ │ ├── autocomplete
│ │ │ └── route.ts
│ │ ├── delete
│ │ │ └── route.ts
│ │ ├── update
│ │ │ └── route.ts
│ │ ├── create
│ │ │ └── route.ts
│ │ ├── search
│ │ │ └── route.ts
│ │ └── route.ts
│ └── auth
│ │ ├── [...nextauth]
│ │ └── route.tsx
│ │ └── register
│ │ └── route.tsx
├── page.tsx
├── layout.tsx
└── dashboardPage.tsx
├── components
├── PageLayout
│ ├── index.tsx
│ └── PageLayout.tsx
├── UserAvatar
│ ├── index.tsx
│ ├── UserAvatar.module.css
│ └── UserAvatar.tsx
├── Auth
│ ├── index.tsx
│ ├── AuthFromContainer.module.css
│ ├── AuthFormContainer.tsx
│ ├── LoginPage.tsx
│ └── SignupPage.tsx
└── SettingsProvider
│ ├── index.tsx
│ └── SettingsProvider.tsx
├── public
├── icon
│ ├── favicon.ico
│ ├── PartPilot-Icon.png
│ ├── favicon-16x16.png
│ ├── favicon-32x32.png
│ ├── apple-touch-icon.png
│ ├── android-chrome-192x192.png
│ └── android-chrome-512x512.png
├── images
│ ├── PartPilot-Logo.png
│ ├── PartPilot-Logo-Background.png
│ ├── image-bg.svg
│ ├── start.svg
│ ├── image-add.svg
│ ├── about.svg
│ ├── faq.svg
│ └── 404.svg
└── fonts
│ └── Montserrat-Regular.woff2
├── next.config.mjs
├── prisma
├── migrations
│ ├── 20240327083304_inductance
│ │ └── migration.sql
│ ├── migration_lock.toml
│ ├── 20240222180549_unique_part
│ │ └── migration.sql
│ ├── 20240224184224_big_int
│ │ └── migration.sql
│ ├── 20240325070525_decimal
│ │ └── migration.sql
│ ├── 20240316134531_possible_null
│ │ └── migration.sql
│ ├── 20240222175843_init
│ │ └── migration.sql
│ ├── 20240224185022_
│ │ └── migration.sql
│ └── 20240331202711_next_auth_models
│ │ └── migration.sql
├── seed.mjs
└── schema.prisma
├── .dockerignore
├── lib
├── components
│ ├── LoadingSkeleton.tsx
│ ├── NotFoundPage.module.css
│ ├── NavHeader.module.css
│ ├── NotFoundPage.tsx
│ ├── NavFooter.tsx
│ ├── NavFooter.module.css
│ ├── unit
│ │ └── UnitForm.tsx
│ ├── search
│ │ └── ValueSearch.tsx
│ └── NavHeader.tsx
├── prisma.ts
├── helper
│ ├── part_state.ts
│ └── lcsc_api.ts
└── middleware
│ └── handler.ts
├── postcss.config.cjs
├── .gitignore
├── docker-compose.yml
├── docker-compose-release.yml
├── Dockerfile
├── tsconfig.json
├── .env
├── package.json
├── .github
└── workflows
│ └── deploy-image.yml
└── README.md
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "next/core-web-vitals"
3 | }
4 |
--------------------------------------------------------------------------------
/app/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PartPilotLab/PartPilot/HEAD/app/favicon.ico
--------------------------------------------------------------------------------
/components/PageLayout/index.tsx:
--------------------------------------------------------------------------------
1 | export {default as PageLayout} from './PageLayout'
2 |
--------------------------------------------------------------------------------
/components/UserAvatar/index.tsx:
--------------------------------------------------------------------------------
1 | export {default as UserAvatar} from './UserAvatar'
2 |
--------------------------------------------------------------------------------
/public/icon/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PartPilotLab/PartPilot/HEAD/public/icon/favicon.ico
--------------------------------------------------------------------------------
/public/icon/PartPilot-Icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PartPilotLab/PartPilot/HEAD/public/icon/PartPilot-Icon.png
--------------------------------------------------------------------------------
/public/icon/favicon-16x16.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PartPilotLab/PartPilot/HEAD/public/icon/favicon-16x16.png
--------------------------------------------------------------------------------
/public/icon/favicon-32x32.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PartPilotLab/PartPilot/HEAD/public/icon/favicon-32x32.png
--------------------------------------------------------------------------------
/next.config.mjs:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 | const nextConfig = {};
3 |
4 | export default nextConfig;
5 |
--------------------------------------------------------------------------------
/public/icon/apple-touch-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PartPilotLab/PartPilot/HEAD/public/icon/apple-touch-icon.png
--------------------------------------------------------------------------------
/public/images/PartPilot-Logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PartPilotLab/PartPilot/HEAD/public/images/PartPilot-Logo.png
--------------------------------------------------------------------------------
/public/fonts/Montserrat-Regular.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PartPilotLab/PartPilot/HEAD/public/fonts/Montserrat-Regular.woff2
--------------------------------------------------------------------------------
/components/Auth/index.tsx:
--------------------------------------------------------------------------------
1 | export { default as LoginPage } from './LoginPage';
2 | export { default as SignupPage } from './SignupPage';
--------------------------------------------------------------------------------
/public/icon/android-chrome-192x192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PartPilotLab/PartPilot/HEAD/public/icon/android-chrome-192x192.png
--------------------------------------------------------------------------------
/public/icon/android-chrome-512x512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PartPilotLab/PartPilot/HEAD/public/icon/android-chrome-512x512.png
--------------------------------------------------------------------------------
/prisma/migrations/20240327083304_inductance/migration.sql:
--------------------------------------------------------------------------------
1 | -- AlterTable
2 | ALTER TABLE "Parts" ADD COLUMN "inductance" DOUBLE PRECISION;
3 |
--------------------------------------------------------------------------------
/public/images/PartPilot-Logo-Background.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PartPilotLab/PartPilot/HEAD/public/images/PartPilot-Logo-Background.png
--------------------------------------------------------------------------------
/app/login/page.tsx:
--------------------------------------------------------------------------------
1 | import { LoginPage } from "@/components/Auth";
2 |
3 | export default async function Login() {
4 | return
5 | }
6 |
--------------------------------------------------------------------------------
/app/signup/page.tsx:
--------------------------------------------------------------------------------
1 | import { SignupPage } from "@/components/Auth";
2 |
3 | export default function Signup() {
4 | return
5 | }
6 |
--------------------------------------------------------------------------------
/app/not-found.tsx:
--------------------------------------------------------------------------------
1 | import NotFoundPage from "@/lib/components/NotFoundPage";
2 |
3 | export default function NotFound() {
4 | return ( );
5 | }
6 |
--------------------------------------------------------------------------------
/app/loading.tsx:
--------------------------------------------------------------------------------
1 | import LoadingSkeleton from "@/lib/components/LoadingSkeleton";
2 |
3 | export default function Loading() {
4 | return
5 | }
--------------------------------------------------------------------------------
/app/add/loading.tsx:
--------------------------------------------------------------------------------
1 | import LoadingSkeleton from "@/lib/components/LoadingSkeleton";
2 |
3 | export default function Loading() {
4 | return
5 | }
--------------------------------------------------------------------------------
/prisma/migrations/migration_lock.toml:
--------------------------------------------------------------------------------
1 | # Please do not edit this file manually
2 | # It should be added in your version-control system (i.e. Git)
3 | provider = "postgresql"
--------------------------------------------------------------------------------
/app/categories/loading.tsx:
--------------------------------------------------------------------------------
1 | import LoadingSkeleton from "@/lib/components/LoadingSkeleton";
2 |
3 | export default function Loading() {
4 | return
5 | }
--------------------------------------------------------------------------------
/app/part/[partId]/page.module.css:
--------------------------------------------------------------------------------
1 | .grid {
2 | @media (max-width: $mantine-breakpoint-sm) {
3 | width: calc(100vw - var(--mantine-spacing-lg) * 3);
4 | }
5 | }
--------------------------------------------------------------------------------
/app/part/[partId]/loading.tsx:
--------------------------------------------------------------------------------
1 | import LoadingSkeleton from "@/lib/components/LoadingSkeleton";
2 |
3 | export default function Loading() {
4 | return
5 | }
--------------------------------------------------------------------------------
/.dockerignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | .next
3 | next-env.d.ts
4 | docker-compose.yml
5 | Dockerfile
6 | *.md
7 | .env
8 | LICENSE
9 | .gitignore
10 | Dockerfile
11 | docker-compose.yml
--------------------------------------------------------------------------------
/components/SettingsProvider/index.tsx:
--------------------------------------------------------------------------------
1 | export {default as SettingsProvider} from './SettingsProvider'
2 | export {SettingsConsumer} from './SettingsProvider'
3 | export type {Settings} from './SettingsProvider'
4 |
--------------------------------------------------------------------------------
/components/UserAvatar/UserAvatar.module.css:
--------------------------------------------------------------------------------
1 | .form {
2 | padding: 15px;
3 | display: flex;
4 | flex-direction: column;
5 | gap: 10px;
6 | }
7 |
8 | .avatar {
9 | cursor: pointer;
10 | }
11 |
--------------------------------------------------------------------------------
/app/globals.css:
--------------------------------------------------------------------------------
1 | ::-webkit-scrollbar {
2 | width: 20px;
3 | }
4 | ::-webkit-scrollbar-thumb {
5 | background-color: #d6dee1;
6 | border-radius: 20px;
7 | border: 6px solid transparent;
8 | background-clip: content-box;
9 | }
10 | ::-webkit-scrollbar-thumb:hover {
11 | background-color: #a8bbbf;
12 | }
13 |
--------------------------------------------------------------------------------
/prisma/migrations/20240222180549_unique_part/migration.sql:
--------------------------------------------------------------------------------
1 | /*
2 | Warnings:
3 |
4 | - A unique constraint covering the columns `[productCode]` on the table `Parts` will be added. If there are existing duplicate values, this will fail.
5 |
6 | */
7 | -- CreateIndex
8 | CREATE UNIQUE INDEX "Parts_productCode_key" ON "Parts"("productCode");
9 |
--------------------------------------------------------------------------------
/lib/components/LoadingSkeleton.tsx:
--------------------------------------------------------------------------------
1 | import { Center, Flex, Loader } from "@mantine/core";
2 |
3 | export default function LoadingSkeleton() {
4 | return (
5 |
6 |
7 |
8 |
9 |
10 | );
11 | }
12 |
--------------------------------------------------------------------------------
/components/Auth/AuthFromContainer.module.css:
--------------------------------------------------------------------------------
1 | .root {
2 | margin-top: 55px;
3 | display: flex;
4 | justify-content: center;
5 | align-items: center;
6 | height: calc(100vh - 438px);
7 | }
8 |
9 | .form {
10 | padding: 30px;
11 | display: flex;
12 | flex-direction: column;
13 | gap: 15px;
14 | width: 100%;
15 | max-width: 400px;
16 | }
--------------------------------------------------------------------------------
/prisma/migrations/20240224184224_big_int/migration.sql:
--------------------------------------------------------------------------------
1 | -- AlterTable
2 | ALTER TABLE "Parts" ALTER COLUMN "voltage" SET DATA TYPE BIGINT,
3 | ALTER COLUMN "capacitance" SET DATA TYPE BIGINT,
4 | ALTER COLUMN "current" SET DATA TYPE BIGINT,
5 | ALTER COLUMN "power" SET DATA TYPE BIGINT,
6 | ALTER COLUMN "resistance" SET DATA TYPE BIGINT,
7 | ALTER COLUMN "frequency" SET DATA TYPE BIGINT;
8 |
--------------------------------------------------------------------------------
/postcss.config.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | 'postcss-preset-mantine': {},
4 | 'postcss-simple-vars': {
5 | variables: {
6 | 'mantine-breakpoint-xs': '36em',
7 | 'mantine-breakpoint-sm': '48em',
8 | 'mantine-breakpoint-md': '62em',
9 | 'mantine-breakpoint-lg': '75em',
10 | 'mantine-breakpoint-xl': '88em',
11 | },
12 | },
13 | },
14 | };
--------------------------------------------------------------------------------
/prisma/migrations/20240325070525_decimal/migration.sql:
--------------------------------------------------------------------------------
1 | -- AlterTable
2 | ALTER TABLE "Parts" ALTER COLUMN "voltage" SET DATA TYPE DOUBLE PRECISION,
3 | ALTER COLUMN "capacitance" SET DATA TYPE DOUBLE PRECISION,
4 | ALTER COLUMN "current" SET DATA TYPE DOUBLE PRECISION,
5 | ALTER COLUMN "power" SET DATA TYPE DOUBLE PRECISION,
6 | ALTER COLUMN "resistance" SET DATA TYPE DOUBLE PRECISION,
7 | ALTER COLUMN "frequency" SET DATA TYPE DOUBLE PRECISION;
8 |
--------------------------------------------------------------------------------
/app/part/[partId]/page.tsx:
--------------------------------------------------------------------------------
1 | export const dynamic = 'force-dynamic';
2 | import { PartState } from "@/lib/helper/part_state";
3 | import PartPage from "./partPage";
4 | import prisma from "@/lib/prisma";
5 |
6 | export default async function Part({params}: {params: {partId: string}}) {
7 | const partInfo = await prisma.parts.findUnique({where: {id: parseInt(params.partId)}}) as PartState;
8 | return ;
9 | }
10 |
--------------------------------------------------------------------------------
/lib/prisma.ts:
--------------------------------------------------------------------------------
1 | declare global {
2 | var prisma: PrismaClient; // This must be a `var` and not a `let / const`
3 | }
4 | import { PrismaClient } from '@prisma/client';
5 |
6 | let prisma: PrismaClient;
7 |
8 | if (process.env.NODE_ENV === 'production') {
9 | prisma = new PrismaClient();
10 | } else {
11 | if (!global.prisma) {
12 | global.prisma = new PrismaClient();
13 | }
14 | prisma = global.prisma;
15 | }
16 |
17 | export default prisma;
--------------------------------------------------------------------------------
/app/categories/page.tsx:
--------------------------------------------------------------------------------
1 | export const dynamic = "force-dynamic";
2 | import { PartState } from "@/lib/helper/part_state";
3 | import CategoriesPage from "./categoriesPage";
4 | import prisma from "@/lib/prisma";
5 |
6 | export default async function Categories() {
7 | const catalogItems = await prisma.parts.findMany({
8 | distinct: ['parentCatalogName'],
9 | select: {
10 | parentCatalogName: true,
11 | productImages: true,
12 | },
13 | });
14 |
15 | return ;
16 | }
17 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 | .yarn/install-state.gz
8 |
9 | # testing
10 | /coverage
11 |
12 | # next.js
13 | /.next/
14 | /out/
15 |
16 | # production
17 | /build
18 |
19 | # misc
20 | .DS_Store
21 | *.pem
22 |
23 | # debug
24 | npm-debug.log*
25 | yarn-debug.log*
26 | yarn-error.log*
27 |
28 | # local env files
29 | .env*.local
30 |
31 | # vercel
32 | .vercel
33 |
34 | # typescript
35 | *.tsbuildinfo
36 | next-env.d.ts
37 |
38 | # others
39 | .idea
40 |
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: "3.0"
2 |
3 | services:
4 | postgres:
5 | image: postgres
6 | ports:
7 | - 5432:5432
8 | environment:
9 | - POSTGRES_USER=partpilot
10 | - POSTGRES_PASSWORD=partpilotPass
11 | - POSTGRES_DB=partpilotdb
12 | partpilot:
13 | build: . # for local
14 | depends_on:
15 | - postgres
16 | ports:
17 | - 3000:3000
18 | environment:
19 | - DATABASE_URL=postgresql://partpilot:partpilotPass@postgres:5432/partpilotdb?schema=public
20 | - NEXTAUTH_SECRET=secret
21 | - NEXTAUTH_URL=http://localhost:3000
22 |
--------------------------------------------------------------------------------
/prisma/migrations/20240316134531_possible_null/migration.sql:
--------------------------------------------------------------------------------
1 | -- AlterTable
2 | ALTER TABLE "Parts" ALTER COLUMN "title" DROP NOT NULL,
3 | ALTER COLUMN "quantity" SET DEFAULT 0,
4 | ALTER COLUMN "productId" DROP NOT NULL,
5 | ALTER COLUMN "productModel" DROP NOT NULL,
6 | ALTER COLUMN "productDescription" DROP NOT NULL,
7 | ALTER COLUMN "parentCatalogName" DROP NOT NULL,
8 | ALTER COLUMN "catalogName" DROP NOT NULL,
9 | ALTER COLUMN "brandName" DROP NOT NULL,
10 | ALTER COLUMN "encapStandard" DROP NOT NULL,
11 | ALTER COLUMN "pdfLink" DROP NOT NULL,
12 | ALTER COLUMN "productLink" DROP NOT NULL,
13 | ALTER COLUMN "prices" DROP NOT NULL;
14 |
--------------------------------------------------------------------------------
/components/Auth/AuthFormContainer.tsx:
--------------------------------------------------------------------------------
1 | import { Stack } from "@mantine/core"
2 | import { FormEvent, ReactNode } from "react"
3 | import classes from './AuthFromContainer.module.css'
4 |
5 | type Props = {
6 | children: ReactNode,
7 | handleSubmit: (e: FormEvent) => void
8 | }
9 |
10 | export default function AuthFormContainer({ children, handleSubmit }: Props) {
11 | return (
12 |
13 |
19 |
20 | )
21 | }
--------------------------------------------------------------------------------
/lib/components/NotFoundPage.module.css:
--------------------------------------------------------------------------------
1 | .root {
2 | padding-top: rem(80px);
3 | padding-bottom: rem(80px);
4 | }
5 |
6 | .title {
7 | font-weight: 900;
8 | font-size: rem(34px);
9 | margin-bottom: var(--mantine-spacing-md);
10 | font-family: Greycliff CF, var(--mantine-font-family);
11 |
12 | @media (max-width: $mantine-breakpoint-sm) {
13 | font-size: rem(32px);
14 | }
15 | }
16 |
17 | .control {
18 | @media (max-width: $mantine-breakpoint-sm) {
19 | width: 100%;
20 | }
21 | }
22 |
23 | .mobileImage {
24 | @media (min-width: 48em) {
25 | display: none;
26 | }
27 | }
28 |
29 | .desktopImage {
30 | @media (max-width: 47.99em) {
31 | display: none;
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/docker-compose-release.yml:
--------------------------------------------------------------------------------
1 | version: "3.0"
2 |
3 | services:
4 | postgres:
5 | image: postgres
6 | ports:
7 | - 5432:5432
8 | environment:
9 | - POSTGRES_USER=partpilot
10 | - POSTGRES_PASSWORD=partpilotPass
11 | - POSTGRES_DB=partpilotdb
12 | volumes:
13 | - pgdata:/var/lib/postgresql/data #replace to bind mount if needed
14 | partpilot:
15 | image: ghcr.io/partpilotlab/partpilot:latest
16 | depends_on:
17 | - postgres
18 | ports:
19 | - 3000:3000 #replace host port(left side) as needed
20 | environment:
21 | - DATABASE_URL=postgresql://partpilot:partpilotPass@postgres:5432/partpilotdb?schema=public
22 | - NEXTAUTH_SECRET=secret
23 | - NEXTAUTH_URL=http://localhost:3000
24 |
25 | volumes: #remove when using bind mount
26 | pgdata:
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM node:21-bookworm as Builder
2 |
3 | WORKDIR /app
4 |
5 | COPY package*.json ./
6 | COPY prisma ./prisma/
7 |
8 | RUN npm ci
9 |
10 | COPY . .
11 |
12 | RUN npx prisma generate && npm run build
13 |
14 |
15 | FROM node:21-bookworm
16 | LABEL org.opencontainers.image.source=https://github.com/PartPilotLab/PartPilot
17 | LABEL org.opencontainers.image.description="Electronic Part Catalog"
18 | LABEL org.opencontainers.image.licenses=AGPL-3.0
19 |
20 | WORKDIR /app
21 |
22 | COPY --from=Builder /app/public ./public
23 | COPY --from=Builder /app/.next ./.next
24 | COPY --from=builder /app/node_modules ./node_modules
25 | COPY --from=builder /app/package*.json ./
26 | COPY --from=Builder /app/prisma ./prisma
27 |
28 |
29 | EXPOSE 3000
30 |
31 | ENV PORT 3000
32 |
33 | CMD ["npm", "run", "start:migrate"]
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "lib": [
4 | "dom",
5 | "dom.iterable",
6 | "esnext"
7 | ],
8 | "allowJs": true,
9 | "skipLibCheck": true,
10 | // "strict": true,
11 | "noImplicitAny": false,
12 | "noEmit": true,
13 | "esModuleInterop": true,
14 | "module": "esnext",
15 | "moduleResolution": "bundler",
16 | "resolveJsonModule": true,
17 | "isolatedModules": true,
18 | "jsx": "preserve",
19 | "incremental": true,
20 | "plugins": [
21 | {
22 | "name": "next"
23 | }
24 | ],
25 | "paths": {
26 | "@/*": [
27 | "./*"
28 | ]
29 | },
30 | "strict": false
31 | },
32 | "include": [
33 | "next-env.d.ts",
34 | "**/*.ts",
35 | "**/*.tsx",
36 | ".next/types/**/*.ts"
37 | ],
38 | "exclude": [
39 | "node_modules"
40 | ]
41 | }
--------------------------------------------------------------------------------
/app/categories/page.module.css:
--------------------------------------------------------------------------------
1 | .title {
2 | font-size: rem(34px);
3 | font-weight: 900;
4 |
5 | @media (max-width: $mantine-breakpoint-sm) {
6 | font-size: rem(24px);
7 | }
8 | }
9 |
10 | .description {
11 | max-width: rem(600px);
12 | margin: auto;
13 |
14 | &::after {
15 | content: "";
16 | display: block;
17 | background-color: var(--mantine-color-cyan-filled);
18 | width: rem(45px);
19 | height: rem(2px);
20 | margin-top: var(--mantine-spacing-sm);
21 | margin-left: auto;
22 | margin-right: auto;
23 | }
24 | }
25 |
26 | .card {
27 | border: rem(1px) solid
28 | light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5));
29 | cursor: pointer;
30 | }
31 |
32 | .cardTitle {
33 | &::after {
34 | content: "";
35 | display: block;
36 | background-color: var(--mantine-color-cyan-filled);
37 | width: rem(45px);
38 | height: rem(2px);
39 | margin-top: var(--mantine-spacing-sm);
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/prisma/migrations/20240222175843_init/migration.sql:
--------------------------------------------------------------------------------
1 | -- CreateTable
2 | CREATE TABLE "Parts" (
3 | "id" SERIAL NOT NULL,
4 | "title" TEXT NOT NULL,
5 | "quantity" INTEGER NOT NULL,
6 | "productId" INTEGER NOT NULL,
7 | "productCode" TEXT NOT NULL,
8 | "productModel" TEXT NOT NULL,
9 | "productDescription" TEXT NOT NULL,
10 | "parentCatalogName" TEXT NOT NULL,
11 | "catalogName" TEXT NOT NULL,
12 | "brandName" TEXT NOT NULL,
13 | "encapStandard" TEXT NOT NULL,
14 | "productImages" TEXT[],
15 | "pdfLink" TEXT NOT NULL,
16 | "productLink" TEXT NOT NULL,
17 | "prices" JSONB NOT NULL,
18 | "voltage" INTEGER,
19 | "capacitance" INTEGER,
20 | "current" INTEGER,
21 | "power" INTEGER,
22 | "resistance" INTEGER,
23 | "frequency" INTEGER,
24 | "tolerance" TEXT,
25 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
26 | "updatedAt" TIMESTAMP(3) NOT NULL,
27 |
28 | CONSTRAINT "Parts_pkey" PRIMARY KEY ("id")
29 | );
30 |
--------------------------------------------------------------------------------
/app/api/parts/autocomplete/route.ts:
--------------------------------------------------------------------------------
1 | import { extractPartInfoFromLCSCResponse } from "@/lib/helper/lcsc_api";
2 | import { NextRequest, NextResponse } from "next/server";
3 |
4 | export async function POST(request: NextRequest) {
5 | try {
6 | const res = await request.json();
7 | console.log(res);
8 |
9 | const pcNumber = res.productCode;
10 | console.log(pcNumber);
11 |
12 | const LSCSPart = await fetch(
13 | "https://wmsc.lcsc.com/ftps/wm/product/detail?productCode=" + pcNumber
14 | )
15 | .then((response) => {
16 | return response.json();
17 | })
18 | .catch((e: ErrorCallback | any) => {
19 | console.error(e.message);
20 | });
21 | console.log(LSCSPart);
22 | const partInfo = extractPartInfoFromLCSCResponse(LSCSPart);
23 | return NextResponse.json({ status: 200, body: partInfo });
24 | } catch (error: ErrorCallback | any) {
25 | console.log(error);
26 | return NextResponse.json({ status: 500, error: error });
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/app/api/auth/[...nextauth]/route.tsx:
--------------------------------------------------------------------------------
1 | import NextAuth from "next-auth";
2 | import CredentialsProvider from "next-auth/providers/credentials";
3 | import { compare } from "bcrypt";
4 |
5 | const handler = NextAuth({
6 | providers: [
7 | CredentialsProvider({
8 | name: "Credentials",
9 | credentials: {
10 | password: {},
11 | email: {},
12 | },
13 | async authorize(credentials, req) {
14 | const user = await prisma.user.findUnique({
15 | where: { email: credentials.email },
16 | });
17 | if (!user) return null;
18 | const passwordCorrect = await compare(
19 | credentials.password,
20 | user.password
21 | );
22 | if (passwordCorrect)
23 | return {
24 | id: user.id,
25 | name: user.name,
26 | email: user.email,
27 | image: user.image,
28 | };
29 | return null;
30 | },
31 | }),
32 | ],
33 | session: { strategy: "jwt" },
34 | });
35 |
36 | export { handler as GET, handler as POST };
37 |
--------------------------------------------------------------------------------
/lib/components/NavHeader.module.css:
--------------------------------------------------------------------------------
1 | .header {
2 | height: rem(56px);
3 | margin-bottom: rem(120px);
4 | background-color: var(--mantine-color-body);
5 |
6 | border-bottom: rem(1px) solid
7 | light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
8 | }
9 |
10 | .inner {
11 | height: rem(56px);
12 | display: flex;
13 | justify-content: space-between;
14 | align-items: center;
15 | }
16 |
17 | .link {
18 | display: block;
19 | line-height: 1;
20 | padding: rem(8px) rem(12px);
21 | border-radius: var(--mantine-radius-sm);
22 | text-decoration: none;
23 | color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-0));
24 | font-size: var(--mantine-font-size-sm);
25 | font-weight: 500;
26 |
27 | @mixin hover {
28 | background-color: light-dark(
29 | var(--mantine-color-gray-0),
30 | var(--mantine-color-dark-6)
31 | );
32 | }
33 | }
34 |
35 | .linkLabel {
36 | margin-right: rem(5px);
37 | }
38 |
39 | .avatar {
40 | position: absolute !important;
41 | top: 0;
42 | right: 0;
43 | margin: 12px;
44 | }
45 |
46 |
--------------------------------------------------------------------------------
/app/api/auth/register/route.tsx:
--------------------------------------------------------------------------------
1 | import {NextRequest, NextResponse} from "next/server";
2 | import {hash} from 'bcrypt'
3 | import {z} from "zod"
4 |
5 | const UserSchema = z.object({
6 | name: z.string().optional(),
7 | email: z.string().email(),
8 | password: z.string()
9 | })
10 |
11 | export async function POST(req: NextRequest) {
12 | try {
13 | const parsedBody = await req.json()
14 |
15 | const validationResult = UserSchema.safeParse(parsedBody)
16 | if (!validationResult.success) return NextResponse
17 | .json({error: "Invalid user creation payload"}, {status: 400})
18 |
19 | const hashedPassword = await hash(validationResult.data.password, 10)
20 |
21 | const user = await prisma.user.create({
22 | data: {
23 | ...validationResult.data,
24 | password: hashedPassword
25 | }
26 | })
27 |
28 | return NextResponse.json(user)
29 | } catch (e) {
30 | if(e.code === "P2002") {
31 | // Unique constraint error --> Email already exists
32 | return NextResponse.json({error: "Email already registered"}, {status: 400})
33 | }
34 | return NextResponse.json({error: "Error while creating a new user"}, {status: 400})
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/.env:
--------------------------------------------------------------------------------
1 | # Environment variables declared in this file are automatically made available to Prisma.
2 | # See the documentation for more detail: https://pris.ly/d/prisma-schema#accessing-environment-variables-from-the-schema
3 |
4 | # Prisma supports the native connection string format for PostgreSQL, MySQL, SQLite, SQL Server, MongoDB and CockroachDB.
5 | # See the documentation for all the connection string options: https://pris.ly/d/connection-strings
6 |
7 | # DATABASE_URL="postgresql://partpilot:partpilotPass@postgres:5432/partpilotdb?schema=public" #for docker
8 | DATABASE_URL="postgresql://partpilot:partpilotPass@localhost:5432/partpilotdb?schema=public" #for local
9 |
10 | # Good guide for db: https://stackoverflow.com/questions/30641512/create-database-from-command-line-in-postgresql
11 | # For roles: https://stackoverflow.com/questions/43734650/createdb-database-creation-failed-error-permission-denied-to-create-database
12 | # For windows: https://www.cherryservers.com/blog/postgresql-create-user
13 |
14 | # openssl rand -base64 32
15 | NEXTAUTH_SECRET="K3l7o+g1mCZUQjszYk6SH0k66mmYeX1gh1ANqJA6/9o=" # for local
16 | NEXTAUTH_URL="http://localhost:3000" # for local
17 |
--------------------------------------------------------------------------------
/app/api/parts/delete/route.ts:
--------------------------------------------------------------------------------
1 | import { NextRequest, NextResponse } from "next/server";
2 | import prisma from "@/lib/prisma";
3 |
4 | export async function POST(request: NextRequest) {
5 | try {
6 | const res = await request.json();
7 |
8 | const partId = res.id;
9 | // const
10 | const deletedPart = await prisma.parts.delete({ where: { id: partId } });
11 | const itemCount = await prisma.parts.aggregate({ _count: true });
12 | const parentCatalogNamesRaw = await prisma.parts.groupBy({
13 | by: ["parentCatalogName"],
14 | });
15 | const parentCatalogNames = parentCatalogNamesRaw.map(
16 | (item: any) => item.parentCatalogName
17 | );
18 | if (deletedPart) {
19 | return NextResponse.json({
20 | status: 200,
21 | body: deletedPart,
22 | itemCount: itemCount._count,
23 | parentCatalogNames: parentCatalogNames,
24 | message: "Part deleted",
25 | });
26 | } else {
27 | return NextResponse.json({ status: 500, error: "Part not deleted" });
28 | }
29 | } catch (error: ErrorCallback | any) {
30 | return NextResponse.json({ status: 500, error: error });
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/prisma/seed.mjs:
--------------------------------------------------------------------------------
1 | import {PrismaClient} from "@prisma/client";
2 |
3 | const prisma = new PrismaClient()
4 |
5 | async function main() {
6 | await prisma.parts.upsert({
7 | where: {productCode: 'C1591'},
8 | update: {},
9 | create: {
10 | productCode: 'C1591',
11 | productModel: 'CL10B104KB8NNNC',
12 | quantity: 2,
13 | capacitance: 1,
14 | prices: []
15 | },
16 | })
17 | await prisma.parts.upsert({
18 | where: {productCode: 'C154120'},
19 | update: {},
20 | create: {
21 | productCode: 'C154120',
22 | productModel: 'SDFL2012T150KTF',
23 | quantity: 20,
24 | capacitance: 1,
25 | prices: []
26 | },
27 | })
28 | await prisma.parts.upsert({
29 | where: {productCode: 'C29538'},
30 | update: {},
31 | create: {
32 | productCode: 'C29538',
33 | productModel: 'X322530MSB4SI',
34 | quantity: 5,
35 | capacitance: 1,
36 | prices: []
37 | },
38 | })
39 | }
40 |
41 | main().then(async () => {
42 | await prisma.$disconnect()
43 | }).catch(async (e) => {
44 | console.error(e)
45 | await prisma.$disconnect()
46 | process.exit(1)
47 | })
48 |
--------------------------------------------------------------------------------
/app/page.tsx:
--------------------------------------------------------------------------------
1 | export const dynamic = "force-dynamic";
2 | import { PartState } from "@/lib/helper/part_state";
3 | import DashboardPage from "./dashboardPage";
4 | import prisma from "@/lib/prisma";
5 |
6 | type Props = {
7 | searchParams?: {
8 | catalog?: string;
9 | };
10 | };
11 |
12 | export default async function Home(props: Props) {
13 | //Get catalog search parameter from URL
14 | const catalog = decodeURIComponent(props.searchParams.catalog);
15 | //Get first 10 items
16 | const parts = (await prisma.parts.findMany({
17 | orderBy: { id: "desc" },
18 | take: 10,
19 | where: { parentCatalogName: { equals: catalog } },
20 | })) as PartState[];
21 | //Get total part count and parent catalog names
22 | const aggregatedParts = await prisma.parts.aggregate({ _count: true });
23 | const parentCatalogNamesRaw = await prisma.parts.groupBy({
24 | by: ["parentCatalogName"],
25 | });
26 | //Filter parent catalog names (exclude null values)
27 | const parentCatalogNames = parentCatalogNamesRaw
28 | .filter((item) => item.parentCatalogName !== null)
29 | .map((item) => item.parentCatalogName);
30 |
31 | return (
32 |
38 | );
39 | }
40 |
--------------------------------------------------------------------------------
/lib/components/NotFoundPage.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Image,
3 | Container,
4 | Title,
5 | Text,
6 | Button,
7 | SimpleGrid,
8 | } from "@mantine/core";
9 | import classes from "./NotFoundPage.module.css";
10 |
11 | export default function NotFoundPage() {
12 | return (
13 |
14 |
15 |
20 |
21 |
Something is not right...
22 |
23 | Page you are trying to open does not exist. You may have mistyped
24 | the address, or the page has been moved to another URL. If you think
25 | this is an error contact support.
26 |
27 |
33 | Get back to home page
34 |
35 |
36 |
41 |
42 |
43 | );
44 | }
45 |
--------------------------------------------------------------------------------
/prisma/migrations/20240224185022_/migration.sql:
--------------------------------------------------------------------------------
1 | /*
2 | Warnings:
3 |
4 | - You are about to alter the column `voltage` on the `Parts` table. The data in that column could be lost. The data in that column will be cast from `BigInt` to `Integer`.
5 | - You are about to alter the column `capacitance` on the `Parts` table. The data in that column could be lost. The data in that column will be cast from `BigInt` to `Integer`.
6 | - You are about to alter the column `current` on the `Parts` table. The data in that column could be lost. The data in that column will be cast from `BigInt` to `Integer`.
7 | - You are about to alter the column `power` on the `Parts` table. The data in that column could be lost. The data in that column will be cast from `BigInt` to `Integer`.
8 | - You are about to alter the column `resistance` on the `Parts` table. The data in that column could be lost. The data in that column will be cast from `BigInt` to `Integer`.
9 | - You are about to alter the column `frequency` on the `Parts` table. The data in that column could be lost. The data in that column will be cast from `BigInt` to `Integer`.
10 |
11 | */
12 | -- AlterTable
13 | ALTER TABLE "Parts" ALTER COLUMN "voltage" SET DATA TYPE INTEGER,
14 | ALTER COLUMN "capacitance" SET DATA TYPE INTEGER,
15 | ALTER COLUMN "current" SET DATA TYPE INTEGER,
16 | ALTER COLUMN "power" SET DATA TYPE INTEGER,
17 | ALTER COLUMN "resistance" SET DATA TYPE INTEGER,
18 | ALTER COLUMN "frequency" SET DATA TYPE INTEGER;
19 |
--------------------------------------------------------------------------------
/app/api/parts/update/route.ts:
--------------------------------------------------------------------------------
1 | import { NextRequest, NextResponse } from "next/server";
2 | import prisma from "@/lib/prisma";
3 |
4 | export async function POST(request: NextRequest) {
5 | try {
6 | const res = await request.json();
7 | const partId = res.id;
8 | const updatedPart = await prisma.parts.update({
9 | where: {
10 | id: partId,
11 | },
12 | data: {
13 | title: res.title,
14 | quantity: res.quantity,
15 | productId: res.productId,
16 | productCode: res.productCode,
17 | productModel: res.productModel,
18 | productDescription: res.productDescription,
19 | parentCatalogName: res.parentCatalogName,
20 | catalogName: res.catalogName,
21 | brandName: res.brandName,
22 | encapStandard: res.encapStandard,
23 | productImages: res.productImages,
24 | pdfLink: res.pdfLink,
25 | productLink: res.productLink,
26 | prices: res.prices,
27 | voltage: res.voltage,
28 | resistance: res.resistance,
29 | power: res.power,
30 | current: res.current,
31 | tolerance: res.tolerance,
32 | frequency: res.frequency,
33 | capacitance: res.capacitance,
34 | },
35 | });
36 | if(updatedPart){
37 | return NextResponse.json({ status: 200, body: updatedPart, message: "Part updated"});
38 | }
39 | else {
40 | return NextResponse.json({ status: 500, error: "Part not updated" });
41 | }
42 | } catch (error: ErrorCallback | any) {
43 | return NextResponse.json({ status: 500, error: error });
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/lib/helper/part_state.ts:
--------------------------------------------------------------------------------
1 | export interface PartState {
2 | id: number;
3 |
4 | title?: string; //title
5 |
6 | quantity: number; //quantity
7 |
8 | productId?: number; //productId
9 | productCode: string; //productCode
10 | productModel?: string; //productModel
11 | productDescription?: string; //productIntroEn
12 |
13 | parentCatalogName?: string; //parentCatalogName
14 | catalogName?: string; //catalogName
15 | brandName?: string; //brandNameEn
16 |
17 | encapStandard?: string; //--> called Package
18 |
19 |
20 | productImages?: string[]; //productImages
21 | pdfLink?: string; //pdfUrl
22 |
23 | productLink?: string; //catalogName + _ + title (brackets to -) + _ + productCode
24 | // package: string;
25 | // manufacturer: string; //brandNameEn
26 |
27 | prices?: { ladder: string; price: number }[];
28 |
29 |
30 | voltage?: number; //param_10953_n --> paramNameEn: "Voltage Rated"
31 | resistance?: number; //param_10835_n --> paramNameEn: "Resistance"
32 | power?: number; //param_10837_n --> paramNameEn: "Power(Watts)"
33 | current?: number; //param_11284_n --> paramNameEn: "Rated Current"
34 | tolerance?: string; //param_10836_s --> paramNameEn: "Tolerance"
35 | frequency?: number; //param_11373_n --> paramNameEn: "Frequency"
36 | // type?: string;
37 | capacitance?: number; //param_10951_n --> paramNameEn: "Capacitance"
38 | inductance?: number; //--> paramNameEn: "Inductance"
39 | // tempretureCoefficient?: number;
40 | // minBuyQuantity?: number;
41 |
42 | createdAt: Date;
43 | updatedAt: Date;
44 | }
--------------------------------------------------------------------------------
/components/SettingsProvider/SettingsProvider.tsx:
--------------------------------------------------------------------------------
1 | import {createContext, ReactNode, useCallback, useEffect, useState} from 'react';
2 | import {useWindowEvent} from "@mantine/hooks";
3 |
4 | const SettingsContext = createContext(null);
5 |
6 | const settingsKey = 'APP.SETTINGS';
7 |
8 | export type Settings = {
9 | initialized: boolean,
10 | paletteMode: 'light' | 'dark'
11 | }
12 |
13 | const defaultValues: Settings = {
14 | paletteMode: 'light',
15 | initialized: false
16 | }
17 |
18 | type Props = {
19 | children: ReactNode
20 | }
21 |
22 | export default function SettingsProvider({children}: Props) {
23 | const [settings, setSettings] = useState(defaultValues)
24 |
25 | const updateSettings = useCallback((values: Partial) => {
26 | setSettings(prevState => {
27 | const newState = {...prevState, ...values}
28 | localStorage.setItem(settingsKey, JSON.stringify(newState))
29 | if (values.paletteMode) document.documentElement.setAttribute("data-mantine-color-scheme", values.paletteMode);
30 | return newState
31 | });
32 | }, [])
33 |
34 | useEffect(() => {
35 | const storedSettings = localStorage.getItem(settingsKey);
36 | if (storedSettings) updateSettings({...JSON.parse(storedSettings), initialized: true})
37 | else updateSettings( {initialized: true})
38 | }, [])
39 |
40 | useWindowEvent('keydown', e => {
41 | if (e.ctrlKey && e.key === 'j') updateSettings({paletteMode: settings.paletteMode === 'dark' ? 'light' : 'dark'})
42 | });
43 |
44 | return (
45 |
46 | {children}
47 |
48 | )
49 | }
50 |
51 | export const SettingsConsumer = SettingsContext.Consumer;
52 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "partpilot",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev",
7 | "build": "next build",
8 | "start": "next start",
9 | "lint": "next lint",
10 | "start:migrate": "prisma migrate deploy && npm run start",
11 | "start:dev": "prisma migrate deploy && prisma db seed && npm run dev",
12 | "prisma:seed": "prisma db seed",
13 | "prisma:studio": "prisma studio",
14 | "prisma:generate": "prisma generate"
15 | },
16 | "prisma": {
17 | "seed": "node prisma/seed.mjs"
18 | },
19 | "dependencies": {
20 | "@auth/prisma-adapter": "^1.5.2",
21 | "@mantine/carousel": "^7.6.1",
22 | "@mantine/core": "^7.6.1",
23 | "@mantine/form": "^7.6.1",
24 | "@mantine/hooks": "^7.6.1",
25 | "@mantine/notifications": "^7.6.1",
26 | "@tabler/icons-react": "^2.47.0",
27 | "bcrypt": "^5.1.1",
28 | "embla-carousel-react": "^7.1.0",
29 | "framer-motion": "^11.0.5",
30 | "lodash": "^4.17.21",
31 | "mathjs": "^12.4.0",
32 | "next": "14.1.0",
33 | "next-auth": "^4.24.7",
34 | "onscan.js": "^1.5.2",
35 | "react": "^18",
36 | "react-dom": "^18",
37 | "zod": "^3.22.4"
38 | },
39 | "devDependencies": {
40 | "@prisma/client": "^5.10.2",
41 | "@types/bcrypt": "^5.0.2",
42 | "@types/node": "^20.11.19",
43 | "@types/onscan.js": "^1.5.6",
44 | "@types/react": "^18",
45 | "@types/react-dom": "^18",
46 | "eslint": "^8",
47 | "eslint-config-next": "14.1.0",
48 | "postcss": "^8.4.35",
49 | "postcss-preset-mantine": "^1.13.0",
50 | "postcss-simple-vars": "^7.0.1",
51 | "prisma": "^5.10.2",
52 | "ts-node": "^10.9.2",
53 | "typescript": "^5.3.3"
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/components/PageLayout/PageLayout.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import {ReactNode} from "react";
4 | import {Notifications} from "@mantine/notifications";
5 | import NavHeader from "@/lib/components/NavHeader";
6 | import NavFooter from "@/lib/components/NavFooter";
7 | import {createTheme, MantineProvider} from "@mantine/core";
8 | import localFont from "next/font/local";
9 | import {SettingsProvider, SettingsConsumer, Settings} from "@/components/SettingsProvider";
10 | import {SessionProvider} from "next-auth/react";
11 | import {Session} from "next-auth";
12 |
13 | const myFont = localFont({
14 | src: "../../public/fonts/Montserrat-Regular.woff2",
15 | });
16 |
17 | const theme = createTheme({
18 | fontFamily: myFont.style.fontFamily,
19 | primaryColor: "light",
20 | colors: {
21 | "light": [
22 | "#e8fbfe",
23 | "#d9f1f6",
24 | "#b3e1ea",
25 | "#89d0df",
26 | "#69c2d5",
27 | "#55bacf",
28 | "#47b5cc",
29 | "#369fb5",
30 | "#278ea2",
31 | "#007b8f"
32 | ]
33 | },
34 | });
35 |
36 | type Props = {
37 | children: ReactNode,
38 | session: Session
39 | }
40 |
41 | export default function PageLayout({children, session}: Props) {
42 | return (
43 |
44 |
45 |
46 |
47 |
48 | {({initialized}: Settings) => {
49 | return initialized && (
50 |
51 |
52 |
53 |
54 | {children}
55 |
58 |
59 | )
60 | }}
61 |
62 |
63 |
64 |
65 | )
66 | }
67 |
--------------------------------------------------------------------------------
/components/Auth/LoginPage.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { Button, TextInput } from "@mantine/core";
4 | import { notifications } from "@mantine/notifications";
5 | import { signIn } from "next-auth/react";
6 | import { FormEvent } from "react";
7 | import { useRouter } from "next/navigation";
8 | import AuthFormContainer from "./AuthFormContainer";
9 |
10 | export default function LoginPage() {
11 | const router = useRouter();
12 |
13 | const handleLogin = async (e: FormEvent) => {
14 | e.preventDefault();
15 | const formData = new FormData(e.currentTarget)
16 | await signIn('credentials', {
17 | email: formData.get("email"),
18 | password: formData.get("password"),
19 | redirect: false
20 | }).then(({ ok }) => {
21 | //Check if the login was successful
22 | if (ok) {
23 | notifications.show({ title: "Success", message: "Logged in successfully!", color: "green" })
24 | router.push("/");
25 | } else {
26 | notifications.show({ title: "Error", message: "You have entered an invalid email or password.", color: "red" })
27 | }
28 | })
29 | }
30 |
31 | return (
32 |
33 |
40 |
47 | Login
48 |
49 |
50 | )
51 | }
52 |
--------------------------------------------------------------------------------
/app/api/parts/create/route.ts:
--------------------------------------------------------------------------------
1 | import { NextRequest, NextResponse } from "next/server";
2 | import prisma from "@/lib/prisma";
3 | import { Prisma } from "@prisma/client";
4 |
5 | //Manually create a part
6 |
7 | export async function POST(request: NextRequest) {
8 | try {
9 | //Request body containing part details
10 | const reqBody = await request.json();
11 |
12 | console.log(reqBody)
13 | //Check if part already exists
14 | const pcNumber = reqBody.productCode;
15 | const partExists = await prisma.parts.findUnique({
16 | where: {
17 | productCode: pcNumber,
18 | },
19 | });
20 | if (partExists) {
21 | console.log("Part already exists");
22 | return NextResponse.json({error: "Part already exists" }, {status: 409});
23 | } else {
24 | //reqBody could contain null values which will raise an error on create
25 | //Thus we get rid of null values
26 | const validData = Object.fromEntries(
27 | Object.entries(reqBody).filter(([key, value]) => value && value !== null)
28 | ) as Prisma.PartsUncheckedCreateInput;
29 | //As PartsUncheckedCreateInput needed, otherwise a TypeError will be raised
30 | console.log("Creating part...");
31 | const partCreate = await prisma.parts.create({
32 | data: validData,
33 | });
34 |
35 |
36 |
37 | if (partCreate) {
38 | console.log("Created part:")
39 | console.log(partCreate)
40 | return NextResponse.json({
41 | body: partCreate,
42 | message: "Part created",
43 | }, {status: 200});
44 | } else {
45 | return NextResponse.json({ error: "Part not created" }, {status: 500});
46 | }
47 | }
48 | } catch (error: ErrorCallback | any) {
49 | console.log("Error creating part", error);
50 | return NextResponse.json({ error: "Error creating part" }, {status: 500});
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/lib/middleware/handler.ts:
--------------------------------------------------------------------------------
1 | // import type { NextApiRequest, NextApiResponse } from "next";
2 | // import { getToken } from "next-auth/jwt"
3 | // import jwt from 'jsonwebtoken';
4 | // import { logger } from "../lib/logger";
5 | // const secret = process.env.JWT_KEY
6 |
7 | // //API Handler, which checks if the request is authorized
8 |
9 | // //Props:
10 | // //req and res for pulling the token
11 | // //method (e.g: "POST") for defining the handled (allowed) request type
12 | // //func --> executed of authenticated
13 | // export default async function handler(
14 | // req: NextApiRequest,
15 | // res: NextApiResponse,
16 | // method: string,
17 | // func: () => void
18 | // ) {
19 | // //Check if the allowed method is the actual method
20 | // if (req.method === method) {
21 | // //Fetch the token from the request
22 | // const token = await getToken({ req, secret: secret})
23 |
24 | // //If a token is existing (and not null, which would mean, that the request is unauthenticated)
25 | // if(token) {
26 | // try {
27 | // //Check if the token is expired
28 | // //@ts-ignore
29 | // if(token.exp * 1000 < Date.now()) {
30 | // //EXPIRED
31 | // logger.debug("EXPIRED TOKEN")
32 | // res.status(401).send({message: "Token expired"})
33 | // } else {
34 | // //Token is not expired and authorized --> execute function
35 | // await func()
36 | // }
37 |
38 | // } catch (e) {
39 | // //Not authenticated return status
40 | // res.status(401)
41 | // }
42 | // } else {
43 | // res.status(401)
44 | // }
45 | // } else {
46 | // //Method not allowed
47 | // throw new Error(
48 | // `The HTTP ${req.method} method is not supported at this route.`
49 | // );
50 | // }
51 | // res.end()
52 | // }
--------------------------------------------------------------------------------
/.github/workflows/deploy-image.yml:
--------------------------------------------------------------------------------
1 | #
2 | name: Create and publish a Docker image for Partpilot
3 |
4 | # Configures this workflow to run every time a change is pushed to the branch called `release`.
5 | on:
6 | release:
7 | types: [published]
8 |
9 | # Defines two custom environment variables for the workflow. These are used for the Container registry domain, and a name for the Docker image that this workflow builds.
10 | env:
11 | REGISTRY: ghcr.io
12 | IMAGE_NAME: ${{ github.repository }}
13 |
14 |
15 | # There is a single job in this workflow. It's configured to run on the latest available version of Ubuntu.
16 | jobs:
17 | build-and-push-image:
18 | runs-on: ubuntu-latest
19 | # Sets the permissions granted to the `GITHUB_TOKEN` for the actions in this job.
20 | permissions:
21 | contents: read
22 | packages: write
23 | #
24 | steps:
25 | - name: Checkout repository
26 | uses: actions/checkout@v4
27 | # Uses the `docker/login-action` action to log in to the Container registry registry using the account and password that will publish the packages. Once published, the packages are scoped to the account defined here.
28 | - name: Log in to the Container registry
29 | uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1
30 | with:
31 | registry: ${{ env.REGISTRY }}
32 | username: ${{ github.actor }}
33 | password: ${{ secrets.GITHUB_TOKEN }}
34 |
35 | - name: Extract metadata (tags, labels) for Docker
36 | id: meta
37 | uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7
38 | with:
39 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
40 |
41 | - name: Build and push Docker image
42 | uses: docker/build-push-action@f2a1d5e99d037542a71f64918e516c093c6f3fc4
43 | with:
44 | context: .
45 | push: true
46 | tags: ${{ steps.meta.outputs.tags }}
47 | labels: ${{ steps.meta.outputs.labels }}
48 |
--------------------------------------------------------------------------------
/prisma/migrations/20240331202711_next_auth_models/migration.sql:
--------------------------------------------------------------------------------
1 | -- CreateTable
2 | CREATE TABLE "Account" (
3 | "id" TEXT NOT NULL,
4 | "userId" TEXT NOT NULL,
5 | "type" TEXT NOT NULL,
6 | "provider" TEXT NOT NULL,
7 | "providerAccountId" TEXT NOT NULL,
8 | "refresh_token" TEXT,
9 | "access_token" TEXT,
10 | "expires_at" INTEGER,
11 | "token_type" TEXT,
12 | "scope" TEXT,
13 | "id_token" TEXT,
14 | "session_state" TEXT,
15 |
16 | CONSTRAINT "Account_pkey" PRIMARY KEY ("id")
17 | );
18 |
19 | -- CreateTable
20 | CREATE TABLE "Session" (
21 | "id" TEXT NOT NULL,
22 | "sessionToken" TEXT NOT NULL,
23 | "userId" TEXT NOT NULL,
24 | "expires" TIMESTAMP(3) NOT NULL,
25 |
26 | CONSTRAINT "Session_pkey" PRIMARY KEY ("id")
27 | );
28 |
29 | -- CreateTable
30 | CREATE TABLE "User" (
31 | "id" TEXT NOT NULL,
32 | "name" TEXT,
33 | "email" TEXT,
34 | "emailVerified" TIMESTAMP(3),
35 | "password" TEXT,
36 | "image" TEXT,
37 |
38 | CONSTRAINT "User_pkey" PRIMARY KEY ("id")
39 | );
40 |
41 | -- CreateTable
42 | CREATE TABLE "VerificationToken" (
43 | "identifier" TEXT NOT NULL,
44 | "token" TEXT NOT NULL,
45 | "expires" TIMESTAMP(3) NOT NULL
46 | );
47 |
48 | -- CreateIndex
49 | CREATE UNIQUE INDEX "Account_provider_providerAccountId_key" ON "Account"("provider", "providerAccountId");
50 |
51 | -- CreateIndex
52 | CREATE UNIQUE INDEX "Session_sessionToken_key" ON "Session"("sessionToken");
53 |
54 | -- CreateIndex
55 | CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
56 |
57 | -- CreateIndex
58 | CREATE UNIQUE INDEX "VerificationToken_token_key" ON "VerificationToken"("token");
59 |
60 | -- CreateIndex
61 | CREATE UNIQUE INDEX "VerificationToken_identifier_token_key" ON "VerificationToken"("identifier", "token");
62 |
63 | -- AddForeignKey
64 | ALTER TABLE "Account" ADD CONSTRAINT "Account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
65 |
66 | -- AddForeignKey
67 | ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
68 |
--------------------------------------------------------------------------------
/lib/components/NavFooter.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import { Text, Container, ActionIcon, Group, rem, Image, ThemeIcon } from "@mantine/core";
3 | import {
4 | IconBrandTwitter
5 | } from "@tabler/icons-react";
6 | import classes from "./NavFooter.module.css";
7 | import Link from "next/link";
8 | import { ReactNode } from "react";
9 |
10 | const data = [
11 | {
12 | title: "Project",
13 | links: [
14 | // { label: "About Us", link: "/about", icon: undefined },
15 | { label: "GitHub", link: "https://github.com/PartPilotLab/PartPilot", icon: undefined },
16 | ],
17 | },
18 | {
19 | title: 'Community',
20 | links: [
21 | // { label: 'Join Discord', link: '#', icon: },
22 | { label: 'Follow on X', link: '#', icon: },
23 | ],
24 | },
25 | ] as {title: string, links: {label: string, link: string, icon: undefined | ReactNode}[]}[];
26 |
27 | export default function NavFooter() {
28 | const groups = data.map((group) => {
29 | const links = group.links.map((link, index) => (
30 |
31 |
35 | {link.label}
36 |
37 |
38 | ));
39 |
40 | return (
41 |
42 | {group.title}
43 | {links}
44 |
45 | );
46 | });
47 |
48 | return (
49 |
50 |
51 |
52 |
58 |
59 |
60 | {groups}
61 |
62 |
63 |
64 | © 2024 PartPilot. Built with ❤️ by Lenni and
65 | supporters.
66 |
67 |
68 |
69 | );
70 | }
71 |
--------------------------------------------------------------------------------
/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import type { Metadata } from "next";
2 | import "@mantine/core/styles.css";
3 | import "@mantine/carousel/styles.css";
4 | import "./globals.css";
5 | import "@mantine/notifications/styles.css";
6 | import {PageLayout} from "@/components/PageLayout";
7 | import {getServerSession} from "next-auth";
8 |
9 | export const metadata: Metadata = {
10 | title: "PartPilot - Electronics Part Management",
11 | description: "Free And Open Source Electronics Part Management For LCSC",
12 | };
13 |
14 | export default async function RootLayout({
15 | children,
16 | }: Readonly<{
17 | children: React.ReactNode;
18 | }>) {
19 | const session = await getServerSession()
20 |
21 | return (
22 |
23 |
24 |
25 |
26 |
27 |
32 |
38 |
44 |
45 |
46 |
50 |
51 |
55 |
56 |
57 |
61 |
62 |
63 |
64 |
65 | {children}
66 |
67 |
68 |
69 | );
70 | }
71 |
--------------------------------------------------------------------------------
/lib/helper/lcsc_api.ts:
--------------------------------------------------------------------------------
1 | import { abs, bignumber, unit } from "mathjs";
2 | import { PartState } from "./part_state";
3 |
4 | export function extractPartInfoFromLCSCResponse(lcsc_response: any): PartState {
5 | const result = lcsc_response.result;
6 |
7 | const productLink = `https://www.lcsc.com/product-detail/${result.catalogName.replace(/\s/g, '-').replace(/-\(/g, '_').replace(/\)-/g, '_').replace(/-{2,}/g, '-').replace(/\//g, '_')}_${result.title.replace(/\s/g, '-').replace(/-\(/g, '_').replace(/\)-/g, '_').replace(/-{2,}/g, '-').replace(/\//g, '_')}_${result.productCode}.html`;
8 | let paramVOList = [];
9 | if(result.paramVOList) {
10 | paramVOList = result.paramVOList.reduce((acc: any, curr: any) => {
11 | let value = curr.paramValueEnForSearch;
12 | if(curr.paramNameEn == "Tolerance"){
13 | value = curr.paramValueEn
14 | }
15 | acc[curr.paramNameEn] = value;
16 | return acc;
17 | }, {});
18 | }
19 | console.log(paramVOList);
20 |
21 | return {
22 | id: result.productId.toString(),
23 | title: result.title,
24 | quantity: result.stockNumber,
25 | productId: result.productId,
26 | productCode: result.productCode,
27 | productModel: result.productModel,
28 | productDescription: result.productIntroEn,
29 | parentCatalogName: result.parentCatalogName,
30 | catalogName: result.catalogName,
31 | brandName: result.brandNameEn,
32 | encapStandard: result.encapStandard,
33 | productImages: result.productImages,
34 | pdfLink: result.pdfUrl,
35 | productLink: productLink,
36 | prices: result.productPriceList.map((price: any) => ({
37 | ladder: price.ladder.toString(),
38 | price: parseFloat(price.productPrice),
39 | })),
40 | voltage: paramVOList["Voltage Rated"] ?? undefined,
41 | resistance: paramVOList["Resistance"] ?? undefined,
42 | power: paramVOList["Power(Watts)"] ?? undefined,
43 | current: paramVOList["Rated Current"] ?? undefined,
44 | tolerance: paramVOList["Tolerance"] ?? undefined,
45 | frequency: paramVOList["Frequency"] ?? undefined,
46 | capacitance: paramVOList["Capacitance"] ?? undefined, //in pF (pico Farad)
47 | inductance: paramVOList["Inductance"] ?? undefined, //in uH (micro Henry)
48 | createdAt: new Date(),
49 | updatedAt: new Date(),
50 | };
51 | }
--------------------------------------------------------------------------------
/lib/components/NavFooter.module.css:
--------------------------------------------------------------------------------
1 | .footer {
2 | /* margin-top: rem(120px); */
3 | padding-top: calc(var(--mantine-spacing-xl) * 2);
4 | padding-bottom: calc(var(--mantine-spacing-xl) * 2);
5 | background-color: light-dark(
6 | var(--mantine-color-gray-0),
7 | var(--mantine-color-dark-6)
8 | );
9 | border-top: rem(1px) solid
10 | light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
11 | }
12 |
13 | .logo {
14 | max-width: rem(200px);
15 |
16 | @media (max-width: $mantine-breakpoint-sm) {
17 | display: flex;
18 | flex-direction: column;
19 | align-items: center;
20 | }
21 | }
22 |
23 | .description {
24 | margin-top: rem(5px);
25 |
26 | @media (max-width: $mantine-breakpoint-sm) {
27 | margin-top: var(--mantine-spacing-xs);
28 | text-align: center;
29 | }
30 | }
31 |
32 | .inner {
33 | display: flex;
34 | justify-content: space-between;
35 |
36 | @media (max-width: $mantine-breakpoint-sm) {
37 | flex-direction: column;
38 | align-items: center;
39 | }
40 | }
41 |
42 | .groups {
43 | display: flex;
44 | flex-wrap: wrap;
45 |
46 | @media (max-width: $mantine-breakpoint-sm) {
47 | display: none;
48 | }
49 | }
50 |
51 | .wrapper {
52 | width: rem(160px);
53 | }
54 |
55 | .link {
56 | display: block;
57 | color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-1));
58 | font-size: var(--mantine-font-size-sm);
59 | padding-top: rem(3px);
60 | padding-bottom: rem(3px);
61 |
62 | &:hover {
63 | text-decoration: underline;
64 | }
65 | }
66 |
67 | .title {
68 | font-size: var(--mantine-font-size-lg);
69 | font-weight: 700;
70 | font-family: Greycliff CF, var(--mantine-font-family);
71 | margin-bottom: calc(var(--mantine-spacing-xs) / 2);
72 | color: light-dark(var(--mantine-color-black), var(--mantine-color-white));
73 | }
74 |
75 | .afterFooter {
76 | display: flex;
77 | justify-content: space-between;
78 | align-items: center;
79 | margin-top: var(--mantine-spacing-xl);
80 | padding-top: var(--mantine-spacing-xl);
81 | padding-bottom: var(--mantine-spacing-xl);
82 | border-top: rem(1px) solid
83 | light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4));
84 |
85 | @media (max-width: $mantine-breakpoint-sm) {
86 | flex-direction: column;
87 | }
88 | }
89 |
90 | .social {
91 | @media (max-width: $mantine-breakpoint-sm) {
92 | margin-top: var(--mantine-spacing-xs);
93 | }
94 | }
95 |
--------------------------------------------------------------------------------
/app/categories/categoriesPage.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import {
4 | Badge,
5 | Group,
6 | Paper,
7 | SimpleGrid,
8 | Stack,
9 | Title,
10 | Text,
11 | Card,
12 | Image,
13 | Space,
14 | Tooltip,
15 | ActionIcon,
16 | } from "@mantine/core";
17 | import classes from "./page.module.css";
18 | import { PartState } from "@/lib/helper/part_state";
19 | import { useRouter } from "next/navigation";
20 | import { IconChevronLeft } from "@tabler/icons-react";
21 |
22 | export default function CategoriesPage({
23 | catalogItems,
24 | }: {
25 | catalogItems: PartState[];
26 | }) {
27 | const router = useRouter();
28 | return (
29 |
30 |
31 | {
36 | router.push("/");
37 | }}
38 | >
39 |
40 |
41 |
42 |
43 |
44 |
45 | Categories
46 |
47 |
48 |
49 | Directly Sort by Category
50 |
51 |
52 |
53 | Click on a category to see all the products in that category
54 |
55 |
56 |
57 | {catalogItems.map((item) => (
58 | {
65 | router.push(`/?catalog=${encodeURIComponent(item.parentCatalogName)}`);
66 | }}
67 | >
68 |
69 |
74 |
75 |
76 | {item.parentCatalogName}
77 |
78 |
79 | ))}
80 |
81 |
82 |
83 | );
84 | }
85 |
--------------------------------------------------------------------------------
/prisma/schema.prisma:
--------------------------------------------------------------------------------
1 | // This is your Prisma schema file,
2 | // learn more about it in the docs: https://pris.ly/d/prisma-schema
3 |
4 | // Looking for ways to speed up your queries, or scale easily with your serverless or edge functions?
5 | // Try Prisma Accelerate: https://pris.ly/cli/accelerate-init
6 |
7 | generator client {
8 | provider = "prisma-client-js"
9 | }
10 |
11 | datasource db {
12 | provider = "postgresql"
13 | url = env("DATABASE_URL")
14 | }
15 |
16 | model Parts {
17 | id Int @id @default(autoincrement())
18 | title String?
19 | quantity Int @default(0)
20 |
21 | productId Int?
22 | productCode String @unique
23 | productModel String?
24 | productDescription String?
25 |
26 | parentCatalogName String?
27 | catalogName String?
28 | brandName String?
29 |
30 | encapStandard String?
31 |
32 | productImages String[]
33 | pdfLink String?
34 | productLink String?
35 |
36 | prices Json?
37 |
38 | voltage Float? //Float is a "double precision" in Postgres
39 | capacitance Float?
40 | current Float?
41 | power Float?
42 | resistance Float?
43 | frequency Float?
44 | inductance Float?
45 |
46 | tolerance String?
47 |
48 | createdAt DateTime @default(now())
49 | updatedAt DateTime @updatedAt
50 | }
51 |
52 | model Account {
53 | id String @id @default(cuid())
54 | userId String
55 | type String
56 | provider String
57 | providerAccountId String
58 | refresh_token String? @db.Text
59 | access_token String? @db.Text
60 | expires_at Int?
61 | token_type String?
62 | scope String?
63 | id_token String? @db.Text
64 | session_state String?
65 |
66 | user User @relation(fields: [userId], references: [id], onDelete: Cascade)
67 |
68 | @@unique([provider, providerAccountId])
69 | }
70 |
71 | model Session {
72 | id String @id @default(cuid())
73 | sessionToken String @unique
74 | userId String
75 | expires DateTime
76 | user User @relation(fields: [userId], references: [id], onDelete: Cascade)
77 | }
78 |
79 | model User {
80 | id String @id @default(cuid())
81 | name String?
82 | email String? @unique
83 | emailVerified DateTime?
84 | password String?
85 | image String?
86 | accounts Account[]
87 | sessions Session[]
88 | }
89 |
90 | model VerificationToken {
91 | identifier String
92 | token String @unique
93 | expires DateTime
94 |
95 | @@unique([identifier, token])
96 | }
97 |
--------------------------------------------------------------------------------
/components/Auth/SignupPage.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { Button, TextInput } from "@mantine/core";
4 | import { notifications } from "@mantine/notifications";
5 | import { signIn } from "next-auth/react";
6 | import { FormEvent } from "react";
7 | import { useRouter } from "next/navigation";
8 | import AuthFormContainer from "./AuthFormContainer";
9 |
10 | export default function SignupPage() {
11 | const router = useRouter();
12 |
13 | const handleSignup = async (e: FormEvent) => {
14 | e.preventDefault()
15 | const formData = new FormData(e.currentTarget)
16 | const response = await fetch('/api/auth/register', {
17 | method: "POST",
18 | body: JSON.stringify({
19 | name: formData.get('name'),
20 | email: formData.get('email'),
21 | password: formData.get('password'),
22 | })
23 | }).then(res => res.json())
24 | if (response.error) {
25 | console.error(response.error)
26 | notifications.show({ title: "Error", message: response.error, color: "red" })
27 | } else {
28 | //Sign user in when registration is successful
29 | await signIn('credentials', {
30 | email: formData.get('email'),
31 | password: formData.get('password'),
32 | redirect: false
33 | }).then(({ ok }) => {
34 | if (ok) {
35 | router.push('/')
36 | notifications.show({ title: "Success", message: "Registered and logged in successfully!", color: "green" })
37 | } else {
38 | notifications.show({ title: "Error", message: "Something went wrong while logging in. Try to login again.", color: "red" })
39 | }
40 | });
41 | }
42 | }
43 |
44 | return (
45 |
46 |
53 |
60 |
67 | Sign Up
68 |
69 | )
70 | }
71 |
--------------------------------------------------------------------------------
/app/api/parts/search/route.ts:
--------------------------------------------------------------------------------
1 | import { NextRequest, NextResponse } from "next/server";
2 | import prisma from "@/lib/prisma";
3 | import { Prisma } from "@prisma/client";
4 |
5 | // For advanced search: fetch all distinct values for each column
6 | // Then have a selector allowing to choose from those values
7 | function convertOperation(operation: string) {
8 | switch (operation) {
9 | case ">":
10 | return "gt";
11 | case ">=":
12 | return "gte";
13 | case "=":
14 | return "equals";
15 | case "<":
16 | return "lt";
17 | case "<=":
18 | return "lte";
19 | default:
20 | throw new Error(`Invalid operation: ${operation}`);
21 | }
22 | }
23 |
24 | export async function POST(request: NextRequest) {
25 | try {
26 | const res = await request.json();
27 | const filter = res.filter;
28 | const page = res.page;
29 |
30 | let where: Prisma.PartsWhereInput = {};
31 |
32 | if (filter.productCode) {
33 | where.productCode = {
34 | contains: filter.productCode,
35 | };
36 | }
37 |
38 | if (filter.productTitle) {
39 | where.title = {
40 | contains: filter.productTitle,
41 | };
42 | }
43 |
44 | if (filter.productDescription) {
45 | where.productDescription = {
46 | contains: filter.productDescription,
47 | };
48 | }
49 |
50 | if (filter.parentCatalogName) {
51 | where.parentCatalogName = {
52 | equals: filter.parentCatalogName,
53 | };
54 | }
55 | if (filter.encapStandard) {
56 | where.encapStandard = {
57 | contains: filter.encapStandard,
58 | };
59 | }
60 |
61 | // Add conditions based on the filter object
62 | for (const key in filter) {
63 | if (key === 'productCode' || key === 'productTitle' || key === 'productDescription' || key === 'parentCatalogName' || key === 'encapStandard') {
64 | continue; // Skip these keys, they are already handled above
65 | }
66 | if (
67 | filter[key] !== null &&
68 | filter[key].value !== null &&
69 | filter[key].operation !== null
70 | ) {
71 | let operation = convertOperation(filter[key].operation);
72 | let temp: Prisma.PartsWhereInput = {};
73 | if (filter[key].value) {
74 | (temp as any)[key] = {
75 | [operation]: filter[key].value,
76 | };
77 | }
78 | where = { ...where, ...temp };
79 | }
80 | }
81 |
82 | const parts = await prisma.parts.findMany({
83 | where,
84 | orderBy: { id: "desc" },
85 | take: 10,
86 | skip: page ? (page - 1) * 10 : 0,
87 | });
88 | return NextResponse.json({ status: 200, parts: parts });
89 | } catch (error: ErrorCallback | any) {
90 | console.log(error);
91 | return NextResponse.json({ status: 500, error: error });
92 | }
93 | }
94 |
--------------------------------------------------------------------------------
/public/images/image-bg.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/lib/components/unit/UnitForm.tsx:
--------------------------------------------------------------------------------
1 | import { Paper, Group, NumberInput, Select } from "@mantine/core";
2 | import { unit } from "mathjs";
3 | import { useState, forwardRef, useImperativeHandle } from "react";
4 |
5 | import { create, all } from "mathjs";
6 | //Todos: pay attention to nF; get initial value from autocomplete (and type in case of capacitance);
7 |
8 | const math = create(all);
9 | interface UnitFormProps {
10 | valueType: ValueType;
11 | }
12 | type UnitPrefixes =
13 | | "p"
14 | | "n"
15 | // | "μ"
16 | | "u"
17 | | "m"
18 | | ""
19 | | "k"
20 | | "M"
21 | | "G"
22 | | "T"
23 | | "P"
24 | | "E"
25 | | "Z"
26 | | "Y";
27 | type ValueType =
28 | | "voltage"
29 | | "current"
30 | | "resistance"
31 | | "power"
32 | | "frequency"
33 | | "capacitance"
34 | | "inductance"; // Add more value types here
35 |
36 | const prefixes: UnitPrefixes[] = [
37 | "p",
38 | "n",
39 | // "μ",
40 | "u",
41 | "m",
42 | "",
43 | "k",
44 | "M",
45 | "G",
46 | "T",
47 | "P",
48 | "E",
49 | "Z",
50 | "Y",
51 | ];
52 | const baseUnits: Record = {
53 | voltage: "V",
54 | current: "A",
55 | resistance: "Ω",
56 | power: "W",
57 | frequency: "Hz",
58 | capacitance: "F",
59 | inductance: "H",
60 | };
61 |
62 | const units: Record = Object.keys(baseUnits).reduce(
63 | (acc, key) => {
64 | acc[key as ValueType] = prefixes.map(
65 | (prefix) => prefix + baseUnits[key as ValueType]
66 | );
67 | return acc;
68 | },
69 | {} as Record
70 | );
71 |
72 | export interface UnitFormRef {
73 | getSearchParameters: () => { value: number | null };
74 | setValue: (value: number | null, unit?: string) => void;
75 | clear: () => void;
76 | }
77 |
78 | const UnitForm = forwardRef(
79 | ({ valueType }, ref) => {
80 | const [value, setValue] = useState(null);
81 | const [unit, setUnit] = useState(baseUnits[valueType]);
82 |
83 | useImperativeHandle(ref, () => ({
84 | getSearchParameters: () => {
85 | let siValue = value;
86 | if (value !== null && unit !== null && valueType !== "capacitance") {
87 | let adjustedUnit = unit;
88 | if (unit === "Ω") {
89 | adjustedUnit = "ohm";
90 | }
91 | siValue = math.unit(value, adjustedUnit).toSI().value;
92 | }
93 | if (valueType === "capacitance" && value !== null && unit !== null) {
94 | siValue = math.unit(value, unit).toNumber("pF");
95 | }
96 | if (valueType === "inductance" && value !== null && unit !== null) {
97 | siValue = math.unit(value, unit).toNumber("uH");
98 | }
99 | return { value: siValue };
100 | },
101 | setValue: (value: number | null, unit?: string) => {
102 | if (unit) {
103 | setUnit(unit);
104 | }
105 | setValue(value);
106 | },
107 | clear: () => {
108 | setValue(null);
109 | setUnit(baseUnits[valueType]);
110 | },
111 | }));
112 |
113 | return (
114 |
115 |
116 | setValue(Number(value))}
120 | w={"75%"}
121 | size="sm"
122 | radius={0}
123 | />
124 |
133 |
134 |
135 | );
136 | }
137 | );
138 | UnitForm.displayName = "UnitForm";
139 | export default UnitForm;
140 |
--------------------------------------------------------------------------------
/app/api/parts/route.ts:
--------------------------------------------------------------------------------
1 | import { extractPartInfoFromLCSCResponse } from "@/lib/helper/lcsc_api";
2 | import { NextRequest, NextResponse } from "next/server";
3 | import prisma from "@/lib/prisma";
4 | import { NextApiRequest } from "next";
5 |
6 | export async function GET(request: NextRequest) {
7 | try {
8 |
9 | const {searchParams} = new URL(request.url);
10 | const page = searchParams.get("page");
11 | const parts = await prisma.parts.findMany({orderBy: {id: 'desc'}, take: 10, skip: page ? (parseInt(page) - 1) * 10 : 0});
12 | return NextResponse.json({ status: 200, parts: parts });
13 | } catch (error: ErrorCallback | any) {
14 | return NextResponse.json({ status: 500, error: error });
15 | }
16 | }
17 |
18 | export async function POST(request: NextRequest) {
19 | try {
20 | const res = await request.json();
21 | const pcNumber = res.pc;
22 | const partExists = await prisma.parts.findUnique({
23 | where: {
24 | productCode: pcNumber,
25 |
26 | }
27 | });
28 | if(partExists){
29 | const partUpdate = await prisma.parts.update({
30 | where: {
31 | id: partExists.id,
32 | },
33 | data: {
34 | quantity: res.quantity + partExists.quantity,
35 | }
36 | });
37 | if (partUpdate) {
38 | return NextResponse.json({ status: 200, body: partUpdate, message: "Part updated"});
39 | }
40 | else {
41 | return NextResponse.json({ status: 500, error: "Part not updated" });
42 | }
43 | } else {
44 | const LSCSPart = await fetch(
45 | "https://wmsc.lcsc.com/wmsc/product/detail?productCode=" + pcNumber
46 | )
47 | .then((response) => {
48 | return response.json();
49 | })
50 | .catch((e: ErrorCallback | any) => {
51 | console.error(e.message);
52 | });
53 | const partInfo = extractPartInfoFromLCSCResponse(LSCSPart);
54 |
55 | const partCreate = await prisma.parts.create({
56 | data: {
57 | title: partInfo.title,
58 | quantity: res.quantity,
59 | productId: partInfo.productId,
60 | productCode: partInfo.productCode,
61 | productModel: partInfo.productModel,
62 | productDescription: partInfo.productDescription,
63 | parentCatalogName: partInfo.parentCatalogName,
64 | catalogName: partInfo.catalogName,
65 | brandName: partInfo.brandName,
66 | encapStandard: partInfo.encapStandard,
67 | productImages: partInfo.productImages,
68 | pdfLink: partInfo.pdfLink,
69 | productLink: partInfo.productLink,
70 | prices: partInfo.prices,
71 | voltage: partInfo.voltage,
72 | resistance: partInfo.resistance,
73 | power: partInfo.power,
74 | current: partInfo.current,
75 | tolerance: partInfo.tolerance,
76 | frequency: partInfo.frequency,
77 | capacitance: partInfo.capacitance,
78 | inductance: partInfo.inductance,
79 | },
80 | });
81 | const itemCount = await prisma.parts.aggregate({_count: true});
82 | const parentCatalogNamesRaw = await prisma.parts.groupBy({by: ['parentCatalogName']})
83 | const parentCatalogNames = parentCatalogNamesRaw.map(item => item.parentCatalogName);
84 | if (partCreate) {
85 | return NextResponse.json({ status: 200, body: partCreate, itemCount: itemCount._count, parentCatalogNames: parentCatalogNames, message: "Part created"});
86 | } else {
87 | return NextResponse.json({ status: 500, error: "Part not created" });
88 | }
89 | }
90 |
91 |
92 |
93 |
94 | // res.status(200).json(LSCSPart);
95 | } catch (error: ErrorCallback | any) {
96 | return NextResponse.json({ status: 500, error: error });
97 |
98 | // res.status(500).json({ message: e.message });
99 | }
100 | }
101 |
--------------------------------------------------------------------------------
/lib/components/search/ValueSearch.tsx:
--------------------------------------------------------------------------------
1 | import { Paper, Group, NumberInput, Select } from "@mantine/core";
2 | import { unit } from "mathjs";
3 | import { useState, forwardRef, useImperativeHandle } from "react";
4 |
5 | import { create, all } from "mathjs";
6 |
7 | const math = create(all);
8 | interface ValueSearchProps {
9 | valueType: ValueType;
10 | }
11 | type UnitPrefixes =
12 | | "p"
13 | | "n"
14 | // | "μ"
15 | | "u"
16 | | "m"
17 | | ""
18 | | "k"
19 | | "M"
20 | | "G"
21 | | "T"
22 | | "P"
23 | | "E"
24 | | "Z"
25 | | "Y";
26 | type ValueType =
27 | | "voltage"
28 | | "current"
29 | | "resistance"
30 | | "power"
31 | | "frequency"
32 | | "capacitance"
33 | | "inductance"; // Add more value types here
34 |
35 | const prefixes: UnitPrefixes[] = [
36 | "p",
37 | "n",
38 | // "μ",
39 | "u",
40 | "m",
41 | "",
42 | "k",
43 | "M",
44 | "G",
45 | "T",
46 | "P",
47 | "E",
48 | "Z",
49 | "Y",
50 | ];
51 | const baseUnits: Record = {
52 | voltage: "V",
53 | current: "A",
54 | resistance: "Ω",
55 | power: "W",
56 | frequency: "Hz",
57 | capacitance: "F",
58 | inductance: "H",
59 | };
60 |
61 | const units: Record = Object.keys(baseUnits).reduce(
62 | (acc, key) => {
63 | acc[key as ValueType] = prefixes.map(
64 | (prefix) => prefix + baseUnits[key as ValueType]
65 | );
66 | return acc;
67 | },
68 | {} as Record
69 | );
70 |
71 | const operations = ["=", "<", ">", "<=", ">="];
72 |
73 | export interface ValueSearchRef {
74 | getSearchParameters: () => { value: number | null; operation: string | null };
75 | clear: () => void;
76 | }
77 |
78 | const ValueSearch = forwardRef(
79 | ({ valueType }, ref) => {
80 | const [value, setValue] = useState(null);
81 | const [unit, setUnit] = useState(baseUnits[valueType]);
82 | const [operation, setOperation] = useState(operations[0]);
83 |
84 | useImperativeHandle(ref, () => ({
85 | getSearchParameters: () => {
86 | let siValue = value;
87 | if (value !== null && unit !== null && valueType !== "capacitance") {
88 | let adjustedUnit = unit;
89 | if (unit === "Ω") {
90 | adjustedUnit = "ohm";
91 | }
92 | siValue = math.unit(value, adjustedUnit).toSI().value;
93 | }
94 | if (valueType === "capacitance" && value !== null && unit !== null) {
95 | siValue = math.unit(value, unit).toNumber("pF");
96 | }
97 | if (valueType === "inductance" && value !== null && unit !== null) {
98 | siValue = math.unit(value, unit).toNumber("uH");
99 | }
100 | return { value: siValue, operation };
101 | },
102 | clear: () => {
103 | setValue(null);
104 | setUnit(baseUnits[valueType]);
105 | setOperation(operations[0]);
106 | },
107 | }));
108 |
109 | return (
110 |
111 |
112 | >}
120 | rightSectionWidth={0}
121 | rightSectionPointerEvents="none"
122 | />
123 | setValue(Number(value))}
127 | w={"55%"}
128 | size="sm"
129 | radius={0}
130 | />
131 | >}
139 | rightSectionWidth={0}
140 | rightSectionPointerEvents="none"
141 | />
142 |
143 |
144 | );
145 | }
146 | );
147 | ValueSearch.displayName = "ValueSearch";
148 | export default ValueSearch;
149 |
--------------------------------------------------------------------------------
/public/images/start.svg:
--------------------------------------------------------------------------------
1 | runner_start
--------------------------------------------------------------------------------
/components/UserAvatar/UserAvatar.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { Avatar, Button, Menu, Stack, Tabs, TextInput } from "@mantine/core";
4 | import classes from "./UserAvatar.module.css";
5 | import { FormEvent } from "react";
6 | import { signIn, signOut, useSession } from "next-auth/react";
7 | import { notifications } from "@mantine/notifications";
8 |
9 | type Props = {
10 | styles?: string
11 | }
12 |
13 | export default function UserAvatar({ styles }: Props) {
14 | const session = useSession()
15 |
16 | const handleRegistration = async (e: FormEvent) => {
17 | e.preventDefault()
18 | const formData = new FormData(e.currentTarget)
19 | const response = await fetch('/api/auth/register', {
20 | method: "POST",
21 | body: JSON.stringify({
22 | name: formData.get('name'),
23 | email: formData.get('email'),
24 | password: formData.get('password'),
25 | })
26 | }).then(res => res.json())
27 | if (response.error) {
28 | console.error(response.error)
29 | notifications.show({ title: "Error", message: response.error, color: "red" })
30 | } else {
31 | //Sign user in when registration is successful
32 | await signIn('credentials', {
33 | email: formData.get('email'),
34 | password: formData.get('password'),
35 | redirect: false
36 | }).then(({ ok }) => {
37 | if (ok) {
38 | notifications.show({ title: "Success", message: "Registered and logged in successfully!", color: "green" })
39 | } else {
40 | notifications.show({ title: "Error", message: "Something went wrong while logging in. Try to login again.", color: "red" })
41 | }
42 | });
43 | }
44 | }
45 |
46 | const handleLogin = async (e: FormEvent) => {
47 | e.preventDefault();
48 | const formData = new FormData(e.currentTarget)
49 | await signIn('credentials', {
50 | email: formData.get("email"),
51 | password: formData.get("password"),
52 | redirect: false
53 | }).then(({ ok, error }) => {
54 | //Check if the login was successful
55 | if (ok) {
56 | notifications.show({ title: "Success", message: "Logged in successfully!", color: "green" })
57 | } else {
58 | notifications.show({ title: "Error", message: "You have entered an invalid email or password.", color: "red" })
59 | }
60 | })
61 | }
62 |
63 | const handleLogout = async () => {
64 | await signOut()
65 | }
66 |
67 | return (
68 |
69 |
70 |
71 | {session.data?.user?.email ? (
72 |
77 | {session.data.user.name.slice(0, 2)}
78 |
79 | ) : (
80 |
84 | )}
85 |
86 |
87 |
88 | {session.data?.user?.email ? (
89 |
90 |
User Account
91 |
92 | {session.data.user.name}
93 |
94 |
95 | {session.data.user.email}
96 |
97 | Logout
98 |
99 | ) : (
100 |
101 |
102 |
103 | Login
104 |
105 |
106 | Signup
107 |
108 |
109 |
110 |
130 |
131 |
132 |
159 |
160 |
161 | )}
162 |
163 |
164 | )
165 | }
166 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
PartPilot
12 |
13 |
14 | Navigating the World of Parts.
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 | ## ✨ About PartPilot
28 |
29 |
30 |
31 |
32 | Welcome to PartPilot, the ultimate open-source solution designed to streamline and enhance your electronics part management experience. Whether you're a hobbyist, a professional engineer, or part of an educational or research institution, PartPilot is here to transform the way you organize, track, and interact with your electronic components.
33 |
34 | ### Table of Contents
35 |
36 | - [Features](#features)
37 |
38 | - [Usage](#usage)
39 |
40 | - [Development](#development)
41 |
42 | - [Contribution](#contribution)
43 |
44 | - [License](#license)
45 |
46 |
47 |
48 | ### Features
49 |
50 | - 🏬 **Inventory Management**: Effortlessly catalog your electronic parts with detailed information, including datasheets, supplier data, stock levels, and more.
51 |
52 | - 🖥️ **Direct LCSC Integration**: Seamlessly connect with LCSC for direct access to a vast inventory of parts, enabling easy addition and management of components within PartPilot.
53 |
54 | - 👁️ **Barcode Scanner Functionality**: Add parts to your inventory swiftly using the barcode scanner feature, enhancing efficiency and accuracy in part management.
55 |
56 | - 🕵️ **Search and Filter**: Quickly find the components you need with powerful search and filtering capabilities.
57 |
58 | - 🖼️ **Intuitive Interface**: Enjoy a user-friendly experience designed to make electronics part management as efficient and straightforward as possible.
59 |
60 | ### Built on Open Source
61 |
62 | - 💻 [Typescript](https://www.typescriptlang.org/)
63 |
64 | - 🚀 [Next.js](https://nextjs.org/)
65 |
66 | - ⚛️ [React](https://reactjs.org/)
67 |
68 | - 🎨 [Mantine](https://mantine.dev/)
69 |
70 | - 📚 [Prisma](https://prisma.io/)
71 |
72 | - 🔒 [NextAuth](https://next-auth.js.org/)
73 |
74 |
75 |
76 |
77 |
78 | ## Usage
79 |
80 | ### 🐳 Using Docker
81 |
82 | To host PartPilot on your homeserver using docker-compose:
83 | copy the contents of the `docker-compose-release.yml` into a `docker-compose.yml` file on your server
84 | start the service using `docker-compose up -d` or `docker compose up -d`.
85 |
86 |
87 |
88 | ## 👨💻 Development
89 |
90 | ### Prerequisites
91 |
92 | Here is what you need to be able to develop PartPilot:
93 |
94 | - [Node.js](https://nodejs.org/en) (Version: >=18.x)
95 |
96 | - [Docker](https://www.docker.com/) - to run PostgreSQL
97 |
98 | ### Setup
99 |
100 | Excited to have you onboard! Lets get you started.
101 |
102 | **Step 1: Clone the Repository**
103 | First things first, let's get the code on your machine. Open up your terminal and run:
104 | ```
105 | git clone https://github.com/PartPilotLab/PartPilot.git
106 | cd PartPilot
107 | ```
108 |
109 | **Step 2: Spin Up Docker**
110 | With Docker, you don't need to worry about setting up Next.js or PostgreSQL manually. We've got a docker-compose.yml file that will do the heavy lifting for you.
111 | Run the following command to build and start your containers:
112 | ```
113 | docker-compose up --build
114 | ```
115 | This command kicks off the magic. It'll pull in the necessary images, set up your database, and get the Next.js app running. It's like hitting the power button on your awesome electronics workstation!
116 |
117 | **Step 3: Check It Out**
118 | Once Docker has done its thing, your PartPilot should be up and running. Open your favorite browser and head to http://localhost:3000. Voilà! You should see the PartPilot homepage smiling back at you.
119 |
120 | **Step 4: Make It Your Own**
121 | Now that you're up and running, it's time to explore! Add some parts, link them to projects, play around with the barcode scanner feature, and see what PartPilot can do.
122 |
123 |
124 |
125 | ## ✍️ Contribution
126 |
127 | We are very happy if you are interested in contributing to PartPilot 🤗
128 |
129 | PartPilot thrives on community involvement! Whether you're interested in contributing code, providing feedback, or sharing your expertise, there's a place for you in the PartPilot community. Explore our issues, contribute to our discussions, and help us shape the future of electronics part management.
130 |
131 | Here are a few options:
132 |
133 | - Star this repo.
134 |
135 | - Create issues every time you feel something is missing or goes wrong.
136 |
137 |
138 |
139 | ## 👩⚖️ License
140 |
141 | ### License: Embrace the Open-Source Spirit with AGPL-3.0
142 |
143 | PartPilot is all about sharing, growing, and collaborating. That's why we've chosen the AGPL-3.0 license for our project. This license ensures that you, the community, have the freedom to use, modify, and share PartPilot, all while keeping the same freedom for others.
144 |
145 | By using PartPilot, you're part of a larger movement that values open access to technology and collaborative improvement. The AGPL-3.0 license guarantees that any modifications or versions of the project you distribute will remain free and open, ensuring the community benefits from each other's improvements and contributions.
146 |
147 | So, dive in, tweak it, twist it, and make it your own. And if you do something cool, the world gets to see and build upon it too. That's the beauty of AGPL-3.0 – it's all about giving back and moving forward together.
148 |
149 | ### Note
150 | PartPilot is currently not production ready. There will be breaking changes.
151 |
152 | 🔼 Back to top
153 |
--------------------------------------------------------------------------------
/lib/components/NavHeader.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import {
4 | Burger,
5 | Button,
6 | Center,
7 | Container,
8 | Group,
9 | Menu,
10 | Image,
11 | Box,
12 | Drawer,
13 | ScrollArea,
14 | Divider,
15 | rem,
16 | Stack,
17 | } from "@mantine/core";
18 | import classes from "./NavHeader.module.css";
19 | import { IconChevronDown, IconPlus } from "@tabler/icons-react";
20 | import { usePathname, useRouter } from "next/navigation";
21 | import onScan from "onscan.js";
22 | import { useDisclosure } from "@mantine/hooks";
23 | import UserAvatar from "../../components/UserAvatar/UserAvatar";
24 | import Link from "next/link";
25 | import { ReactNode } from "react";
26 | import { signOut, useSession } from "next-auth/react";
27 |
28 | type NavLink = {
29 | link: string;
30 | label: string;
31 | links?: { link: string; label: string }[];
32 | isHiddenInDesktop?: boolean
33 | }
34 |
35 | const links: Array = [
36 | { link: "/", label: "Dashboard" },
37 | { link: "/categories", label: "Categories" }
38 | // { link: "/about", label: "About Us" },
39 | ];
40 |
41 | const mobileLinks: Array = [
42 | { link: "/login", label: "Log In", isHiddenInDesktop: true },
43 | { link: "/signup", label: "Sign Up", isHiddenInDesktop: true }
44 | ]
45 |
46 | export default function NavHeader() {
47 | const session = useSession()
48 | const router = useRouter();
49 | const pathname = usePathname();
50 | const [drawerOpened, { toggle: toggleDrawer, close: closeDrawer }] =
51 | useDisclosure(false);
52 |
53 | const items = (navLinks: Array): Array => {
54 | return navLinks.map((link) => {
55 | const menuItems = link.links?.map((item) => (
56 | {item.label}
57 | ));
58 |
59 | if (menuItems) {
60 | return (
61 |
67 |
68 | event.preventDefault()}
72 | >
73 |
74 | {link.label}
75 |
76 |
77 |
78 |
79 | {menuItems}
80 |
81 | );
82 | }
83 |
84 | return (
85 | {
90 | event.preventDefault();
91 | if (link.link === "/") {
92 | if (pathname !== link.link) {
93 | router.push(link.link);
94 | } else {
95 | window.location.href = "/";
96 | }
97 | } else {
98 | if (pathname !== link.link) {
99 | if (
100 | typeof document !== "undefined" &&
101 | typeof onScan !== "undefined"
102 | ) {
103 | if (onScan.isAttachedTo(document)) {
104 | onScan.detachFrom(document);
105 | }
106 | }
107 | }
108 | router.push(link.link);
109 | }
110 | closeDrawer();
111 | }}
112 | >
113 | {link.label}
114 |
115 | );
116 | })
117 | };
118 |
119 | return (
120 |
121 |
122 |
123 |
124 | {
131 | router.push("/");
132 | }}
133 | style={{ cursor: "pointer" }}
134 | />
135 |
136 | {items(links)}
137 |
138 |
144 |
145 | }
147 | onClick={() => {
148 | if (
149 | typeof document !== "undefined" &&
150 | typeof onScan !== "undefined"
151 | ) {
152 | if (onScan.isAttachedTo(document)) {
153 | onScan.detachFrom(document);
154 | }
155 | }
156 | router.push("/add");
157 | }}
158 | >
159 | Add Part
160 |
161 |
162 |
163 |
164 |
165 |
166 |
174 |
175 |
176 |
177 | {items(links)}
178 | {session.data?.user?.email ? (
179 | signOut()}>Log Out
180 | ) : (
181 | items(mobileLinks)
182 | )}
183 |
184 |
185 |
186 | }
188 | onClick={() => {
189 | if (
190 | typeof document !== "undefined" &&
191 | typeof onScan !== "undefined"
192 | ) {
193 | if (onScan.isAttachedTo(document)) {
194 | onScan.detachFrom(document);
195 | }
196 | }
197 | router.push("/add");
198 | }}
199 | >
200 | Add Part
201 |
202 |
203 |
204 |
205 |
206 | );
207 | }
208 |
--------------------------------------------------------------------------------
/public/images/image-add.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/images/about.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/app/part/[partId]/partPage.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import { PartState } from "@/lib/helper/part_state";
3 | import { Carousel } from "@mantine/carousel";
4 | export const dynamic = "force-dynamic";
5 | import {
6 | Paper,
7 | Group,
8 | Center,
9 | Stack,
10 | Text,
11 | Image,
12 | ActionIcon,
13 | Tooltip,
14 | Title,
15 | TextInput,
16 | NumberInput,
17 | Button,
18 | Grid,
19 | SimpleGrid,
20 | Flex,
21 | LoadingOverlay,
22 | ThemeIcon,
23 | rem,
24 | } from "@mantine/core";
25 | import { useForm } from "@mantine/form";
26 | import { notifications } from "@mantine/notifications";
27 | import { IconChevronLeft, IconEdit, IconPlus } from "@tabler/icons-react";
28 | import { useRouter } from "next/navigation";
29 | import { useState } from "react";
30 | import classes from "./page.module.css";
31 | const units = {
32 | voltage: "V",
33 | resistance: "Ω",
34 | power: "W",
35 | current: "A",
36 | frequency: "Hz",
37 | capacitance: "nF",
38 | };
39 |
40 | export default function PartPage({ part }: { part: PartState }) {
41 | const router = useRouter();
42 | const [isLoading, setIsLoading] = useState(false);
43 |
44 |
45 | const form = useForm({
46 | initialValues: {
47 | title: part.title || undefined,
48 | quantity: part.quantity || undefined,
49 | productId: part.productId || undefined,
50 | productCode: part.productCode || undefined,
51 | productModel: part.productModel || undefined,
52 | productDescription: part.productDescription || undefined,
53 | parentCatalogName: part.parentCatalogName || undefined,
54 | catalogName: part.catalogName || undefined,
55 | brandName: part.brandName || undefined,
56 | encapStandard: part.encapStandard || undefined,
57 | productImages: part.productImages || undefined,
58 | pdfLink: part.pdfLink || undefined,
59 | productLink: part.productLink || undefined,
60 | tolerance: part.tolerance || undefined,
61 | voltage: part.voltage || undefined,
62 | resistance: part.resistance || undefined,
63 | power: part.power || undefined,
64 | current: part.current || undefined,
65 | frequency: part.frequency || undefined,
66 | capacitance: part.capacitance || undefined,
67 | prices:
68 | part.prices ||
69 | ([] as {
70 | ladder: string;
71 | price: number;
72 | }[]),
73 | },
74 | validate: {
75 | productCode: (value) =>
76 | value.length > 0 ? null : "Product Code is required",
77 | },
78 | });
79 | async function updatePart() {
80 | form.validate();
81 | if (form.isValid()) {
82 | setIsLoading(true);
83 | const response = await fetch("/api/parts/update", {
84 | method: "POST",
85 | body: JSON.stringify({ ...form.values, id: part.id }),
86 | }).then((response) =>
87 | response
88 | .json()
89 | .then((data) => ({ status: response.status, body: data }))
90 | );
91 | if (response.status == 200) {
92 | notifications.show({
93 | title: "Part Update Successful",
94 | message: `The part ${form.values.productCode} was updated.`,
95 | });
96 | // form.reset();
97 | } else {
98 | if (response.status == 500) {
99 | if (response.body.error == "Part already exists") {
100 | notifications.show({
101 | title: "Part Update Failed",
102 | message: `The part ${form.values.productCode} already exists.`,
103 | });
104 | } else {
105 | notifications.show({
106 | title: "Part Update Failed",
107 | message: `The part could not be updated. Please try again.`,
108 | });
109 | }
110 | } else {
111 | notifications.show({
112 | title: "Part Update Failed",
113 | message: `The part could not be updated. Please try again.`,
114 | });
115 | }
116 | }
117 | } else {
118 | notifications.show({
119 | title: "Part Update Failed",
120 | message: `Please fill out all required fields.`,
121 | });
122 | }
123 | setIsLoading(false);
124 | }
125 |
126 | return (
127 |
128 |
129 | {
134 | router.push("/");
135 | }}
136 | >
137 |
138 |
139 |
140 |
145 |
146 |
147 | {form.values.productImages.length == 0 ? (
148 |
149 |
155 |
156 | ) : (
157 | form.values.productImages.map((image, index) => (
158 |
159 |
160 |
161 | ))
162 | )}
163 |
164 |
165 |
166 |
312 |
313 |
314 | {" "}
315 |
316 | );
317 | }
318 |
--------------------------------------------------------------------------------
/public/images/faq.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/app/add/page.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | export const dynamic = "force-dynamic";
3 | import {
4 | Paper,
5 | Group,
6 | Center,
7 | Tabs,
8 | Text,
9 | Stack,
10 | TextInput,
11 | Button,
12 | SimpleGrid,
13 | Grid,
14 | NumberInput,
15 | ActionIcon,
16 | Tooltip,
17 | Flex,
18 | Image,
19 | ThemeIcon,
20 | LoadingOverlay,
21 | } from "@mantine/core";
22 | import { useEffect, useRef, useState } from "react";
23 | import { scannerInputToType } from "../dashboardPage";
24 | import { notifications } from "@mantine/notifications";
25 | import { useForm } from "@mantine/form";
26 | import { IconChevronLeft, IconInfoCircle, IconPlus } from "@tabler/icons-react";
27 | import { useRouter } from "next/navigation";
28 | import { Carousel } from "@mantine/carousel";
29 | import UnitForm, { UnitFormRef } from "@/lib/components/unit/UnitForm";
30 |
31 | export default function Add() {
32 | const [isLoading, setLoading] = useState(false);
33 | //TODO: integrate the UnitForm more efficiently by directly changing the form value from UnitForm
34 | const form = useForm({
35 | initialValues: {
36 | title: undefined,
37 | quantity: 1,
38 | productId: undefined,
39 | productCode: undefined,
40 | productModel: undefined,
41 | productDescription: undefined,
42 | parentCatalogName: undefined,
43 | catalogName: undefined,
44 | brandName: undefined,
45 | encapStandard: undefined,
46 | productImages: [],
47 | pdfLink: undefined,
48 | productLink: undefined,
49 | tolerance: undefined,
50 | voltage: undefined,
51 | resistance: undefined,
52 | power: undefined,
53 | current: undefined,
54 | frequency: undefined,
55 | capacitance: undefined,
56 | inductance: undefined,
57 | prices: [] as {
58 | ladder: string;
59 | price: number;
60 | }[],
61 | },
62 | validate: {
63 | productCode: (value) =>
64 | value && value.length > 0 ? null : "Product Code is required",
65 | },
66 | });
67 |
68 | const [scannerInput, setScannerInput] = useState("");
69 | const [productCode, setProductCode] = useState("");
70 |
71 | //When the user presses the Autocomplete Button
72 | async function handleAutocomplete() {
73 | //Firstly checking of there is a valid input by the scanner form
74 | const partInfoFromScanner = scannerInputToType(
75 | JSON.parse(JSON.stringify(scannerInput)) ?? ""
76 | );
77 | const validScannerInput =
78 | partInfoFromScanner.pc != "" && partInfoFromScanner.pc;
79 | //Checking of there is a valid input
80 | if (productCode != "" || validScannerInput) {
81 | //Using the scanner product code if both are given
82 | let productCodeInternal = partInfoFromScanner.pc
83 | ? partInfoFromScanner.pc
84 | : productCode;
85 | setLoading(true);
86 |
87 | const response = await fetch("/api/parts/autocomplete", {
88 | method: "POST",
89 | body: JSON.stringify({
90 | productCode: productCodeInternal,
91 | }),
92 | }).then((response) =>
93 | response
94 | .json()
95 | .then((data) => ({ status: response.status, body: data }))
96 | );
97 | if (response.body.status == 200) {
98 | notifications.show({
99 | title: "Autocomplete Successful",
100 | message: `The product code ${productCodeInternal} was found.`,
101 | });
102 | console.log(response.body.body);
103 | if (response.body.body) {
104 | Object.keys(response.body.body).forEach((key) => {
105 | //check if exists, otherwise dont create
106 | if (form.values.hasOwnProperty(key)) {
107 | if (key == "quantity") {
108 | //If quantity is received from the scanner
109 | if (partInfoFromScanner.qty) {
110 | form.setFieldValue(key, partInfoFromScanner.qty);
111 | }
112 | //If I receive a quantity, but not from the scanner --> do nothing
113 | } else if (refMapping.hasOwnProperty(key)) {
114 | // If the key corresponds to a ref, update the value of the corresponding UnitForm
115 | if (key == "capacitance") {
116 | refMapping[key].current.setValue(
117 | response.body.body[key],
118 | "pF"
119 | );
120 | } else if (key == "inductance") {
121 | refMapping[key].current.setValue(
122 | response.body.body[key],
123 | "mH"
124 | );
125 | } else {
126 | refMapping[key].current.setValue(response.body.body[key]);
127 | }
128 | } else {
129 | form.setFieldValue(key, response.body.body[key]);
130 | }
131 | }
132 | });
133 | }
134 | } else {
135 | notifications.show({
136 | title: "Autocomplete Failed",
137 | message: `The product code ${productCodeInternal} was not found. Please enter a valid product code or scanner input`,
138 | });
139 | }
140 | } else {
141 | notifications.show({
142 | title: "Autocomplete Failed",
143 | message: `Please enter a valid scanner input or product code.`,
144 | });
145 | return;
146 | }
147 | setLoading(false);
148 | }
149 |
150 | async function addPart() {
151 | //If the user presses on add part: check if from is valid
152 | form.validate();
153 | setLoading(true);
154 | if (form.isValid()) {
155 | //Making an object ob the form (to then update it later)
156 | let currentPartInfo = form.values || ({} as any);
157 | if (currentPartInfo) {
158 | currentPartInfo.voltage =
159 | voltageFormRef.current?.getSearchParameters().value;
160 | currentPartInfo.resistance =
161 | resistanceFormRef.current?.getSearchParameters().value;
162 | currentPartInfo.power =
163 | powerFormRef.current?.getSearchParameters().value;
164 | currentPartInfo.current =
165 | currentFormRef.current?.getSearchParameters().value;
166 | currentPartInfo.frequency =
167 | frequencyFormRef.current?.getSearchParameters().value;
168 | currentPartInfo.capacitance =
169 | capacitanceFormRef.current?.getSearchParameters().value;
170 | currentPartInfo.inductance =
171 | inductanceFormRef.current?.getSearchParameters().value;
172 | }
173 | const response = await fetch("/api/parts/create", {
174 | method: "POST",
175 | body: JSON.stringify(form.values),
176 | }).then((response) =>
177 | response
178 | .json()
179 | .then((data) => ({ status: response.status, body: data }))
180 | );
181 | console.log(response);
182 | if (response.status == 200) {
183 | notifications.show({
184 | title: "Part Add Successful",
185 | message: `The part ${form.values.productCode} was added.`,
186 | color: "green",
187 | });
188 | form.reset();
189 | } else {
190 | if (response.status == 500) {
191 | notifications.show({
192 | title: "Part Add Failed",
193 | message: `The part could not be added. Please try again.`,
194 | color: "red",
195 | });
196 | } else if (response.status == 409) {
197 | notifications.show({
198 | title: "Part Add Failed",
199 | message: `The part ${form.values.productCode} already exists.`,
200 | color: "red",
201 | });
202 | } else {
203 | notifications.show({
204 | title: "Part Add Failed",
205 | message: `The part could not be added. Please try again.`,
206 | color: "red",
207 | });
208 | }
209 | }
210 | } else {
211 | notifications.show({
212 | title: "Part Add Failed",
213 | message: `Please fill out all required fields.`,
214 | });
215 | }
216 | setLoading(false);
217 | }
218 |
219 | const router = useRouter();
220 | const units = {
221 | voltage: "V",
222 | resistance: "Ω",
223 | power: "W",
224 | current: "A",
225 | frequency: "Hz",
226 | capacitance: "nF",
227 | inductance: "uH",
228 | };
229 | // A ref for every unit input to keep track of its state and have the possiblity to get its contents when pressing on add part
230 | const voltageFormRef = useRef(null);
231 | const resistanceFormRef = useRef(null);
232 | const powerFormRef = useRef(null);
233 | const currentFormRef = useRef(null);
234 | const frequencyFormRef = useRef(null);
235 | const capacitanceFormRef = useRef(null);
236 | const inductanceFormRef = useRef(null);
237 | const refMapping = {
238 | voltage: voltageFormRef,
239 | resistance: resistanceFormRef,
240 | power: powerFormRef,
241 | current: currentFormRef,
242 | frequency: frequencyFormRef,
243 | capacitance: capacitanceFormRef,
244 | inductance: inductanceFormRef,
245 | };
246 |
247 | return (
248 |
249 |
250 | {
255 | router.push("/");
256 | }}
257 | >
258 |
259 |
260 |
261 |
266 |
267 |
268 | {form.values.productImages.length == 0 ? (
269 |
270 |
276 |
277 | ) : (
278 | form.values.productImages.map((image, index) => (
279 |
280 |
281 |
282 | ))
283 | )}
284 |
285 |
286 |
287 |
483 |
484 |
485 | {" "}
486 |
487 | );
488 | }
489 |
--------------------------------------------------------------------------------
/public/images/404.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/app/dashboardPage.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import {
3 | Box,
4 | Button,
5 | Center,
6 | Group,
7 | HoverCard,
8 | Image,
9 | Loader,
10 | LoadingOverlay,
11 | MultiSelect,
12 | NavLink,
13 | NumberInput,
14 | NumberInputHandlers,
15 | Pagination,
16 | Paper,
17 | Select,
18 | SimpleGrid,
19 | Space,
20 | Stack,
21 | Table,
22 | Tabs,
23 | Text,
24 | TextInput,
25 | ThemeIcon,
26 | } from "@mantine/core";
27 | import { useEffect, useRef, useState } from "react";
28 | import onScan from "onscan.js";
29 | import { PartState } from "@/lib/helper/part_state";
30 | import { format, round, unit } from "mathjs";
31 | import {
32 | IconArrowLeftFromArc,
33 | IconLink,
34 | IconPdf,
35 | IconSearch,
36 | IconTrash,
37 | } from "@tabler/icons-react";
38 | import { notifications } from "@mantine/notifications";
39 | import ValueSearch, {
40 | ValueSearchRef,
41 | } from "@/lib/components/search/ValueSearch";
42 | import { usePathname, useRouter, useSearchParams } from "next/navigation";
43 | import { useForm } from "@mantine/form";
44 |
45 | export function scannerInputToType(partScannerInput: string): ScannerPartState {
46 | var json = {} as { [key: string]: string };
47 | if (partScannerInput) {
48 | partScannerInput.split(",").forEach((item) => {
49 | var key = item.split(":")[0];
50 | var value = item.split(":")[1];
51 | json[key] = value;
52 | });
53 | var pmVal = json.pm;
54 | var qtyVal = json.qty;
55 | var pdi = json.pdi;
56 | var pc = json.pc;
57 | var on = json.on;
58 | var pbn = json.pbn;
59 |
60 | return { pm: pmVal, qty: Number(qtyVal), pdi, pc, on, pbn };
61 | }
62 | return { pm: "", qty: 0, pc: "", error: true };
63 | }
64 | export interface ScannerPartState {
65 | pbn?: string;
66 | on?: string;
67 | pc: string; //LCSC Product Code
68 | pm: string; //Manufacturer Product Number
69 | qty: number; //Quantity
70 | pdi?: string;
71 | error?: boolean;
72 | }
73 | type Operations = ">" | "<" | "=" | "<=" | ">=";
74 |
75 | export interface FilterState {
76 | productCode?: string;
77 | productTitle?: string;
78 | productDescription?: string; //Deep search
79 | parentCatalogName?: string;
80 | encapStandard?: string;
81 | voltage?: { operation: Operations | string | null; value: number | null }; //operation referring to "<" ">" "="
82 | resistance?: {
83 | operation: Operations | string | null;
84 | value: number | null;
85 | };
86 | power?: { operation: Operations | string | null; value: number | null };
87 | current?: { operation: Operations | string | null; value: number | null };
88 | tolerance?: string; //Selector
89 | frequency?: { operation: Operations | string | null; value: number | null };
90 | capacitance?: {
91 | operation: Operations | string | null;
92 | value: number | null;
93 | }; //value is in pF
94 | inductance?: {
95 | operation: Operations | string | null;
96 | value: number | null;
97 | }; //value is in uH
98 | }
99 |
100 | export default function DashboardPage({
101 | loadedParts,
102 | itemCount,
103 | parentCatalogNames,
104 | searchCatalog,
105 | }: {
106 | loadedParts: PartState[];
107 | itemCount: number;
108 | parentCatalogNames: string[];
109 | searchCatalog?: string;
110 | }) {
111 | const itemsPerPage = 10;
112 |
113 | const [isLoading, setLoading] = useState(false);
114 | const [itemCountState, setItemCountState] = useState(itemCount);
115 | const router = useRouter();
116 |
117 | const [parts, setParts] = useState(loadedParts);
118 | const [isSearchResult, setIsSearchResult] = useState(
119 | searchCatalog ? searchCatalog.length > 0 : false ?? false
120 | );
121 | const [activePage, setPage] = useState(1);
122 |
123 | const [parentCatalogNamesState, setParentCatalogNamesState] =
124 | useState(parentCatalogNames);
125 | const voltageSearchRef = useRef(null);
126 | const resistanceSearchRef = useRef(null);
127 | const powerSearchRef = useRef(null);
128 | const currentSearchRef = useRef(null);
129 | const frequencySearchRef = useRef(null);
130 | const capacitanceSearchRef = useRef(null);
131 | const inductanceSearchRef = useRef(null);
132 |
133 | const searchForm = useForm({
134 | initialValues: {
135 | productTitle: null,
136 | productCode: null,
137 | productDescription: null,
138 | encapStandard: null,
139 | parentCatalogName: searchCatalog || null,
140 | },
141 | });
142 |
143 | useEffect(() => {
144 | if (typeof document !== "undefined") {
145 | if (onScan.isAttachedTo(document) == false) {
146 | console.log("attaching onScan");
147 | onScan.attachTo(document, {
148 | suffixKeyCodes: [13], // enter-key expected at the end of a scan
149 | keyCodeMapper: function (oEvent) {
150 | if (oEvent.keyCode === 190) {
151 | return ":";
152 | }
153 | if (oEvent.keyCode === 188) {
154 | return ",";
155 | }
156 | return onScan.decodeKeyEvent(oEvent);
157 | },
158 | onScan: async function (sCode, iQty) {
159 | if (sCode) {
160 | let scanJson = JSON.parse(JSON.stringify(sCode));
161 | if (scanJson) {
162 | let partInfo = scannerInputToType(scanJson);
163 | setLoading(true);
164 | await getPartInfoFromLCSC(
165 | partInfo.pc,
166 | partInfo.qty
167 | );
168 | setLoading(false);
169 | }
170 | }
171 | },
172 | });
173 | } else {
174 | console.log("onScan already attached to document");
175 | }
176 | }
177 | }, []);
178 |
179 | const updatePartInState = (part: PartState) => {
180 | setParts((prevParts) => {
181 | const index = prevParts.findIndex((p) => p.id === part.id);
182 | if (index !== -1) {
183 | const updatedPart = { ...part };
184 | const newParts = [...prevParts];
185 | newParts[index] = updatedPart;
186 | return newParts;
187 | }
188 | return prevParts;
189 | });
190 | };
191 |
192 | async function searchParts(page: number) {
193 | setLoading(true);
194 | try {
195 | // let currentSearchFilter = searchFilter || {};
196 | let currentSearchFilter = searchForm.values || {};
197 | if (currentSearchFilter) {
198 | currentSearchFilter.voltage =
199 | voltageSearchRef.current?.getSearchParameters();
200 | currentSearchFilter.resistance =
201 | resistanceSearchRef.current?.getSearchParameters();
202 | currentSearchFilter.power =
203 | powerSearchRef.current?.getSearchParameters();
204 | currentSearchFilter.current =
205 | currentSearchRef.current?.getSearchParameters();
206 | currentSearchFilter.frequency =
207 | frequencySearchRef.current?.getSearchParameters();
208 | currentSearchFilter.capacitance =
209 | capacitanceSearchRef.current?.getSearchParameters();
210 | currentSearchFilter.inductance = inductanceSearchRef.current?.getSearchParameters();
211 | }
212 | console.log(currentSearchFilter);
213 |
214 | const res = await fetch("/api/parts/search", {
215 | method: "POST",
216 | body: JSON.stringify({
217 | filter: currentSearchFilter,
218 | page: page,
219 | }),
220 | }).then((response) =>
221 | response
222 | .json()
223 | .then((data) => ({ status: response.status, body: data }))
224 | );
225 | if (res.status !== 200) {
226 | throw new Error(res.body.error);
227 | }
228 | const response = res.body.parts as PartState[];
229 | if (response) {
230 | setParts(response);
231 | setItemCountState(response.length)
232 | if (!isSearchResult) {
233 | setIsSearchResult(true);
234 | setPage(page);
235 | }
236 | }
237 | // setParts(res.body);
238 | } catch (e: ErrorCallback | any) {
239 | console.error(e.message);
240 | }
241 | setLoading(false);
242 | }
243 |
244 | async function getParts(page: number) {
245 | setLoading(true);
246 | try {
247 | const res = await fetch("/api/parts?page=" + page).then(
248 | (response) =>
249 | response.json().then((data) => ({
250 | status: response.status,
251 | body: data,
252 | }))
253 | );
254 | if (res.status !== 200) {
255 | throw new Error(res.body.message);
256 | }
257 | const response = res.body.parts as PartState[];
258 | if (response) {
259 | setParts(response);
260 | }
261 | // setParts(res.body);
262 | } catch (e: ErrorCallback | any) {
263 | console.error(e.message);
264 | }
265 | setLoading(false);
266 | }
267 |
268 | async function clearSearch() {
269 | setIsSearchResult(false);
270 | voltageSearchRef?.current?.clear();
271 | resistanceSearchRef?.current?.clear();
272 | powerSearchRef?.current?.clear();
273 | currentSearchRef?.current?.clear();
274 | frequencySearchRef?.current?.clear();
275 | capacitanceSearchRef?.current?.clear();
276 | inductanceSearchRef?.current?.clear();
277 | router.replace("/", undefined);
278 | searchForm.reset();
279 | searchForm.setFieldValue("parentCatalogName", null);
280 | setPage(1);
281 | await getParts(1);
282 | }
283 |
284 | async function getPartInfoFromLCSC(pc: string, quantity: number) {
285 | // fetch part info from LCSC
286 | // return part info
287 | try {
288 | const res = await fetch("/api/parts", {
289 | method: "POST",
290 | body: JSON.stringify({ pc: pc, quantity }),
291 | }).then((response) =>
292 | response
293 | .json()
294 | .then((data) => ({ status: response.status, body: data }))
295 | );
296 | if (res.status !== 200) {
297 | throw new Error(res.body.message);
298 | }
299 | if (res.body.message == "Part updated") {
300 | notifications.show({
301 | title: "Part Updated",
302 | message: `The quantity of the part (${res.body.body.productCode}) was successfully updated to ${res.body.body.quantity}!`,
303 | });
304 | updatePartInState(res.body.body);
305 | }
306 | if (res.body.message == "Part created") {
307 | notifications.show({
308 | title: "Part Added",
309 | message: `The part ${res.body.body.productCode} was successfully added!`,
310 | });
311 | console.log("PART CREATED");
312 | // if(Math.ceil(itemCountState / itemsPerPage) > Math.ceil(res.body.itemCount)) {
313 |
314 | getParts(activePage);
315 | setParentCatalogNamesState(
316 | res.body.parentCatalogNames
317 | .filter((item) => item.parentCatalogName !== null)
318 | .map((item) => item.parentCatalogName)
319 | );
320 | setItemCountState(res.body.itemCount);
321 | }
322 | // console.log(res.body);
323 | // await getParts();
324 | } catch (e: ErrorCallback | any) {
325 | console.error(e.message);
326 | }
327 | }
328 |
329 | async function deletePart(partId: number) {
330 | // setLoading(true)
331 | try {
332 | const res = await fetch("/api/parts/delete", {
333 | method: "POST",
334 | body: JSON.stringify({ id: partId }),
335 | }).then((response) =>
336 | response
337 | .json()
338 | .then((data) => ({ status: response.status, body: data }))
339 | );
340 | if (res.status !== 200) {
341 | console.log(res.body.message);
342 | }
343 | if (res.status == 200) {
344 | if (
345 | Math.ceil(itemCountState / itemsPerPage) >
346 | Math.ceil(res.body.itemCount) &&
347 | activePage == Math.ceil(itemCountState / itemsPerPage)
348 | ) {
349 | navigatePage(activePage - 1);
350 | } else {
351 | getParts(activePage);
352 | }
353 | setParentCatalogNamesState(
354 | res.body.parentCatalogNames
355 | .filter((item) => item.parentCatalogName !== null)
356 | .map((item) => item.parentCatalogName)
357 | );
358 | setItemCountState(res.body.itemCount);
359 | notifications.show({
360 | title: "Part Deleted",
361 | message: `The part ${res.body.body.productCode} was successfully deleted!`,
362 | });
363 | }
364 | console.log(res.body);
365 | // await getParts();
366 | } catch (e: ErrorCallback | any) {
367 | console.error(e.message);
368 | }
369 | }
370 |
371 | async function updatePartQuantity(partId: number, quantity: number) {
372 | setLoading(true);
373 | try {
374 | const res = await fetch("/api/parts/update", {
375 | method: "POST",
376 | body: JSON.stringify({ id: partId, quantity }),
377 | }).then((response) =>
378 | response
379 | .json()
380 | .then((data) => ({ status: response.status, body: data }))
381 | );
382 | if (res.status !== 200) {
383 | throw new Error(res.body.message);
384 | }
385 | console.log(res.body);
386 | if (res.status == 200) {
387 | notifications.show({
388 | title: "Quantity Updated",
389 | message: `The quantity of ${res.body.body.productCode} was successfully updated to ${quantity}!`,
390 | });
391 | updatePartInState(res.body.body);
392 | }
393 | } catch (e: ErrorCallback | any) {
394 | console.error(e.message);
395 | }
396 | setLoading(false);
397 | }
398 |
399 | async function navigatePage(page: number) {
400 | setPage(page);
401 | if (isSearchResult) {
402 | await searchParts(page);
403 | } else {
404 | await getParts(page);
405 | }
406 | }
407 |
408 | return (
409 |
410 |
416 |
417 |
516 |
517 |
518 |
519 |
520 |
521 | {parts != null && parts.length > 0 ? (
522 |
523 |
524 |
525 |
530 |
531 | Image
532 | {/* ID */}
533 | Title
534 | ProductCode
535 | Quantity
536 | Quantity Actions
537 | ProductID
538 | ProductModel
539 | Description
540 | ParentCatalogName
541 | CatalogName
542 | BrandName
543 | EncapStandard
544 | Pdf
545 | Link
546 | Price
547 | Voltage
548 | Resistance
549 | Power
550 | Current
551 | Tolerance
552 | Frequency
553 | Capacitance
554 | Inductance
555 | Delete
556 |
557 |
558 |
559 | {parts.map((element) => (
560 |
569 | ))}
570 |
571 |
572 |
573 | ) : (
574 |
575 | No Parts Found
576 |
577 | )}
578 |
579 | {
583 | await navigatePage(value);
584 | }}
585 | />
586 | ({itemCountState})
587 |
588 |
589 |
590 |
591 | );
592 | }
593 |
594 | function PartItem({
595 | part,
596 | isLoading,
597 | updatePartQuantity,
598 | deletePart,
599 | }: {
600 | part: PartState;
601 | isLoading: boolean;
602 | updatePartQuantity: (partId: number, quantity: number) => Promise;
603 | deletePart: (partId: number) => Promise;
604 | }) {
605 | const addQuantityRef = useRef(null);
606 | const removeQuantityRef = useRef(null);
607 | return (
608 |
609 |
610 |
616 |
617 |
618 |
619 | {part.title}
620 |
626 |
627 |
628 | }
629 | />
630 |
631 |
632 | {part.productCode}
633 | {part.quantity}
634 |
635 |
636 |
637 | Add
638 | Remove
639 |
640 |
641 |
642 |
650 | {
653 | console.log(addQuantityRef.current?.value);
654 | await updatePartQuantity(
655 | part.id,
656 | part.quantity +
657 | Number(
658 | addQuantityRef.current?.value
659 | )
660 | );
661 | }}
662 | >
663 | Add
664 |
665 |
666 |
667 |
668 |
669 |
678 | {
681 | console.log(
682 | removeQuantityRef.current?.value
683 | );
684 | await updatePartQuantity(
685 | part.id,
686 | part.quantity -
687 | Number(
688 | removeQuantityRef.current?.value
689 | )
690 | );
691 | }}
692 | >
693 | Remove
694 |
695 |
696 |
697 |
698 |
699 | {part.productId}
700 | {part.productModel}
701 | {part.productDescription}
702 | {part.parentCatalogName}
703 | {part.catalogName}
704 | {part.brandName}
705 | {part.encapStandard}
706 |
707 | {part.pdfLink ? (
708 |
715 |
716 |
717 | }
718 | />
719 | ) : (
720 | <>>
721 | )}
722 |
723 |
724 | {part.productLink ? (
725 |
732 |
733 |
734 | }
735 | />
736 | ) : (
737 | <>>
738 | )}
739 |
740 |
741 |
742 |
743 | {part.prices.at(0)?.price
744 | ? part.prices.at(0)?.price + "$"
745 | : ""}
746 |
747 |
748 |
749 |
750 |
751 |
752 | Quantity
753 | Price
754 |
755 |
756 |
757 | {part.prices.map((price) => (
758 |
759 | {price.ladder}
760 | {price.price + "$"}
761 |
762 | ))}
763 |
764 |
765 |
766 |
767 | {formatVoltage(part.voltage)}
768 | {formatResistance(part.resistance)}
769 | {formatPower(part.power)}
770 | {formatCurrent(part.current)}
771 | {part.tolerance}
772 | {formatFrequency(part.frequency)}
773 | {formatCapacitance(part.capacitance)}
774 | {formatInductance(part.inductance)}
775 |
776 |
779 |
780 |
781 | }
782 | color="red"
783 | variant="light"
784 | onClick={async () => {
785 | deletePart(part.id);
786 | }}
787 | >
788 | Delete
789 |
790 |
791 |
792 | );
793 | }
794 |
795 | const formatVoltage = (voltage: any) =>
796 | `${
797 | voltage < 1
798 | ? round(unit(Number(voltage), "V").toNumeric("mV")) + " mV"
799 | : format(voltage, { lowerExp: -2, upperExp: 2 }) + " V"
800 | }`;
801 | const formatResistance = (resistance: any) =>
802 | `${
803 | resistance < 1
804 | ? round(unit(Number(resistance), "ohm").toNumeric("mohm")) + " mΩ"
805 | : resistance >= 1000
806 | ? round(unit(Number(resistance), "ohm").toNumeric("kohm")) + " kΩ"
807 | : format(resistance, { lowerExp: -2, upperExp: 2 }) + " Ω"
808 | }`;
809 | const formatPower = (power: any) =>
810 | `${
811 | power < 1
812 | ? round(unit(Number(power), "W").toNumeric("mW")) + " mW"
813 | : format(power, { lowerExp: -2, upperExp: 2 }) + " W"
814 | }`;
815 | const formatCurrent = (current: any) =>
816 | `${
817 | current < 1
818 | ? round(unit(Number(current), "A").toNumeric("mA")) + " mA"
819 | : format(current, { lowerExp: -2, upperExp: 2 }) + " A"
820 | }`;
821 | const formatFrequency = (frequency: any) =>
822 | `${
823 | frequency < 1
824 | ? round(unit(Number(frequency), "Hz").toNumeric("mHz")) + " mHz"
825 | : frequency >= 1000000
826 | ? round(unit(Number(frequency), "Hz").toNumeric("MHz")) + " MHz"
827 | : frequency >= 1000
828 | ? round(unit(Number(frequency), "Hz").toNumeric("kHz")) + " kHz"
829 | : format(frequency, { lowerExp: -2, upperExp: 2 }) + " Hz"
830 | }`;
831 | const formatCapacitance = (capacitance: any) => {
832 | return `${round(unit(Number(capacitance), "pF").toNumeric("nF"))} nF`;
833 | };
834 | const formatInductance = (inductance: any) => {
835 | return `${round(Number(inductance ?? null))} uH`;
836 | };
837 |
--------------------------------------------------------------------------------