diff --git a/docker/backend/Dockerfile b/docker/backend/Dockerfile index 30d8287b..3c7d8796 100644 --- a/docker/backend/Dockerfile +++ b/docker/backend/Dockerfile @@ -1,5 +1,5 @@ # ----------------------- base ----------------- -FROM node:lts-alpine AS base +FROM node:22-alpine AS base USER root # Update image @@ -36,7 +36,7 @@ RUN yarn generate2 RUN yarn build:be # ------------------------ runner --------------------- -FROM node:lts-alpine AS runner +FROM node:22-alpine AS runner WORKDIR /app diff --git a/docker/frontend/Dockerfile b/docker/frontend/Dockerfile index abffd959..a7397492 100644 --- a/docker/frontend/Dockerfile +++ b/docker/frontend/Dockerfile @@ -1,4 +1,4 @@ -FROM node:lts-alpine AS base +FROM node:22-alpine AS base USER root # Update image @@ -40,7 +40,7 @@ RUN yarn generate2 RUN yarn build:fe # ----------------------- runner ----------------- -FROM node:lts-alpine AS runner +FROM node:22-alpine AS runner # USER namviek WORKDIR /app diff --git a/package.json b/package.json index 8ce4cc47..8d3d69d2 100644 --- a/package.json +++ b/package.json @@ -61,10 +61,11 @@ "apexcharts": "^3.41.0", "axios": "^1.4.0", "bcryptjs": "^2.4.3", - "bson": "^5.3.0", + "bson": "^6.9.0", "bullmq": "^5.1.4", "cors": "^2.8.5", "date-fns": "^2.30.0", + "docx-preview": "^0.3.3", "dotenv": "^16.3.1", "emoji-picker-react": "^4.5.16", "express": "^4.18.1", @@ -93,7 +94,7 @@ "react-dnd-html5-backend": "^16.0.1", "react-dom": "18.2.0", "react-hook-form": "^7.44.3", - "react-icons": "^4.9.0", + "react-icons": "^5.3.0", "react-pdf": "^7.7.1", "read-excel-file": "^5.6.1", "resend": "^1.0.0", @@ -105,6 +106,7 @@ "zustand": "^4.3.8" }, "devDependencies": { + "@faker-js/faker": "^9.2.0", "@nx/cypress": "16.2.2", "@nx/esbuild": "16.2.2", "@nx/eslint-plugin": "16.2.2", diff --git a/packages/be-gateway/src/lib/log.ts b/packages/be-gateway/src/lib/log.ts index 6542653e..560ae3e2 100644 --- a/packages/be-gateway/src/lib/log.ts +++ b/packages/be-gateway/src/lib/log.ts @@ -3,7 +3,21 @@ import { WinstonTransport as AxiomTransport } from '@axiomhq/winston'; // const { combine, timestamp, label, prettyPrint, simple } = format +const axiomDataset = process.env.AXIOM_DATASET || '' +const axiomToken = process.env.AXIOM_TOKEN || '' + +const defaultLogger = { + info: (...args: any[]) => console.log(1), + error: (...args: any[]) => console.log(1), + debug: (...args: any[]) => console.log(1) + +} + export const createModuleLog = (module: string) => { + if (!axiomToken && !axiomDataset) { + return defaultLogger + } + return createLogger({ // format: combine(label({ label: module }), timestamp(), prettyPrint()), // transports: [ @@ -17,8 +31,8 @@ export const createModuleLog = (module: string) => { defaultMeta: { service: 'user-service' }, transports: [ new AxiomTransport({ - dataset: process.env.AXIOM_DATASET || '', - token: process.env.AXIOM_TOKEN || '', + dataset: axiomDataset, + token: axiomToken, }), ], diff --git a/packages/be-gateway/src/lib/redis.ts b/packages/be-gateway/src/lib/redis.ts index 1878df85..da564304 100644 --- a/packages/be-gateway/src/lib/redis.ts +++ b/packages/be-gateway/src/lib/redis.ts @@ -51,11 +51,16 @@ try { console.log('redis connection established') }) + redis.on('connect', () => { + connected = true + error = true + }) + redis.on('error', err => { - if (error) return - console.log('redis connection error') + connected = false error = true - // console.log(error) + console.log('redis connection error') + if (error) return }) } catch (error) { console.log('redis connection error') diff --git a/packages/be-gateway/src/middlewares/authMiddleware.ts b/packages/be-gateway/src/middlewares/authMiddleware.ts index f80526f1..a6822f11 100644 --- a/packages/be-gateway/src/middlewares/authMiddleware.ts +++ b/packages/be-gateway/src/middlewares/authMiddleware.ts @@ -1,18 +1,47 @@ import { NextFunction, Response } from 'express'; import { decodeToken, extractToken, generateRefreshToken, generateToken, verifyRefreshToken } from '../lib/jwt'; -import { AuthRequest, JWTPayload } from '../types'; +import { AuthRequest, JWTPayload, JWTType } from '../types'; +import { pmClient } from 'packages/shared-models/src/lib/_prisma'; export const authMiddleware = async (req: AuthRequest, res: Response, next: NextFunction) => { const headers = req.headers; const authorization = headers.authorization; const refreshToken = headers.refreshtoken as string; + const clientId = headers['x-client-id'] as string; + const clientSecret = headers['x-client-secret'] as string; + + console.log('auth middleware run', clientId, clientSecret) + + if (clientId && clientSecret) { + const application = await pmClient.application.findFirst({ + where: { + clientId, + clientSecret, + } + }) + + if (!application) { + console.log('Application is INACTIVE or doesnt exist') + return res.status(401).end(); + } + + req.authen = { + id: application.id, + email: application.name, + name: application.name, + type: JWTType.APP, + photo: '' + } + + return next(); + } try { const validToken = extractToken(authorization); if (validToken) { // console.log('token is valid'); const { id, email, name, photo } = validToken as JWTPayload; - req.authen = { id, email, name, photo }; + req.authen = { id, email, name, photo, type: JWTType.USER }; // make sure that all tokens cleared res.setHeader('Authorization', ''); res.setHeader('RefreshToken', ''); @@ -51,7 +80,7 @@ export const authMiddleware = async (req: AuthRequest, res: Response, next: Next console.log('genereated succesfully'); - req.authen = user; + req.authen = {...user, type: JWTType.USER}; return next(); } diff --git a/packages/be-gateway/src/middlewares/beProjectMemberMiddleware.ts b/packages/be-gateway/src/middlewares/beProjectMemberMiddleware.ts index f9c86418..44c36c68 100644 --- a/packages/be-gateway/src/middlewares/beProjectMemberMiddleware.ts +++ b/packages/be-gateway/src/middlewares/beProjectMemberMiddleware.ts @@ -1,13 +1,13 @@ import { NextFunction, Response } from 'express' import { mdMemberBelongToProject } from '@shared/models' -import { AuthRequest } from '../types' +import { AuthRequest, JWTType } from '../types' export const beProjectMemberMiddleware = async ( req: AuthRequest, res: Response, next: NextFunction ) => { - const { id } = req.authen + const { id, type } = req.authen const { body, query, params } = req const projectId = query.projectId || body.projectId || params.projectId const projectIds = query.projectIds as string[] @@ -24,6 +24,11 @@ export const beProjectMemberMiddleware = async ( return } + if (type === JWTType.APP) { + next() + return + } + const result = await mdMemberBelongToProject(id, projectId) if (!result) { diff --git a/packages/be-gateway/src/providers/storage/AwsS3StorageProvider.ts b/packages/be-gateway/src/providers/storage/AwsS3StorageProvider.ts index db10c54d..625574c9 100644 --- a/packages/be-gateway/src/providers/storage/AwsS3StorageProvider.ts +++ b/packages/be-gateway/src/providers/storage/AwsS3StorageProvider.ts @@ -90,6 +90,10 @@ export default class AwsS3StorageProvider { } } + console.log('S3 configuration') + console.log('minio', minioEndpoint) + console.log(JSON.stringify(s3Config, null, ' ')) + this.client = new S3Client(s3Config) clientMapper.set(orgId, { diff --git a/packages/be-gateway/src/queues/Common/FieldSortableJob.ts b/packages/be-gateway/src/queues/Common/FieldSortableJob.ts new file mode 100644 index 00000000..7bed2a81 --- /dev/null +++ b/packages/be-gateway/src/queues/Common/FieldSortableJob.ts @@ -0,0 +1,21 @@ +import { FieldService } from '../../services/field' +import { BaseJob } from '../BaseJob' + +interface ISortatbleFieldData { + id: string + order: number +} + +export class FieldSortableJob extends BaseJob { + name = 'fieldSortable' + fieldService: FieldService + constructor() { + super() + this.fieldService = new FieldService() + } + async implement(data: ISortatbleFieldData[]) { + console.log('implement field sortable') + await this.fieldService.sortable(data) + console.log('finish implementation') + } +} diff --git a/packages/be-gateway/src/queues/Common/index.ts b/packages/be-gateway/src/queues/Common/index.ts new file mode 100644 index 00000000..4e18c1b8 --- /dev/null +++ b/packages/be-gateway/src/queues/Common/index.ts @@ -0,0 +1,22 @@ +import { FieldSortableJob } from './FieldSortableJob' +import { BaseQueue } from '../BaseQueue' + +export class CommonQueue extends BaseQueue { + constructor() { + super() + this.queueName = 'Common' + this.jobs = [new FieldSortableJob()] + + this.run() + } +} + +let instance: CommonQueue = null + +export const getCommonQueueInstance = () => { + if (!instance) { + instance = new CommonQueue() + } + + return instance +} diff --git a/packages/be-gateway/src/routes/apps/index.controller.ts b/packages/be-gateway/src/routes/apps/index.controller.ts new file mode 100644 index 00000000..8f515390 --- /dev/null +++ b/packages/be-gateway/src/routes/apps/index.controller.ts @@ -0,0 +1,101 @@ +import { + BaseController, + Body, + Controller, + Delete, + Get, + Post, + Put, + Req, + UseMiddleware +} from '../../core' +import { authMiddleware } from '../../middlewares' +import { ApplicationRepository } from '@shared/models' +import { randomBytes } from 'crypto' +import { AuthRequest } from '../../types' +import { Application } from '@prisma/client' + +@Controller('/apps') +@UseMiddleware([authMiddleware]) +export class ApplicationController extends BaseController { + private appRepo: ApplicationRepository + + constructor() { + super() + this.appRepo = new ApplicationRepository() + } + + @Get('/:orgId') + async getApps(@Req() req: AuthRequest) { + const orgId = req.params.orgId + + if (!orgId) { + throw new Error('Organization ID is required') + } + + const apps = await this.appRepo.getByOrgId(orgId) + return apps + } + + @Post('') + async create(@Req() req: AuthRequest, @Body() body: { name: string, desc?: string, orgId: string }) { + const { name, desc, orgId } = body + const { id: uid } = req.authen + + // Validate required fields + if (!name || !orgId) { + throw new Error('Name and Organization are required') + } + + const clientId = randomBytes(8).toString('hex') + const clientSecret = randomBytes(16).toString('hex') + + const result = await this.appRepo.create({ + name, + description: desc || '', + organizationId: orgId, + clientId, + scopes: [], + clientSecret, + updatedBy: null, + createdBy: uid, + createdAt: new Date(), + updatedAt: null + }) + + return result + } + + @Put('') + async update(@Body() body: Application, @Req() req: AuthRequest) { + const { id, ...rest } = body + const { id: uid } = req.authen + + + if (!id) { + throw new Error('Application ID and status are required') + } + + const result = await this.appRepo.update(id, { + ...rest, + updatedBy: uid, + updatedAt: new Date() + }) + + return result + } + + @Delete('/:id') + async delete(@Req() req: AuthRequest) { + const { id } = req.params + + console.log('1', id) + + if (!id) { + throw new Error('Application ID is required') + } + + const result = await this.appRepo.delete(id) + return result + } +} diff --git a/packages/be-gateway/src/routes/fields/index.ts b/packages/be-gateway/src/routes/fields/index.ts new file mode 100644 index 00000000..563ee443 --- /dev/null +++ b/packages/be-gateway/src/routes/fields/index.ts @@ -0,0 +1,93 @@ +import { Request, Response } from 'express' +import { Field } from '@prisma/client' +import { + BaseController, + Controller, + Res, + Req, + Body, + Next, + Query, + ExpressResponse, + Get, + Post, + Put, + Delete, + Param +} from '../../core' +import { FieldService } from '../../services/field' +import { CommonQueue, getCommonQueueInstance } from '../../queues/Common' + +@Controller('/fields') +export default class FieldController extends BaseController { + fieldService: FieldService + commonQueue: CommonQueue + constructor() { + super() + this.fieldService = new FieldService() + this.commonQueue = getCommonQueueInstance() + } + + @Get('/:projectId') + async getAllFieldsByProject( + @Res() res: Response, + @Req() req: Request, + ) { + const { projectId } = req.params as { projectId: string } + + console.log('projectId', req.params) + + const result = await this.fieldService.getAllByProjectId(projectId) + + res.json({ status: 200, data: result }) + } + + @Post('') + async create( + @Body() body: Omit, + @Res() res: ExpressResponse + ) { + + console.log('Field data 2', body) + + const result = await this.fieldService.create(body.type, body) + + console.log('ret data', result) + + res.json({ status: 200, data: result }) + + } + + @Put('') + async update(@Res() res: Response, @Req() req: Request, @Next() next) { + const body = req.body as Field + console.log('edit custom field', body) + const result = await this.fieldService.update(body) + + res.json({ status: 200, data: result }) + } + + @Put('/sortable') + async sortable(@Res() res: Response, @Req() req: Request, @Next() next) { + const { items } = req.body as { items: { id: string, order: number }[] } + + await this.commonQueue.addJob('fieldSortable', items) + // await this.fieldService.sortable(items) + console.log('done 1') + res.json({ status: 200, data: 1 }) + } + + @Delete('/:id') + async delete(@Param() params, @Res() res: Response) { + try { + const { id } = params + await this.fieldService.delete(id) + res.json({ status: 200, data: 1 }) + } catch (error) { + res.json({ + status: 500, + err: error + }) + } + } +} diff --git a/packages/be-gateway/src/routes/grid/index.ts b/packages/be-gateway/src/routes/grid/index.ts new file mode 100644 index 00000000..55e8356f --- /dev/null +++ b/packages/be-gateway/src/routes/grid/index.ts @@ -0,0 +1,112 @@ +import { FieldType } from "@prisma/client"; +import { BaseController, UseMiddleware, Controller, Put, Post, Body, Req, Delete, Param, Query } from "../../core"; +import { authMiddleware, beProjectMemberMiddleware } from "../../middlewares"; +import GridService, { IFilterAdvancedData } from "../../services/grid/grid.service"; +import { AuthRequest } from "../../types"; + + +@Controller('/project/grid') +@UseMiddleware([authMiddleware, beProjectMemberMiddleware]) +export default class ProjectGridController extends BaseController { + private gridService: GridService + + constructor() { + super() + this.gridService = new GridService() + } + + @Put('') + async update(@Req() req: AuthRequest, @Body() body: { value: string | string[], taskId: string, fieldId: string, type: FieldType }) { + const { id: uid } = req.authen + const ret = await this.gridService.update(uid, body) + return ret + } + + @Put('/update-many') + async updateMany(@Req() req: AuthRequest, @Body() body: { + taskIds: string[], + data: { + [fieldId: string]: { value: string, type: FieldType } + } + }) { + + console.log('Update multi field called') + const { id: uid } = req.authen + const ret = await this.gridService.updateMany(uid, body.taskIds, body.data) + + return ret + + } + + @Post('/query') + async queryCustomField(@Body() body: { + projectId: string, + filter: IFilterAdvancedData, + options: { + cursor?: string + limit?: number + orderBy?: { [key: string]: 'asc' | 'desc' } + } + }) { + try { + const { filter, projectId, options } = body + const result = await this.gridService.queryCustomField(projectId, filter, { + limit: options ? options.limit : 50, + cursor: options ? options.cursor : '' + }) + return result + } catch (error) { + console.error('Custom query error:', error) + return { status: 500, error: error.message } + } + } + + @Post('/create-row') + async createRow(@Req() req: AuthRequest, @Body() body: { + projectId: string, + row: Record + }) { + console.log('1') + const { id: uid } = req.authen + const ret = await this.gridService.createRow(uid, { + projectId: body.projectId, + data: body.row + }) + return ret + } + + @Post('/create') + async create(@Req() req: AuthRequest, @Body() body: { + projectId: string, + }) { + const { id: uid } = req.authen + const ret = await this.gridService.create(uid, { + projectId: body.projectId + }) + return ret + } + + @Post('/create-rows') + async createRows(@Req() req: AuthRequest, @Body() body: { + projectId: string, + rows: Record[] + }) { + const { id: uid } = req.authen + console.log('rows', body.rows) + const result = await this.gridService.createRows(uid, { + projectId: body.projectId, + rows: body.rows + }); + return result; + } + + @Delete('/delete') + async deleteRows(@Req() req: AuthRequest, @Query() params: { + rowIds: string[] + }) { + const { id: uid } = req.authen + console.log('params delete', params) + const result = await this.gridService.deleteRows(params.rowIds); + return result; + } +} diff --git a/packages/be-gateway/src/routes/index.ts b/packages/be-gateway/src/routes/index.ts index a6530d9c..f3b7af62 100644 --- a/packages/be-gateway/src/routes/index.ts +++ b/packages/be-gateway/src/routes/index.ts @@ -35,6 +35,9 @@ import TaskChecklistController from './task/checklist.controller' import ReportController from './report' import { createModuleLog } from '../lib/log' import { LoadTestController } from './test/loadtest.controller' +import FieldController from './fields' +import ProjectGridController from './grid' +import { ApplicationController } from './apps/index.controller' const router = Router() const logger = createModuleLog('Request') @@ -70,7 +73,11 @@ router.use( SchedulerController, TaskReorderController, TaskChecklistController, - ReportController + // TaskCustomFieldController, + ProjectGridController, + ApplicationController, + ReportController, + FieldController ]) ) // middlewares diff --git a/packages/be-gateway/src/routes/storage/index.ts b/packages/be-gateway/src/routes/storage/index.ts index 0a791dcb..1b7ac3fe 100644 --- a/packages/be-gateway/src/routes/storage/index.ts +++ b/packages/be-gateway/src/routes/storage/index.ts @@ -60,7 +60,7 @@ router.post('/create-presigned-url', async (req, res, next) => { } const { presignedUrl, randName, url } = await storageService.createPresignedUrl({ - projectId, + path: projectId, name, type }) diff --git a/packages/be-gateway/src/routes/task/index.ts b/packages/be-gateway/src/routes/task/index.ts index 0944d2c5..6d4647d2 100644 --- a/packages/be-gateway/src/routes/task/index.ts +++ b/packages/be-gateway/src/routes/task/index.ts @@ -34,6 +34,8 @@ import TaskCreateService from '../../services/task/create.service' import TaskUpdateService from '../../services/task/update.service' import TaskReminderJob from '../../jobs/reminder.job' import TaskPusherJob from '../../jobs/task.pusher.job' +import { FieldType } from '@prisma/client' + const taskPusherJob = new TaskPusherJob() diff --git a/packages/be-gateway/src/routes/test/index.ts b/packages/be-gateway/src/routes/test/index.ts index 01613c2f..94bd6005 100644 --- a/packages/be-gateway/src/routes/test/index.ts +++ b/packages/be-gateway/src/routes/test/index.ts @@ -7,7 +7,8 @@ import { Get, Post, Req, - Res + Res, + UseMiddleware } from '../../core' import { CounterType } from '@prisma/client' import { @@ -21,8 +22,10 @@ import { } from '../../lib/redis' import { TaskQueue, getTaskQueueInstance } from '../../queues' +import { authMiddleware } from '../../middlewares' @Controller('/test') +@UseMiddleware([authMiddleware]) export class TestController extends BaseController { taskQueue: TaskQueue constructor() { @@ -35,7 +38,7 @@ export class TestController extends BaseController { async testHanetWebhook() { console.log(this.req.url, this.req.method) console.log('body:', this.req.body) - return 1 + return 3 } calculateSecondBetween2Date() { diff --git a/packages/be-gateway/src/services/field/create.checkbox.field.strategy.ts b/packages/be-gateway/src/services/field/create.checkbox.field.strategy.ts new file mode 100644 index 00000000..4ebe1719 --- /dev/null +++ b/packages/be-gateway/src/services/field/create.checkbox.field.strategy.ts @@ -0,0 +1,31 @@ +import { FieldRepository } from "@shared/models"; +import { FieldCreate, FieldFactoryBase } from "./type"; +import { Field, Prisma } from "@prisma/client"; + +export class FieldCheckboxStrategy implements FieldFactoryBase { + fieldRepo: FieldRepository + constructor() { + this.fieldRepo = new FieldRepository() + } + async create(data: FieldCreate): Promise { + console.log('create checkbox') + const { data: fData, config: fConfig, ...restData } = data + const fieldConfig = fConfig as Prisma.JsonObject + const fieldData = fData as Prisma.JsonObject + try { + const result = await this.fieldRepo.create({ + ...{ + data: { ...fieldData }, + config: { ...{ width: 100 }, ...fieldConfig } + }, + ...restData + }) + + return result + } catch (error) { + console.log(error) + throw new Error('Create text field error') + } + } +} + diff --git a/packages/be-gateway/src/services/field/create.date.field.strategy.ts b/packages/be-gateway/src/services/field/create.date.field.strategy.ts new file mode 100644 index 00000000..c2e2e913 --- /dev/null +++ b/packages/be-gateway/src/services/field/create.date.field.strategy.ts @@ -0,0 +1,32 @@ +import { FieldRepository } from "@shared/models"; +import { FieldCreate, FieldFactoryBase } from "./type"; +import { Field, Prisma } from "@prisma/client"; + +export class FieldDateStrategy implements FieldFactoryBase { + fieldRepo: FieldRepository + constructor() { + this.fieldRepo = new FieldRepository() + } + async create(data: FieldCreate): Promise { + console.log('create date') + const { data: fData, config: fConfig, ...restData } = data + const fieldConfig = fConfig as Prisma.JsonObject + const fieldData = fData as Prisma.JsonObject + try { + const result = await this.fieldRepo.create({ + ...{ + data: { ...fieldData }, + config: { ...{ width: 200 }, ...fieldConfig } + }, + ...restData + }) + + // const result = await this.fieldRepo.create({ ...{ data: {}, config: { width: 100 } }, ...data }) + return result + } catch (error) { + console.log(error) + throw new Error('Create text field error') + } + } +} + diff --git a/packages/be-gateway/src/services/field/create.multiselect.field.strategy.ts b/packages/be-gateway/src/services/field/create.multiselect.field.strategy.ts new file mode 100644 index 00000000..9744be2a --- /dev/null +++ b/packages/be-gateway/src/services/field/create.multiselect.field.strategy.ts @@ -0,0 +1,30 @@ +import { FieldRepository } from "@shared/models"; +import { FieldCreate, FieldFactoryBase } from "./type"; +import { Field, Prisma } from "@prisma/client"; + +export class FieldMultiSelectStrategy implements FieldFactoryBase { + fieldRepo: FieldRepository + constructor() { + this.fieldRepo = new FieldRepository() + } + async create(data: FieldCreate): Promise { + console.log('create multi select') + const { data: fData, config: fConfig, ...restData } = data + const fieldConfig = fConfig as Prisma.JsonObject + const fieldData = fData as Prisma.JsonObject + try { + const result = await this.fieldRepo.create({ + ...{ + data: { ...fieldData }, + config: { ...{ width: 100 }, ...fieldConfig } + }, + ...restData + }) + return result + } catch (error) { + console.log(error) + throw new Error('Create text field error') + } + } +} + diff --git a/packages/be-gateway/src/services/field/create.number.field.strategy.ts b/packages/be-gateway/src/services/field/create.number.field.strategy.ts new file mode 100644 index 00000000..b35807ce --- /dev/null +++ b/packages/be-gateway/src/services/field/create.number.field.strategy.ts @@ -0,0 +1,31 @@ +import { FieldRepository } from "@shared/models"; +import { FieldCreate, FieldFactoryBase } from "./type"; +import { Field, Prisma } from "@prisma/client"; + +export class FieldNumberStrategy implements FieldFactoryBase { + fieldRepo: FieldRepository + constructor() { + this.fieldRepo = new FieldRepository() + } + async create(data: FieldCreate): Promise { + console.log('create number') + const { data: fData, config: fConfig, ...restData } = data + const fieldConfig = fConfig as Prisma.JsonObject + const fieldData = fData as Prisma.JsonObject + try { + const result = await this.fieldRepo.create({ + ...{ + data: { ...fieldData }, + config: { ...{ width: 100 }, ...fieldConfig } + }, + ...restData + }) + + return result + } catch (error) { + console.log(error) + throw new Error('Create text field error') + } + } +} + diff --git a/packages/be-gateway/src/services/field/create.select.field.strategy.ts b/packages/be-gateway/src/services/field/create.select.field.strategy.ts new file mode 100644 index 00000000..8498a2b2 --- /dev/null +++ b/packages/be-gateway/src/services/field/create.select.field.strategy.ts @@ -0,0 +1,30 @@ +import { FieldRepository } from "@shared/models"; +import { FieldCreate, FieldFactoryBase } from "./type"; +import { Field, Prisma } from "@prisma/client"; + +export class FieldSelectStrategy implements FieldFactoryBase { + fieldRepo: FieldRepository + constructor() { + this.fieldRepo = new FieldRepository() + } + async create(data: FieldCreate): Promise { + console.log('create select') + const { data: fData, config: fConfig, ...restData } = data + const fieldConfig = fConfig as Prisma.JsonObject + const fieldData = fData as Prisma.JsonObject + try { + const result = await this.fieldRepo.create({ + ...{ + data: { ...fieldData }, + config: { ...{ width: 100 }, ...fieldConfig } + }, + ...restData + }) + return result + } catch (error) { + console.log(error) + throw new Error('Create text field error') + } + } +} + diff --git a/packages/be-gateway/src/services/field/create.text.field.strategy.ts b/packages/be-gateway/src/services/field/create.text.field.strategy.ts new file mode 100644 index 00000000..58392c27 --- /dev/null +++ b/packages/be-gateway/src/services/field/create.text.field.strategy.ts @@ -0,0 +1,33 @@ +import { FieldRepository } from "@shared/models"; +import { FieldCreate, FieldFactoryBase } from "./type"; +import { Field, Prisma } from "@prisma/client"; + +export class FieldTextStrategy implements FieldFactoryBase { + + fieldRepo: FieldRepository + constructor() { + this.fieldRepo = new FieldRepository() + } + + async create(data: FieldCreate): Promise { + console.log('create text') + const { data: fData, config: fConfig, ...restData } = data + const fieldConfig = fConfig as Prisma.JsonObject + const fieldData = fData as Prisma.JsonObject + try { + const result = await this.fieldRepo.create({ + ...{ + width: 100, + data: { ...fieldData }, + config: { ...{ width: 100 }, ...fieldConfig } + }, + ...restData + }) + return result + } catch (error) { + console.log(error) + throw new Error('Create text field error') + } + } +} + diff --git a/packages/be-gateway/src/services/field/index.ts b/packages/be-gateway/src/services/field/index.ts new file mode 100644 index 00000000..4851f2f9 --- /dev/null +++ b/packages/be-gateway/src/services/field/index.ts @@ -0,0 +1,191 @@ +import { Field, FieldType, Prisma } from "@prisma/client"; +// import { FieldFactoryBase } from "./type"; +// import { FieldTextStrategy } from "./create.text.field.strategy"; +// import { FieldNumberStrategy } from "./create.number.field.strategy"; +// import { FieldDateStrategy } from "./create.date.field.strategy"; +// import { FieldSelectStrategy } from "./create.select.field.strategy"; +// import { FieldCheckboxStrategy } from "./create.checkbox.field.strategy"; +import { FieldRepository } from "@shared/models"; +import { pmClient } from "packages/shared-models/src/lib/_prisma"; + +export class FieldService { + fieldRepo: FieldRepository + constructor() { + this.fieldRepo = new FieldRepository() + } + + async getAllByProjectId(projectId: string) { + try { + const result = await this.fieldRepo.getAllByProjectId(projectId) + const sorted = result.sort((a, b) => a.order - b.order) + + return sorted + } catch (error) { + console.log(error) + throw new Error('FieldService.getAllByProjectId error') + } + } + + async delete(id: string) { + await this.fieldRepo.delete(id) + } + + async sortable(items: { id: string, order: number }[]) { + await pmClient.$transaction(async (tx) => { + const updatePromises = [] + console.log('start updating') + for (let i = 0; i < items.length; i++) { + const item = items[i]; + console.log('item', item.id, item.order) + // await tx.field.update({ + // where: { + // id: item.id + // }, + // data: { + // order: item.order + // } + // }) + + updatePromises.push(tx.field.update({ + where: { + id: item.id + }, + data: { + order: item.order + } + })) + } + + const result = await Promise.all(updatePromises).catch(err => { + console.log(err) + }) + return result + + // return 1 + }, { + timeout: 20000 + }) + } + + async update(data: Field) { + const result = await this.fieldRepo.update(data.id, data) + + console.log('Updated custom field:', data.id, data.name, data.type) + console.log(result) + return result + } + + async updateOrder() { + console.log('update order') + await pmClient.$transaction(async tx => { + console.log('1') + }) + } + + async create(type: FieldType, data: Omit) { + + console.log('create custom field', type) + + const { data: fData, config: fConfig, ...restData } = data + const fieldConfig = fConfig as Prisma.JsonObject + const fieldData = fData as Prisma.JsonObject + + const result = await pmClient.$transaction(async (tx) => { + const field = await tx.field.findFirst({ + where: { + projectId: data.projectId + }, + orderBy: { + order: 'desc' + }, + select: { + order: true + } + }) + + let biggestOrder = 0 + if (field && field.order >= 0) { + biggestOrder = field.order + 1 + } + + console.log('fieldConfig', fieldConfig) + console.log('restdata', restData) + + const createdField = await tx.field.create({ + data: { + ...{ + width: 100, + data: { ...fieldData }, + config: fieldConfig + }, + ...restData, + ...{ order: biggestOrder } + } + }) + + return createdField + + }) + + return result + + + + // const order = await this.fieldRepo.countProjectCustomField(data.projectId) + // + // try { + // const result = await this.fieldRepo.create({ + // ...{ + // width: 100, + // data: { ...fieldData }, + // config: { ...{ width: 100 }, ...fieldConfig } + // }, + // ...restData, + // ...{ order } + // }) + // + // return result + // } catch (error) { + // console.log(error) + // throw new Error('Create custom field error') + // } + // ============================================================================================================================================== + + // let createFieldFactory: FieldFactoryBase + // + // switch (type) { + // case FieldType.URL: + // case FieldType.EMAIL: + // case FieldType.TEXT: + // createFieldFactory = new FieldTextStrategy() + // break + // + // case FieldType.NUMBER: + // createFieldFactory = new FieldNumberStrategy() + // break + // + // case FieldType.DATE: + // createFieldFactory = new FieldDateStrategy() + // break + // + // case FieldType.SELECT: + // case FieldType.MULTISELECT: + // createFieldFactory = new FieldSelectStrategy() + // break + // + // // createFieldFactory = new FieldMultiSelectStrategy() + // // break + // + // case FieldType.CHECKBOX: + // createFieldFactory = new FieldCheckboxStrategy() + // break + // } + // + // if (!createFieldFactory) throw new Error('Field type missing') + // + // const result = await createFieldFactory.create(data) + // + // return result + + } +} diff --git a/packages/be-gateway/src/services/field/type.ts b/packages/be-gateway/src/services/field/type.ts new file mode 100644 index 00000000..93db457e --- /dev/null +++ b/packages/be-gateway/src/services/field/type.ts @@ -0,0 +1,6 @@ +import { Field } from "@prisma/client" + +export type FieldCreate = Omit +export interface FieldFactoryBase { + create(data: FieldCreate): Promise +} diff --git a/packages/be-gateway/src/services/grid/builders/boolean.builder.ts b/packages/be-gateway/src/services/grid/builders/boolean.builder.ts new file mode 100644 index 00000000..a4d8ebe9 --- /dev/null +++ b/packages/be-gateway/src/services/grid/builders/boolean.builder.ts @@ -0,0 +1,25 @@ +export function buildBooleanQuery(path: string, operator: string, value: string) { + switch (operator) { + case 'is': + return { [path]: value.toLowerCase() } + + case 'is empty': + return { + $or: [ + { [path]: null }, + { [path]: '' } + ] + } + + case 'is not empty': + return { + [path]: { + $exists: true, + $nin: ['', null] + } + } + + default: + return { [path]: value.toLowerCase() } + } +} diff --git a/packages/be-gateway/src/services/grid/builders/date.builder.ts b/packages/be-gateway/src/services/grid/builders/date.builder.ts new file mode 100644 index 00000000..9e86c87b --- /dev/null +++ b/packages/be-gateway/src/services/grid/builders/date.builder.ts @@ -0,0 +1,242 @@ +function getStartAndEndTime(date: Date) { + const startTime = new Date(date); + startTime.setHours(0, 0, 0, 0); // Set to start of the day + + const endTime = new Date(date); + endTime.setHours(23, 59, 59, 999); // Set to end of the day + + return [startTime, endTime]; +} + +function getThisWeekStartAndEnd(date: Date) { + const currentDate = new Date(date); + + // Calculate the previous Monday + const previousMonday = new Date(currentDate); + previousMonday.setDate(currentDate.getDate() - (currentDate.getDay() + 6) % 7); // Adjust to previous Monday + + // Calculate the previous Sunday + const previousSunday = new Date(previousMonday); + previousSunday.setDate(previousMonday.getDate() + 6); // Move to the following Sunday + + return [previousMonday, previousSunday]; +} + +function getPreviousSunday(date: Date) { + const previousSunday = new Date(date); + previousSunday.setDate(date.getDate() - date.getDay()); + return previousSunday; +} + +function getNextMonday(date: Date): Date { + // Create a new date object to avoid modifying the input + const nextMonday = new Date(date) + + // Reset time to start of day + nextMonday.setHours(0, 0, 0, 0) + + // Get current day of week (0 = Sunday, 1 = Monday, ..., 6 = Saturday) + const currentDay = nextMonday.getDay() + + // Calculate days to add to get to next Monday + const daysToAdd = currentDay === 0 ? 1 : // If Sunday, add 1 day + currentDay === 1 ? 7 : // If Monday, add 7 days (next Monday) + 8 - currentDay // For other days, calculate remaining days until next Monday + + // Add the calculated days + nextMonday.setDate(nextMonday.getDate() + daysToAdd) + + return nextMonday +} + +function getMonthRange(date: Date): Date[] { + // Create start date: 1st day of the month at 00:00:00 + const startDate = new Date(date.getFullYear(), date.getMonth(), 1) + startDate.setHours(0, 0, 0, 0) + + // Create end date: last day of the month at 23:59:59.999 + const endDate = new Date(date.getFullYear(), date.getMonth() + 1, 0) + endDate.setHours(23, 59, 59, 999) + + return [startDate, endDate] +} + +function getFirstDateOfPrevMonth(date: Date): Date { + // Create new date object to avoid modifying the input + const firstDate = new Date(date) + + // Set to first day of current month + firstDate.setDate(1) + + // Move to previous month + firstDate.setMonth(firstDate.getMonth() - 1) + + // Reset time to start of day + firstDate.setHours(0, 0, 0, 0) + + return firstDate +} + +function getFirstDateOfPrevYear(date: Date): Date { + const d = new Date(date) + const year = d.getFullYear(); + const passDate = new Date(year - 1, 0, 1) + passDate.setHours(0, 0, 0, 0) + + return passDate +} +function getFirstDateOfNextYear(date: Date): Date { + const d = new Date(date) + const year = d.getFullYear(); + const futureDate = new Date(year + 1, 0, 1) + futureDate.setHours(0, 0, 0, 0) + + return futureDate +} + +function getFirstDateOfNextMonth(date: Date): Date { + // Create new date object to avoid modifying the input + const firstDate = new Date(date) + + // Set to first day of current month + firstDate.setDate(1) + + // Move to previous month + firstDate.setMonth(firstDate.getMonth() + 1) + + // Reset time to start of day + firstDate.setHours(0, 0, 0, 0) + + return firstDate +} + +function getFirstAndLastDateOfYear(date) { + const year = new Date(date).getFullYear(); + + // First date of the year (January 1st at 00:00) + const firstDate = new Date(year, 0, 1, 0, 0, 0, 0); // January is month 0 + + // Last date of the year (December 31st at 23:59) + const lastDate = new Date(year, 11, 31, 23, 59, 59, 999); // December is month 11 + + return [firstDate, lastDate]; +} + + +export function getRelativeDate(value: string, subValue?: string): Date[] { + + const now = new Date(); + const startOfDay = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + + // Handle specific date strings + const yesterday = new Date(startOfDay); + const tomorrow = new Date(startOfDay); + const parsedDate = new Date(value); + + + switch (value) { + case 'today': + return getStartAndEndTime(startOfDay); + + case 'yesterday': + yesterday.setDate(yesterday.getDate() - 1); + return getStartAndEndTime(yesterday); + + case 'tomorrow': + tomorrow.setDate(tomorrow.getDate() + 1); + return getStartAndEndTime(tomorrow); + + case 'one week ago': + return getThisWeekStartAndEnd(getPreviousSunday(startOfDay)); + + case 'this week': + return getThisWeekStartAndEnd(startOfDay); + + case 'next week': + return getThisWeekStartAndEnd(getNextMonday(startOfDay)); + + // ----------------------------------------------------------------- + case 'one month ago': + return getMonthRange(getFirstDateOfPrevMonth(startOfDay)) + + case 'this month': + return getMonthRange(startOfDay) + + case 'next month': + return getMonthRange(getFirstDateOfNextMonth(startOfDay)) + + case 'one year ago': + // oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1); + // return oneYearAgo; + return getFirstAndLastDateOfYear(getFirstDateOfPrevYear(startOfDay)) + + case 'this year': + // return new Date(now.getFullYear(), 0, 1); + return getFirstAndLastDateOfYear(startOfDay) + + case 'next year': + // return new Date(now.getFullYear() + 1, 0, 1); + return getFirstAndLastDateOfYear(getFirstDateOfNextYear(startOfDay)) + + case 'exact date': + return getStartAndEndTime(subValue ? new Date(subValue) : startOfDay); + + default: + // Try to parse the value as a date string + return getStartAndEndTime(isNaN(parsedDate.getTime()) ? now : parsedDate); + } +} + +export function buildDateQuery(path: string, operator: string, value: string, subValue?: string) { + console.log('sub value', subValue); + const dateValue = getRelativeDate(value, subValue); + + switch (operator) { + case 'is': + // return handleIsOperator(path, value, dateValue); + return { + $and: [ + { [path]: { $gte: dateValue[0] } }, + { [path]: { $lte: dateValue[1] } } + ] + }; + + case 'is not': + // return handleIsNotOperator(path, value, dateValue); + return { + $or: [ + { [path]: { $gte: dateValue[0] } }, + { [path]: { $lte: dateValue[1] } } + ] + }; + + case 'is before': + return { [path]: { $lte: dateValue[0] } }; + + case 'is after': + return { [path]: { $gte: dateValue[1] } }; + + // case 'is within': + // return handleIsWithin(path, value, dateValue, getEndOfDay); + + case 'is empty': + return { + $or: [ + { [path]: null }, + { [path]: '' } + ] + }; + + case 'is not empty': + return { + [path]: { + $exists: true, + $nin: ['', null] + } + }; + + default: + return { [path]: dateValue }; + } +} + diff --git a/packages/be-gateway/src/services/grid/builders/number.builder.ts b/packages/be-gateway/src/services/grid/builders/number.builder.ts new file mode 100644 index 00000000..b48a5f9f --- /dev/null +++ b/packages/be-gateway/src/services/grid/builders/number.builder.ts @@ -0,0 +1,56 @@ +export function buildNumberQuery(path: string, operator: string, value: string) { + const numValue = parseFloat(value) + + switch (operator) { + case 'is': + return { [path]: numValue } + + case 'is not': + return { [path]: { $ne: numValue } } + + case 'contains': + return { [path]: { $regex: value, $options: 'i' } } + + case 'doesn\'t contain': + return { [path]: { $not: { $regex: value, $options: 'i' } } } + + case 'higher than': + return { [path]: { $gt: numValue } } + + case 'higher than or equal': + return { [path]: { $gte: numValue } } + + case 'lower than': + return { [path]: { $lt: numValue } } + + case 'lower than or equal': + return { [path]: { $lte: numValue } } + + case 'is even and whole': + return { + $and: [ + { [path]: { $mod: [2, 0] } }, + { [path]: { $type: "int" } } + ] + } + + case 'is empty': + return { + $or: [ + { [path]: null }, + { [path]: '' } + ] + } + + case 'is not empty': + return { + [path]: { + $exists: true, + $nin: ['', null] + } + } + + default: + return { [path]: numValue } + } +} \ No newline at end of file diff --git a/packages/be-gateway/src/services/grid/builders/person.builder.ts b/packages/be-gateway/src/services/grid/builders/person.builder.ts new file mode 100644 index 00000000..951176df --- /dev/null +++ b/packages/be-gateway/src/services/grid/builders/person.builder.ts @@ -0,0 +1,31 @@ +export function buildPersonQuery(path: string, operator: string, value: string) { + console.log('build person', value) + const persons = value.split(',') + switch (operator) { + case 'contains': + return { [path]: { $in: persons } } + + case 'doesn\'t contain': + return { [path]: { $nin: persons } } + + case 'is empty': + return { + $or: [ + { [path]: null }, + { [path]: '' }, + { [path]: { $size: 0 } } + ] + } + + case 'is not empty': + return { + [path]: { + $exists: true, + $nin: ['', null, []] + } + } + + default: + return { [path]: { $in: persons } } + } +} diff --git a/packages/be-gateway/src/services/grid/builders/select.builder.ts b/packages/be-gateway/src/services/grid/builders/select.builder.ts new file mode 100644 index 00000000..99e6c91f --- /dev/null +++ b/packages/be-gateway/src/services/grid/builders/select.builder.ts @@ -0,0 +1,35 @@ +export function buildSelectQuery(path: string, operator: string, value: string) { + switch (operator) { + case 'contains': + return { [path]: value } + + case 'doesn\'t contain': + return { [path]: { $ne: value } } + + case 'is': + return { [path]: value } + + case 'is not': + return { [path]: { $ne: value } } + + case 'is empty': + return { + $or: [ + { [path]: null }, + { [path]: '' }, + { [path]: { $size: 0 } } + ] + } + + case 'is not empty': + return { + [path]: { + $exists: true, + $nin: ['', null, []] + } + } + + default: + return { [path]: value } + } +} \ No newline at end of file diff --git a/packages/be-gateway/src/services/grid/builders/text.builder.ts b/packages/be-gateway/src/services/grid/builders/text.builder.ts new file mode 100644 index 00000000..1fe7c8d1 --- /dev/null +++ b/packages/be-gateway/src/services/grid/builders/text.builder.ts @@ -0,0 +1,59 @@ +export function buildTextQuery(path: string, operator: string, value: string) { + switch (operator) { + case 'is': + return { [path]: value } + + case 'is not': + return { [path]: { $ne: value } } + + case 'contains': + return { [path]: { $regex: value, $options: 'i' } } + + case 'doesn\'t contain': + return { [path]: { $not: { $regex: value, $options: 'i' } } } + + case 'contains word': + return { + $or: value.split(' ').map(word => ({ + [path]: { $regex: word.trim(), $options: 'i' } + })) + } + + case 'doesn\'t contain word': + return { + $and: value.split(' ').map(word => ({ + [path]: { $not: { $regex: word.trim(), $options: 'i' } } + })) + } + + case 'length is lower than': + const length = parseInt(value, 10) + if (isNaN(length)) return { [path]: { $exists: true } } + return { + $and: [ + { [path]: { $exists: true } }, + { [path]: { $expr: { $lt: [{ $strLenCP: `$${path}` }, length] } } } + ] + } + + case 'is empty': + return { + $or: [ + { [path]: { $exists: false } }, + { [path]: '' }, + { [path]: null } + ] + } + + case 'is not empty': + return { + [path]: { + $exists: true, + $nin: ['', null] + } + } + + default: + return { [path]: { $regex: value, $options: 'i' } } + } +} \ No newline at end of file diff --git a/packages/be-gateway/src/services/grid/grid.service.ts b/packages/be-gateway/src/services/grid/grid.service.ts new file mode 100644 index 00000000..9f496250 --- /dev/null +++ b/packages/be-gateway/src/services/grid/grid.service.ts @@ -0,0 +1,303 @@ +import { FieldType } from "@prisma/client" +import { pmClient } from "packages/shared-models/src/lib/_prisma" +import { buildTextQuery } from "./builders/text.builder" +import { buildNumberQuery } from "./builders/number.builder" +import { buildDateQuery } from "./builders/date.builder" +import { buildSelectQuery } from "./builders/select.builder" +import { buildBooleanQuery } from "./builders/boolean.builder" +import { FieldRepository, GridRepository } from "@shared/models" +import { buildPersonQuery } from "./builders/person.builder" + +export enum EFilterCondition { + AND = 'AND', + OR = 'OR' +} + +export type TFilterAdvancedItem = { + id: string + type: FieldType + operator: string + value: string + subValue?: string +} + +export interface IFilterAdvancedData { + condition: EFilterCondition + list: TFilterAdvancedItem[] +} + +interface PaginationOptions { + limit?: number + cursor?: string + page?: number +} + +export default class GridService { + gridRepo: GridRepository + fieldRepo: FieldRepository + constructor() { + this.gridRepo = new GridRepository() + this.fieldRepo = new FieldRepository() + } + + async updateMany(uid: string, rowIds: string[], data: { + [fieldId: string]: { value: string, type: FieldType } + }) { + console.log(rowIds, data) + const promises = [] + for (const rowId of rowIds) { + promises.push(this.gridRepo.updateMultiField(uid, { + id: rowId, + data + })) + } + + const results = await Promise.allSettled(promises) + + console.log('results', JSON.stringify(results[0], null, ' ')) + return results + } + + async update(uid: string, data: { value: string | string[], taskId: string, fieldId: string, type: FieldType }) { + try { + + const result = await this.gridRepo.update(uid, { + id: data.taskId, + fieldId: data.fieldId, + type: data.type, + value: data.value + }) + + return result + } catch (error) { + console.log('Update Custom field error:', error) + return null + } + } + + private buildCustomFieldQuery(filter: IFilterAdvancedData) { + const conditions = filter.list.map(item => { + return this.buildSingleFieldQuery(item) + }) + + if (!conditions || !conditions.length) return {} + + // Combine conditions based on AND/OR + if (filter.condition === EFilterCondition.AND) { + return { $and: conditions } + } else { + return { $or: conditions } + } + } + + async create(uid: string, data: { projectId: string }) { + try { + const result = await this.gridRepo.create(uid, { + projectId: data.projectId, + }) + + return result + } catch (error) { + console.log('Create Custom field error:', error) + return null + } + } + private buildSingleFieldQuery(item: TFilterAdvancedItem) { + const fieldPath = `customFields.${item.id}` + + switch (item.type) { + case FieldType.NUMBER: + return buildNumberQuery(fieldPath, item.operator, item.value) + + case FieldType.DATE: + return buildDateQuery(fieldPath, item.operator, item.value, item.subValue) + + case FieldType.CREATED_AT: + return buildDateQuery('createdAt', item.operator, item.value, item.subValue) + case FieldType.UPDATED_AT: + return buildDateQuery('updatedAt', item.operator, item.value, item.subValue) + + case FieldType.SELECT: + case FieldType.MULTISELECT: + return buildSelectQuery(fieldPath, item.operator, item.value) + + case FieldType.CHECKBOX: + return buildBooleanQuery(fieldPath, item.operator, item.value) + + case FieldType.PERSON: + return buildPersonQuery(fieldPath, item.operator, item.value) + case FieldType.CREATED_BY: + return buildPersonQuery('createdBy', item.operator, item.value) + case FieldType.UPDATED_BY: + return buildPersonQuery('updatedBy', item.operator, item.value) + + case FieldType.TEXT: + case FieldType.URL: + case FieldType.EMAIL: + default: + return buildTextQuery(fieldPath, item.operator, item.value) + } + } + + async queryCustomField( + projectId: string, + filter: IFilterAdvancedData, + pagination: PaginationOptions = {} + ) { + try { + const { limit = 50, cursor, page = 1 } = pagination + const safeLimit = Math.min(limit, 100) + + // Get total count for pagination info + const countQuery = this.buildQueryFilter(projectId, filter) + let countResults = await pmClient.grid.findRaw({ + filter: countQuery + }) + const totalCount = Array.isArray(countResults) ? countResults.length : 0 + countResults = null + + // Build and execute query + const query = this.buildQueryFilter(projectId, filter, cursor) + console.log('query', JSON.stringify(query, null, ' ')) + const results = await pmClient.grid.findRaw({ + filter: query, + options: { + limit: safeLimit + 1, + sort: { _id: -1 }, + } + }) + + // Process results + const normalizedResults = this.normalizeMongoResults(results as unknown as Record[]) + const items = normalizedResults.slice(0, safeLimit) + console.log('items', items.length) + const hasNextPage = normalizedResults.length > safeLimit + const nextCursor = hasNextPage ? items[items.length - 1].id : null + + return { + status: 200, + data: items, + pageInfo: { + hasNextPage, + nextCursor, + totalPages: Math.ceil(totalCount / safeLimit), + totalRecords: totalCount, + currentPage: page + } + } + } catch (error) { + console.error('Custom field query error:', error) + throw error + } + } + + private buildQueryFilter(projectId: string, filter: IFilterAdvancedData, cursor?: string) { + const baseFilter = { + projectId: { $eq: { $oid: projectId } }, + ...this.buildCustomFieldQuery(filter) + } + + if (cursor) { + baseFilter['_id'] = { $gt: { $oid: cursor } } + } + + return baseFilter + } + + private normalizeMongoResults(results: Record[]) { + return Array.from(results).map(task => { + const normalized = { ...task } + + // Handle ObjectIds + if (normalized._id?.$oid) normalized._id = normalized._id.$oid + normalized.id = normalized._id + if (normalized.projectId?.$oid) normalized.projectId = normalized.projectId.$oid + + // Handle Dates + const dateFields = ['createdAt', 'updatedAt', 'dueDate', 'startDate', 'plannedStartDate', 'plannedDueDate'] + dateFields.forEach(field => { + if (normalized[field]?.$date) normalized[field] = normalized[field].$date + }) + + // Handle Arrays + if (Array.isArray(normalized.assigneeIds)) { + normalized.assigneeIds = normalized.assigneeIds.map(id => id?.$oid || id) + } + if (Array.isArray(normalized.fileIds)) { + normalized.fileIds = normalized.fileIds.map(id => id?.$oid || id) + } + + return normalized + }) + } + + async createRow(uid: string, params: { + projectId: string, + data: Record + }) { + const { projectId, data } = params + // 1. Get all fields for the project + const projectFields = await this.fieldRepo.getAllByProjectId(projectId); + + // 2. Create customFields object by mapping field names to field IDs + const customFields = {}; + for (const field of projectFields) { + if (data[field.name]) { + // Use convertType to ensure proper data type conversion + customFields[field.id] = this.gridRepo.convertType(field.type, data[field.name]); + } + } + + // 3. Create new grid row using gridRepo + const newTask = await this.gridRepo.create(uid, { + projectId, + customFields + }); + + return newTask; + } + + async createRows(uid: string, params: { + projectId: string, + rows: Record[] + }) { + try { + + // 1. Get all fields for the project + const projectFields = await this.fieldRepo.getAllByProjectId(params.projectId); + + // 2. Create customFields objects for each row + const rows = params.rows.map(rowData => { + const customFields = {}; + for (const field of projectFields) { + if (rowData[field.name]) { + customFields[field.id] = this.gridRepo.convertType(field.type, rowData[field.name]); + } + } + return { customFields }; + }); + + // 3. Create multiple grid rows using gridRepo + const result = await this.gridRepo.createMany(uid, { + projectId: params.projectId, + rows + }); + + return result; + } catch (error) { + throw new Error(error) + } + } + + async deleteRows(rowIds: string[]) { + try { + // Delete the grid rows + await this.gridRepo.deleteMany(rowIds); + + return 1 + } catch (error) { + console.error('Error deleting grid rows:', error); + throw error; + } + } +} diff --git a/packages/be-gateway/src/services/storage.service.ts b/packages/be-gateway/src/services/storage.service.ts index 5ed3ca4f..897b8325 100644 --- a/packages/be-gateway/src/services/storage.service.ts +++ b/packages/be-gateway/src/services/storage.service.ts @@ -132,10 +132,11 @@ export class StorageService { } - async createPresignedUrl({ projectId, type, name }: { projectId: string, name: string, type: string }) { + async createPresignedUrl({ path, type, name }: { path: string, name: string, type: string }) { + path = [this.orgId, path].filter(Boolean).join('/') const s3Store = await this.initS3Client() - const randName = `${this.orgId}/${projectId}/` + s3Store.randomObjectKeyName(name) + const randName = `${path}/` + s3Store.randomObjectKeyName(name) try { const presignedUrl = await s3Store.createPresignedUrlWithClient(randName, type) diff --git a/packages/be-gateway/src/services/task/create.service.ts b/packages/be-gateway/src/services/task/create.service.ts index c6443371..f63a22f9 100644 --- a/packages/be-gateway/src/services/task/create.service.ts +++ b/packages/be-gateway/src/services/task/create.service.ts @@ -72,6 +72,7 @@ export default class TaskCreateService { checklistTodos: 0, desc, done, + customFields: {}, fileIds: [], projectId, priority, diff --git a/packages/be-gateway/src/types.ts b/packages/be-gateway/src/types.ts index e54a180d..6de8a9d1 100644 --- a/packages/be-gateway/src/types.ts +++ b/packages/be-gateway/src/types.ts @@ -1,8 +1,14 @@ -import { User } from "@prisma/client"; -import { Request } from "express"; - -export type JWTPayload = Pick - -export interface AuthRequest extends Request { - authen: JWTPayload -} +import { User } from "@prisma/client"; +import { Request } from "express"; + +export type JWTPayload = Pick +export enum JWTType { + USER = 'USER', + APP = 'APP' +} + +export interface AuthRequest extends Request { + authen: JWTPayload & { + type?: JWTType + } +} diff --git a/packages/shared-libs/src/lib/localCache.ts b/packages/shared-libs/src/lib/localCache.ts index 885cc6c4..d0b8977f 100644 --- a/packages/shared-libs/src/lib/localCache.ts +++ b/packages/shared-libs/src/lib/localCache.ts @@ -1,7 +1,9 @@ export enum LCK { RECENT_VISIT = 'RECENT_VISIT_', - PROJECT_BADGE = 'PROJECT_BADGE' + PROJECT_BADGE = 'PROJECT_BADGE', + PROJECT_FILTER = 'PROJECT_FILTER_' } + export const setLocalCache = (name: LCK | string, value: string) => { try { localStorage.setItem(name, value) @@ -55,3 +57,22 @@ export const getRecentVisit = (uid: string) => { return null } } + +// Project filter cache methods +export const setProjectFilter = (projectId: string, filter: any) => { + try { + localStorage.setItem(`${LCK.PROJECT_FILTER}${projectId}`, JSON.stringify(filter)) + } catch (error) { + console.log(error) + } +} + +export const getProjectFilter = (projectId: string) => { + try { + const data = localStorage.getItem(`${LCK.PROJECT_FILTER}${projectId}`) + if (!data) return null + return JSON.parse(data) + } catch (error) { + return null + } +} diff --git a/packages/shared-models/src/lib/_prisma.ts b/packages/shared-models/src/lib/_prisma.ts index 6bc2f7ac..419f9e54 100644 --- a/packages/shared-models/src/lib/_prisma.ts +++ b/packages/shared-models/src/lib/_prisma.ts @@ -12,6 +12,7 @@ export const taskChecklistModel = pmClient.taskChecklist export const tagModel = pmClient.tag export const favModel = pmClient.favorites export const taskModel = pmClient.task +export const gridModel = pmClient.grid export const memberModel = pmClient.members export const userModel = pmClient.user export const orgModel = pmClient.organization @@ -25,4 +26,5 @@ export const visionModel = pmClient.vision export const activityModel = pmClient.activity export const commentModel = pmClient.comment export const statsModel = pmClient.stats +export const fieldModel = pmClient.field diff --git a/packages/shared-models/src/lib/application.repository.ts b/packages/shared-models/src/lib/application.repository.ts new file mode 100644 index 00000000..55fe6d20 --- /dev/null +++ b/packages/shared-models/src/lib/application.repository.ts @@ -0,0 +1,35 @@ +import { Application } from '@prisma/client' +import { pmClient } from './_prisma' + +const mdApp = pmClient.application +export class ApplicationRepository { + async create(data: Omit) { + return mdApp.create({ + data + }) + } + + async update(id: string, data: Partial) { + return mdApp.update({ + where: { id }, + data + }) + } + + async getByOrgId(organizationId: string) { + return mdApp.findMany({ + where: { + organizationId + }, + orderBy: { + createdAt: 'desc' + } + }) + } + + async delete(id: string) { + return mdApp.delete({ + where: { id } + }) + } +} diff --git a/packages/shared-models/src/lib/field.repository.ts b/packages/shared-models/src/lib/field.repository.ts new file mode 100644 index 00000000..3bdcb7d6 --- /dev/null +++ b/packages/shared-models/src/lib/field.repository.ts @@ -0,0 +1,42 @@ +import { Field } from '@prisma/client' +import { fieldModel } from './_prisma' + + +export class FieldRepository { + + async getAllByProjectId(projectId: string) { + return fieldModel.findMany({ + where: { + projectId + } + }) + } + + async countProjectCustomField(projectId: string) { + + return fieldModel.count({ + where: { + projectId + } + }) + } + + async create(data: Omit) { + + return fieldModel.create({ + data + }) + } + + async update(fieldId: string, data: Partial) { + const { id, ...restData } = data + return fieldModel.update({ + where: { id: fieldId }, + data: restData + }) + } + + async delete(id: string) { + return fieldModel.delete({ where: { id } }) + } +} diff --git a/packages/shared-models/src/lib/grid.repository.ts b/packages/shared-models/src/lib/grid.repository.ts new file mode 100644 index 00000000..93e38a8c --- /dev/null +++ b/packages/shared-models/src/lib/grid.repository.ts @@ -0,0 +1,134 @@ +import { FieldType, Grid, Prisma } from "@prisma/client" +import { gridModel } from "./_prisma" + +export class GridRepository { + + convertType(type: FieldType, value: string | string[]) { + if (type === FieldType.MULTISELECT && Array.isArray(value)) { + return value + } + if (type === FieldType.NUMBER) { + return +value + } + + if (type === FieldType.DATE && !Array.isArray(value)) { + console.log('date', new Date(value)) + return new Date(value).toISOString() + } + + return value + } + + async update(uid: string, { id, fieldId, value, type }: { id: string, type: FieldType, value: string | string[], fieldId: string }) { + const oldTask = await gridModel.findFirst({ where: { id } }) + const oldCustomData = (oldTask.customFields || {}) as Prisma.JsonObject + + const result = await gridModel.update({ + where: { + id + }, + data: { + updatedAt: new Date(), + updatedBy: uid, + customFields: { + ...oldCustomData, + [fieldId]: this.convertType(type, value) + } + } + }) + + console.log('update data', result) + + return result + } + + async create(uid: string, data: Partial) { + const newTask = await gridModel.create({ + data: { + title: 'Untitled', + cover: null, + customFields: data.customFields || {}, + projectId: data.projectId, + createdBy: uid, + createdAt: new Date(), + updatedAt: null, + updatedBy: null, + } + }); + + console.log('create data', newTask); + + return newTask; + } + + async updateMultiField(uid: string, { id, data }: { + id: string, data: { + [fieldId: string]: { value: string, type: FieldType } + } + }) { + + const oldTask = await gridModel.findFirst({ where: { id } }) + const oldCustomData = (oldTask.customFields || {}) as Prisma.JsonObject + + const convertedData = {} + + for (const key in data) { + const dt = data[key] + convertedData[key] = this.convertType(dt.type, dt.value) + } + + console.log('convertedDAta', convertedData) + + const result = await gridModel.update({ + where: { + id + }, + data: { + updatedAt: new Date(), + updatedBy: uid, + customFields: { + ...oldCustomData, + ...convertedData + } + } + }) + + + return result + } + + async createMany(uid: string, data: { + projectId: string, + rows: { customFields: Record }[] + }) { + const now = new Date(); + const tasks = data.rows.map(row => ({ + title: 'Untitled', + cover: null, + customFields: row.customFields, + projectId: data.projectId, + createdBy: uid, + createdAt: now, + updatedAt: null, + updatedBy: null, + })); + + const result = await gridModel.createMany({ + data: tasks + }); + + console.log('create many data', result); + + return result; + } + + async deleteMany(rowIds: string[]) { + const result = await gridModel.deleteMany({ + where: { + id: { in: rowIds } + } + }) + return result + } + +} diff --git a/packages/shared-models/src/lib/index.ts b/packages/shared-models/src/lib/index.ts index b42da16d..28035ccd 100644 --- a/packages/shared-models/src/lib/index.ts +++ b/packages/shared-models/src/lib/index.ts @@ -24,3 +24,6 @@ export * from './scheduler.repository' export * from './comment.repository' export * from './task.checklist.repository' export * from './stats.repository' +export * from './field.repository' +export * from './grid.repository' +export * from './application.repository' diff --git a/packages/shared-models/src/prisma/schema.prisma b/packages/shared-models/src/prisma/schema.prisma index 8d3e6915..7218c236 100644 --- a/packages/shared-models/src/prisma/schema.prisma +++ b/packages/shared-models/src/prisma/schema.prisma @@ -143,6 +143,38 @@ model Test { order Int } +enum FieldType { + NUMBER + TEXT + DATE + SELECT + MULTISELECT + CHECKBOX + URL + EMAIL + FILES + PHONE + PERSON + CREATED_AT + CREATED_BY + UPDATED_AT + UPDATED_BY +} + +model Field { + id String @id @default(auto()) @map("_id") @db.ObjectId + projectId String @db.ObjectId + name String + type FieldType + icon String? + hidden Boolean? + width Int + order Int + desc String? + data Json? + config Json? +} + enum TaskType { TASK BUG @@ -178,6 +210,23 @@ model Task { // do not store `point` as objectID, cuz we just need to fill the point value taskPoint Int? + customFields Json? + + createdBy String? + createdAt DateTime? + updatedBy String? + updatedAt DateTime? +} + +model Grid { + id String @id @default(auto()) @map("_id") @db.ObjectId + title String + cover String? + projectId String @db.ObjectId + + customFields Json? + isDeleted Boolean? + createdBy String? createdAt DateTime? updatedBy String? @@ -263,6 +312,7 @@ enum FileType { } enum FileOwnerType { + USER TASK DISCUSSION DOCUMENT @@ -321,7 +371,6 @@ model ProjectSettingNotification { } enum ProjectViewType { - DASHBOARD LIST BOARD CALENDAR @@ -329,6 +378,8 @@ enum ProjectViewType { GOAL TEAM ACTIVITY + DASHBOARD + GRID } model ProjectView { @@ -472,3 +523,18 @@ model Comment { createdAt DateTime updatedAt DateTime } + +model Application { + id String @id @default(auto()) @map("_id") @db.ObjectId + name String + description String? + clientId String @unique + clientSecret String + organizationId String @db.ObjectId + scopes String[] // Array of permitted scopes + + createdAt DateTime? + createdBy String? + updatedAt DateTime? + updatedBy String? +} diff --git a/packages/shared-models/src/prisma/seed.ts b/packages/shared-models/src/prisma/seed.ts index 66efb755..8e3de1af 100644 --- a/packages/shared-models/src/prisma/seed.ts +++ b/packages/shared-models/src/prisma/seed.ts @@ -5,6 +5,7 @@ import { createProject } from './seeder/project' import { generateIconName, generateOrgName, generateProjectName } from './dummy' import { runTest } from './seeder/test' import { generateDailyData } from './seeder/report' +import { generateCustomFieldData, truncateCustomField, truncateData } from './seeder/customData' const args = process.argv const prisma = new PrismaClient() @@ -79,6 +80,15 @@ password: ${process.env.DEFAULT_PWD || '123123123'} case 'daily-stats': await generateDailyData() break; + case 'truncate': + // truncateCustomField('667547bbe186cf14067ef458') + truncateData('667547bbe186cf14067ef458') + break; + case 'custom-field': + await generateCustomFieldData( + '667547bbe186cf14067ef458', + 10); + break case 'test': await runTest() break; diff --git a/packages/shared-models/src/prisma/seeder/customData.ts b/packages/shared-models/src/prisma/seeder/customData.ts new file mode 100644 index 00000000..43a0e295 --- /dev/null +++ b/packages/shared-models/src/prisma/seeder/customData.ts @@ -0,0 +1,106 @@ +import { faker } from '@faker-js/faker' +import { FieldType } from '@prisma/client' +import { pmClient } from '../../lib/_prisma' + +const generateFieldValue = async (type: FieldType, config: any, data: any, memberIds: string[]) => { + switch (type) { + case 'NUMBER': + return faker.number.int({ min: 10, max: 70 }) + case 'TEXT': + return faker.person.fullName() + case 'DATE': + return faker.date.between({ from: new Date(2024, 0, 1), to: new Date(2024, 11, 1) }).toISOString() + case 'SELECT': + // const options = config?.options || ['Option 1', 'Option 2', 'Option 3'] + const options = data?.options || [] + const item = faker.helpers.arrayElement(options) as any + return item.value + case 'MULTISELECT': + // const multiOptions = config?.options || ['Option 1', 'Option 2', 'Option 3'] + const multiOptions = data?.options || [] + const selected = faker.helpers.arrayElements(multiOptions, { min: 1, max: 3 }) as { value: string, color: string }[] + return selected.map(s => s.value) + case 'CHECKBOX': + return faker.datatype.boolean() ? 'true' : 'false' + case 'URL': + return faker.internet.url() + case 'EMAIL': + return faker.internet.email() + case 'PHONE': + return faker.phone.number() + case 'PERSON': + return faker.helpers.arrayElements(memberIds, { min: 1, max: 2 }) + default: + return null + } +} + +export const generateCustomFieldData = async (projectId: string, totalRecords = 10) => { + try { + const [fields, members] = await Promise.all([ + pmClient.field.findMany({ + where: { projectId } + }), + pmClient.members.findMany({ + where: { projectId }, + select: { uid: true } + }) + ]) + + const memberIds = members.map(m => m.uid).filter(Boolean) as string[] + + let counter = 0 + for (let i = 0; i < totalRecords; i++) { + const customFields = {} + + for (const field of fields) { + customFields[field.id] = await generateFieldValue(field.type, field.config, field.data, memberIds) + } + + const result = await pmClient.grid.create({ + data: { + title: faker.lorem.sentence(), + projectId, + customFields, + createdAt: new Date(), + updatedAt: new Date() + } + }) + + counter++ + console.log('inserted', counter, result.id) + } + + console.log(`Created ${totalRecords} tasks with custom fields`) + } catch (error) { + console.error('Error generating custom field data:', error) + throw error + } +} + +export const truncateCustomField = async (projectId: string) => { + const promises = [] + promises.push(pmClient.grid.deleteMany({ + where: { + projectId + } + })) + promises.push(pmClient.field.deleteMany({ + where: { + projectId + } + })) + const result = await Promise.all(promises) + console.log('done') +} + +export const truncateData = async (projectId: string) => { + const promises = [] + promises.push(pmClient.task.deleteMany({ + where: { + projectId + } + })) + const result = await Promise.all(promises) + console.log('done') +} diff --git a/packages/shared-models/src/prisma/seeder/date.builder.ts b/packages/shared-models/src/prisma/seeder/date.builder.ts new file mode 100644 index 00000000..ffdd3f1b --- /dev/null +++ b/packages/shared-models/src/prisma/seeder/date.builder.ts @@ -0,0 +1,268 @@ +function getStartAndEndTime(date: Date) { + const startTime = new Date(date); + startTime.setHours(0, 0, 0, 0); // Set to start of the day + + const endTime = new Date(date); + endTime.setHours(23, 59, 59, 999); // Set to end of the day + + return [startTime, endTime]; +} + +function getThisWeekStartAndEnd(date: Date) { + const currentDate = new Date(date); + + // Calculate the previous Monday + const previousMonday = new Date(currentDate); + previousMonday.setDate(currentDate.getDate() - (currentDate.getDay() + 6) % 7); // Adjust to previous Monday + + // Calculate the previous Sunday + const previousSunday = new Date(previousMonday); + previousSunday.setDate(previousMonday.getDate() + 6); // Move to the following Sunday + + return [previousMonday, previousSunday]; +} + +function getPreviousSunday(date: Date) { + const previousSunday = new Date(date); + previousSunday.setDate(date.getDate() - date.getDay()); + return previousSunday; +} + +function getNextMonday(date: Date): Date { + // Create a new date object to avoid modifying the input + const nextMonday = new Date(date) + + // Reset time to start of day + nextMonday.setHours(0, 0, 0, 0) + + // Get current day of week (0 = Sunday, 1 = Monday, ..., 6 = Saturday) + const currentDay = nextMonday.getDay() + + // Calculate days to add to get to next Monday + const daysToAdd = currentDay === 0 ? 1 : // If Sunday, add 1 day + currentDay === 1 ? 7 : // If Monday, add 7 days (next Monday) + 8 - currentDay // For other days, calculate remaining days until next Monday + + // Add the calculated days + nextMonday.setDate(nextMonday.getDate() + daysToAdd) + + return nextMonday +} + +function getMonthRange(date: Date): Date[] { + // Create start date: 1st day of the month at 00:00:00 + const startDate = new Date(date.getFullYear(), date.getMonth(), 1) + startDate.setHours(0, 0, 0, 0) + + // Create end date: last day of the month at 23:59:59.999 + const endDate = new Date(date.getFullYear(), date.getMonth() + 1, 0) + endDate.setHours(23, 59, 59, 999) + + return [startDate, endDate] +} + +function getFirstDateOfPrevMonth(date: Date): Date { + // Create new date object to avoid modifying the input + const firstDate = new Date(date) + + // Set to first day of current month + firstDate.setDate(1) + + // Move to previous month + firstDate.setMonth(firstDate.getMonth() - 1) + + // Reset time to start of day + firstDate.setHours(0, 0, 0, 0) + + return firstDate +} + +function getFirstDateOfPrevYear(date: Date): Date { + const d = new Date(date) + const year = d.getFullYear(); + const passDate = new Date(year - 1, 0, 1) + passDate.setHours(0, 0, 0, 0) + + return passDate +} +function getFirstDateOfNextYear(date: Date): Date { + const d = new Date(date) + const year = d.getFullYear(); + const futureDate = new Date(year + 1, 0, 1) + futureDate.setHours(0, 0, 0, 0) + + return futureDate +} + +function getFirstDateOfNextMonth(date: Date): Date { + // Create new date object to avoid modifying the input + const firstDate = new Date(date) + + // Set to first day of current month + firstDate.setDate(1) + + // Move to previous month + firstDate.setMonth(firstDate.getMonth() + 1) + + // Reset time to start of day + firstDate.setHours(0, 0, 0, 0) + + return firstDate +} + +function getFirstAndLastDateOfYear(date) { + const year = new Date(date).getFullYear(); + + // First date of the year (January 1st at 00:00) + const firstDate = new Date(year, 0, 1, 0, 0, 0, 0); // January is month 0 + + // Last date of the year (December 31st at 23:59) + const lastDate = new Date(year, 11, 31, 23, 59, 59, 999); // December is month 11 + + return [firstDate, lastDate]; +} + + +export function getRelativeDate(value: string): Date[] { + const now = new Date(); + const startOfDay = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + + // Handle specific date strings + const yesterday = new Date(startOfDay); + const tomorrow = new Date(startOfDay); + const parsedDate = new Date(value); + + + switch (value) { + case 'today': + return getStartAndEndTime(startOfDay); + + case 'yesterday': + yesterday.setDate(yesterday.getDate() - 1); + return getStartAndEndTime(yesterday); + + case 'tomorrow': + tomorrow.setDate(tomorrow.getDate() + 1); + return getStartAndEndTime(tomorrow); + + case 'one week ago': + return getThisWeekStartAndEnd(getPreviousSunday(startOfDay)); + + case 'this week': + return getThisWeekStartAndEnd(startOfDay); + + case 'next week': + return getThisWeekStartAndEnd(getNextMonday(startOfDay)); + + // ----------------------------------------------------------------- + case 'one month ago': + return getMonthRange(getFirstDateOfPrevMonth(startOfDay)) + + case 'this month': + return getMonthRange(startOfDay) + + case 'next month': + return getMonthRange(getFirstDateOfNextMonth(startOfDay)) + + case 'one year ago': + // oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1); + // return oneYearAgo; + return getFirstAndLastDateOfYear(getFirstDateOfPrevYear(startOfDay)) + + case 'this year': + // return new Date(now.getFullYear(), 0, 1); + return getFirstAndLastDateOfYear(startOfDay) + + case 'next year': + // return new Date(now.getFullYear() + 1, 0, 1); + return getFirstAndLastDateOfYear(getFirstDateOfNextYear(startOfDay)) + + case 'exact date': + return getStartAndEndTime(startOfDay); + + default: + // Try to parse the value as a date string + return getStartAndEndTime(isNaN(parsedDate.getTime()) ? now : parsedDate); + } +} + +// New helper function to handle 'is within' logic +const handleIsWithin = (path: string, value: string, dateValue: Date, getEndOfDay: (date: Date) => Date) => { + let endDate: Date; + switch (value) { + case 'this week': + endDate = new Date(dateValue); + endDate.setDate(endDate.getDate() + 7); + break; + case 'this month': + endDate = new Date(dateValue); + endDate.setMonth(endDate.getMonth() + 1); + break; + case 'this year': + endDate = new Date(dateValue); + endDate.setFullYear(endDate.getFullYear() + 1); + break; + default: + endDate = getEndOfDay(dateValue); + } + return { + $and: [ + { [path]: { $gte: dateValue } }, + { [path]: { $lt: endDate } } + ] + }; +}; + +export function buildDateQuery(path: string, operator: string, value: string, subValue?: string) { + console.log('sub value', subValue); + const dateValue = getRelativeDate(value); + + switch (operator) { + case 'is': + // return handleIsOperator(path, value, dateValue); + return { + $and: [ + { [path]: { $gte: dateValue[0] } }, + { [path]: { $lte: dateValue[1] } } + ] + }; + + case 'is not': + // return handleIsNotOperator(path, value, dateValue); + return { + $or: [ + { [path]: { $gte: dateValue[0] } }, + { [path]: { $lte: dateValue[1] } } + ] + }; + + case 'is before': + return { [path]: { $lte: dateValue[0] } }; + + case 'is after': + return { [path]: { $gte: dateValue[1] } }; + + // case 'is within': + // return handleIsWithin(path, value, dateValue, getEndOfDay); + + case 'is empty': + return { + $or: [ + { [path]: null }, + { [path]: '' } + ] + }; + + case 'is not empty': + return { + [path]: { + $exists: true, + $nin: ['', null] + } + }; + + default: + return { [path]: dateValue }; + } +} + diff --git a/packages/shared-models/src/prisma/seeder/test.ts b/packages/shared-models/src/prisma/seeder/test.ts index ac6bc68c..34fef75b 100644 --- a/packages/shared-models/src/prisma/seeder/test.ts +++ b/packages/shared-models/src/prisma/seeder/test.ts @@ -1,231 +1,48 @@ -import { StatsType, StatusType } from "@prisma/client" -import { pmClient } from "../../lib/_prisma" -import { lastDayOfMonth } from "date-fns"; - -async function runUnDoneTask(projectId: string) { - const doneStatus = await pmClient.taskStatus.findMany({ - where: { - type: StatusType.DONE, - }, - select: { - id: true, - } - }) - - const ids = doneStatus.map(d => d.id) - - console.log('projectId', projectId) - - const now = new Date() - const y = now.getFullYear() - const m = now.getMonth() - const d = now.getDate() - const month = m + 1 - - const firstDay = new Date(y, m, 1, 0, 0) - const lastDay = lastDayOfMonth(now) - lastDay.setHours(23) - lastDay.setMinutes(59) - - const result = await pmClient.task.findMany({ - where: { - projectId, - taskStatusId: { - notIn: ids - }, - OR: [ - { - AND: [ - { - dueDate: { - gte: firstDay - } - }, - { - dueDate: { - lte: lastDay - } - }, - - ] - - } - ] - }, - select: { - id: true, - dueDate: true, - } - }) - - console.log(result.length) - const existing = await pmClient.stats.findFirst({ - where: { - projectId, - year: y, - month, - date: d - } - }) - - if (existing) { - await pmClient.stats.update({ - where: { - id: existing.id - }, - data: { - data: { - unDoneTotal: result.length - }, - updatedAt: new Date() - } - }) - } else { - console.log(1) - await pmClient.stats.create({ - data: { - type: StatsType.PROJECT_TASK_BY_DAY, - projectId, - year: y, - month, - date: d, - data: { - unDoneTotal: result.length - }, - updatedAt: new Date() - } - }) - } -} - -async function runDoneTaskByMem(projectId: string) { - const doneStatus = await pmClient.taskStatus.findMany({ - where: { - type: StatusType.DONE, - }, - select: { - id: true, - } - }) - - const ids = doneStatus.map(d => d.id) - - console.log('projectId', projectId) - - const now = new Date() - const y = now.getFullYear() - const m = now.getMonth() - const d = now.getDate() - const month = m + 1 - - const firstDay = new Date(y, m, 1, 0, 0) - const lastDay = lastDayOfMonth(now) - lastDay.setHours(23) - lastDay.setMinutes(59) - - const result = await pmClient.task.findMany({ - where: { - projectId, - assigneeIds: { - isEmpty: false - }, - taskStatusId: { - in: ids - }, - OR: [ - { - AND: [ - { - dueDate: { - gte: firstDay - } - }, - { - dueDate: { - lte: lastDay - } - }, - - ] - - } - ] - }, - select: { - id: true, - assigneeIds: true, - dueDate: true, - } - }) - - - const totalByMembers = new Map() - - result.forEach(r => { - r.assigneeIds.forEach(a => { - if (totalByMembers.has(a)) { - totalByMembers.set(a, totalByMembers.get(a) + 1) - } else { - totalByMembers.set(a, 1) - } - }) - - }) - - totalByMembers.forEach(async (total, uid) => { - - const existing = await pmClient.stats.findFirst({ - where: { - projectId, - type: StatsType.MEMBER_TASK_BY_DAY, - uid, - year: y, - month, - date: d - } - }) - - // create new if doesn't exist - if (!existing) { - await pmClient.stats.create({ - data: { - type: StatsType.MEMBER_TASK_BY_DAY, - projectId, - uid, - year: y, - month, - date: d, - data: { - doneTotal: total - }, - updatedAt: new Date() - } - }) - - // update if existing - } else { - await pmClient.stats.update({ - where: { - id: existing.id - }, - data: { - data: { - doneTotal: total - }, - updatedAt: new Date() - } - - }) - } - }) - - -} +import { buildDateQuery } from "./date.builder" + +const now = new Date(); +const startOfDay = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + +// Test cases for "is" operator +const testCases = [ + 'today', + 'yesterday', + 'tomorrow', + 'one week ago', + 'this week', + 'next week', + 'one month ago', + 'this month', + 'next month', + 'one year ago', + 'this year', + 'next year', + 'exact date', + 'days ago', + 'days from now', + 'weeks ago', + 'weeks from now', + 'months ago', + 'months from now', + 'years ago', + 'years from now' +]; export const runTest = async () => { - - // await runUnDoneTask('65e93b8c34df285397fd0b60') - await runDoneTaskByMem('65e93b8c34df285397fd0b60') + const path = '8792837498723' + // Print results for "is" operator + console.log('=== Test Cases for "is" Operator ==='); + testCases.forEach(testCase => { + console.log(`\nIS: "${testCase.toUpperCase()}" >>>>>>>>>>>>>>>>>>>>>>`); + console.log(JSON.stringify(buildDateQuery(path, 'is', testCase), null, 2)); + }); + + // Print results for "is not" operator + console.log('=== Test Cases for "is not" Operator ==='); + testCases.forEach(testCase => { + console.log(`\nIS NOT: "${testCase.toUpperCase()}" >>>>>>>>>>>>>>>>>>>>>>`); + console.log(JSON.stringify(buildDateQuery(path, 'is not', testCase), null, 2)); + }); } diff --git a/packages/shared-ui/src/components/Avatar/index.tsx b/packages/shared-ui/src/components/Avatar/index.tsx index f89a5186..c747a33a 100644 --- a/packages/shared-ui/src/components/Avatar/index.tsx +++ b/packages/shared-ui/src/components/Avatar/index.tsx @@ -14,6 +14,7 @@ interface IAvatar { } export default function Avatar({ src, name, size = 'base' }: IAvatar) { + const none = name?.toLowerCase() === 'none' ? 'avatar-none' : '' return ( {(name || '').slice(0, 2).toUpperCase()} diff --git a/packages/shared-ui/src/components/Avatar/style.css b/packages/shared-ui/src/components/Avatar/style.css index a86f38da..eb88b8cd 100644 --- a/packages/shared-ui/src/components/Avatar/style.css +++ b/packages/shared-ui/src/components/Avatar/style.css @@ -44,6 +44,10 @@ justify-content: center; line-height: 1; font-weight: 500; + + &.avatar-none { + @apply bg-gray-600; + } } .avatar-root.size-base .avatar-fallback { diff --git a/packages/shared-ui/src/components/Button/index.css b/packages/shared-ui/src/components/Button/index.css index 59f3eb5d..929601db 100644 --- a/packages/shared-ui/src/components/Button/index.css +++ b/packages/shared-ui/src/components/Button/index.css @@ -60,6 +60,10 @@ @apply hover:bg-yellow-300 active:bg-yellow-500; } +.btn.btn-ghost { + @apply bg-transparent border-transparent; +} + .btn.btn-sm { @apply px-2.5 py-1 text-sm rounded-md; } diff --git a/packages/shared-ui/src/components/Button/index.tsx b/packages/shared-ui/src/components/Button/index.tsx index 7b5287da..56911e0a 100644 --- a/packages/shared-ui/src/components/Button/index.tsx +++ b/packages/shared-ui/src/components/Button/index.tsx @@ -18,6 +18,7 @@ interface IButtonProps { title?: string primary?: boolean danger?: boolean + ghost?: boolean warn?: boolean leadingIcon?: React.ReactNode loading?: boolean @@ -40,6 +41,7 @@ const Button = ({ primary, danger, warn, + ghost, block, size = 'base', type = 'button', @@ -52,6 +54,7 @@ const Button = ({ const classes = [ 'btn', leadingIcon && 'has-leading-icon', + ghost && 'btn-ghost', primary && 'btn-primary', danger && 'btn-danger', warn && 'btn-warning', diff --git a/packages/shared-ui/src/components/Card/index.tsx b/packages/shared-ui/src/components/Card/index.tsx new file mode 100644 index 00000000..8cd3d579 --- /dev/null +++ b/packages/shared-ui/src/components/Card/index.tsx @@ -0,0 +1,75 @@ +import { ReactNode } from 'react' +import './style.css' + +interface CardProps { + children: ReactNode + className?: string + padding?: 'none' | 'sm' | 'base' | 'lg' + bordered?: boolean + hoverable?: boolean +} + +export default function Card({ + children, + className = '', + padding = 'base', + bordered = true, + hoverable = false +}: CardProps) { + const classes = [ + 'card', + `p-${padding}`, + bordered ? 'bordered' : '', + hoverable ? 'hoverable' : '', + className + ].filter(Boolean) + + return ( +
+ {children} +
+ ) +} + +// Sub-components for better organization +Card.Header = function CardHeader({ + children, + className = '' +}: { + children: ReactNode + className?: string +}) { + return ( +
+ {children} +
+ ) +} + +Card.Body = function CardBody({ + children, + className = '' +}: { + children: ReactNode + className?: string +}) { + return ( +
+ {children} +
+ ) +} + +Card.Footer = function CardFooter({ + children, + className = '' +}: { + children: ReactNode + className?: string +}) { + return ( +
+ {children} +
+ ) +} diff --git a/packages/shared-ui/src/components/Card/style.css b/packages/shared-ui/src/components/Card/style.css new file mode 100644 index 00000000..f04dcd40 --- /dev/null +++ b/packages/shared-ui/src/components/Card/style.css @@ -0,0 +1,46 @@ +.card { + @apply bg-white rounded-lg; + @apply dark:bg-gray-900; +} + +.card.bordered { + @apply border border-gray-200; + @apply dark:border-gray-700; +} + +.card.hoverable { + @apply transition-shadow duration-200; + @apply hover:shadow-md; +} + +/* Padding variants */ +.card.p-none { + @apply p-0; +} + +.card.p-sm { + @apply p-3; +} + +.card.p-base { + @apply p-4; +} + +.card.p-lg { + @apply p-6; +} + +/* Sub-components */ +.card-header { + @apply px-6 py-4 border-b border-gray-200; + @apply dark:border-gray-700; +} + +.card-body { + @apply p-6; +} + +.card-footer { + @apply px-6 py-4 border-t border-gray-200; + @apply dark:border-gray-700; +} diff --git a/packages/shared-ui/src/components/Controls/CheckboxControl/index.css b/packages/shared-ui/src/components/Controls/CheckboxControl/index.css index 641af0ee..821d0dce 100644 --- a/packages/shared-ui/src/components/Controls/CheckboxControl/index.css +++ b/packages/shared-ui/src/components/Controls/CheckboxControl/index.css @@ -3,7 +3,7 @@ } .inp-checkbox input[type='checkbox'] { - @apply h-4 w-4 rounded border-gray-300 text-indigo-600; + @apply rounded border-gray-300 text-indigo-600; @apply dark:bg-gray-800 dark:border-gray-700; @apply focus:ring-indigo-600; } @@ -16,6 +16,14 @@ @apply bg-gray-300; } +.inp-checkbox.size-base input[type='checkbox'] { + @apply h-4 w-4 +} + +.inp-checkbox.size-lg input[type='checkbox'] { + @apply h-5 w-5 +} + /* .inp-checkbox input[checked] { */ /* @apply border-indigo-600; */ /* } */ diff --git a/packages/shared-ui/src/components/Controls/CheckboxControl/index.tsx b/packages/shared-ui/src/components/Controls/CheckboxControl/index.tsx index a85de0f0..0f989f96 100644 --- a/packages/shared-ui/src/components/Controls/CheckboxControl/index.tsx +++ b/packages/shared-ui/src/components/Controls/CheckboxControl/index.tsx @@ -5,6 +5,8 @@ import './index.css' interface CheckboxProps { title?: string checked?: boolean + size?: 'base' | 'lg' + uid?: string name?: string onChange?: (checked: boolean) => void desc?: string | React.ReactNode @@ -15,6 +17,8 @@ interface CheckboxProps { const CheckboxControl = ({ title, checked, + uid, + size = 'base', onChange, name, desc, @@ -22,7 +26,7 @@ const CheckboxControl = ({ disabled }: CheckboxProps) => { const [isChecked, setIsChecked] = useState(!!checked) - const inputId = randomId() + const inputId = uid ?? randomId() const handleChange = (event: ChangeEvent) => { onChange && onChange(event.target.checked) @@ -31,6 +35,7 @@ const CheckboxControl = ({ const classNames = [ 'inp-checkbox', disabled ? 'disabled' : null, + size ? `size-${size}` : null, className ].filter(Boolean) diff --git a/packages/shared-ui/src/components/Controls/ListControl/ListButton.tsx b/packages/shared-ui/src/components/Controls/ListControl/ListButton.tsx index c80e3a53..b074fb8e 100644 --- a/packages/shared-ui/src/components/Controls/ListControl/ListButton.tsx +++ b/packages/shared-ui/src/components/Controls/ListControl/ListButton.tsx @@ -36,7 +36,7 @@ export default function ListButton({ size, children }: ListButtonProps) { ref={buttonRef} tabIndex={0} onClick={() => setVisible(!visible)}> - {multiple ?
{child()}
: child()} + {multiple ?
{child()}
: child()}
{children ? ( - {children} -
+ const ref = useRef(null) + const { visible } = useListContext() + const [pos, setPos] = useState({ top: 0, left: 0 }) + + useEffect(() => { + const elem = ref.current + if (visible && elem) { + const rect = elem.getBoundingClientRect() + setPos({ + top: rect.top, + left: rect.left + }) + } + }, [visible, ref]) + + return (
+ +
+ {children} +
+
+
) } diff --git a/packages/shared-ui/src/components/Controls/ListControl/ListPortal.tsx b/packages/shared-ui/src/components/Controls/ListControl/ListPortal.tsx new file mode 100644 index 00000000..d084e90d --- /dev/null +++ b/packages/shared-ui/src/components/Controls/ListControl/ListPortal.tsx @@ -0,0 +1,6 @@ +import { ReactNode, useEffect } from "react"; +import { createPortal } from "react-dom"; + +export default function ListPortal({ children }: { children: ReactNode }) { + return createPortal(
{children}
, document.body) +} diff --git a/packages/shared-ui/src/components/Controls/ListControl/index.tsx b/packages/shared-ui/src/components/Controls/ListControl/index.tsx index 5dd5bda0..3baa9af0 100644 --- a/packages/shared-ui/src/components/Controls/ListControl/index.tsx +++ b/packages/shared-ui/src/components/Controls/ListControl/index.tsx @@ -22,7 +22,7 @@ interface ListControlProps { value: ListItemValue | ListItemValue[] children: JSX.Element[] onFormikChange?: FormikFunc - onChange?: Dispatch> + onChange?: Dispatch> | ((val: ListItemValue) => void) onMultiChange?: Dispatch> } diff --git a/packages/shared-ui/src/components/Controls/ListControl/type.ts b/packages/shared-ui/src/components/Controls/ListControl/type.ts index a1884bf7..49c49e74 100644 --- a/packages/shared-ui/src/components/Controls/ListControl/type.ts +++ b/packages/shared-ui/src/components/Controls/ListControl/type.ts @@ -8,7 +8,7 @@ export interface ListItemValue { } export type FormikFunc = (field: string, value: any) => void -export type ListOnChange = Dispatch> +export type ListOnChange = Dispatch> | ((val: ListItemValue) => void) export type ListOnMultiChange = Dispatch> export interface ListContextProps { diff --git a/packages/shared-ui/src/components/Controls/PopoverControl/index.tsx b/packages/shared-ui/src/components/Controls/PopoverControl/index.tsx index 0cd412da..732194a6 100644 --- a/packages/shared-ui/src/components/Controls/PopoverControl/index.tsx +++ b/packages/shared-ui/src/components/Controls/PopoverControl/index.tsx @@ -2,11 +2,16 @@ import React, { SetStateAction } from 'react'; import * as Popover from '@radix-ui/react-popover'; interface PopoverControl { + align?: 'center' | 'start' | 'end' + alignOffset?: number; + sideOffset?: number; triggerBy: React.ReactNode; content: React.ReactNode; } const PopoverControl = ({ + align = 'center', + alignOffset = 0, sideOffset = 0, triggerBy, content, }: PopoverControl) => { @@ -14,7 +19,11 @@ const PopoverControl = ({ {triggerBy} - +
{content}
diff --git a/packages/shared-ui/src/components/DatePicker/index.tsx b/packages/shared-ui/src/components/DatePicker/index.tsx index 7fc73587..10f21eeb 100644 --- a/packages/shared-ui/src/components/DatePicker/index.tsx +++ b/packages/shared-ui/src/components/DatePicker/index.tsx @@ -8,6 +8,7 @@ import './style.css' export interface IDatePicker { className?: string + dateFormat?: string disabled?: boolean enableTimer?: boolean title?: string @@ -20,6 +21,7 @@ export interface IDatePicker { export default function DatePicker({ title, className, + dateFormat, disabled, enableTimer, value, @@ -95,10 +97,20 @@ export default function DatePicker({ } const showDateStr = (d: Date) => { - if (toNow) { + if (toNow || dateFormat === 'from-now') { return formatDistanceToNowStrict(d, { addSuffix: true }) } + if (dateFormat) { + try { + const formatData = format(d, dateFormat) + return formatData + } catch (error) { + console.log('datepicker format string error: ', error) + return format(d, 'PP') + } + } + return format(d, 'PP') } diff --git a/packages/shared-ui/src/components/Dialog/DialogContent.tsx b/packages/shared-ui/src/components/Dialog/DialogContent.tsx index f39b0ee8..6074d1c6 100644 --- a/packages/shared-ui/src/components/Dialog/DialogContent.tsx +++ b/packages/shared-ui/src/components/Dialog/DialogContent.tsx @@ -5,11 +5,13 @@ import { useDialogContext } from "./context"; export default function DialogContent({ size = 'base', className, + position = 'center', children }: { className?: string children?: ReactNode size?: 'sm' | 'base' | 'lg' | 'xl' + position?: 'center' | 'right' }) { const { open, onOpenChange } = useDialogContext() @@ -26,13 +28,19 @@ export default function DialogContent({ ev.stopPropagation() } + let pos = 'justify-center py-[100px]' + if (position === 'right') { + pos = 'justify-end p-3 ' + classes.push('h-full') + } + return
-
-
+
+
{children} diff --git a/packages/shared-ui/src/components/IconColorPicker/index.tsx b/packages/shared-ui/src/components/IconColorPicker/index.tsx new file mode 100644 index 00000000..1723d8a5 --- /dev/null +++ b/packages/shared-ui/src/components/IconColorPicker/index.tsx @@ -0,0 +1,128 @@ +'use client' + +import * as Popover from '@radix-ui/react-popover' +import dynamic from 'next/dynamic' +import { EmojiClickData, EmojiStyle } from 'emoji-picker-react' +import { useState } from 'react' + +// Dynamically import EmojiPicker to avoid SSR issues +const EmojiPicker = dynamic(() => import('emoji-picker-react'), { ssr: false }) + +export const colors = [ + '#f9fafb', '#f3f4f6', '#d1d5db', '#9ca3af', '#4b5563', '#1f2937', + '#f7fee7', '#ecfccb', '#d9f99d', '#bef264', '#a3e635', '#65a30d', + '#ecfdf5', '#d1fae5', '#6ee7b7', '#34d399', '#10b981', '#047857', + '#fefce8', '#fef9c3', '#fde047', '#facc15', '#eab308', '#ca8a04', + '#fdf2f8', '#fce7f3', '#fbcfe8', '#f9a8d4', '#ec4899', '#be185d', + '#fef2f2', '#fee2e2', '#fecaca', '#fca5a5', '#f87171', '#dc2626' +] + +function getContrastColor(hexColor: string): string { + const r = parseInt(hexColor.slice(1, 3), 16) + const g = parseInt(hexColor.slice(3, 5), 16) + const b = parseInt(hexColor.slice(5, 7), 16) + const brightness = (r * 299 + g * 587 + b * 114) / 1000 + return brightness > 128 ? '#000000' : '#FFFFFF' +} + +type PickerState = { type: 'color'; value: string } | { type: 'emoji'; value: string } + +interface ColorPickerProps { + value?: string; + onChange?: (value: string) => void; +} + +export default function IconColorPicker({ value, onChange }: ColorPickerProps) { + const [pickerState, setPickerState] = useState(() => { + if (!value) return { type: 'color', value: '#f9fafb' }; + return value.startsWith('#') ? { type: 'color', value } : { type: 'emoji', value }; + }); + const [isOpen, setIsOpen] = useState(false) + const [activeTab, setActiveTab] = useState<'color' | 'emoji'>('color') + + const handleColorSelect = (color: string) => { + setPickerState({ type: 'color', value: color }) + setIsOpen(false) + onChange?.(color) + } + + const handleEmojiSelect = (emojiData: EmojiClickData) => { + setPickerState({ type: 'emoji', value: emojiData.imageUrl }) + setIsOpen(false) + onChange?.(emojiData.imageUrl) + } + + return ( + + + + + + +
+ + +
+ {activeTab === 'color' ? ( +
+ {colors.map((color) => { + const isSelected = pickerState.type === 'color' && pickerState.value === color + const buttonContrastColor = getContrastColor(color) + return ( + + ) + })} +
+ ) : ( +
+ +
+ )} + +
+
+
+ ) +} diff --git a/packages/shared-ui/src/doc.md b/packages/shared-ui/src/doc.md new file mode 100644 index 00000000..42137b7a --- /dev/null +++ b/packages/shared-ui/src/doc.md @@ -0,0 +1,130 @@ +## Dialog + +```tsx +// You need to import it first +import { Dialog } from '@shared/ui' + +// How to use it +const MyDialog = () => { + const [open, setOpen] = useState(false) + + return + + {/* You can use any element as a trigger */} + + + + {/* Portal is used to render the dialog content outside the parent element + So its required to use it + */} + + {/* Dialog content, you can customize the size */} + + {/* You can use any React element as a content */} +

My Dialog

+
+
+
+} +``` + +## Button +```tsx +// You need to import it first +import { Button } from '@shared/ui' + +// How to use it +const MyButton = () => { + return <> + {/* You can use title or leadingIcon */} +
+
) } diff --git a/packages/ui-app/app/[orgName]/project/[projectId]/ProjectTabContent.tsx b/packages/ui-app/app/[orgName]/project/[projectId]/ProjectTabContent.tsx index b8fd9116..9846fea0 100644 --- a/packages/ui-app/app/[orgName]/project/[projectId]/ProjectTabContent.tsx +++ b/packages/ui-app/app/[orgName]/project/[projectId]/ProjectTabContent.tsx @@ -27,6 +27,10 @@ const Vision = dynamic(() => import('@/features/Project/Vision'), { loading: () => }) +const Grid = dynamic(() => import('@/features/Project/GridView'), { + loading: () => +}) + const Settings = dynamic(() => import('./settings'), { loading: () => }) @@ -110,6 +114,9 @@ export default function ProjectTabContent() { + + + diff --git a/packages/ui-app/app/[orgName]/project/[projectId]/style.css b/packages/ui-app/app/[orgName]/project/[projectId]/style.css index dc74b75a..b4c5ae99 100644 --- a/packages/ui-app/app/[orgName]/project/[projectId]/style.css +++ b/packages/ui-app/app/[orgName]/project/[projectId]/style.css @@ -2,6 +2,10 @@ @apply relative; } +.list-view-container .list-cell { + height: initial !important; +} + .task-assignee .select-button, .task-type-cell .select-button, .task-priority .select-button, diff --git a/packages/ui-app/app/[orgName]/project/[projectId]/views/ListMode.tsx b/packages/ui-app/app/[orgName]/project/[projectId]/views/ListMode.tsx index b75d1277..799a67de 100644 --- a/packages/ui-app/app/[orgName]/project/[projectId]/views/ListMode.tsx +++ b/packages/ui-app/app/[orgName]/project/[projectId]/views/ListMode.tsx @@ -23,7 +23,7 @@ export default function ListMode() { const { tasks, taskLoading } = useTaskStore() return ( -
+
{groupByItems.map(group => { return (
+} diff --git a/packages/ui-app/app/_components/DataFetcher/context.ts b/packages/ui-app/app/_components/DataFetcher/context.ts new file mode 100644 index 00000000..2f11b94a --- /dev/null +++ b/packages/ui-app/app/_components/DataFetcher/context.ts @@ -0,0 +1,28 @@ +import { Dispatch, SetStateAction, createContext } from 'react' +import { ExtendedTask } from '@/store/task' + +export interface DataFetcherContextType { + data: ExtendedTask[] + cursor: string + totalRecords: number + restRecords: number + isLoading: boolean + hasNextPage: boolean + deleteRow: (ids: string | string[]) => void + setData: Dispatch> + fetchNextPage: () => void + updateCustomFields: (taskIds: string[], customFields: Record) => void +} + +export const DataFetcherContext = createContext({ + cursor: '', + data: [], + totalRecords: 0, + restRecords: 0, + isLoading: false, + hasNextPage: false, + deleteRow: () => console.log(1), + setData: () => console.log(1), + fetchNextPage: () => console.log(1), + updateCustomFields: () => console.log(1) +}) diff --git a/packages/ui-app/app/_components/DataFetcher/index.tsx b/packages/ui-app/app/_components/DataFetcher/index.tsx new file mode 100644 index 00000000..a3940a1a --- /dev/null +++ b/packages/ui-app/app/_components/DataFetcher/index.tsx @@ -0,0 +1,69 @@ +import { useEffect, ReactNode } from 'react' +import { useParams } from 'next/navigation' +import { EFilterCondition, IFilterAdvancedData } from '@/features/FilterAdvanced/type' +import { useTaskFetcher } from './useTaskFetcher' +import { DataFetcherContext, DataFetcherContextType } from './context' + +interface DataFetcherProps { + children: ReactNode + groupBy?: string + filter?: IFilterAdvancedData + initialCursor?: string + limit?: number + orderBy?: { [key: string]: 'asc' | 'desc' } + +} +export default function DataFetcher({ + children, + filter = { condition: EFilterCondition.AND, list: [] }, + groupBy = '', + initialCursor, + limit = 20, + orderBy = { id: 'asc' } +}: DataFetcherProps) { + const { projectId } = useParams() + const { + data, + cursor, + setData, + isLoading, + hasNextPage, + deleteRow, + totalRecords, + restRecords, + fetchNextPage, + updateCustomFields, + fetchData + } = useTaskFetcher({ + projectId, + groupBy, + filter, + limit, + orderBy, + initialCursor + }) + + useEffect(() => { + const controller = fetchData(initialCursor) + return () => controller.abort() + }, [JSON.stringify(filter), initialCursor, projectId, limit, JSON.stringify(orderBy)]) + + const contextValue: DataFetcherContextType = { + cursor, + setData, + deleteRow, + data, + totalRecords, + restRecords, + isLoading, + hasNextPage, + fetchNextPage, + updateCustomFields + } + + return ( + + {children} + + ) +} diff --git a/packages/ui-app/app/_components/DataFetcher/useDataFetcher.ts b/packages/ui-app/app/_components/DataFetcher/useDataFetcher.ts new file mode 100644 index 00000000..c14d6478 --- /dev/null +++ b/packages/ui-app/app/_components/DataFetcher/useDataFetcher.ts @@ -0,0 +1,24 @@ +import { useContext, useMemo, useRef } from 'react' +import { DataFetcherContext, DataFetcherContextType } from './context' + +export function useDataFetcher(): DataFetcherContextType +export function useDataFetcher( + selector: (state: DataFetcherContextType) => Selected, + equalityFn?: (a: Selected, b: Selected) => boolean +): Selected +export function useDataFetcher( + selector?: (state: DataFetcherContextType) => Selected, +) { + const context = useContext(DataFetcherContext) + + const selectedValue = useMemo( + () => selector && context ? selector(context) : context, + [context, selector] + ) + + if (!selector) { + return context + } + + return selectedValue +} diff --git a/packages/ui-app/app/_components/DataFetcher/useTaskAdd.ts b/packages/ui-app/app/_components/DataFetcher/useTaskAdd.ts new file mode 100644 index 00000000..2b750f96 --- /dev/null +++ b/packages/ui-app/app/_components/DataFetcher/useTaskAdd.ts @@ -0,0 +1,43 @@ +import { useDataFetcher } from "./useDataFetcher" +import { randomId } from "@shared/ui" +import { useParams } from "next/navigation" +import { ExtendedTask } from "@/store/task" +import { projectGridSv } from "@/services/project.grid" + +export const useTaskAdd = () => { + const { setData } = useDataFetcher() + const { projectId } = useParams() + + const addNewRow = (data?: Partial) => { + const id = `TASK_RAND_${randomId()}` + const insertedData = { + id, + title: '', + projectId, + customFields: {} + } as ExtendedTask + + setData(prevData => { + return [ + ...prevData, + insertedData + ] + }) + + projectGridSv.create(insertedData).then(res => { + console.log(res) + const { data, status } = res.data + if (status !== 200) return + + setData(prevData => prevData.map(dt => { + if (dt.id === id) return data + return dt + })) + + }) + } + + return { + addNewRow + } +} diff --git a/packages/ui-app/app/_components/DataFetcher/useTaskFetcher.ts b/packages/ui-app/app/_components/DataFetcher/useTaskFetcher.ts new file mode 100644 index 00000000..40015e3e --- /dev/null +++ b/packages/ui-app/app/_components/DataFetcher/useTaskFetcher.ts @@ -0,0 +1,130 @@ +import { useCallback, useState } from 'react' +import { IFilterAdvancedData } from '@/features/FilterAdvanced/type' +import { ExtendedTask } from '@/store/task' +import { FieldType } from '@prisma/client' +import { projectGridSv } from '@/services/project.grid' + +interface UseTaskFetcherProps { + projectId: string + filter: IFilterAdvancedData + limit: number + groupBy: string + orderBy: { [key: string]: 'asc' | 'desc' } + initialCursor?: string +} + +type FieldValues = { + [fieldId: string]: { value: string, type: FieldType } +} + +export function useTaskFetcher({ + projectId, + filter, + limit, + orderBy, + initialCursor +}: UseTaskFetcherProps) { + const [data, setData] = useState([]) + const [cursor, setCursor] = useState(initialCursor || '') + const [hasNextPage, setHasNextPage] = useState(false) + const [isLoading, setIsLoading] = useState(false) + const [totalRecords, setTotalRecords] = useState(0) + + const fetchData = useCallback((nextCursor?: string) => { + // const fetchData = (nextCursor?: string) => { + const controller = new AbortController() + setIsLoading(true) + + projectGridSv.get( + projectId, + filter, + controller.signal, + { + cursor: nextCursor, + limit, + orderBy + } + ).then(res => { + const { data: resData } = res.data + const { data: items, pageInfo, status } = resData + + if (status === 200) { + if (nextCursor) { + setData(prev => [...prev, ...items]) + } else { + setData(items) + } + + setHasNextPage(pageInfo.hasNextPage) + setCursor(pageInfo.nextCursor) + setTotalRecords(pageInfo.totalRecords) + + } else { + setData([]) + } + }).catch(err => { + console.error('Failed to fetch data:', err) + }).finally(() => { + setIsLoading(false) + }) + + return controller + // } + }, [filter, projectId, limit, orderBy]) + + const fetchNextPage = useCallback(() => { + if (hasNextPage && cursor && !isLoading) { + fetchData(cursor) + } + }, [hasNextPage, cursor, isLoading, fetchData]) + + const updateCustomFields = useCallback((taskIds: string[], customFields: FieldValues) => { + + const newCustomFields = Object.entries(customFields) + .reduce((acc, [fieldId, { value }]) => ({ + ...acc, + [fieldId]: value + }), {}) + + setData(prevData => { + const result = prevData.map(task => { + if (!taskIds.includes(task.id)) { + return task + } + + return { + ...task, + updatedAt: new Date(), + customFields: { ...Object(task.customFields), ...newCustomFields } + } + }) + return result + } + + ) + }, []) + + const deleteRow = (ids: string | string[]) => { + const deletedIds = Array.isArray(ids) ? ids : [ids] + + setData(prevData => { + return prevData.filter(dt => { + return deletedIds.includes(dt.id) ? false : true + }) + }) + } + + return { + data, + setData, + cursor, + restRecords: Math.max(0, totalRecords - data.length), + totalRecords, + isLoading, + hasNextPage, + fetchNextPage, + fetchData, + deleteRow, + updateCustomFields + } +} diff --git a/packages/ui-app/app/_components/DataFetcher/useTaskUpdate.ts b/packages/ui-app/app/_components/DataFetcher/useTaskUpdate.ts new file mode 100644 index 00000000..288279b7 --- /dev/null +++ b/packages/ui-app/app/_components/DataFetcher/useTaskUpdate.ts @@ -0,0 +1,45 @@ +import { useDataFetcher } from "./useDataFetcher" +import { messageSuccess } from "@shared/ui" +import { projectGridSv } from "@/services/project.grid" +import { FieldType } from "@prisma/client" +import { useUser } from "@goalie/nextjs" + +export const useTaskUpdate = () => { + const { setData } = useDataFetcher() + const { user } = useUser() + + const updateOneField = ({ taskId, value, fieldId, type }: { + taskId: string, + value: string | string[], + fieldId: string, + type: FieldType + }) => { + + setData(prevData => prevData.map(dt => { + if (dt.id === taskId) { + return { + ...dt, + updatedAt: new Date(), + updatedBy: user?.id || '' + } + } + return dt + })) + + projectGridSv.update({ + taskId, + type, + value, + fieldId + }).then(res => { + const { data, status } = res.data + console.log('returned data:', data, status) + if (status !== 200) return + messageSuccess('Update field value sucecss') + }) + } + + return { + updateOneField + } +} diff --git a/packages/ui-app/app/_components/DocViewer/index.tsx b/packages/ui-app/app/_components/DocViewer/index.tsx new file mode 100644 index 00000000..ad508a42 --- /dev/null +++ b/packages/ui-app/app/_components/DocViewer/index.tsx @@ -0,0 +1,45 @@ +'use client' + +import { Loading } from '@shared/ui' +import { useState, useEffect } from 'react' +import { renderAsync } from 'docx-preview' + +export default function DocViewer({ src, className }: { src: string, className?: string }) { + const [loading, setLoading] = useState(true) + + useEffect(() => { + const loadDoc = async () => { + try { + // Fetch the document + const response = await fetch(src) + const blob = await response.blob() + + // Create container for the document + const container = document.getElementById('doc-container') + if (!container) return + + // Render the document + await renderAsync(blob, container, container) + + setLoading(false) + } catch (error) { + console.error('Error loading document:', error) + setLoading(false) + } + } + + loadDoc() + }, [src]) + + return ( +
+ +
+
+ ) +} diff --git a/packages/ui-app/app/_components/DynamicIcon.tsx b/packages/ui-app/app/_components/DynamicIcon.tsx index 5c88514d..5a2e49f6 100644 --- a/packages/ui-app/app/_components/DynamicIcon.tsx +++ b/packages/ui-app/app/_components/DynamicIcon.tsx @@ -47,7 +47,8 @@ import { HiOutlineBars3, HiOutlineBars3BottomLeft, HiOutlineBars3BottomRight, - HiOutlineBars3CenterLeft + HiOutlineBars3CenterLeft, + HiOutlineTableCells } from 'react-icons/hi2' import { GoLaw, GoVersions, GoCodeOfConduct } from 'react-icons/go' @@ -55,6 +56,7 @@ const icons: { [key: string]: IconType } = { GoLaw, GoVersions, GoCodeOfConduct, + HiOutlineTableCells, HiOutlineBattery0, HiOutlineBattery100, diff --git a/packages/ui-app/app/_components/FileKits/FileCarousel.tsx b/packages/ui-app/app/_components/FileKits/FileCarousel.tsx index 0d2f1950..29695739 100644 --- a/packages/ui-app/app/_components/FileKits/FileCarousel.tsx +++ b/packages/ui-app/app/_components/FileKits/FileCarousel.tsx @@ -9,15 +9,20 @@ import { useEffect } from 'react' import PdfViewer from '../PdfViewer' import './carousel.css' import { createPortal } from 'react-dom' +import DocViewer from '../DocViewer' function FileCarouselDisplay({ file }: { file: IFileItem }) { if (!file) return null const isPdf = file.ext.toLowerCase() === 'pdf' + const isDoc = ['doc', 'docx'].includes(file.ext.toLowerCase()) const isVideo = file.ext.toLowerCase() === 'mp4' const url = isImage(file.mimeType) ? file.url : getIconUrl(file.ext) const getPreview = () => { + if (isDoc && file.url) { + return + } if (isPdf && file.url) { return (
@@ -27,7 +32,6 @@ function FileCarouselDisplay({ file }: { file: IFileItem }) { } if (isVideo) { - console.log(file) return (
+} diff --git a/packages/ui-app/app/_features/Project/GridView/GridRowContainer.tsx b/packages/ui-app/app/_features/Project/GridView/GridRowContainer.tsx new file mode 100644 index 00000000..84ba857c --- /dev/null +++ b/packages/ui-app/app/_features/Project/GridView/GridRowContainer.tsx @@ -0,0 +1,24 @@ +import { ExtendedTask } from "@/store/task"; +import './grid-style.css' +import CreateNewRow from "./CreateNewRow"; +import GridHeadingRow from "./GridHeadingRow"; +import GridContentRow from "./GridContentRow"; +import GridLoadMore from "./GridLoadMore"; + + +export default function GridRowContainer({ tasks }: { + tasks: ExtendedTask[], +}) { + + return
+
+ + {tasks.map(task => { + return + })} + + +
+ +
+} diff --git a/packages/ui-app/app/_features/Project/GridView/GridViewContainer.tsx b/packages/ui-app/app/_features/Project/GridView/GridViewContainer.tsx new file mode 100644 index 00000000..454427d5 --- /dev/null +++ b/packages/ui-app/app/_features/Project/GridView/GridViewContainer.tsx @@ -0,0 +1,30 @@ +'use client' + +import { useDataFetcher } from '@/components/DataFetcher/useDataFetcher' +import CustomFieldMultiAction from '@/features/CustomFieldMultiAction' +import GridDataFilter from './GridDataFilter' +import GridRowContainer from './GridRowContainer' + +export default function GridViewContainer() { + + return ( + +
+ +
+ +
+ +
+
+ + ) +} + +function TaskData() { + const data = useDataFetcher(state => state.data) + return ( + + ) +} + diff --git a/packages/ui-app/app/_features/Project/GridView/grid-style.css b/packages/ui-app/app/_features/Project/GridView/grid-style.css new file mode 100644 index 00000000..021be540 --- /dev/null +++ b/packages/ui-app/app/_features/Project/GridView/grid-style.css @@ -0,0 +1,38 @@ +.list-table { + @apply divide-y dark:divide-gray-700; +} + +.list-heading { + @apply bg-gray-50 dark:bg-gray-800; +} + +.list-heading .list-cell { + @apply px-3 py-2 ; + @apply bg-gray-50 dark:bg-gray-800; +} + +.list-cell { + @apply leading-[20px] h-[35px] shrink-0 truncate text-ellipsis; + @apply bg-white dark:bg-gray-900; +} + +.list-row .list-cell .grid-actions { + @apply absolute top-1.5 right-2 opacity-0 pointer-events-none; + @apply transition-all; +} + +.list-row:hover .list-cell .grid-actions { + @apply opacity-100 pointer-events-auto; +} + +.list-cell-sortable-hover { + border: 2px solid #6366f1 !important; +} + +.list-row { + @apply flex divide-x dark:divide-gray-700 +} + +.list-cell { + @apply relative; +} diff --git a/packages/ui-app/app/_features/Project/GridView/index.tsx b/packages/ui-app/app/_features/Project/GridView/index.tsx new file mode 100644 index 00000000..7dd530fc --- /dev/null +++ b/packages/ui-app/app/_features/Project/GridView/index.tsx @@ -0,0 +1,15 @@ + +import FilterAdvanced from '@/features/FilterAdvanced' +import GridViewContainer from './GridViewContainer' + +export default function GridContent() { + return ( +
+
+ +
+ + +
+ ) +} diff --git a/packages/ui-app/app/_features/ProjectContainer/index.tsx b/packages/ui-app/app/_features/ProjectContainer/index.tsx index 94c9e52d..772d9bcc 100644 --- a/packages/ui-app/app/_features/ProjectContainer/index.tsx +++ b/packages/ui-app/app/_features/ProjectContainer/index.tsx @@ -22,6 +22,8 @@ import { useEventSyncProjectView } from '@/events/useEventSyncProjectView' import { useEventSyncProjectStatus } from '@/events/useEventSyncProjectStatus' import { useGetProjectViewList } from './useGetProjectViewList' import { useEventSyncProjectTask } from '@/events/useEventSyncProjectTask' +import { useGetCustomFields } from './useGetCustomFields' +import ClearCheckedCheckboxes from '../CustomFieldCheckbox/ClearCheckedCheckboxes' function SaveRecentVisitPage() { const { projectId, orgName } = useParams() @@ -76,6 +78,8 @@ function PrefetchData() { useGetProjectPoint() useGetProjectViewList() useGetAutomationRulesByProject() + useGetCustomFields() + // this hook generates objects in Map object // that helps to get task item as quickly as possible @@ -89,5 +93,7 @@ export default function ProjectContainer() { return <> - + + + } diff --git a/packages/ui-app/app/_features/ProjectContainer/useGetCustomFields.ts b/packages/ui-app/app/_features/ProjectContainer/useGetCustomFields.ts new file mode 100644 index 00000000..909f3aa9 --- /dev/null +++ b/packages/ui-app/app/_features/ProjectContainer/useGetCustomFields.ts @@ -0,0 +1,61 @@ +import { fieldSv } from '@/services/field' +import { useProjectCustomFieldStore } from '@/store/customFields' +import { Field } from '@prisma/client' +import localforage from 'localforage' +import { useParams } from 'next/navigation' +import { useEffect } from 'react' + +export const useGetCustomFieldHandler = (projectId: string) => { + const addCustomFields = useProjectCustomFieldStore(state => state.addAllCustomField) + const key = `PROJECT_CUSTOM_FIELD_${projectId}` + + const fetchDataNCache = () => { + const memberController = new AbortController() + + fieldSv.getByProjectId(projectId) + .then(res => { + const { data, status } = res.data + console.log('custom field list', data, status) + if (status !== 200) { + addCustomFields([]) + return + } + + localforage.setItem(key, data) + setTimeout(() => { + addCustomFields(data) + }, 300) + }) + .catch(err => { + console.log(err) + }) + return { abortController: memberController } + } + + return { + fetch: fetchDataNCache + } +} + +export const useGetCustomFields = () => { + const { projectId } = useParams() + const addAllCustomField = useProjectCustomFieldStore(state => state.addAllCustomField) + const { fetch } = useGetCustomFieldHandler(projectId) + const key = `PROJECT_CUSTOM_FIELD_${projectId}` + + useEffect(() => { + localforage.getItem(key).then(val => { + if (val) { + addAllCustomField(val as Field[]) + } + }) + }, [projectId]) + + useEffect(() => { + const { abortController } = fetch() + + return () => { + abortController.abort() + } + }, []) +} diff --git a/packages/ui-app/app/_features/ProjectContainer/useGetTask.ts b/packages/ui-app/app/_features/ProjectContainer/useGetTask.ts index b4e25289..86d2a6a5 100644 --- a/packages/ui-app/app/_features/ProjectContainer/useGetTask.ts +++ b/packages/ui-app/app/_features/ProjectContainer/useGetTask.ts @@ -69,6 +69,7 @@ export const useGetTaskHandler = () => { ).then(res => { const { data, status, error } = res.data + console.log('useGetTaskData', data) if (status !== 200) { addAllTasks([]) localforage.removeItem(key) diff --git a/packages/ui-app/app/_features/ProjectContainer/useGetTaskBackup.ts b/packages/ui-app/app/_features/ProjectContainer/useGetTaskBackup.ts new file mode 100644 index 00000000..0f102b26 --- /dev/null +++ b/packages/ui-app/app/_features/ProjectContainer/useGetTaskBackup.ts @@ -0,0 +1,129 @@ +import { useCallback, useEffect } from 'react' +import { extractDueDate } from '@shared/libs' +import { ExtendedTask, useTaskStore } from '@/store/task' +import { taskGetByCond } from '@/services/task' +import { useParams } from 'next/navigation' +import { messageError } from '@shared/ui' +import localforage from 'localforage' +import useTaskFilterContext from '../TaskFilter/useTaskFilterContext' +import { getGoalieUser } from '@goalie/nextjs' + +const getAssigneeIds = (assigneeIds: string[]) => { + if (!assigneeIds || !assigneeIds.length) return ['null'] + if (assigneeIds.includes('ALL')) return undefined + + assigneeIds = assigneeIds.map(uid => { + const user = getGoalieUser() + if (uid === 'ME' && user?.id) { + return user.id + } + + return uid + }) + + return assigneeIds.filter(a => a !== 'ALL') +} + +export const useGetTaskHandler = () => { + const { projectId } = useParams() + const { addAllTasks, setTaskLoading } = useTaskStore() + const { filter } = useTaskFilterContext() + const { groupBy, status, statusIds, ...filterWithoutGroupBy } = filter + + const key = `TASKLIST_${projectId}` + + const fetchNCache = useCallback(() => { + const controller = new AbortController() + const { + term, + date, + startDate: start, + endDate: end, + dateOperator, + done, + assigneeIds, + priority, + point + } = filter + + const { startDate, endDate } = extractDueDate({ + dateOperator, + date, + start, + end + }) + + setTaskLoading(true) + + taskGetByCond( + { + title: term || undefined, + taskPoint: +point === -1 ? undefined : +point, + priority: priority === 'ALL' ? undefined : priority, + assigneeIds: getAssigneeIds(assigneeIds), + done, + projectId, + dueDate: [startDate || 'null', endDate || 'null'] + }, + controller.signal + ).then(res => { + const { data, status, error } = res.data + + if (status !== 200) { + addAllTasks([]) + localforage.removeItem(key) + setTaskLoading(false) + messageError(error) + return + } + + localforage.setItem(key, data) + setTimeout(() => { + addAllTasks(data) + setTaskLoading(false) + }, 300) + }) + + return () => { + controller.abort() + } + + // only re-fetching data when filter changes + // excpet groupBy filter + }, [projectId, filter, key, addAllTasks, setTaskLoading]) + + return { + fetchNCache, + filterWithoutGroupBy + } +} + +function useFillTaskFromCache() { + const { projectId } = useParams() + const { addAllTasks } = useTaskStore() + const key = `TASKLIST_${projectId}` + + useEffect(() => { + localforage + .getItem(key) + .then(val => { + if (val) { + addAllTasks(val as ExtendedTask[]) + } + }) + .catch(err => { + console.log('errpr loading cached task', err) + }) + }, [projectId]) +} + +export default function useGetTaskBackup() { + const { fetchNCache, filterWithoutGroupBy } = useGetTaskHandler() + useFillTaskFromCache() + + useEffect(() => { + fetchNCache() + // only re-fetch data as filter changed + }, [JSON.stringify(filterWithoutGroupBy)]) + +} diff --git a/packages/ui-app/app/_features/ProjectView/ProjectViewIcon.tsx b/packages/ui-app/app/_features/ProjectView/ProjectViewIcon.tsx index dcf6ea26..57b606f9 100644 --- a/packages/ui-app/app/_features/ProjectView/ProjectViewIcon.tsx +++ b/packages/ui-app/app/_features/ProjectView/ProjectViewIcon.tsx @@ -6,7 +6,8 @@ import { HiOutlineRectangleGroup, HiOutlineRocketLaunch, HiOutlineUserGroup, - HiOutlineViewColumns + HiOutlineViewColumns, + HiOutlineTableCells } from 'react-icons/hi2' import { TbTimeline } from 'react-icons/tb' @@ -39,6 +40,10 @@ export default function ProjectViewIcon({ type }: { type: ProjectViewType }) { return } + if (type === ProjectViewType.GRID) { + return + } + return } diff --git a/packages/ui-app/app/_features/ProjectView/ProjectViewModalForm.tsx b/packages/ui-app/app/_features/ProjectView/ProjectViewModalForm.tsx index fe143752..fcbfca46 100644 --- a/packages/ui-app/app/_features/ProjectView/ProjectViewModalForm.tsx +++ b/packages/ui-app/app/_features/ProjectView/ProjectViewModalForm.tsx @@ -2,7 +2,7 @@ import { ProjectView, ProjectViewType } from '@prisma/client' import { IBoardFilter, useProjectViewContext } from './context' import { useParams } from 'next/navigation' -import { useState } from 'react' +import { useState, useEffect } from 'react' import { useProjectViewAdd } from './useProjectViewAdd' import ProjectViewFilterByBoard from '../ProjectViewFilter/BoardFilter' import ProjectViewFilterByList from '../ProjectViewFilter/ListFilter' @@ -10,6 +10,7 @@ import ProjectViewFilterByCalendar from '../ProjectViewFilter/CalendarFilter' import ProjectViewFilterByGoal from '../ProjectViewFilter/GoalFilter' import ProjectViewFilterByTeam from '../ProjectViewFilter/TeamFilter' import ProjectViewFilterByDashboard from '../ProjectViewFilter/DashboardFilter' +import ProjectViewFilterByGrid from '../ProjectViewFilter/GridFilter' import { Loading, messageError, messageSuccess } from '@shared/ui' import { useProjectViewUpdateContext } from './updateContext' import { useProjectViewStore } from '@/store/projectView' @@ -163,6 +164,7 @@ export default function ProjectViewModalForm({ +
) } diff --git a/packages/ui-app/app/_features/ProjectView/useDefaultViewTypes.ts b/packages/ui-app/app/_features/ProjectView/useDefaultViewTypes.ts index 227a8ff6..74176870 100644 --- a/packages/ui-app/app/_features/ProjectView/useDefaultViewTypes.ts +++ b/packages/ui-app/app/_features/ProjectView/useDefaultViewTypes.ts @@ -20,6 +20,12 @@ export const useDefaultViewTypes = () => { title: 'Calendar', desc: 'Calendar view is your place for planning, scheduling, and resource management.' }, + { + icon: 'HiOutlineTableCells', + type: ProjectViewType.GRID, + title: 'Grid', + desc: 'View your tasks in a grid layout for better visualization.' + }, ] const otherViews = [ diff --git a/packages/ui-app/app/_features/ProjectViewFilter/GoalFilter.tsx b/packages/ui-app/app/_features/ProjectViewFilter/GoalFilter.tsx index e171946f..fb18f3c9 100644 --- a/packages/ui-app/app/_features/ProjectViewFilter/GoalFilter.tsx +++ b/packages/ui-app/app/_features/ProjectViewFilter/GoalFilter.tsx @@ -9,10 +9,11 @@ export default function ProjectViewFilterByGoal({ type, desc, onAdd, isUpdate }: onAdd: () => void }) { if (type !== ProjectViewType.GOAL) return null - return <> + return <> +

Goal

{desc}

diff --git a/packages/ui-app/app/_features/ProjectViewFilter/GridFilter.tsx b/packages/ui-app/app/_features/ProjectViewFilter/GridFilter.tsx new file mode 100644 index 00000000..8e104a61 --- /dev/null +++ b/packages/ui-app/app/_features/ProjectViewFilter/GridFilter.tsx @@ -0,0 +1,42 @@ +// packages/ui-app/app/_features/ProjectViewFilter/GridFilter.tsx +import { ProjectViewType } from '@prisma/client' +import { useProjectViewContext } from '../ProjectView/context' +import ProjectViewForMe from '../ProjectView/ProjectViewForMe' +import { Button } from '@shared/ui' + +export default function ProjectViewFilterByGrid({ + type, + desc, + isUpdate, + onAdd +}: { + type: ProjectViewType + desc: string + isUpdate: boolean + onAdd: () => void +}) { + + if (type !== ProjectViewType.GRID) { + return null + } + + return <> + +
+

Grid

+

{desc}

+ +
+
+
+ + +} diff --git a/packages/ui-app/app/_features/SettingApps/ApplicationList.tsx b/packages/ui-app/app/_features/SettingApps/ApplicationList.tsx new file mode 100644 index 00000000..add2f794 --- /dev/null +++ b/packages/ui-app/app/_features/SettingApps/ApplicationList.tsx @@ -0,0 +1,98 @@ +import { useEffect, useState } from 'react' +import { Application } from '@prisma/client' +import { useApplicationStore } from '@/store/application' +import { Button, Card, confirmAlert, messageSuccess } from '@shared/ui' +import { FiEye, FiEyeOff, FiCopy, FiTrash2 } from 'react-icons/fi' +import { useGetParams } from '@/hooks/useGetParams' + +export const ApplicationList = () => { + const { applications, fetchApplications } = useApplicationStore() + const { orgId } = useGetParams() + + useEffect(() => { + fetchApplications(orgId) + }, [orgId]) + + return ( +
+ {applications.map((app: Application) => ( + + ))} +
+ ) +} + +const ApplicationCard = ({ application }: { application: Application }) => { + const [showSecret, setShowSecret] = useState(false) + const { deleteApplication } = useApplicationStore() + + const copyToClipboard = (text: string) => { + navigator.clipboard.writeText(text) + .then(() => { + messageSuccess('Copied to clipboard') + // Optionally, you can show a toast or notification here + console.log('Copied to clipboard') + }) + .catch((err) => { + console.error('Failed to copy: ', err) + }) + } + + const handleDelete = async () => { + confirmAlert({ + title: 'Delete Application', + message: 'Are you sure you want to delete this application?', + yes: async () => { + await deleteApplication(application.id) + messageSuccess('Application deleted successfully') + } + }) + } + + return ( + +
+
+

{application.name}

+
+
+ Client ID: + {application.clientId} +
+
+ Client Secret: +
+ + {showSecret ? application.clientSecret : '••••••••••••••••'} + +
+
+
+
+
+
+ ) +} diff --git a/packages/ui-app/app/_features/SettingApps/CreateApplication.tsx b/packages/ui-app/app/_features/SettingApps/CreateApplication.tsx new file mode 100644 index 00000000..d0221c12 --- /dev/null +++ b/packages/ui-app/app/_features/SettingApps/CreateApplication.tsx @@ -0,0 +1,56 @@ +'use client' + +import { useGetParams } from '@/hooks/useGetParams' +import { useApplicationStore } from '@/store/application' +import { Button, Dialog, Form, messageError } from '@shared/ui' +import { useState } from 'react' + +export default function CreateApplication() { + const { addApplication, isLoading } = useApplicationStore() + const { orgId } = useGetParams() + const [open, setOpen] = useState(false) + const [name, setName] = useState('') + const [desc, setDesc] = useState('') + const onAdd = async () => { + if (!name || !orgId) { + messageError('Name and Organization are required') + return + } + + await addApplication({ + name, + orgId, + desc + }) + + setOpen(false) + setName('') + setDesc('') + } + + return ( + + +
+
+
+ + +
+

Create new application

+ + setName(ev.target.value)} /> + setDesc(ev.target.value)} /> +
+
+
+
+
+
+ ) +} diff --git a/packages/ui-app/app/_features/SettingApps/index.tsx b/packages/ui-app/app/_features/SettingApps/index.tsx new file mode 100644 index 00000000..2f37a596 --- /dev/null +++ b/packages/ui-app/app/_features/SettingApps/index.tsx @@ -0,0 +1,15 @@ +'use client' +import { ApplicationList } from './ApplicationList' +import CreateApplication from './CreateApplication' + +export default function SettingAppsContainer() { + return ( +
+
+

Applications

+ +
+ +
+ ) +} diff --git a/packages/ui-app/app/profile/[userId]/page.tsx b/packages/ui-app/app/profile/[userId]/page.tsx index 90483452..883ae788 100644 --- a/packages/ui-app/app/profile/[userId]/page.tsx +++ b/packages/ui-app/app/profile/[userId]/page.tsx @@ -1,3 +1,5 @@ +import Profile from "@/features/Profile"; + export default function Page() { - return
Page
+ return } diff --git a/packages/ui-app/layouts/UserSection.tsx b/packages/ui-app/layouts/UserSection.tsx index d0e90a28..195a4f7c 100644 --- a/packages/ui-app/layouts/UserSection.tsx +++ b/packages/ui-app/layouts/UserSection.tsx @@ -12,11 +12,11 @@ export default function UserSection() { const { user } = useUser() const menus = [ - // { - // icon: TbUserCircle, - // link: `/profile/${user?.id}`, - // title: 'Update profile' - // }, + { + icon: TbUserCircle, + link: `/profile/${user?.id}`, + title: 'Update profile' + }, { icon: IoMdLogOut, link: `/sign-out`, diff --git a/packages/ui-app/services/apps.ts b/packages/ui-app/services/apps.ts new file mode 100644 index 00000000..8892af3a --- /dev/null +++ b/packages/ui-app/services/apps.ts @@ -0,0 +1,20 @@ +import { Application } from "@prisma/client" +import { httpPost, httpPut, httpGet, httpDel } from "./_req" + +export const applicationSv = { + update(data: Partial) { + return httpPut(`/api/apps`, data) + }, + + get(orgId: string) { + return httpGet(`/api/apps/${orgId}`) + }, + + create(data: { name: string, desc?: string, orgId: string }) { + return httpPost('/api/apps', data) + }, + + delete(id: string) { + return httpDel(`/api/apps/${id}`) + } +} diff --git a/packages/ui-app/services/field.ts b/packages/ui-app/services/field.ts new file mode 100644 index 00000000..726e453a --- /dev/null +++ b/packages/ui-app/services/field.ts @@ -0,0 +1,32 @@ +import { Field } from "@prisma/client" +import { httpDel, httpGet, httpPost, httpPut } from "./_req" + +type PartialField = Partial + +export const fieldSv = { + create(data: PartialField) { + console.log('fieldSv.create', data) + return httpPost('/api/fields', data) + }, + + getByProjectId(projectId: string, + abortSignal?: AbortSignal + ) { + return httpGet(`/api/fields/${projectId}`, { + signal: abortSignal + }) + }, + + delete(id: string) { + return httpDel(`/api/fields/${id}`) + }, + + update(data: PartialField) { + return httpPut('/api/fields', data) + }, + + sortable(fields: { id: string, order: number }[]) { + return httpPut('/api/fields/sortable', { items: fields }) + } + +} diff --git a/packages/ui-app/services/project.grid.ts b/packages/ui-app/services/project.grid.ts new file mode 100644 index 00000000..d487458c --- /dev/null +++ b/packages/ui-app/services/project.grid.ts @@ -0,0 +1,54 @@ +import { FieldType } from "@prisma/client" +import { httpDel, httpPost, httpPut } from "./_req" +import { ExtendedTask } from "@/store/task" +import { IFilterAdvancedData } from "@/features/FilterAdvanced/type" + +export interface ICustomFieldData { + [fieldId: string]: { value: string, type: FieldType } +} + +export const projectGridSv = { + update(data: { value: string | string[], taskId: string, fieldId: string, type: FieldType }) { + return httpPut('/api/project/grid', data) + }, + + get( + projectId: string, + filter: IFilterAdvancedData, + signal?: AbortSignal, + options?: { + cursor?: string + limit?: number + orderBy?: { [key: string]: 'asc' | 'desc' } + } + ) { + return httpPost('/api/project/grid/query', { + projectId, + filter, + options + }, { + signal + }) + }, + + updateMany( + taskIds: string[], + data: ICustomFieldData) { + return httpPut('/api/project/grid/update-many', { + taskIds, + data + }) + }, + + create(data: ExtendedTask) { + return httpPost('/api/project/grid/create', data) + }, + + delete(rowIds: string[]) { + return httpDel('/api/project/grid/delete', { + params: { + rowIds + } + }) + } +} diff --git a/packages/ui-app/services/task.ts b/packages/ui-app/services/task.ts index f9f372c9..3c5f6637 100644 --- a/packages/ui-app/services/task.ts +++ b/packages/ui-app/services/task.ts @@ -1,5 +1,6 @@ import { Task, TaskPriority } from '@prisma/client' import { httpDel, httpGet, httpPost, httpPut } from './_req' +import { IFilterAdvancedData } from '@/features/FilterAdvanced/type' type ITaskFields = Partial @@ -104,3 +105,22 @@ export const serviceTask = { return httpPost('/api/task/reorder', data) } } + +export const taskGetCustomQuery = ( + projectId: string, + filter: IFilterAdvancedData, + signal?: AbortSignal, + options?: { + cursor?: string + limit?: number + orderBy?: { [key: string]: 'asc' | 'desc' } + } +) => { + return httpPost('/api/project/task/custom-field/query', { + projectId, + filter, + options + }, { + signal + }) +} diff --git a/packages/ui-app/store/application.ts b/packages/ui-app/store/application.ts new file mode 100644 index 00000000..0f2f3124 --- /dev/null +++ b/packages/ui-app/store/application.ts @@ -0,0 +1,87 @@ +import { create } from 'zustand' +import { produce } from 'immer' +import { Application } from '@prisma/client' +import { applicationSv } from '@/services/apps' + +interface ApplicationState { + applications: Application[] + isLoading: boolean + error: string | null + fetchApplications: (orgId: string) => Promise + addApplication: (data: { name: string; desc?: string; orgId: string }) => Promise + updateApplication: (id: string, data: Partial) => Promise + deleteApplication: (id: string) => Promise +} + +export const useApplicationStore = create((set) => ({ + applications: [], + isLoading: false, + error: null, + + fetchApplications: async (orgId: string) => { + set({ isLoading: true, error: null }) + try { + const result = await applicationSv.get(orgId) + const { data } = result.data + set({ applications: data }) + } catch (err) { + set({ error: 'Failed to fetch applications' }) + } finally { + set({ isLoading: false }) + } + }, + + addApplication: async (data: { name: string, desc?: string, orgId: string }) => { + set({ isLoading: true, error: null }) + try { + const res = await applicationSv.create(data) + const { data: respondData } = res.data + set((state) => ({ + applications: [...state.applications, respondData] + })) + } catch (err) { + set({ error: 'Failed to create application' }) + } finally { + set({ isLoading: false }) + } + }, + + updateApplication: async (id: string, data: Partial) => { + try { + const response = await applicationSv.update({ ...data, id }) + const { data: updatedApplication, status } = response.data + + if (status !== 200) { + throw new Error('Failed to update application') + } + + set( + produce((state: ApplicationState) => { + const app = state.applications.find(app => app.id === id) + if (app) { + Object.assign(app, updatedApplication) + } + }) + ) + } catch (error) { + console.error('Failed to update application:', error) + throw error + } + }, + + deleteApplication: async (id: string) => { + set({ isLoading: true, error: null }) + try { + await applicationSv.delete(id) + set( + produce((state: ApplicationState) => { + state.applications = state.applications.filter(app => app.id !== id) + }) + ) + } catch (err) { + set({ error: 'Failed to delete application' }) + } finally { + set({ isLoading: false }) + } + }, +})) diff --git a/packages/ui-app/store/customFields.ts b/packages/ui-app/store/customFields.ts new file mode 100644 index 00000000..33dd9444 --- /dev/null +++ b/packages/ui-app/store/customFields.ts @@ -0,0 +1,63 @@ +import { create } from 'zustand' +import { Field } from '@prisma/client' +import { produce } from 'immer' + + +interface FieldState { + customFields: Field[] + addCustomField: (field: Field) => void + updateCustomField: (field: Field) => void + updateFieldWidth: (index: number, width: number) => void + removeCustomField: (id: string) => void + addAllCustomField: (fields: Field[]) => void +} + +export const useProjectCustomFieldStore = create(set => ({ + customFields: [], + + removeCustomField: (id: string) => set(produce((state: FieldState) => { + + state.customFields = state.customFields.filter(f => f.id !== id) + })), + + addCustomField: (field: Field) => set(produce((state: FieldState) => { + + state.customFields = [...state.customFields, field] + + })), + + updateFieldWidth: (index: number, width: number) => set(produce((state: FieldState) => { + const fieldData = state.customFields[index] + + if (!fieldData) { + return + } + + state.customFields[index] = { + ...fieldData, + ...{ + width + } + } + })), + + updateCustomField: (field: Field) => set(produce((state: FieldState) => { + + const newCustomFields = state.customFields.map(cf => { + if (cf.id === field.id) { + field.desc = new Date().toString() + return { ...cf, ...field } + } + return cf + }) + + state.customFields = newCustomFields + + })), + + addAllCustomField: (fields: Field[]) => set(produce((state: FieldState) => { + state.customFields = fields + + })), + +})) diff --git a/test.txt b/test.txt new file mode 100644 index 00000000..58b476b4 --- /dev/null +++ b/test.txt @@ -0,0 +1,547 @@ +yarn run v1.22.19 +$ prisma db seed --schema=./packages/shared-models/src/prisma/schema.prisma test +Environment variables loaded from .env +Running seed command `ts-node ./packages/shared-models/src/prisma/seed test` ... +>>>>>> +type test +value undefined +>>>>>> +=== Test Cases for "is" Operator === + +IS: "TODAY" >>>>>>>>>>>>>>>>>>>>>> +sub value undefined +{ + "$and": [ + { + "8792837498723": { + "$gte": "2024-11-09T17:00:00.000Z" + } + }, + { + "8792837498723": { + "$lte": "2024-11-10T16:59:59.999Z" + } + } + ] +} + +IS: "YESTERDAY" >>>>>>>>>>>>>>>>>>>>>> +sub value undefined +{ + "$and": [ + { + "8792837498723": { + "$gte": "2024-11-08T17:00:00.000Z" + } + }, + { + "8792837498723": { + "$lte": "2024-11-09T16:59:59.999Z" + } + } + ] +} + +IS: "TOMORROW" >>>>>>>>>>>>>>>>>>>>>> +sub value undefined +{ + "$and": [ + { + "8792837498723": { + "$gte": "2024-11-10T17:00:00.000Z" + } + }, + { + "8792837498723": { + "$lte": "2024-11-11T16:59:59.999Z" + } + } + ] +} + +IS: "ONE WEEK AGO" >>>>>>>>>>>>>>>>>>>>>> +sub value undefined +{ + "$and": [ + { + "8792837498723": { + "$gte": "2024-11-02T17:00:00.000Z" + } + }, + { + "8792837498723": { + "$lte": "2024-11-03T16:59:59.999Z" + } + } + ] +} + +IS: "THIS WEEK" >>>>>>>>>>>>>>>>>>>>>> +sub value undefined +{ + "$and": [ + { + "8792837498723": { + "$gte": "2024-11-09T17:00:00.000Z" + } + }, + { + "8792837498723": { + "$lte": "2024-11-10T16:59:59.999Z" + } + } + ] +} + +IS: "NEXT WEEK" >>>>>>>>>>>>>>>>>>>>>> +sub value undefined +{ + "$and": [ + { + "8792837498723": { + "$gte": "2024-11-16T17:00:00.000Z" + } + }, + { + "8792837498723": { + "$lte": "2024-11-17T16:59:59.999Z" + } + } + ] +} + +IS: "ONE MONTH AGO" >>>>>>>>>>>>>>>>>>>>>> +sub value undefined +{ + "$and": [ + { + "8792837498723": { + "$gte": "2024-10-09T17:00:00.000Z" + } + }, + { + "8792837498723": { + "$lte": "2024-10-10T16:59:59.999Z" + } + } + ] +} + +IS: "THIS MONTH" >>>>>>>>>>>>>>>>>>>>>> +sub value undefined +{ + "$and": [ + { + "8792837498723": { + "$gte": "2024-10-31T17:00:00.000Z" + } + }, + { + "8792837498723": { + "$lte": "2024-11-01T16:59:59.999Z" + } + } + ] +} + +IS: "NEXT MONTH" >>>>>>>>>>>>>>>>>>>>>> +sub value undefined +{ + "$and": [ + { + "8792837498723": { + "$gte": "2024-11-30T17:00:00.000Z" + } + }, + { + "8792837498723": { + "$lte": "2024-12-01T16:59:59.999Z" + } + } + ] +} + +IS: "ONE YEAR AGO" >>>>>>>>>>>>>>>>>>>>>> +sub value undefined +{ + "$and": [ + { + "8792837498723": { + "$gte": "2023-11-09T17:00:00.000Z" + } + }, + { + "8792837498723": { + "$lte": "2023-11-10T16:59:59.999Z" + } + } + ] +} + +IS: "THIS YEAR" >>>>>>>>>>>>>>>>>>>>>> +sub value undefined +{ + "$and": [ + { + "8792837498723": { + "$gte": "2023-12-31T17:00:00.000Z" + } + }, + { + "8792837498723": { + "$lte": "2024-01-01T16:59:59.999Z" + } + } + ] +} + +IS: "NEXT YEAR" >>>>>>>>>>>>>>>>>>>>>> +sub value undefined +{ + "$and": [ + { + "8792837498723": { + "$gte": "2024-12-31T17:00:00.000Z" + } + }, + { + "8792837498723": { + "$lte": "2025-01-01T16:59:59.999Z" + } + } + ] +} + +IS: "EXACT DATE" >>>>>>>>>>>>>>>>>>>>>> +sub value undefined +{ + "8792837498723": "2024-11-09T17:00:00.000Z" +} + +IS: "DAYS AGO" >>>>>>>>>>>>>>>>>>>>>> +sub value undefined +{ + "8792837498723": "2024-11-10T01:08:00.735Z" +} + +IS: "DAYS FROM NOW" >>>>>>>>>>>>>>>>>>>>>> +sub value undefined +{ + "8792837498723": "2024-11-10T01:08:00.736Z" +} + +IS: "WEEKS AGO" >>>>>>>>>>>>>>>>>>>>>> +sub value undefined +{ + "8792837498723": "2024-11-10T01:08:00.736Z" +} + +IS: "WEEKS FROM NOW" >>>>>>>>>>>>>>>>>>>>>> +sub value undefined +{ + "8792837498723": "2024-11-10T01:08:00.736Z" +} + +IS: "MONTHS AGO" >>>>>>>>>>>>>>>>>>>>>> +sub value undefined +{ + "8792837498723": "2024-11-10T01:08:00.736Z" +} + +IS: "MONTHS FROM NOW" >>>>>>>>>>>>>>>>>>>>>> +sub value undefined +{ + "8792837498723": "2024-11-10T01:08:00.736Z" +} + +IS: "YEARS AGO" >>>>>>>>>>>>>>>>>>>>>> +sub value undefined +{ + "8792837498723": "2024-11-10T01:08:00.736Z" +} + +IS: "YEARS FROM NOW" >>>>>>>>>>>>>>>>>>>>>> +sub value undefined +{ + "8792837498723": "2024-11-10T01:08:00.736Z" +} +=== Test Cases for "is not" Operator === + +IS NOT: "TODAY" >>>>>>>>>>>>>>>>>>>>>> +sub value undefined +{ + "$or": [ + { + "8792837498723": { + "$lt": "2024-11-09T17:00:00.000Z" + } + }, + { + "8792837498723": { + "$gt": "2024-11-10T16:59:59.999Z" + } + } + ] +} + +IS NOT: "YESTERDAY" >>>>>>>>>>>>>>>>>>>>>> +sub value undefined +{ + "$or": [ + { + "8792837498723": { + "$lt": "2024-11-08T17:00:00.000Z" + } + }, + { + "8792837498723": { + "$gt": "2024-11-09T16:59:59.999Z" + } + } + ] +} + +IS NOT: "TOMORROW" >>>>>>>>>>>>>>>>>>>>>> +sub value undefined +{ + "$or": [ + { + "8792837498723": { + "$lt": "2024-11-10T17:00:00.000Z" + } + }, + { + "8792837498723": { + "$gt": "2024-11-11T16:59:59.999Z" + } + } + ] +} + +IS NOT: "ONE WEEK AGO" >>>>>>>>>>>>>>>>>>>>>> +sub value undefined +{ + "$or": [ + { + "8792837498723": { + "$lt": "2024-11-02T17:00:00.000Z" + } + }, + { + "8792837498723": { + "$gt": "2024-11-03T16:59:59.999Z" + } + } + ] +} + +IS NOT: "THIS WEEK" >>>>>>>>>>>>>>>>>>>>>> +sub value undefined +{ + "$or": [ + { + "8792837498723": { + "$lt": "2024-11-09T17:00:00.000Z" + } + }, + { + "8792837498723": { + "$gt": "2024-11-10T16:59:59.999Z" + } + } + ] +} + +IS NOT: "NEXT WEEK" >>>>>>>>>>>>>>>>>>>>>> +sub value undefined +{ + "$or": [ + { + "8792837498723": { + "$lt": "2024-11-16T17:00:00.000Z" + } + }, + { + "8792837498723": { + "$gt": "2024-11-17T16:59:59.999Z" + } + } + ] +} + +IS NOT: "ONE MONTH AGO" >>>>>>>>>>>>>>>>>>>>>> +sub value undefined +{ + "$or": [ + { + "8792837498723": { + "$lt": "2024-10-09T17:00:00.000Z" + } + }, + { + "8792837498723": { + "$gt": "2024-10-10T16:59:59.999Z" + } + } + ] +} + +IS NOT: "THIS MONTH" >>>>>>>>>>>>>>>>>>>>>> +sub value undefined +{ + "$or": [ + { + "8792837498723": { + "$lt": "2024-10-31T17:00:00.000Z" + } + }, + { + "8792837498723": { + "$gt": "2024-11-01T16:59:59.999Z" + } + } + ] +} + +IS NOT: "NEXT MONTH" >>>>>>>>>>>>>>>>>>>>>> +sub value undefined +{ + "$or": [ + { + "8792837498723": { + "$lt": "2024-11-30T17:00:00.000Z" + } + }, + { + "8792837498723": { + "$gt": "2024-12-01T16:59:59.999Z" + } + } + ] +} + +IS NOT: "ONE YEAR AGO" >>>>>>>>>>>>>>>>>>>>>> +sub value undefined +{ + "$or": [ + { + "8792837498723": { + "$lt": "2023-11-09T17:00:00.000Z" + } + }, + { + "8792837498723": { + "$gt": "2023-11-10T16:59:59.999Z" + } + } + ] +} + +IS NOT: "THIS YEAR" >>>>>>>>>>>>>>>>>>>>>> +sub value undefined +{ + "$or": [ + { + "8792837498723": { + "$lt": "2023-12-31T17:00:00.000Z" + } + }, + { + "8792837498723": { + "$gt": "2024-01-01T16:59:59.999Z" + } + } + ] +} + +IS NOT: "NEXT YEAR" >>>>>>>>>>>>>>>>>>>>>> +sub value undefined +{ + "$or": [ + { + "8792837498723": { + "$lt": "2024-12-31T17:00:00.000Z" + } + }, + { + "8792837498723": { + "$gt": "2025-01-01T16:59:59.999Z" + } + } + ] +} + +IS NOT: "EXACT DATE" >>>>>>>>>>>>>>>>>>>>>> +sub value undefined +{ + "8792837498723": { + "$ne": "2024-11-09T17:00:00.000Z" + } +} + +IS NOT: "DAYS AGO" >>>>>>>>>>>>>>>>>>>>>> +sub value undefined +{ + "8792837498723": { + "$ne": "2024-11-10T01:08:00.738Z" + } +} + +IS NOT: "DAYS FROM NOW" >>>>>>>>>>>>>>>>>>>>>> +sub value undefined +{ + "8792837498723": { + "$ne": "2024-11-10T01:08:00.738Z" + } +} + +IS NOT: "WEEKS AGO" >>>>>>>>>>>>>>>>>>>>>> +sub value undefined +{ + "8792837498723": { + "$ne": "2024-11-10T01:08:00.738Z" + } +} + +IS NOT: "WEEKS FROM NOW" >>>>>>>>>>>>>>>>>>>>>> +sub value undefined +{ + "8792837498723": { + "$ne": "2024-11-10T01:08:00.738Z" + } +} + +IS NOT: "MONTHS AGO" >>>>>>>>>>>>>>>>>>>>>> +sub value undefined +{ + "8792837498723": { + "$ne": "2024-11-10T01:08:00.738Z" + } +} + +IS NOT: "MONTHS FROM NOW" >>>>>>>>>>>>>>>>>>>>>> +sub value undefined +{ + "8792837498723": { + "$ne": "2024-11-10T01:08:00.739Z" + } +} + +IS NOT: "YEARS AGO" >>>>>>>>>>>>>>>>>>>>>> +sub value undefined +{ + "8792837498723": { + "$ne": "2024-11-10T01:08:00.739Z" + } +} + +IS NOT: "YEARS FROM NOW" >>>>>>>>>>>>>>>>>>>>>> +sub value undefined +{ + "8792837498723": { + "$ne": "2024-11-10T01:08:00.739Z" + } +} + +🌱 The seed command has been executed. +Done in 3.48s. diff --git a/yarn.lock b/yarn.lock index 5a5cbe99..2e9dfbbf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1993,6 +1993,11 @@ minimatch "^3.1.2" strip-json-comments "^3.1.1" +"@faker-js/faker@^9.2.0": + version "9.2.0" + resolved "https://registry.yarnpkg.com/@faker-js/faker/-/faker-9.2.0.tgz#269ee3a5d2442e88e10d984e106028422bcb9551" + integrity sha512-ulqQu4KMr1/sTFIYvqSdegHT8NIkt66tFAkugGnHA+1WAfEn6hMzNR+svjXGFRVLnapxvej67Z/LwchFrnLBUg== + "@fastify/busboy@^1.2.1": version "1.2.1" resolved "https://registry.yarnpkg.com/@fastify/busboy/-/busboy-1.2.1.tgz#9c6db24a55f8b803b5222753b24fe3aea2ba9ca3" @@ -6919,10 +6924,10 @@ bser@2.1.1: dependencies: node-int64 "^0.4.0" -bson@^5.3.0: - version "5.4.0" - resolved "https://registry.npmjs.org/bson/-/bson-5.4.0.tgz" - integrity sha512-WRZ5SQI5GfUuKnPTNmAYPiKIof3ORXAF4IRU5UcgmivNIon01rWQlw5RUH954dpu8yGL8T59YShVddIPaU/gFA== +bson@^6.9.0: + version "6.9.0" + resolved "https://registry.yarnpkg.com/bson/-/bson-6.9.0.tgz#2be50049430dceaa9300402520fe03e4ed5fdfd6" + integrity sha512-X9hJeyeM0//Fus+0pc5dSUMhhrrmWwQUtdavaQeF3Ta6m69matZkGWV/MrBcnwUeLC8W9kwwc2hfkZgUuCX3Ig== buffer-crc32@~0.2.3: version "0.2.13" @@ -8129,6 +8134,13 @@ doctrine@^3.0.0: dependencies: esutils "^2.0.2" +docx-preview@^0.3.3: + version "0.3.3" + resolved "https://registry.yarnpkg.com/docx-preview/-/docx-preview-0.3.3.tgz#e6aef5ff0af5da5c4f276fb1e9ba838d89cebedc" + integrity sha512-vg0bjmp5Q/hWYgalvVsnOOdqKlSBvgzdbrqX8pKw4KuTH8FkX2FIs21w10HZdx6ZhApmMbgfae+lOXjdCSsc8A== + dependencies: + jszip ">=3.0.0" + dom-accessibility-api@^0.5.9: version "0.5.16" resolved "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz" @@ -11105,6 +11117,16 @@ jsprim@^2.0.2: object.assign "^4.1.4" object.values "^1.1.6" +jszip@>=3.0.0: + version "3.10.1" + resolved "https://registry.yarnpkg.com/jszip/-/jszip-3.10.1.tgz#34aee70eb18ea1faec2f589208a157d1feb091c2" + integrity sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g== + dependencies: + lie "~3.3.0" + pako "~1.0.2" + readable-stream "~2.3.6" + setimmediate "^1.0.5" + jwa@^1.4.1: version "1.4.1" resolved "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz" @@ -11271,6 +11293,13 @@ lie@3.1.1: dependencies: immediate "~3.0.5" +lie@~3.3.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/lie/-/lie-3.3.0.tgz#dcf82dee545f46074daf200c7c1c5a08e0f40f6a" + integrity sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ== + dependencies: + immediate "~3.0.5" + lilconfig@^2.0.3, lilconfig@^2.0.5, lilconfig@^2.0.6: version "2.1.0" resolved "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz" @@ -12423,6 +12452,11 @@ p-try@^2.0.0: resolved "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz" integrity sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ== +pako@~1.0.2: + version "1.0.11" + resolved "https://registry.yarnpkg.com/pako/-/pako-1.0.11.tgz#6c9599d340d54dfd3946380252a35705a6b992bf" + integrity sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw== + parent-module@^1.0.0: version "1.0.1" resolved "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz" @@ -13346,10 +13380,10 @@ react-hook-form@^7.44.3: resolved "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.45.1.tgz" integrity sha512-6dWoFJwycbuFfw/iKMcl+RdAOAOHDiF11KWYhNDRN/OkUt+Di5qsZHwA0OwsVnu9y135gkHpTw9DJA+WzCeR9w== -react-icons@^4.9.0: - version "4.10.1" - resolved "https://registry.npmjs.org/react-icons/-/react-icons-4.10.1.tgz" - integrity sha512-/ngzDP/77tlCfqthiiGNZeYFACw85fUjZtLbedmJ5DTlNDIwETxhwBzdOJ21zj4iJdvc0J3y7yOsX3PpxAJzrw== +react-icons@^5.3.0: + version "5.3.0" + resolved "https://registry.yarnpkg.com/react-icons/-/react-icons-5.3.0.tgz#ccad07a30aebd40a89f8cfa7d82e466019203f1c" + integrity sha512-DnUk8aFbTyQPSkCfF8dbX6kQjXA9DktMeJqfjrg6cK9vwQVMxmcA3BfP4QoiztVmEHtwlTgLFsPuH2NskKT6eg== react-is@^16.13.1, react-is@^16.7.0: version "16.13.1" @@ -13949,7 +13983,7 @@ set-blocking@^2.0.0: resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7" integrity sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw== -setimmediate@~1.0.4: +setimmediate@^1.0.5, setimmediate@~1.0.4: version "1.0.5" resolved "https://registry.yarnpkg.com/setimmediate/-/setimmediate-1.0.5.tgz#290cbb232e306942d7d7ea9b83732ab7856f8285" integrity sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==