diff --git a/server/.gitignore b/server/.gitignore index af40b5b..0938d32 100644 --- a/server/.gitignore +++ b/server/.gitignore @@ -36,3 +36,4 @@ lerna-debug.log* # Keep environment variables out of version control .env +/test diff --git a/server/package.json b/server/package.json index 0661ea4..d190922 100644 --- a/server/package.json +++ b/server/package.json @@ -17,7 +17,8 @@ "test:watch": "jest --watch", "test:cov": "jest --coverage", "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", - "test:e2e": "jest --config ./test/jest-e2e.json" + "test:e2e": "jest --config ./test/jest-e2e.json", + "seed": "ts-node src/models/seed.ts" }, "dependencies": { "@nestjs/common": "^10.0.0", @@ -31,10 +32,13 @@ "rxjs": "^7.8.1" }, "devDependencies": { + "@faker-js/faker": "^8.0.2", "@nestjs/cli": "^10.0.0", "@nestjs/schematics": "^10.0.0", + "@nestjs/swagger": "^7.1.10", "@nestjs/testing": "^10.0.0", "@types/express": "^4.17.17", + "@types/faker": "^6.6.9", "@types/jest": "^29.5.2", "@types/node": "^20.3.1", "@types/supertest": "^2.0.12", diff --git a/server/src/api/jobs/dto/job-list.dto.ts b/server/src/api/jobs/dto/job-list.dto.ts new file mode 100644 index 0000000..717769e --- /dev/null +++ b/server/src/api/jobs/dto/job-list.dto.ts @@ -0,0 +1,10 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Job } from '@prisma/client'; + +export class JobListDto { + @ApiProperty() + jobs: Job[]; + + @ApiProperty() + count: number; +} diff --git a/server/src/api/jobs/dto/job-query.dto.ts b/server/src/api/jobs/dto/job-query.dto.ts new file mode 100644 index 0000000..2ce8e7b --- /dev/null +++ b/server/src/api/jobs/dto/job-query.dto.ts @@ -0,0 +1,38 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Status, Tag } from '@prisma/client'; +import { Transform } from 'class-transformer'; +import { IsEnum, IsNumber, IsOptional } from 'class-validator'; +import { IsDateRange } from '../../../utils/validators/dateRange.validator'; + +export class JobQueryDto { + @ApiProperty() + @IsNumber() + @Transform(({ value }) => parseInt(value)) + page: number; + + @ApiProperty({ required: false }) + @IsOptional() + @IsNumber() + @Transform(({ value }) => parseInt(value)) + perPage?: number; + + @ApiProperty({ required: false }) + @IsOptional() + @IsEnum(Tag) + tag: Tag; + + @ApiProperty({ required: false }) + @IsOptional() + @IsEnum(Status) + status: Status; + + @ApiProperty({ required: false }) + @IsOptional() + @IsDateRange() + startDate: Date; + + @ApiProperty({ required: false }) + @IsOptional() + @IsDateRange() + endDate: Date; +} diff --git a/server/src/api/jobs/jobs.controller.spec.ts b/server/src/api/jobs/jobs.controller.spec.ts new file mode 100644 index 0000000..db2043d --- /dev/null +++ b/server/src/api/jobs/jobs.controller.spec.ts @@ -0,0 +1,123 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { $Enums } from '@prisma/client'; +import { JobsController } from './jobs.controller'; +import { JobsService } from './jobs.service'; + +describe('JobsController', () => { + let jobController: JobsController; + + const mockJobService = { + findAll: jest.fn() + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [JobsController], + providers: [ + { + provide: JobsService, + useValue: mockJobService, + }, + ], + }).compile(); + + jobController = module.get(JobsController); + }); + + describe('findAll', () => { + it('should return a list of jobs with the total number of jobs', async () => { + const jobQuery = { page: 1, perPage: 2, tag: null, status: null, startDate: null, endDate: null }; + const jobList = { + jobs: [ + { + id: 1, + title: "Job A", + type: "Type A", + tags: [], + remarks: null, + customerId: 1, + paymentMethod: null, + userId: 1, + pipelinePhase: null, + createdAt: new Date("2023-09-07T09:38:42.296Z"), + updatedAt: new Date("2023-09-07T09:38:42.296Z"), + }, + { + id: 2, + title: "Job B", + type: "Type B", + tags: [], + remarks: null, + customerId: 2, + paymentMethod: null, + userId: 2, + pipelinePhase: null, + createdAt: new Date("2023-09-07T09:38:42.296Z"), + updatedAt: new Date("2023-09-07T09:38:42.296Z"), + } + ], + count: 2 + }; + + jest.spyOn(mockJobService, 'findAll').mockResolvedValue(jobList); + + const jobs = await jobController.findAll(jobQuery); + + expect(mockJobService.findAll).toHaveBeenCalledWith(jobQuery); + expect(jobs).toEqual(jobList); + }); + }); + + describe('filter', () => { + it('should return a filtered list of jobs with total number of filtered jobs', async () => { + const mockTags = [$Enums.Tag.TAG_A]; + const mockStatus = $Enums.Status.APPROVED; + const jobQuery = { page: 1, perPage: 2, tag: mockTags[0], status: mockStatus, startDate: null, endDate: null }; + + const jobList = { + jobs: [ + { + id: 1, + title: "Job A", + type: "Type A", + tags: mockTags, + remarks: null, + customerId: 1, + paymentMethod: null, + userId: 1, + pipelinePhase: null, + createdAt: new Date("2023-09-07T09:38:42.296Z"), + updatedAt: new Date("2023-09-07T09:38:42.296Z"), + estimation: { + status: mockStatus, + totalCost: 1000.00 + } + }, + { + id: 2, + title: "Job B", + type: "Type B", + tags: [], + remarks: null, + customerId: 2, + paymentMethod: null, + userId: 2, + pipelinePhase: null, + createdAt: new Date("2023-09-07T09:38:42.296Z"), + updatedAt: new Date("2023-09-07T09:38:42.296Z"), + } + ], + count: 2 + }; + + const filteredJobList = { jobs: jobList.jobs.slice(0, 1), count: 1} + + jest.spyOn(mockJobService, 'findAll').mockResolvedValue(filteredJobList); + + const jobs = await jobController.findAll(jobQuery); + + expect(mockJobService.findAll).toHaveBeenCalledWith(jobQuery); + expect(jobs).toEqual(filteredJobList); + }); + }); +}); diff --git a/server/src/api/jobs/jobs.controller.ts b/server/src/api/jobs/jobs.controller.ts new file mode 100644 index 0000000..4106a9c --- /dev/null +++ b/server/src/api/jobs/jobs.controller.ts @@ -0,0 +1,19 @@ +import { Controller, Get, Query, UsePipes, ValidationPipe } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { JobListDto } from './dto/job-list.dto'; +import { JobQueryDto } from './dto/job-query.dto'; +import { JobsService } from './jobs.service'; + +@ApiTags('jobs') +@Controller('jobs') +export class JobsController { + constructor(private readonly jobsService: JobsService) {} + + @Get() + @UsePipes(new ValidationPipe({ transform: true })) + async findAll(@Query() jobQuery: JobQueryDto): Promise { + const jobs = await this.jobsService.findAll(jobQuery); + + return jobs; + } +} diff --git a/server/src/api/jobs/jobs.module.ts b/server/src/api/jobs/jobs.module.ts new file mode 100644 index 0000000..7e16890 --- /dev/null +++ b/server/src/api/jobs/jobs.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { PrismaService } from 'src/database/connection.service'; +import { JobsController } from './jobs.controller'; +import { JobsService } from './jobs.service'; + +@Module({ + controllers: [JobsController], + providers: [JobsService, PrismaService], +}) +export class JobsModule {} diff --git a/server/src/api/jobs/jobs.service.spec.ts b/server/src/api/jobs/jobs.service.spec.ts new file mode 100644 index 0000000..1faebd1 --- /dev/null +++ b/server/src/api/jobs/jobs.service.spec.ts @@ -0,0 +1,118 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { $Enums } from '@prisma/client'; +import { PrismaService } from '../../database/connection.service'; +import { JobsService } from './jobs.service'; + +describe('JobsService', () => { + let jobService: JobsService; + + const mockPrismaService = { + job: { + findMany: jest.fn(), + count: jest.fn(), + }, + $transaction: jest.fn() + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + JobsService, + { + provide: PrismaService, + useValue: mockPrismaService + } + ], + }).compile(); + + jobService = module.get(JobsService); + }); + + describe('findAll', () => { + it('should be return a list of jobs with the total number of jobs', async () => { + const jobQuery = { page: 1, perPage: 2, tag: null, status: null, startDate: null, endDate: null }; + const mockJobList = [ + { + id: 1, + title: "Job A", + type: "Type A", + tags: [], + remarks: null, + customerId: 1, + paymentMethod: null, + userId: 1, + pipelinePhase: null, + createdAt: new Date("2023-09-07T09:38:42.296Z"), + updatedAt: new Date("2023-09-07T09:38:42.296Z"), + }, + { + id: 2, + title: "Job B", + type: "Type B", + tags: [], + remarks: null, + customerId: 2, + paymentMethod: null, + userId: 2, + pipelinePhase: null, + createdAt: new Date("2023-09-07T09:38:42.296Z"), + updatedAt: new Date("2023-09-07T09:38:42.296Z"), + } + ]; + const mockJobCount = 2; + + mockPrismaService.$transaction.mockResolvedValue([ mockJobList, mockJobCount ]); + + const jobs = await jobService.findAll(jobQuery); + + expect(jobs).toEqual({ jobs: mockJobList, count: mockJobCount }); + }); + }); + + describe('filter', () => { + it('should be return a filtered list of jobs with the total number of filtered jobs', async () => { + const mockTags = [$Enums.Tag.TAG_A]; + const mockStatus = $Enums.Status.APPROVED; + const jobQuery = { page: 1, perPage: 2, tag: mockTags[0], status: mockStatus, startDate: null, endDate: null } + const mockJobList = [ + { + id: 1, + title: "Job A", + type: "Type A", + tags: mockTags, + remarks: null, + customerId: 1, + paymentMethod: null, + userId: 1, + pipelinePhase: null, + createdAt: new Date("2023-09-07T09:38:42.296Z"), + updatedAt: new Date("2023-09-07T09:38:42.296Z"), + estimation: { + status: mockStatus, + totalCost: 1000.00 + } + }, + { + id: 2, + title: "Job B", + type: "Type B", + tags: [], + remarks: null, + customerId: 2, + paymentMethod: null, + userId: 2, + pipelinePhase: null, + createdAt: new Date("2023-09-07T09:38:42.296Z"), + updatedAt: new Date("2023-09-07T09:38:42.296Z"), + } + ]; + const mockJobCount = 1; + + mockPrismaService.$transaction.mockResolvedValue([ mockJobList.slice(0, 1), mockJobCount ]); + + const jobs = await jobService.findAll(jobQuery); + + expect(jobs).toEqual({ jobs: mockJobList.slice(0, 1), count: mockJobCount }); + }); + }); +}); diff --git a/server/src/api/jobs/jobs.service.ts b/server/src/api/jobs/jobs.service.ts new file mode 100644 index 0000000..37bfb35 --- /dev/null +++ b/server/src/api/jobs/jobs.service.ts @@ -0,0 +1,92 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; +import { PrismaService } from '../../database/connection.service'; +import { JobListDto } from './dto/job-list.dto'; +import { JobQueryDto } from './dto/job-query.dto'; + +@Injectable() +export class JobsService { + constructor(private prisma: PrismaService){} + + async findAll(query: JobQueryDto): Promise { + try { + const { page, tag, status, startDate, endDate, perPage = 12 } = query; + const skip = (page - 1) * perPage; + + const tagCondition = tag ? { + tags: { + has: tag + } + } : {}; + + const statusCondition = status ? { + estimation: { + status: { + equals: status + } + } + } : {}; + + let dateRangeCondition = {} + if (startDate && endDate) { + const newEndDate = new Date(endDate); + newEndDate.setDate(newEndDate.getDate() + 1); + + dateRangeCondition = { + createdAt: { + lt: newEndDate, + gte: new Date(startDate), + } + }; + } + + const whereCondition = { + AND: [ + tagCondition, + statusCondition, + dateRangeCondition + ] + } + + const [ jobs, count ] = await this.prisma.$transaction([ + this.prisma.job.findMany({ + where: whereCondition, + take: perPage, + skip, + include: { + customer: { + select: { + firstName: true, + lastName: true, + } + }, + schedules: { + select: { + startDate: true, + startTime: true, + endDate: true, + endTime: true + } + }, + estimation: { + select: { + status: true, + totalCost: true, + } + }, + personInCharge: { + select: { + firstName: true, + lastName: true + } + } + } + }), + this.prisma.job.count({ where: whereCondition }) + ]); + + return { jobs, count }; + } catch (err) { + throw new BadRequestException('Something went wrong.'); + } + } +} diff --git a/server/src/api/sample/sample.controller.spec.ts b/server/src/api/sample/sample.controller.spec.ts deleted file mode 100644 index 3164a8c..0000000 --- a/server/src/api/sample/sample.controller.spec.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { SampleController } from './sample.controller'; - -describe('SampleController', () => { - let controller: SampleController; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - controllers: [SampleController], - }).compile(); - - controller = module.get(SampleController); - }); - - it('should be defined', () => { - expect(controller).toBeDefined(); - }); -}); diff --git a/server/src/api/sample/sample.controller.ts b/server/src/api/sample/sample.controller.ts deleted file mode 100644 index 3308a83..0000000 --- a/server/src/api/sample/sample.controller.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Controller, Get } from '@nestjs/common'; - -@Controller() -export class SampleController { - @Get() - getHello(): string { - return '(SERVER) Hello World!'; - } -} diff --git a/server/src/api/sample/sample.dto.ts b/server/src/api/sample/sample.dto.ts deleted file mode 100644 index 42ff6bb..0000000 --- a/server/src/api/sample/sample.dto.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { IsString } from 'class-validator'; - -export class SampleUserDTO { - id: number; - - @IsString() - name: string; - - @IsString() - email: string; -} diff --git a/server/src/api/sample/sample.entities.ts b/server/src/api/sample/sample.entities.ts deleted file mode 100644 index 43baf1a..0000000 --- a/server/src/api/sample/sample.entities.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { SampleUser } from '@prisma/client'; - -export class SampleUserEntity implements SampleUser { - id: number; - name: string; - email: string; -} diff --git a/server/src/app.module.ts b/server/src/app.module.ts index 001246b..b830525 100644 --- a/server/src/app.module.ts +++ b/server/src/app.module.ts @@ -1,10 +1,10 @@ import { Module } from '@nestjs/common'; +import { JobsModule } from './api/jobs/jobs.module'; import { DatabaseModule } from './database/database.module'; -import { SampleController } from './api/sample/sample.controller'; @Module({ - imports: [DatabaseModule], - controllers: [SampleController], + imports: [DatabaseModule, JobsModule], + controllers: [], providers: [], }) export class AppModule {} diff --git a/server/src/main.ts b/server/src/main.ts index 8e348dc..97c7a1a 100644 --- a/server/src/main.ts +++ b/server/src/main.ts @@ -1,8 +1,23 @@ import { NestFactory } from '@nestjs/core'; +import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; import { AppModule } from './app.module'; async function bootstrap() { - const app = await NestFactory.create(AppModule); + const app = await NestFactory.create(AppModule, { + cors: { + origin: process.env.FRONTEND_URL + } + }); + + const config = new DocumentBuilder() + .setTitle('Sim-JMS') + .setDescription('This is the API documentation for sim-jms') + .setVersion('1.0') + .build(); + + const document = SwaggerModule.createDocument(app, config); + SwaggerModule.setup('api', app, document); + await app.listen(4000); } bootstrap(); diff --git a/server/src/models/migrations/20230907065110_init/migration.sql b/server/src/models/migrations/20230907065110_init/migration.sql new file mode 100644 index 0000000..4bc22b3 --- /dev/null +++ b/server/src/models/migrations/20230907065110_init/migration.sql @@ -0,0 +1,107 @@ +-- CreateEnum +CREATE TYPE "Role" AS ENUM ('USER', 'ADMIN'); + +-- CreateEnum +CREATE TYPE "Tag" AS ENUM ('TAG_A', 'TAG_B', 'TAG_C'); + +-- CreateEnum +CREATE TYPE "PaymentMethod" AS ENUM ('CASH', 'CARD', 'BANK_TRANSFER'); + +-- CreateEnum +CREATE TYPE "Status" AS ENUM ('NOT_YET_CREATED', 'MAKING', 'APPROVED', 'SENT_TO_CUSTOMER', 'CLOSED'); + +-- CreateEnum +CREATE TYPE "PipelinePhase" AS ENUM ('NEGOTIATION', 'DELIVERY'); + +-- CreateTable +CREATE TABLE "User" ( + "id" SERIAL NOT NULL, + "firstName" TEXT NOT NULL, + "lastName" TEXT NOT NULL, + "email" TEXT NOT NULL, + "role" "Role" NOT NULL DEFAULT 'USER', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "User_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Job" ( + "id" SERIAL NOT NULL, + "title" TEXT NOT NULL, + "type" TEXT NOT NULL, + "tags" "Tag"[], + "remarks" TEXT, + "customerId" INTEGER NOT NULL, + "paymentMethod" "PaymentMethod" NOT NULL DEFAULT 'CASH', + "userId" INTEGER NOT NULL, + "pipelinePhase" "PipelinePhase" NOT NULL DEFAULT 'NEGOTIATION', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "Job_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Customer" ( + "id" SERIAL NOT NULL, + "firstName" TEXT NOT NULL, + "lastName" TEXT NOT NULL, + "email" TEXT NOT NULL, + "contact" TEXT NOT NULL, + "address" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "Customer_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Schedule" ( + "id" SERIAL NOT NULL, + "startDate" TIMESTAMP(3) NOT NULL, + "endDate" TIMESTAMP(3) NOT NULL, + "startTime" TIMESTAMP(3) NOT NULL, + "endTime" TIMESTAMP(3) NOT NULL, + "jobId" INTEGER NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "Schedule_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Estimation" ( + "id" SERIAL NOT NULL, + "title" TEXT NOT NULL, + "document" TEXT NOT NULL, + "totalCost" DECIMAL(65,30) NOT NULL, + "status" "Status" NOT NULL, + "jobId" INTEGER NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "Estimation_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Job_title_customerId_key" ON "Job"("title", "customerId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Customer_email_key" ON "Customer"("email"); + +-- CreateIndex +CREATE UNIQUE INDEX "Estimation_jobId_key" ON "Estimation"("jobId"); + +-- AddForeignKey +ALTER TABLE "Job" ADD CONSTRAINT "Job_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "Customer"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Job" ADD CONSTRAINT "Job_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Schedule" ADD CONSTRAINT "Schedule_jobId_fkey" FOREIGN KEY ("jobId") REFERENCES "Job"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Estimation" ADD CONSTRAINT "Estimation_jobId_fkey" FOREIGN KEY ("jobId") REFERENCES "Job"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/server/src/models/migrations/migration_lock.toml b/server/src/models/migrations/migration_lock.toml new file mode 100644 index 0000000..fbffa92 --- /dev/null +++ b/server/src/models/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (i.e. Git) +provider = "postgresql" \ No newline at end of file diff --git a/server/src/models/schema.prisma b/server/src/models/schema.prisma index 108018e..fa427aa 100644 --- a/server/src/models/schema.prisma +++ b/server/src/models/schema.prisma @@ -10,9 +10,99 @@ datasource db { url = env("DATABASE_URL") } -// Temporary model so that Prisma Client can initialize -model SampleUser { - id Int @id @default(autoincrement()) - name String - email String @unique +model User { + id Int @id @default(autoincrement()) + firstName String + lastName String + email String + role Role @default(USER) + jobs Job[] + createdAt DateTime @default(now()) + updatedAt DateTime @default(now()) +} + +model Job { + id Int @id @default(autoincrement()) + title String + type String + tags Tag[] + remarks String? + customer Customer @relation(fields: [customerId], references: [id]) + customerId Int + paymentMethod PaymentMethod @default(CASH) + personInCharge User @relation(fields: [userId], references: [id]) + userId Int + schedules Schedule[] + estimation Estimation? + pipelinePhase PipelinePhase @default(NEGOTIATION) + createdAt DateTime @default(now()) + updatedAt DateTime @default(now()) + + @@unique([title, customerId]) +} + +model Customer { + id Int @id @default(autoincrement()) + firstName String + lastName String + email String @unique + contact String + address String + jobs Job[] + createdAt DateTime @default(now()) + updatedAt DateTime @default(now()) +} + +model Schedule { + id Int @id @default(autoincrement()) + startDate DateTime + endDate DateTime + startTime DateTime + endTime DateTime + job Job @relation(fields: [jobId], references: [id]) + jobId Int + createdAt DateTime @default(now()) + updatedAt DateTime @default(now()) +} + +model Estimation { + id Int @id @default(autoincrement()) + title String + document String + totalCost Decimal + status Status + job Job @relation(fields: [jobId], references: [id]) + jobId Int @unique + createdAt DateTime @default(now()) + updatedAt DateTime @default(now()) +} + +enum Role { + USER + ADMIN +} + +enum Tag { + TAG_A + TAG_B + TAG_C +} + +enum PaymentMethod { + CASH + CARD + BANK_TRANSFER +} + +enum Status { + NOT_YET_CREATED + MAKING + APPROVED + SENT_TO_CUSTOMER + CLOSED +} + +enum PipelinePhase { + NEGOTIATION + DELIVERY } diff --git a/server/src/models/seed.ts b/server/src/models/seed.ts new file mode 100644 index 0000000..72958c7 --- /dev/null +++ b/server/src/models/seed.ts @@ -0,0 +1,28 @@ +import { PrismaClient } from "@prisma/client"; + +import seedCustomers from './seeders/customer.seed'; +import seedEstimations from './seeders/estimation'; +import seedJobs from './seeders/job.seed'; +import seedSchedules from './seeders/schedule.seed'; +import seedUsers from "./seeders/user.seed"; + +async function seed() { + const prisma = new PrismaClient(); + + try { + await prisma.$transaction(async () => { + await seedUsers(); + await seedCustomers(); + await seedJobs(); + await seedEstimations(); + await seedSchedules(); + }); + } catch (error) { + console.log('Error seeding database'); + throw error; + } finally { + await prisma.$disconnect(); + } +} + +seed(); diff --git a/server/src/models/seeders/customer.seed.ts b/server/src/models/seeders/customer.seed.ts new file mode 100644 index 0000000..feaebf5 --- /dev/null +++ b/server/src/models/seeders/customer.seed.ts @@ -0,0 +1,24 @@ +import { faker } from '@faker-js/faker'; +import { PrismaClient } from '@prisma/client'; + +export default async function seedCustomers() { + const prisma = new PrismaClient(); + + const seedDataCount = 20; + let customerData = []; + + for (let i = 0; i < seedDataCount; i ++) { + const newCustomer ={ + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + email: faker.internet.email(), + contact: faker.phone.number('+63 9# ### ## ##'), + address: faker.location.streetAddress(true) + } + customerData = [...customerData, newCustomer] + } + + await prisma.customer.createMany({ + data: customerData + }) +} diff --git a/server/src/models/seeders/estimation.ts b/server/src/models/seeders/estimation.ts new file mode 100644 index 0000000..0474b18 --- /dev/null +++ b/server/src/models/seeders/estimation.ts @@ -0,0 +1,25 @@ +import { faker } from '@faker-js/faker'; +import { PrismaClient, Status } from '@prisma/client'; + +export default async function seedEstimations() { + const prisma = new PrismaClient(); + + const status = Object.values(Status); + const seedDataCount = 20 + let estimationData = []; + + for (let i = 0; i < seedDataCount; i ++) { + const newEstimation = { + title: faker.lorem.word(), + document: faker.system.commonFileName('pdf'), + totalCost: faker.finance.amount({min: 1000, max: 10000, dec: 2}), + status: faker.helpers.arrayElement(status), + jobId: i + 1, + } + estimationData = [...estimationData, newEstimation] + } + + await prisma.estimation.createMany({ + data: estimationData + }) +} diff --git a/server/src/models/seeders/job.seed.ts b/server/src/models/seeders/job.seed.ts new file mode 100644 index 0000000..6156395 --- /dev/null +++ b/server/src/models/seeders/job.seed.ts @@ -0,0 +1,27 @@ +import { faker } from '@faker-js/faker'; +import { PaymentMethod, PrismaClient, Tag } from '@prisma/client'; + +export default async function seedJobs() { + const prisma = new PrismaClient(); + + const paymentMethod = Object.values(PaymentMethod); + const tags = Object.values(Tag); + const seedDataCount = 20 + let jobData = []; + + for (let i = 0; i < seedDataCount; i ++) { + const newJob = { + title: faker.lorem.words(), + type: faker.lorem.word(), + userId: faker.number.int({ min: 1, max: 5 }), + customerId: i + 1, + paymentMethod: faker.helpers.arrayElement(paymentMethod), + tags: faker.helpers.arrayElements(tags, { min: 1, max: 3 } ) + } + jobData = [...jobData, newJob] + } + + await prisma.job.createMany({ + data: jobData + }) +} diff --git a/server/src/models/seeders/schedule.seed.ts b/server/src/models/seeders/schedule.seed.ts new file mode 100644 index 0000000..c797bbf --- /dev/null +++ b/server/src/models/seeders/schedule.seed.ts @@ -0,0 +1,24 @@ +import { faker } from '@faker-js/faker'; +import { PrismaClient } from '@prisma/client'; + +export default async function seedSchedules() { + const prisma = new PrismaClient(); + + const seedDataCount = 20; + let scheduleData = []; + + for (let i = 0; i < seedDataCount; i++) { + const newSchedule = { + startDate: faker.date.soon(), + endDate: faker.date.soon(), + startTime: faker.date.soon(), + endTime: faker.date.soon(), + jobId: faker.number.int({ min: 1, max: 20 }) + } + scheduleData = [...scheduleData, newSchedule] + } + + await prisma.schedule.createMany({ + data: scheduleData + }); +} diff --git a/server/src/models/seeders/user.seed.ts b/server/src/models/seeders/user.seed.ts new file mode 100644 index 0000000..31db144 --- /dev/null +++ b/server/src/models/seeders/user.seed.ts @@ -0,0 +1,22 @@ +import { faker } from '@faker-js/faker'; +import { PrismaClient } from '@prisma/client'; + +export default async function seedUsers() { + const prisma = new PrismaClient(); + + const seedDataCount = 5; + let userData = []; + + for (let i = 0; i < seedDataCount; i++) { + const newUser = { + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + email: faker.internet.email(), + } + userData = [...userData, newUser] + } + + await prisma.user.createMany({ + data: userData + }) +} diff --git a/server/src/utils/validators/dateRange.validator.ts b/server/src/utils/validators/dateRange.validator.ts new file mode 100644 index 0000000..0755390 --- /dev/null +++ b/server/src/utils/validators/dateRange.validator.ts @@ -0,0 +1,64 @@ +import { + registerDecorator, + ValidationArguments, + ValidationOptions, + ValidatorConstraint, + ValidatorConstraintInterface, +} from 'class-validator'; + + +@ValidatorConstraint({ async: false }) +export class DateRangeValidator implements ValidatorConstraintInterface { + validate(value: number | Date, args: ValidationArguments) { + const { object } = args; + + const startDateStr = object['startDate']; + const endDateStr = object['endDate']; + + if (startDateStr && !endDateStr) { + args.constraints.push('endDateMissing'); + return false; + } + + if (endDateStr && !startDateStr) { + args.constraints.push('startDateMissing'); + return false; + } + + const startDate = new Date(startDateStr); + const endDate = new Date(endDateStr); + + if (endDate < startDate) { + args.constraints.push('invalidDateRange'); + return false; + } + + return true; + } + + defaultMessage(args: ValidationArguments) { + if (args.constraints.includes('startDateMissing')) { + return 'startDate is missing.'; + } + + if (args.constraints.includes('endDateMissing')) { + return 'endDate is missing.'; + } + + if (args.constraints.includes('invalidDateRange')) { + return 'endDate should be greater than startDate.'; + } + } +} + +export function IsDateRange(validationOptions?: ValidationOptions) { + return function (object: object, propertyName: string) { + registerDecorator({ + target: object.constructor, + propertyName: propertyName, + options: validationOptions, + constraints: [], + validator: DateRangeValidator, + }); + }; +} diff --git a/server/yarn.lock b/server/yarn.lock index d91c693..2b04a6b 100644 --- a/server/yarn.lock +++ b/server/yarn.lock @@ -402,6 +402,11 @@ resolved "https://registry.yarnpkg.com/@eslint/js/-/js-8.46.0.tgz#3f7802972e8b6fe3f88ed1aabc74ec596c456db6" integrity sha512-a8TLtmPi8xzPkCbp/OGFUo5yhRkHM2Ko9kOWP4znJr0WAhWyThaw3PnwX4vOTWOAMsV2uRt32PPDcEz63esSaA== +"@faker-js/faker@^8.0.2": + version "8.0.2" + resolved "https://registry.yarnpkg.com/@faker-js/faker/-/faker-8.0.2.tgz#bab698c5d3da9c52744e966e0e3eedb6c8b05c37" + integrity sha512-Uo3pGspElQW91PCvKSIAXoEgAUlRnH29sX2/p89kg7sP1m2PzCufHINd0FhTXQf6DYGiUlVncdSPa2F9wxed2A== + "@humanwhocodes/config-array@^0.11.10": version "0.11.10" resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.11.10.tgz#5a3ffe32cc9306365fb3fd572596cd602d5e12d2" @@ -731,6 +736,11 @@ path-to-regexp "3.2.0" tslib "2.6.1" +"@nestjs/mapped-types@2.0.2": + version "2.0.2" + resolved "https://registry.yarnpkg.com/@nestjs/mapped-types/-/mapped-types-2.0.2.tgz#c8a090a8d22145b85ed977414c158534210f2e4f" + integrity sha512-V0izw6tWs6fTp9+KiiPUbGHWALy563Frn8X6Bm87ANLRuE46iuBMD5acKBDP5lKL/75QFvrzSJT7HkCbB0jTpg== + "@nestjs/platform-express@^10.0.0": version "10.1.3" resolved "https://registry.yarnpkg.com/@nestjs/platform-express/-/platform-express-10.1.3.tgz#d1f644e86f2bc45c7529b9eed7669613f4392e99" @@ -753,6 +763,17 @@ jsonc-parser "3.2.0" pluralize "8.0.0" +"@nestjs/swagger@^7.1.10": + version "7.1.10" + resolved "https://registry.yarnpkg.com/@nestjs/swagger/-/swagger-7.1.10.tgz#955deb9c428fae779d2988a0d24a55977a7be11d" + integrity sha512-qreCcxgHFyFX1mOfK36pxiziy4xoa/XcxC0h4Zr9yH54WuqMqO9aaNFhFyuQ1iyd/3YBVQB21Un4gQnh9iGm0w== + dependencies: + "@nestjs/mapped-types" "2.0.2" + js-yaml "4.1.0" + lodash "4.17.21" + path-to-regexp "3.2.0" + swagger-ui-dist "5.4.2" + "@nestjs/testing@^10.0.0": version "10.1.3" resolved "https://registry.yarnpkg.com/@nestjs/testing/-/testing-10.1.3.tgz#596a1dc580373b3b0b070ac73bf70e45f80197dd" @@ -940,6 +961,13 @@ "@types/qs" "*" "@types/serve-static" "*" +"@types/faker@^6.6.9": + version "6.6.9" + resolved "https://registry.yarnpkg.com/@types/faker/-/faker-6.6.9.tgz#1064e7c46be58388fa326e2f918a4f02ab740a7a" + integrity sha512-Y9YYm5L//8ooiiknO++4Gr539zzdI0j3aXnOBjo1Vk+kTvffY10GuE2wn78AFPECwZ5MYGTjiDVw1naLLdDimw== + dependencies: + faker "*" + "@types/graceful-fs@^4.1.3": version "4.1.6" resolved "https://registry.yarnpkg.com/@types/graceful-fs/-/graceful-fs-4.1.6.tgz#e14b2576a1c25026b7f02ede1de3b84c3a1efeae" @@ -2318,6 +2346,11 @@ external-editor@^3.0.3: iconv-lite "^0.4.24" tmp "^0.0.33" +faker@*: + version "6.6.6" + resolved "https://registry.yarnpkg.com/faker/-/faker-6.6.6.tgz#e9529da0109dca4c7c5dbfeaadbd9234af943033" + integrity sha512-9tCqYEDHI5RYFQigXFwF1hnCwcWCOJl/hmll0lr5D2Ljjb0o4wphb69wikeJDz5qCEzXCoPvG6ss5SDP6IfOdg== + fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3: version "3.1.3" resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525" @@ -3290,6 +3323,13 @@ js-tokens@^4.0.0: resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== +js-yaml@4.1.0, js-yaml@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.0.tgz#c1fb65f8f5017901cdd2c951864ba18458a10602" + integrity sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA== + dependencies: + argparse "^2.0.1" + js-yaml@^3.13.1: version "3.14.1" resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.14.1.tgz#dae812fdb3825fa306609a8717383c50c36a0537" @@ -3298,13 +3338,6 @@ js-yaml@^3.13.1: argparse "^1.0.7" esprima "^4.0.0" -js-yaml@^4.1.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.0.tgz#c1fb65f8f5017901cdd2c951864ba18458a10602" - integrity sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA== - dependencies: - argparse "^2.0.1" - jsesc@^2.5.1: version "2.5.2" resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-2.5.2.tgz#80564d2e483dacf6e8ef209650a67df3f0c283a4" @@ -3406,7 +3439,7 @@ lodash.merge@^4.6.2: resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a" integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ== -lodash@^4.17.21: +lodash@4.17.21, lodash@^4.17.21: version "4.17.21" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== @@ -4406,6 +4439,11 @@ supports-preserve-symlinks-flag@^1.0.0: resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09" integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w== +swagger-ui-dist@5.4.2: + version "5.4.2" + resolved "https://registry.yarnpkg.com/swagger-ui-dist/-/swagger-ui-dist-5.4.2.tgz#ff7b936bdfc84673a1823a0f05f3a933ba7ccd4c" + integrity sha512-vT5QxP/NOr9m4gLZl+SpavWI3M9Fdh30+Sdw9rEtZbkqNmNNEPhjXas2xTD9rsJYYdLzAiMfwXvtooWH3xbLJA== + symbol-observable@4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/symbol-observable/-/symbol-observable-4.0.0.tgz#5b425f192279e87f2f9b937ac8540d1984b39205" diff --git a/web/.eslintrc.js b/web/.eslintrc.js index 9372a20..1953b89 100644 --- a/web/.eslintrc.js +++ b/web/.eslintrc.js @@ -1,6 +1,8 @@ module.exports = { - extends: ["eslint:recommended"], + extends: ['eslint:recommended'], rules: { - "no-undef": "off", - }, + 'no-undef': 'off', + 'no-unused-vars': 'off', + indent: ['error', 2, { SwitchCase: 1 }] + } }; diff --git a/web/package.json b/web/package.json index 9be89ca..7f3992b 100644 --- a/web/package.json +++ b/web/package.json @@ -14,11 +14,14 @@ "@emotion/styled": "^11.11.0", "@mui/icons-material": "^5.14.6", "@mui/material": "^5.14.3", + "@mui/x-date-pickers": "^6.13.0", "@types/node": "20.4.8", "@types/react": "18.2.18", "@types/react-dom": "18.2.7", + "axios": "^1.5.0", "eslint-config-next": "13.4.12", "jest-junit": "^16.0.0", + "moment": "^2.29.4", "next": "13.4.12", "react": "18.2.0", "react-dom": "18.2.0", diff --git a/web/src/app/job/context.ts b/web/src/app/job/context.ts new file mode 100644 index 0000000..92fe1c4 --- /dev/null +++ b/web/src/app/job/context.ts @@ -0,0 +1,5 @@ +import { JobQuery, JobTable } from '@/utils/types/job'; +import { createContext } from 'react'; + +export const JobListContext = createContext(undefined); +export const JobQueryContext = createContext(undefined); diff --git a/web/src/app/job/hooks.ts b/web/src/app/job/hooks.ts new file mode 100644 index 0000000..f74486f --- /dev/null +++ b/web/src/app/job/hooks.ts @@ -0,0 +1,85 @@ +import { convertTableData } from '@/utils/helpers'; +import { axiosInstance } from '@/utils/services/axios'; +import { JobTableRow } from '@/utils/types/job'; +import { Moment } from 'moment'; +import { useEffect, useState } from 'react'; + +export const useHooks = () => { + const [page, setPage] = useState(1); + const [perPage] = useState(12); + const [jobs, setJobs] = useState([]); + const [count, setCount] = useState(0); + const [pageCount, setPageCount] = useState(1); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(); + + const [isFilter, setIsFilter] = useState(false); + const [tag, setTag] = useState(''); + const [status, setStatus] = useState(''); + const [startDate, setStartDate] = useState(null); + const [endDate, setEndDate] = useState(null); + + const params: { + page: number, + perPage: number, + tag?: string, + status?: string, + startDate?: string, + endDate?: string; + } = { + page, perPage, + }; + + if (isFilter) { + if (tag) { + params.tag = tag; + } + + if (status) { + params.status = status; + } + + if (startDate && endDate) { + params.startDate = startDate.format("MM-DD-YYYY"); + params.endDate = endDate.format("MM-DD-YYYY"); + } + } + + useEffect(() => { + axiosInstance + .get('/jobs', { + params + }) + .then((response) => { + setCount(response?.data.count); + setJobs(convertTableData(response?.data.jobs)); + setPageCount(Math.ceil(response?.data.count / perPage)); + setIsLoading(false); + }) + .catch((e) => { + console.log(e); + setIsLoading(false); + setError('Something went wrong.'); + }); + }, [page, perPage, tag, status, startDate, endDate, isFilter]); + + return { + jobs, + count, + page, + pageCount, + isLoading, + error, + setPage, + tag, + setTag, + status, + setStatus, + startDate, + setStartDate, + endDate, + setEndDate, + isFilter, + setIsFilter + }; +}; diff --git a/web/src/app/job/page.tsx b/web/src/app/job/page.tsx new file mode 100644 index 0000000..4d29637 --- /dev/null +++ b/web/src/app/job/page.tsx @@ -0,0 +1,98 @@ +'use client'; + +import Pagination from '@/components/molecules/Pagination'; +import StatusDisplay from '@/components/molecules/StatusDisplay'; +import JobListTable from '@/components/organisms/JobListTable'; +import SearchFilterHeader from '@/components/organisms/SearchFilterHeader'; +import { JobColumns } from '@/utils/constants/jobTableData'; +import { Grid, Typography } from '@mui/material'; +import { Fragment } from 'react'; +import { JobListContext, JobQueryContext } from './context'; +import { useHooks } from './hooks'; + +const JobList = (): JSX.Element => { + const { + page, + jobs, + count, + pageCount, + setPage, + isLoading, + error, + tag, + setTag, + status, + setStatus, + startDate, + setStartDate, + endDate, + setEndDate, + isFilter, + setIsFilter + } = useHooks(); + + return ( +
+ + + {isLoading ? ( + + ) : error ? ( + + ) : ( + + + + + {!count ? ( + + No jobs found + + ) : ( + + + + + + + + + )} + + )} + + +
+ ); +}; + +export default JobList; diff --git a/web/src/app/layout.tsx b/web/src/app/layout.tsx index ddbd120..0442ce7 100644 --- a/web/src/app/layout.tsx +++ b/web/src/app/layout.tsx @@ -1,24 +1,23 @@ -import type { Metadata } from 'next' -import { ThemeProvider } from '@mui/material' +import { ThemeProvider } from '@mui/material'; +import type { Metadata } from 'next'; -import { theme } from '@/assets/theme' +import { theme } from '@/assets/theme'; import Wrapper from './wrapper'; export const metadata: Metadata = { - title: 'Create Next App', - description: 'Generated by create next app', -} + title: 'JMS' +}; export default function RootLayout({ - children, + children }: { - children: React.ReactNode + children: React.ReactNode; }) { return ( - + {children} - ) + ); } diff --git a/web/src/app/page.tsx b/web/src/app/page.tsx deleted file mode 100644 index 6c76689..0000000 --- a/web/src/app/page.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import { Box, Typography } from "@mui/material"; - -export default function Home() { - return ( -
- - Content goes here - -
- ) -} diff --git a/web/src/app/wrapper.tsx b/web/src/app/wrapper.tsx index e0f4dd0..bcfd35f 100644 --- a/web/src/app/wrapper.tsx +++ b/web/src/app/wrapper.tsx @@ -1,50 +1,53 @@ -'use client' +'use client'; +import { Box, Container } from '@mui/material'; +import { Open_Sans } from 'next/font/google'; import { FC, useState } from 'react'; -import { Open_Sans } from 'next/font/google' -import { Box, Container } from '@mui/material' import Header from '@/components/organisms/Header'; import NavBar from '@/components/organisms/Navbar'; const openSansFont = Open_Sans({ - subsets: ["latin"], - weight: ["300", "400", "500", "600", "700"], + subsets: ['latin'], + weight: ['300', '400', '500', '600', '700'] }); interface Props { - children: React.ReactNode + children: React.ReactNode; } -export const Wrapper:FC = ({ - children - }) => { - - const [isExpanded, setIsExpanded] = useState(true) - let isOpen = true; - - const handleToggle = () => { - setIsExpanded(!isExpanded) - isOpen = !isOpen - } - - return ( - = ({ children }) => { + const [isExpanded, setIsExpanded] = useState(true); + let isOpen = true; + + const handleToggle = () => { + setIsExpanded(!isExpanded); + isOpen = !isOpen; + }; + + return ( + +
+ + + -
- - - - {children} - - - - ) -} - -export default Wrapper \ No newline at end of file + {children} + + + + ); +}; + +export default Wrapper; diff --git a/web/src/assets/icons/FilterCog.tsx b/web/src/assets/icons/FilterCog.tsx new file mode 100644 index 0000000..2efa954 --- /dev/null +++ b/web/src/assets/icons/FilterCog.tsx @@ -0,0 +1,20 @@ +import { SvgIcon } from '@mui/material'; + +interface Props { + style?: string; +} + +export const FilterCog = ({ style = '' }: Props) => { + return ( + + + + + + ); +}; diff --git a/web/src/assets/icons/FilterRemove.tsx b/web/src/assets/icons/FilterRemove.tsx new file mode 100644 index 0000000..4fb39e5 --- /dev/null +++ b/web/src/assets/icons/FilterRemove.tsx @@ -0,0 +1,20 @@ +import { SvgIcon } from '@mui/material'; + +interface Props { + style?: string; +} + +export const FilterRemove = ({ style = '' }: Props) => { + return ( + + + + + + ); +}; diff --git a/web/src/assets/icons/Logo.tsx b/web/src/assets/icons/Logo.tsx index 088c7a6..e6fecd7 100644 --- a/web/src/assets/icons/Logo.tsx +++ b/web/src/assets/icons/Logo.tsx @@ -1,12 +1,26 @@ export const Logo = () => ( - - - + + - - - - + + + + - -) \ No newline at end of file + +); diff --git a/web/src/components/atoms/StatusChip/index.tsx b/web/src/components/atoms/StatusChip/index.tsx new file mode 100644 index 0000000..0c09ea0 --- /dev/null +++ b/web/src/components/atoms/StatusChip/index.tsx @@ -0,0 +1,17 @@ +import { ChipColors } from '@/utils/constants/statusChipColor'; +import { Chip, ChipProps } from '@mui/material'; +import { FC } from 'react'; + +const StatusChip: FC = ({ label }: ChipProps) => { + return ( + + ); +}; + +export default StatusChip; diff --git a/web/src/components/molecules/DateRangeField/hooks.ts b/web/src/components/molecules/DateRangeField/hooks.ts new file mode 100644 index 0000000..ad7f78f --- /dev/null +++ b/web/src/components/molecules/DateRangeField/hooks.ts @@ -0,0 +1,49 @@ +import { type Moment } from 'moment'; +import { useEffect, useState } from 'react'; + +interface Props { + setStartDate: (date: Moment | null) => void, + setEndDate: (date: Moment | null) => void +} + +export const useHooks = ({ setStartDate, setEndDate }: Props) => { + const [initialStartDate, setInitialStartDate] = useState(null); + const [initialEndDate, setInitialEndDate] = useState(null); + const [error, setError] = useState(''); + + const handleStartDateChange = (date: Moment | null): void => { + setInitialStartDate(date); + }; + + const handleEndDateChange = (date: Moment | null): void => { + setInitialEndDate(date); + }; + + useEffect(() => { + if (initialStartDate && !initialEndDate || initialEndDate && !initialStartDate) { + setError('Both start and end dates must be set'); + } + + if (!initialStartDate && !initialEndDate) { + setError(''); + setStartDate(null); + setEndDate(null); + } + + if (initialStartDate && initialEndDate) { + if (initialEndDate < initialStartDate) { + setError('Please set a vaild date'); + } else { + setError(''); + setStartDate(initialStartDate); + setEndDate(initialEndDate); + } + } + }, [initialStartDate, initialEndDate]); + + return { + error, + handleStartDateChange, + handleEndDateChange, + }; +}; diff --git a/web/src/components/molecules/DateRangeField/index.tsx b/web/src/components/molecules/DateRangeField/index.tsx new file mode 100644 index 0000000..557ddf6 --- /dev/null +++ b/web/src/components/molecules/DateRangeField/index.tsx @@ -0,0 +1,89 @@ +'use-client'; + +import { Box } from '@mui/material'; +import { DatePicker } from '@mui/x-date-pickers'; +import { AdapterMoment } from '@mui/x-date-pickers/AdapterMoment'; +import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider'; +import { Moment } from 'moment'; +import { useHooks } from './hooks'; + +interface Props { + label: string; + disableFuture?: boolean; + startDate: Moment | null; + endDate: Moment | null; + setStartDate: (date: Moment | null) => void; + setEndDate: (date: Moment | null) => void; +} + +const DateRangeField = ({ + label, + disableFuture = false, + startDate, + endDate, + setStartDate, + setEndDate +}: Props) => { + const { error, handleStartDateChange, handleEndDateChange } = useHooks({ + setStartDate, + setEndDate + }); + + const sxProps = { + '& .MuiInputBase-root': { + backgroundColor: 'white' + }, + '& .MuiFormHelperText-root .Mui-error': { + backgroundColor: 'transparent' + } + }; + + return ( + + + + + + + ); +}; + +export default DateRangeField; diff --git a/web/src/components/molecules/NavBarItem.tsx b/web/src/components/molecules/NavBarItem.tsx index 9ad1c54..f8e620e 100644 --- a/web/src/components/molecules/NavBarItem.tsx +++ b/web/src/components/molecules/NavBarItem.tsx @@ -1,64 +1,72 @@ -import Link from 'next/link' -import React, { FC } from 'react' -import { Box, Typography } from '@mui/material' -import {SvgIconComponent} from '@mui/icons-material' +'use client'; + +import { SvgIconComponent } from '@mui/icons-material'; +import { Box, Typography } from '@mui/material'; +import Link from 'next/link'; +import { usePathname } from 'next/navigation'; +import { FC } from 'react'; interface Props { - label: string, - Icon: SvgIconComponent - active?: boolean, - width?: number | string, - height?: number | string, - paddingX?: number | string, - gap?: number, - linkTo?: string + label: string; + Icon: SvgIconComponent; + active?: boolean; + width?: number | string; + height?: number | string; + paddingX?: number | string; + gap?: number; + linkTo?: string; } const NavBarItem: FC = ({ - label, - Icon, - active = false, - width = 200, - height = 40, - paddingX = 2, - gap = 1, - linkTo = "/" - }) => { - - const backgroundColor = active ? 'primary.100' : 'primary.700' - const textColor = active? 'dark': 'white' + label, + Icon, + active = false, + width = 200, + height = 40, + paddingX = 2, + gap = 1, + linkTo = '/' +}) => { + const pathname = usePathname(); + active = pathname === linkTo ? true : false; - return( - - div > span": { - color: 'dark', - }, - "> div > svg": { - color: 'dark', - }, - } - }}> - + const backgroundColor = active ? 'primary.100' : 'primary.700'; + const textColor = active ? 'dark' : 'white'; - - {label} - - - - ) -} + return ( + + div > span': { + color: 'dark' + }, + '> div > svg': { + color: 'dark' + } + } + }}> + + + + {label} + + + + + ); +}; -export default NavBarItem +export default NavBarItem; diff --git a/web/src/components/molecules/Pagination/index.tsx b/web/src/components/molecules/Pagination/index.tsx new file mode 100644 index 0000000..ba88df3 --- /dev/null +++ b/web/src/components/molecules/Pagination/index.tsx @@ -0,0 +1,27 @@ +import { Pagination as MUIPagination } from '@mui/material'; +import { FC } from 'react'; + +interface Props { + count: number; + page: number; + onChange: (page: number) => void; +} + +const Pagination: FC = ({ count, page, onChange }: Props) => { + return ( + , newPage: number) => + onChange(newPage) + } + color='primary' + shape='rounded' + showFirstButton + showLastButton + /> + ); +}; + +export default Pagination; diff --git a/web/src/components/molecules/SearchBar/hooks.ts b/web/src/components/molecules/SearchBar/hooks.ts new file mode 100644 index 0000000..e0e5782 --- /dev/null +++ b/web/src/components/molecules/SearchBar/hooks.ts @@ -0,0 +1,15 @@ +import { ChangeEvent, useState } from 'react'; + +export const useHooks = () => { + const [searchKeyword, setSearchKeyword] = useState(''); + + const handleSearch = (e: ChangeEvent) => { + setSearchKeyword(e.target.value); + }; + + return { + searchKeyword, + handleSearch + }; + +}; diff --git a/web/src/components/molecules/SearchBar/index.tsx b/web/src/components/molecules/SearchBar/index.tsx new file mode 100644 index 0000000..75a6e0c --- /dev/null +++ b/web/src/components/molecules/SearchBar/index.tsx @@ -0,0 +1,37 @@ +import styles from '@/styles/Filter.module.css'; +import SearchIcon from '@mui/icons-material/Search'; +import { + FormControl, + InputAdornment, + InputLabel, + OutlinedInput +} from '@mui/material'; +import { useHooks } from './hooks'; + +const SearchBar = () => { + const { searchKeyword, handleSearch } = useHooks(); + + return ( + + Search Job + + + + } + /> + + ); +}; + +export default SearchBar; diff --git a/web/src/components/molecules/SelectDropdown/index.tsx b/web/src/components/molecules/SelectDropdown/index.tsx new file mode 100644 index 0000000..85ce273 --- /dev/null +++ b/web/src/components/molecules/SelectDropdown/index.tsx @@ -0,0 +1,56 @@ +import styles from '@/styles/Filter.module.css'; +import { + FormControl, + InputLabel, + MenuItem, + Select, + SelectChangeEvent +} from '@mui/material'; + +interface Props { + label: string; + name: string; + value: string; + options: Array<{ value: string; name: string }>; + clearable?: boolean; + width?: number; + handleChange: (e: SelectChangeEvent) => void; +} + +const SelectDropdown = ({ + label, + name, + value, + options, + handleChange, + clearable = false, + width = 180 +}: Props) => { + return ( + + {label} + + + ); +}; + +export default SelectDropdown; diff --git a/web/src/components/molecules/StatusDisplay/index.tsx b/web/src/components/molecules/StatusDisplay/index.tsx new file mode 100644 index 0000000..2a7609d --- /dev/null +++ b/web/src/components/molecules/StatusDisplay/index.tsx @@ -0,0 +1,26 @@ +import { Box, CircularProgress, Typography } from '@mui/material'; + +interface Props { + isLoading?: boolean; + error?: string; +} + +const StatusDisplay = ({ isLoading = false, error }: Props): JSX.Element => { + return ( + + {isLoading ? ( + + ) : ( + error && {error} + )} + + ); +}; + +export default StatusDisplay; diff --git a/web/src/components/organisms/Header.tsx b/web/src/components/organisms/Header.tsx index 3d751f8..0bedbc0 100644 --- a/web/src/components/organisms/Header.tsx +++ b/web/src/components/organisms/Header.tsx @@ -1,35 +1,41 @@ -import React, { FC } from 'react' -import { Box, Stack, Typography } from '@mui/material' +import { Box, Stack, Typography } from '@mui/material'; +import { FC } from 'react'; -import {Logo} from '../../assets/icons/Logo' -import HeaderProfile from './HeaderProfile' +import { Logo } from '../../assets/icons/Logo'; +import HeaderProfile from './HeaderProfile'; interface Props { - height?: number | string, + height?: number | string; } -const Header: FC = ({height = 64}) => { - return( - = ({ height = 64 }) => { + return ( + + - - - MeetsOne - - - - ) -} + + MeetsOne + + + + ); +}; -export default Header +export default Header; diff --git a/web/src/components/organisms/HeaderProfile.tsx b/web/src/components/organisms/HeaderProfile.tsx index e620be6..3eac6dd 100644 --- a/web/src/components/organisms/HeaderProfile.tsx +++ b/web/src/components/organisms/HeaderProfile.tsx @@ -1,125 +1,141 @@ -'use client' +'use client'; -import React, { FC } from 'react' +import { ArrowDropDown, Create, Logout } from '@mui/icons-material'; +import { + Avatar, + Box, + Button, + Grow, + IconButton, + Paper, + Popper, + Stack, + Typography +} from '@mui/material'; import ClickAwayListener from '@mui/material/ClickAwayListener'; -import {Create, Logout, ArrowDropDown} from '@mui/icons-material' -import { Box, Typography, Paper, Button, Stack, Avatar, Popper, Grow, IconButton } from '@mui/material' +import React, { FC } from 'react'; interface Props { - name?: string, - email?: string, - avatar?: string, + name?: string; + email?: string; + avatar?: string; } const HeaderProfile: FC = ({ - name = "John Doe", - email = "johndoe@sun-asterisk.com" + name = 'John Doe', + email = 'johndoe@sun-asterisk.com' }) => { - const [open, setOpen] = React.useState(false); - const anchorRef = React.useRef(null); + const [open, setOpen] = React.useState(false); + const anchorRef = React.useRef(null); - const handleToggle = () => { - setOpen((prevOpen) => !prevOpen); - }; + const handleToggle = () => { + setOpen((prevOpen) => !prevOpen); + }; - const handleClose = (event: Event | React.SyntheticEvent) => { - if ( - anchorRef.current && - anchorRef.current.contains(event.target as HTMLElement) - ) { - return; - } + const handleClose = (event: Event | React.SyntheticEvent) => { + if ( + anchorRef.current && + anchorRef.current.contains(event.target as HTMLElement) + ) { + return; + } - setOpen(false); - }; + setOpen(false); + }; - return( - - - - - - - + return ( + + + + + + + - - {({ TransitionProps }) => ( - - + {({ TransitionProps }) => ( + + + + + - - - - - - - {name} - {email} - - - - - - - - )} - - - ) -} + + + + {name} + {email} + + + + + + + + + )} + + + ); +}; -export default HeaderProfile +export default HeaderProfile; diff --git a/web/src/components/organisms/JobListTable/hooks.ts b/web/src/components/organisms/JobListTable/hooks.ts new file mode 100644 index 0000000..385c250 --- /dev/null +++ b/web/src/components/organisms/JobListTable/hooks.ts @@ -0,0 +1,22 @@ +import { JobListContext } from '@/app/job/context'; +import { JobTable } from '@/utils/types/job'; +import { useContext } from 'react'; + +const useJobListContext = (): JobTable => { + const jobs = useContext(JobListContext); + + if (jobs === undefined) { + throw new Error('Missing JobListContext'); + } + + return jobs; +}; + +export const useHooks = () => { + const { columns, data } = useJobListContext(); + + return { + columns, + data + }; +}; \ No newline at end of file diff --git a/web/src/components/organisms/JobListTable/index.tsx b/web/src/components/organisms/JobListTable/index.tsx new file mode 100644 index 0000000..d82e076 --- /dev/null +++ b/web/src/components/organisms/JobListTable/index.tsx @@ -0,0 +1,108 @@ +import StatusChip from '@/components/atoms/StatusChip'; +import { JobTableRow, TableColumn } from '@/utils/types/job'; +import { + Box, + Paper, + Stack, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Typography +} from '@mui/material'; +import { useHooks } from './hooks'; + +const JobListTable = (): JSX.Element => { + const { columns, data } = useHooks(); + + const renderTableCellContent = ( + column: TableColumn, + row: JobTableRow + ): JSX.Element => { + switch (column.key) { + case 'tags': + return ( + + {row.tags.map((tag, key) => ( + + ))} + + ); + case 'schedules': + return ( + + {row.schedules.map((schedule, key) => ( + + {schedule} + + ))} + + ); + case 'pipelinePhase': + return ; + case 'estimationStatus': + return ; + case 'cost': + return {row.estimation.cost}; + default: + return ( + {row[column.key] as string} + ); + } + }; + + return ( + + + + + + + + {columns.map((column) => ( + + + {column.label} + + + ))} + + + + {data?.map((row: JobTableRow) => ( + + {columns.map((column: TableColumn, index) => ( + + {renderTableCellContent(column, row)} + + ))} + + ))} + +
+
+
+
+
+ ); +}; + +export default JobListTable; diff --git a/web/src/components/organisms/Navbar.tsx b/web/src/components/organisms/Navbar.tsx index 0fa7687..0cd018c 100644 --- a/web/src/components/organisms/Navbar.tsx +++ b/web/src/components/organisms/Navbar.tsx @@ -1,72 +1,73 @@ -"use client" +'use client'; -import React, { FC } from 'react' -import { Menu } from '@mui/icons-material' -import { Box, ButtonBase } from '@mui/material' +import { Menu } from '@mui/icons-material'; +import { Box, ButtonBase } from '@mui/material'; +import { FC } from 'react'; -import NavBarItem from '../molecules/NavBarItem' -import { Menus } from '@/utils/constants/navbarMenu' +import { Menus } from '@/utils/constants/navbarMenu'; +import NavBarItem from '../molecules/NavBarItem'; interface Props { - expanded?: boolean, - width?: number |string, - height?: number | string, - paddingY?: number |string, - rowGap?: number |string, - handleToggle: () => void + expanded?: boolean; + width?: number | string; + height?: number | string; + paddingY?: number | string; + rowGap?: number | string; + handleToggle: () => void; } const NavBar: FC = ({ - expanded = true, - width = 200, - paddingY = 2, - rowGap = 2, - handleToggle - }) => { - - return( - { + return ( + + - - - - - + + + + - - {Menus.map((item, index) => ( - - - - ) - )} - - - ) -} + + {Menus.map((item, index) => ( + + + + ))} + + + ); +}; -export default NavBar +export default NavBar; diff --git a/web/src/components/organisms/SearchFilterHeader/hooks.ts b/web/src/components/organisms/SearchFilterHeader/hooks.ts new file mode 100644 index 0000000..fe26cf7 --- /dev/null +++ b/web/src/components/organisms/SearchFilterHeader/hooks.ts @@ -0,0 +1,64 @@ +import { JobQueryContext } from '@/app/job/context'; +import { EstimationStatusEnum } from '@/utils/constants/estimationStatusEnum'; +import { TagsEnum } from '@/utils/constants/tagsEnum'; +import { convertEnumToOptions } from '@/utils/helpers'; +import { JobQuery } from '@/utils/types/job'; +import { SelectChangeEvent } from '@mui/material'; +import { useContext, useState } from 'react'; + +const useJobQueryContext = (): JobQuery => { + const query = useContext(JobQueryContext); + + if (query === undefined) { + throw new Error('Missing JobQueryContext'); + } + + return query; +}; + +export const useHooks = () => { + const { + isFilter, + setIsFilter, + tag, + setTag, + status, + setStatus, + startDate, + endDate, + setStartDate, + setEndDate + } = useJobQueryContext(); + const [isExpanded, setIsExpanded] = useState(false); + + const toggleFilters = () => { + setIsExpanded(!isExpanded); + setIsFilter(!isFilter); + }; + + const handleTagChange = (e: SelectChangeEvent) => { + setTag(e.target.value); + }; + + const handleStatusChange = (e: SelectChangeEvent) => { + setStatus(e.target.value); + }; + + const tagOptions = convertEnumToOptions(TagsEnum); + const statusOptions = convertEnumToOptions(EstimationStatusEnum); + + return { + isExpanded, + toggleFilters, + tag, + handleTagChange, + status, + handleStatusChange, + tagOptions, + statusOptions, + startDate, + endDate, + setStartDate, + setEndDate + }; +}; diff --git a/web/src/components/organisms/SearchFilterHeader/index.tsx b/web/src/components/organisms/SearchFilterHeader/index.tsx new file mode 100644 index 0000000..cf718c0 --- /dev/null +++ b/web/src/components/organisms/SearchFilterHeader/index.tsx @@ -0,0 +1,98 @@ +'use-client'; + +import { FilterCog } from '@/assets/icons/FilterCog'; +import { FilterRemove } from '@/assets/icons/FilterRemove'; +import SelectDropdown from '@/components/molecules/SelectDropdown'; +import styles from '@/styles/Filter.module.css'; +import TableRows from '@mui/icons-material/TableRows'; +import { Box, Button, Collapse } from '@mui/material'; +import DateRangeField from '../../molecules/DateRangeField'; +import SearchBar from '../../molecules/SearchBar'; +import { useHooks } from './hooks'; + +const SearchFilterHeader = (): JSX.Element => { + const { + isExpanded, + toggleFilters, + tag, + tagOptions, + handleTagChange, + status, + handleStatusChange, + statusOptions, + startDate, + endDate, + setStartDate, + setEndDate + } = useHooks(); + + return ( + + + + + + + + + + + + + + + ); +}; + +export default SearchFilterHeader; diff --git a/web/src/styles/Filter.module.css b/web/src/styles/Filter.module.css new file mode 100644 index 0000000..4194212 --- /dev/null +++ b/web/src/styles/Filter.module.css @@ -0,0 +1,36 @@ +.button { + height: 52px; + width: 52px; + background-color: white; + transition: background-color 0.3s; +} + +.button:hover { + background-color: #2A3F52; +} + +.active { + background-color: #2A3F52; +} + +.icon { + height: 20px; + width: 20px; + transition: fill 0.3s; + fill: #3E5367; +} + +.iconWhite { + height: 20px; + width: 20px; + transition: fill 0.3s; + fill: white; +} + +.button:hover .icon { + fill: white; +} + +.input { + background-color: white; +} diff --git a/web/src/utils/constants/estimationStatusEnum.ts b/web/src/utils/constants/estimationStatusEnum.ts new file mode 100644 index 0000000..9b45eed --- /dev/null +++ b/web/src/utils/constants/estimationStatusEnum.ts @@ -0,0 +1,8 @@ + +export enum EstimationStatusEnum { + NOT_YET_CREATED = "NOT_YET_CREATED", + MAKING = "MAKING", + APPROVED = "APPROVED", + SENT_TO_CUSTOMER = "SENT_TO_CUSTOMER", + CLOSED = "CLOSED", +} diff --git a/web/src/utils/constants/jobTableData.ts b/web/src/utils/constants/jobTableData.ts new file mode 100644 index 0000000..17d8aa8 --- /dev/null +++ b/web/src/utils/constants/jobTableData.ts @@ -0,0 +1,13 @@ +import { TableColumn } from '../types/job'; + +export const JobColumns: TableColumn[] = [ + { key: 'title', label: 'Job Title', width: 200 }, + { key: 'customer', label: 'Customer Name', width: 170 }, + { key: 'tags', label: 'Tags', width: 160 }, + { key: 'schedules', label: 'Work Schedule', width: 200 }, + { key: 'estimationStatus', label: 'Estimation Status', width: 180 }, + { key: 'personInCharge', label: 'Person in Charge', width: 170 }, + { key: 'pipelinePhase', label: 'Pipeline Phase', width: 150 }, + { key: 'cost', label: 'Cost', width: 120 }, + { key: 'createdAt', label: 'Created At', width: 120 } +]; diff --git a/web/src/utils/constants/navbarMenu.ts b/web/src/utils/constants/navbarMenu.ts index 1d2cfc2..460d94b 100644 --- a/web/src/utils/constants/navbarMenu.ts +++ b/web/src/utils/constants/navbarMenu.ts @@ -1,30 +1,30 @@ import { - Home, - CalendarMonth, - Person, - SvgIconComponent, + CalendarMonth, + Home, + Person, + SvgIconComponent, } from "@mui/icons-material"; export interface IMenu { - name: string; - Icon: SvgIconComponent; - href: string; + name: string; + Icon: SvgIconComponent; + href: string; } export const Menus: IMenu[] = [ - { - name: "Job List", - Icon: Home, - href: "/", - }, - { - name: "Calendar", - Icon: CalendarMonth, - href: "/", - }, - { - name: "Customer", - Icon: Person, - href: "/", - }, + { + name: "Job List", + Icon: Home, + href: "/job", + }, + { + name: "Calendar", + Icon: CalendarMonth, + href: "/calendar", + }, + { + name: "Customer", + Icon: Person, + href: "/customer", + }, ]; diff --git a/web/src/utils/constants/statusChipColor.ts b/web/src/utils/constants/statusChipColor.ts new file mode 100644 index 0000000..83fd947 --- /dev/null +++ b/web/src/utils/constants/statusChipColor.ts @@ -0,0 +1,11 @@ +export interface ChipProps { + label: string; +} + +export const ChipColors: Record = { + 'Not Yet Created': '#FFB4AF', + Making: '#FDFF8F', + Approved: '#8AFFB2', + 'Sent To Customer': '#84C1FF', + Closed: '#65707b33' +}; diff --git a/web/src/utils/constants/tagsEnum.ts b/web/src/utils/constants/tagsEnum.ts new file mode 100644 index 0000000..7960d02 --- /dev/null +++ b/web/src/utils/constants/tagsEnum.ts @@ -0,0 +1,5 @@ +export enum TagsEnum { + TAG_A = "TAG_A", + TAG_B = "TAG_B", + TAG_C = "TAG_C", +} diff --git a/web/src/utils/helpers/index.tsx b/web/src/utils/helpers/index.tsx new file mode 100644 index 0000000..7245f14 --- /dev/null +++ b/web/src/utils/helpers/index.tsx @@ -0,0 +1,70 @@ +import { JobSchema, JobTableRow } from '../types/job'; +import { ScheduleSchema } from '../types/schedule'; + +export const formatEnum = (value: string): string => { + let words = value.split('_'); + words = words.map( + (word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase() + ); + return words.join(' '); +}; + +export const convertEnumToOptions = ( + enumObj: Record +): Array<{ value: string; name: string }> => { + const options = Object.keys(enumObj).map((key) => ({ + value: enumObj[key], + name: formatEnum(key) + })); + + return options; +}; + +export const convertTableData = (data: JobSchema[]): JobTableRow[] => { + const tableData = data.map((job: JobSchema) => ({ + id: job.id, + title: job.title, + customer: `${job.customer.firstName} ${job.customer.lastName}`, + tags: job.tags.map((tag) => formatEnum(tag)).sort(), + schedules: formatSchedules(job.schedules).sort(), + estimation: { + status: job.estimation?.status + ? formatEnum(job.estimation?.status) + : 'Not Yet Created', + cost: job.estimation?.totalCost ? `₱ ${job.estimation?.totalCost}` : '-' + }, + personInCharge: `${job.personInCharge.firstName} ${job.personInCharge.lastName}`, + pipelinePhase: formatEnum(job.pipelinePhase), + createdAt: formatDate(job.createdAt) + })); + + return tableData; +}; + +const formatDate = (date: string): string => { + return new Date(date).toLocaleDateString('en-US', { + year: 'numeric', + month: '2-digit', + day: '2-digit' + }); +}; + +const formatTime = (date: string): string => { + return new Date(date).toLocaleTimeString('en-US', { + hour: '2-digit', + minute: '2-digit', + hour12: false + }); +}; + +const formatSchedules = (schedules: ScheduleSchema[]): string[] => { + const scheduleData = schedules.map((schedule: ScheduleSchema) => { + const startDateTime = `${formatDate(schedule.startDate)} ${formatTime( + schedule.startTime + )}`; + const endDateTime = `${formatTime(schedule.endTime)}`; + return `${startDateTime} - ${endDateTime}`; + }); + + return scheduleData; +}; diff --git a/web/src/utils/services/axios.ts b/web/src/utils/services/axios.ts new file mode 100644 index 0000000..5584191 --- /dev/null +++ b/web/src/utils/services/axios.ts @@ -0,0 +1,8 @@ +import axios from 'axios'; + +export const axiosInstance = axios.create({ + baseURL: process.env.NEXT_PUBLIC_API_BASE_URL, + headers: { + 'Content-Type': 'application/json', + }, +}); diff --git a/web/src/utils/types/customer.ts b/web/src/utils/types/customer.ts new file mode 100644 index 0000000..a1e6147 --- /dev/null +++ b/web/src/utils/types/customer.ts @@ -0,0 +1,8 @@ +export interface CustomerSchema { + id: number; + firstName: string; + lastName: string; + email: string; + contact: string; + address: string; +} diff --git a/web/src/utils/types/estimation.ts b/web/src/utils/types/estimation.ts new file mode 100644 index 0000000..f9598a6 --- /dev/null +++ b/web/src/utils/types/estimation.ts @@ -0,0 +1,7 @@ +export interface EstimationSchema { + id: number; + title: string; + document: string; + totalCost: number; + status: string; +} diff --git a/web/src/utils/types/job.ts b/web/src/utils/types/job.ts new file mode 100644 index 0000000..017da45 --- /dev/null +++ b/web/src/utils/types/job.ts @@ -0,0 +1,68 @@ +import { Moment } from 'moment'; +import { CustomerSchema } from './customer'; +import { EstimationSchema } from './estimation'; +import { ScheduleSchema } from './schedule'; +import { UserSchema } from './user'; + +export interface TableColumn { + key: string; + label: string; + width?: number; +} + +export interface EstimationType { + status: string; + cost: string | number; +} + +export interface JobTableRow { + id: number; + title: string; + customer: string; + tags: Array; + schedules: Array; + estimation: EstimationType; + personInCharge: string; + pipelinePhase: string; + createdAt: string; + [key: string]: string | number | string[] | EstimationType; +} + +export interface JobTable { + columns: TableColumn[]; + data?: JobTableRow[]; +} + +export interface JobSchema { + id: number; + title: string; + type: string; + tags: string[]; + remarks?: string; + customer: CustomerSchema; + paymentMethod: string; + personInCharge: UserSchema; + schedules: ScheduleSchema[]; + estimation?: EstimationSchema; + pipelinePhase: string; + createdAt: string; + updatedAt: string; +} + +export interface JobListType { + jobs: JobSchema[]; + count: number; +} + +export interface JobQuery { + tag: string; + setTag: (value: string) => void; + status: string; + setStatus: (value: string) => void; + startDate: Moment | null; + setStartDate: (date: Moment | null) => void; + endDate: Moment | null; + setEndDate: (date: Moment | null) => void; + isFilter: boolean; + setIsFilter: (value: boolean) => void; +} diff --git a/web/src/utils/types/schedule.ts b/web/src/utils/types/schedule.ts new file mode 100644 index 0000000..1d98f7d --- /dev/null +++ b/web/src/utils/types/schedule.ts @@ -0,0 +1,7 @@ +export interface ScheduleSchema { + id: number; + startDate: string; + endDate: string; + startTime: string; + endTime: string; +} diff --git a/web/src/utils/types/user.ts b/web/src/utils/types/user.ts new file mode 100644 index 0000000..cd25e32 --- /dev/null +++ b/web/src/utils/types/user.ts @@ -0,0 +1,7 @@ +export interface UserSchema { + id: number; + firstName: string; + lastName: string; + email: string; + role: string; +} diff --git a/web/yarn.lock b/web/yarn.lock index 8662640..3b7148c 100644 --- a/web/yarn.lock +++ b/web/yarn.lock @@ -277,6 +277,13 @@ dependencies: regenerator-runtime "^0.14.0" +"@babel/runtime@^7.22.15": + version "7.22.15" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.22.15.tgz#38f46494ccf6cf020bd4eed7124b425e83e523b8" + integrity sha512-T0O+aa+4w0u06iNmapipJXMV4HoUir03hpx3/YqXXhu9xim3w+dVphjFWl1OH8NbZHw5Lbm9k45drDkgq2VNNA== + dependencies: + regenerator-runtime "^0.14.0" + "@babel/template@^7.22.5", "@babel/template@^7.3.3": version "7.22.5" resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.22.5.tgz#0c8c4d944509875849bd0344ff0050756eefc6ec" @@ -423,6 +430,33 @@ resolved "https://registry.yarnpkg.com/@emotion/weak-memoize/-/weak-memoize-0.3.1.tgz#d0fce5d07b0620caa282b5131c297bb60f9d87e6" integrity sha512-EsBwpc7hBUJWAsNPBmJy4hxWx12v6bshQsldrVmjxJoc3isbxhOrF2IcCpaXxfvq03NwkI7sbsOLXbYuqF/8Ww== +"@floating-ui/core@^1.4.1": + version "1.4.1" + resolved "https://registry.yarnpkg.com/@floating-ui/core/-/core-1.4.1.tgz#0d633f4b76052668afb932492ac452f7ebe97f17" + integrity sha512-jk3WqquEJRlcyu7997NtR5PibI+y5bi+LS3hPmguVClypenMsCY3CBa3LAQnozRCtCrYWSEtAdiskpamuJRFOQ== + dependencies: + "@floating-ui/utils" "^0.1.1" + +"@floating-ui/dom@^1.5.1": + version "1.5.2" + resolved "https://registry.yarnpkg.com/@floating-ui/dom/-/dom-1.5.2.tgz#6812e89d1d4d4ea32f10d15c3b81feb7f9836d89" + integrity sha512-6ArmenS6qJEWmwzczWyhvrXRdI/rI78poBcW0h/456+onlabit+2G+QxHx5xTOX60NBJQXjsCLFbW2CmsXpUog== + dependencies: + "@floating-ui/core" "^1.4.1" + "@floating-ui/utils" "^0.1.1" + +"@floating-ui/react-dom@^2.0.1": + version "2.0.2" + resolved "https://registry.yarnpkg.com/@floating-ui/react-dom/-/react-dom-2.0.2.tgz#fab244d64db08e6bed7be4b5fcce65315ef44d20" + integrity sha512-5qhlDvjaLmAst/rKb3VdlCinwTF4EYMiVxuuc/HVUjs46W0zgtbMmAZ1UTsDrRTxRmUEzl92mOtWbeeXL26lSQ== + dependencies: + "@floating-ui/dom" "^1.5.1" + +"@floating-ui/utils@^0.1.1": + version "0.1.2" + resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.1.2.tgz#b7e9309ccce5a0a40ac482cb894f120dba2b357f" + integrity sha512-ou3elfqG/hZsbmF4bxeJhPHIf3G2pm0ujc39hYEZrfVqt7Vk/Zji6CXc3W0pmYM8BW1g40U+akTl9DKZhFhInQ== + "@istanbuljs/load-nyc-config@^1.0.0": version "1.1.0" resolved "https://registry.yarnpkg.com/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz#fd3db1d59ecf7cf121e80650bb86712f9b55eced" @@ -677,6 +711,21 @@ prop-types "^15.8.1" react-is "^18.2.0" +"@mui/base@^5.0.0-alpha.87": + version "5.0.0-beta.14" + resolved "https://registry.yarnpkg.com/@mui/base/-/base-5.0.0-beta.14.tgz#315b67b0fd231cbd47e8d54f8f92be23122e4d66" + integrity sha512-Je/9JzzYObsuLCIClgE8XvXNFb55IEz8n2NtStUfASfNiVrwiR8t6VVFFuhofehkyTIN34tq1qbBaOjCnOovBw== + dependencies: + "@babel/runtime" "^7.22.10" + "@emotion/is-prop-valid" "^1.2.1" + "@floating-ui/react-dom" "^2.0.1" + "@mui/types" "^7.2.4" + "@mui/utils" "^5.14.8" + "@popperjs/core" "^2.11.8" + clsx "^2.0.0" + prop-types "^15.8.1" + react-is "^18.2.0" + "@mui/core-downloads-tracker@^5.14.4": version "5.14.4" resolved "https://registry.yarnpkg.com/@mui/core-downloads-tracker/-/core-downloads-tracker-5.14.4.tgz#a517265647ee9660170107d68905db5e400576c5" @@ -756,6 +805,30 @@ prop-types "^15.8.1" react-is "^18.2.0" +"@mui/utils@^5.14.7", "@mui/utils@^5.14.8": + version "5.14.8" + resolved "https://registry.yarnpkg.com/@mui/utils/-/utils-5.14.8.tgz#e1737d5fcd54aa413d6b1aaea3ea670af2919402" + integrity sha512-1Ls2FfyY2yVSz9NEqedh3J8JAbbZAnUWkOWLE2f4/Hc4T5UWHMfzBLLrCqExfqyfyU+uXYJPGeNIsky6f8Gh5Q== + dependencies: + "@babel/runtime" "^7.22.10" + "@types/prop-types" "^15.7.5" + "@types/react-is" "^18.2.1" + prop-types "^15.8.1" + react-is "^18.2.0" + +"@mui/x-date-pickers@^6.13.0": + version "6.13.0" + resolved "https://registry.yarnpkg.com/@mui/x-date-pickers/-/x-date-pickers-6.13.0.tgz#de9acfbc7697aed2bcbea6996173b45e3f0ee0d5" + integrity sha512-mNfWXrS9PMLI+lgeEw6R08g5j1Yewat+A7MiI2QuwZteX3b83JQnm73RCGrSFTpvOP1/v6yIBPbxPZin5eu6GA== + dependencies: + "@babel/runtime" "^7.22.15" + "@mui/base" "^5.0.0-alpha.87" + "@mui/utils" "^5.14.7" + "@types/react-transition-group" "^4.4.6" + clsx "^2.0.0" + prop-types "^15.8.1" + react-transition-group "^4.4.5" + "@next/env@13.4.12": version "13.4.12" resolved "https://registry.yarnpkg.com/@next/env/-/env-13.4.12.tgz#0b88115ab817f178bf9dc0c5e7b367277595b58d" @@ -1308,6 +1381,15 @@ axe-core@^4.6.2: resolved "https://registry.yarnpkg.com/axe-core/-/axe-core-4.7.2.tgz#040a7342b20765cb18bb50b628394c21bccc17a0" integrity sha512-zIURGIS1E1Q4pcrMjp+nnEh+16G56eG/MUllJH8yEvw7asDo7Ac9uhC9KIH5jzpITueEZolfYglnCGIuSBz39g== +axios@^1.5.0: + version "1.5.0" + resolved "https://registry.yarnpkg.com/axios/-/axios-1.5.0.tgz#f02e4af823e2e46a9768cfc74691fdd0517ea267" + integrity sha512-D4DdjDo5CY50Qms0qGQTTw6Q44jl7zRwY7bthds06pUGfChBCTcQs+N743eFWGEd6pRTMd6A+I87aWyFV5wiZQ== + dependencies: + follow-redirects "^1.15.0" + form-data "^4.0.0" + proxy-from-env "^1.1.0" + axobject-query@^3.1.1: version "3.2.1" resolved "https://registry.yarnpkg.com/axobject-query/-/axobject-query-3.2.1.tgz#39c378a6e3b06ca679f29138151e45b2b32da62a" @@ -2130,6 +2212,11 @@ find-up@^4.0.0, find-up@^4.1.0: locate-path "^5.0.0" path-exists "^4.0.0" +follow-redirects@^1.15.0: + version "1.15.2" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.2.tgz#b460864144ba63f2681096f274c4e57026da2c13" + integrity sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA== + for-each@^0.3.3: version "0.3.3" resolved "https://registry.yarnpkg.com/for-each/-/for-each-0.3.3.tgz#69b447e88a0a5d32c3e7084f3f1710034b21376e" @@ -3270,6 +3357,11 @@ mkdirp@^1.0.4: resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e" integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw== +moment@^2.29.4: + version "2.29.4" + resolved "https://registry.yarnpkg.com/moment/-/moment-2.29.4.tgz#3dbe052889fe7c1b2ed966fcb3a77328964ef108" + integrity sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w== + ms@2.1.2: version "2.1.2" resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" @@ -3574,6 +3666,11 @@ prop-types@^15.6.2, prop-types@^15.8.1: object-assign "^4.1.1" react-is "^16.13.1" +proxy-from-env@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz#e102f16ca355424865755d2c9e8ea4f24d58c3e2" + integrity sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg== + psl@^1.1.33: version "1.9.0" resolved "https://registry.yarnpkg.com/psl/-/psl-1.9.0.tgz#d0df2a137f00794565fcaf3b2c00cd09f8d5a5a7"