From 51d6df42bbe791c8bec2fccdad1af4eb5e52ecc7 Mon Sep 17 00:00:00 2001 From: yaschalew Date: Wed, 20 May 2026 08:15:29 +0300 Subject: [PATCH] file upload settings --- apps/edr-freight-api/src/app.module.ts | 2 + apps/edr-freight-api/src/main.ts | 25 +- .../dto/create-file-upload-field.dto.ts | 51 ++ .../dto/create-file-upload-setting.dto.ts | 44 ++ .../dto/update-file-upload-field.dto.ts | 7 + .../dto/update-file-upload-setting.dto.ts | 7 + .../entities/file-upload-field.entity.ts | 58 +++ .../entities/file-upload-setting.entity.ts | 52 ++ .../file-upload-settings.controller.ts | 102 ++++ .../file-upload-settings.module.ts | 26 + .../file-upload-settings.repository.ts | 75 +++ .../file-upload-settings.service.ts | 109 +++++ ...le-upload-settings.repository.interface.ts | 38 ++ apps/edr-freight-web/portal/src/App.tsx | 11 + .../portal/src/constants/URLS.ts | 63 +++ .../portal/src/hooks/useFileUploadSettings.ts | 126 +++++ .../admin/DeleteFileUploadSettingDialog.tsx | 65 +++ .../admin/EditFileUploadSettingDialog.tsx | 229 +++++++++ .../pages/admin/FileUploadSettingsPage.tsx | 460 ++++++++++++++++++ .../admin/ManageFileUploadFieldsDialog.tsx | 416 ++++++++++++++++ .../pages/admin/fileUploadSettings.mock.ts | 174 +++++++ .../portal/src/services/crud.ts | 60 +++ .../services/fileUploadSettings.service.ts | 148 ++++++ .../services/files/file_upload_settings.ts | 3 + .../portal/src/types/fileUploadSettings.ts | 39 ++ apps/edr-freight-web/portal/src/utils/api.ts | 6 +- .../types/src/freight/file_upload_settings.ts | 89 ++++ packages/types/src/freight/index.ts | 2 + 28 files changed, 2483 insertions(+), 4 deletions(-) create mode 100644 apps/edr-freight-api/src/modules/file-upload-settings/dto/create-file-upload-field.dto.ts create mode 100644 apps/edr-freight-api/src/modules/file-upload-settings/dto/create-file-upload-setting.dto.ts create mode 100644 apps/edr-freight-api/src/modules/file-upload-settings/dto/update-file-upload-field.dto.ts create mode 100644 apps/edr-freight-api/src/modules/file-upload-settings/dto/update-file-upload-setting.dto.ts create mode 100644 apps/edr-freight-api/src/modules/file-upload-settings/entities/file-upload-field.entity.ts create mode 100644 apps/edr-freight-api/src/modules/file-upload-settings/entities/file-upload-setting.entity.ts create mode 100644 apps/edr-freight-api/src/modules/file-upload-settings/file-upload-settings.controller.ts create mode 100644 apps/edr-freight-api/src/modules/file-upload-settings/file-upload-settings.module.ts create mode 100644 apps/edr-freight-api/src/modules/file-upload-settings/file-upload-settings.repository.ts create mode 100644 apps/edr-freight-api/src/modules/file-upload-settings/file-upload-settings.service.ts create mode 100644 apps/edr-freight-api/src/modules/file-upload-settings/interfaces/file-upload-settings.repository.interface.ts create mode 100644 apps/edr-freight-web/portal/src/constants/URLS.ts create mode 100644 apps/edr-freight-web/portal/src/hooks/useFileUploadSettings.ts create mode 100644 apps/edr-freight-web/portal/src/pages/admin/DeleteFileUploadSettingDialog.tsx create mode 100644 apps/edr-freight-web/portal/src/pages/admin/EditFileUploadSettingDialog.tsx create mode 100644 apps/edr-freight-web/portal/src/pages/admin/FileUploadSettingsPage.tsx create mode 100644 apps/edr-freight-web/portal/src/pages/admin/ManageFileUploadFieldsDialog.tsx create mode 100644 apps/edr-freight-web/portal/src/pages/admin/fileUploadSettings.mock.ts create mode 100644 apps/edr-freight-web/portal/src/services/crud.ts create mode 100644 apps/edr-freight-web/portal/src/services/fileUploadSettings.service.ts create mode 100644 apps/edr-freight-web/portal/src/services/files/file_upload_settings.ts create mode 100644 apps/edr-freight-web/portal/src/types/fileUploadSettings.ts create mode 100644 packages/types/src/freight/file_upload_settings.ts diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index 8cde48e15..28a45a796 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -14,6 +14,7 @@ import { CustomersModule } from "./modules/customers/customers.module"; import { TrackingModule } from "./modules/tracking/tracking.module"; import { BillingModule } from "./modules/billing/billing.module"; import { NotificationsModule } from "./modules/notifications/notifications.module"; +import { FileUploadSettingsModule } from "./modules/file-upload-settings/file-upload-settings.module"; @Module({ imports: [ @@ -35,6 +36,7 @@ import { NotificationsModule } from "./modules/notifications/notifications.modul TrackingModule, BillingModule, NotificationsModule, + FileUploadSettingsModule, ], }) export class AppModule implements OnApplicationBootstrap { diff --git a/apps/edr-freight-api/src/main.ts b/apps/edr-freight-api/src/main.ts index 8fb5d7130..f6f32be91 100644 --- a/apps/edr-freight-api/src/main.ts +++ b/apps/edr-freight-api/src/main.ts @@ -12,7 +12,30 @@ import { import { AppModule } from "./app.module"; async function bootstrap() { - const app = await NestFactory.create(AppModule, { cors: true }); + const app = await NestFactory.create(AppModule); + + // Dev CORS: reflect any localhost origin and allow credentials so the + // freight portal (5173), passenger portal (5174), backoffices (5183/5184) + // and any other dev port can call the API with cookies + Authorization. + // For production, restrict `origin` to known FQDNs. + app.enableCors({ + origin: true, // reflect request origin + credentials: true, + methods: ["GET", "HEAD", "PUT", "PATCH", "POST", "DELETE", "OPTIONS"], + allowedHeaders: [ + "Content-Type", + "Accept", + "Authorization", + "X-Requested-With", + // IAM context headers required by @tria-plc/api-common's JwtGuard + "organization-unit-id", + "delegator-position-id", + "current-project-id", + "current-position-id", + ], + exposedHeaders: ["Content-Disposition"], + maxAge: 86400, // cache preflight for 24h to cut chatter in dev + }); app.setGlobalPrefix("api"); app.useGlobalPipes(createValidationPipe()); diff --git a/apps/edr-freight-api/src/modules/file-upload-settings/dto/create-file-upload-field.dto.ts b/apps/edr-freight-api/src/modules/file-upload-settings/dto/create-file-upload-field.dto.ts new file mode 100644 index 000000000..6d670dae3 --- /dev/null +++ b/apps/edr-freight-api/src/modules/file-upload-settings/dto/create-file-upload-field.dto.ts @@ -0,0 +1,51 @@ +import { + ArrayNotEmpty, + IsArray, + IsBoolean, + IsInt, + IsOptional, + IsString, + Max, + MaxLength, + Min, +} from "class-validator"; + +export class CreateFileUploadFieldDto { + @IsString() + @MaxLength(128) + fileKey!: string; + + @IsString() + @MaxLength(256) + fileLabel!: string; + + @IsOptional() + @IsString() + helpText?: string; + + @IsBoolean() + isRequired!: boolean; + + @IsBoolean() + isMultiple!: boolean; + + @IsInt() + @Min(1) + @Max(50) + maxFiles!: number; + + @IsArray() + @ArrayNotEmpty() + @IsString({ each: true }) + allowedExtensions!: string[]; + + @IsInt() + @Min(1) + @Max(500) + maxSizeMb!: number; + + @IsOptional() + @IsInt() + @Min(0) + order?: number; +} diff --git a/apps/edr-freight-api/src/modules/file-upload-settings/dto/create-file-upload-setting.dto.ts b/apps/edr-freight-api/src/modules/file-upload-settings/dto/create-file-upload-setting.dto.ts new file mode 100644 index 000000000..6a4d62f9e --- /dev/null +++ b/apps/edr-freight-api/src/modules/file-upload-settings/dto/create-file-upload-setting.dto.ts @@ -0,0 +1,44 @@ +import { Type } from "class-transformer"; +import { + IsArray, + IsEnum, + IsOptional, + IsString, + MaxLength, + ValidateNested, +} from "class-validator"; + +import { CreateFileUploadFieldDto } from "./create-file-upload-field.dto"; + +export enum FileUploadEntityDto { + Customer = "customer", + Booking = "booking", + Consignment = "consignment", + Shipment = "shipment", + Invoice = "invoice", + Train = "train", + Other = "other", +} + +export class CreateFileUploadSettingDto { + @IsString() + @MaxLength(128) + code!: string; + + @IsString() + @MaxLength(256) + label!: string; + + @IsOptional() + @IsString() + description?: string; + + @IsEnum(FileUploadEntityDto) + entity!: FileUploadEntityDto; + + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => CreateFileUploadFieldDto) + fields?: CreateFileUploadFieldDto[]; +} diff --git a/apps/edr-freight-api/src/modules/file-upload-settings/dto/update-file-upload-field.dto.ts b/apps/edr-freight-api/src/modules/file-upload-settings/dto/update-file-upload-field.dto.ts new file mode 100644 index 000000000..dae3ca4ca --- /dev/null +++ b/apps/edr-freight-api/src/modules/file-upload-settings/dto/update-file-upload-field.dto.ts @@ -0,0 +1,7 @@ +import { PartialType } from "@nestjs/swagger"; + +import { CreateFileUploadFieldDto } from "./create-file-upload-field.dto"; + +export class UpdateFileUploadFieldDto extends PartialType( + CreateFileUploadFieldDto, +) {} diff --git a/apps/edr-freight-api/src/modules/file-upload-settings/dto/update-file-upload-setting.dto.ts b/apps/edr-freight-api/src/modules/file-upload-settings/dto/update-file-upload-setting.dto.ts new file mode 100644 index 000000000..d8b6eb8ca --- /dev/null +++ b/apps/edr-freight-api/src/modules/file-upload-settings/dto/update-file-upload-setting.dto.ts @@ -0,0 +1,7 @@ +import { OmitType, PartialType } from "@nestjs/swagger"; + +import { CreateFileUploadSettingDto } from "./create-file-upload-setting.dto"; + +export class UpdateFileUploadSettingDto extends PartialType( + OmitType(CreateFileUploadSettingDto, ["fields"] as const), +) {} diff --git a/apps/edr-freight-api/src/modules/file-upload-settings/entities/file-upload-field.entity.ts b/apps/edr-freight-api/src/modules/file-upload-settings/entities/file-upload-field.entity.ts new file mode 100644 index 000000000..157778c0e --- /dev/null +++ b/apps/edr-freight-api/src/modules/file-upload-settings/entities/file-upload-field.entity.ts @@ -0,0 +1,58 @@ +import { BaseEntity } from "@edr/api-common"; +import { + Check, + Column, + Entity, + Index, + JoinColumn, + ManyToOne, +} from "typeorm"; + +import { FileUploadSetting } from "./file-upload-setting.entity"; + +@Entity({ name: "file_upload_fields" }) +@Index(["settingId", "fileKey"], { unique: true }) +@Check(`"max_files" > 0`) +@Check(`"max_size_mb" > 0`) +export class FileUploadField extends BaseEntity { + @ManyToOne(() => FileUploadSetting, (setting) => setting.fields, { + onDelete: "CASCADE", + }) + @JoinColumn({ name: "setting_id" }) + setting!: FileUploadSetting; + + @Column({ name: "setting_id", type: "uuid" }) + settingId!: string; + + @Column({ name: "file_key", type: "varchar", length: 128 }) + fileKey!: string; + + @Column({ name: "file_label", type: "varchar", length: 256 }) + fileLabel!: string; + + @Column({ name: "help_text", type: "text", nullable: true }) + helpText?: string | null; + + @Column({ name: "is_required", type: "boolean", default: false }) + isRequired!: boolean; + + @Column({ name: "is_multiple", type: "boolean", default: false }) + isMultiple!: boolean; + + @Column({ name: "max_files", type: "integer", default: 1 }) + maxFiles!: number; + + @Column({ + name: "allowed_extensions", + type: "text", + array: true, + default: () => "'{}'::text[]", + }) + allowedExtensions!: string[]; + + @Column({ name: "max_size_mb", type: "integer", default: 10 }) + maxSizeMb!: number; + + @Column({ name: "display_order", type: "integer", default: 0 }) + displayOrder!: number; +} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/file-upload-settings/entities/file-upload-setting.entity.ts b/apps/edr-freight-api/src/modules/file-upload-settings/entities/file-upload-setting.entity.ts new file mode 100644 index 000000000..5009cad98 --- /dev/null +++ b/apps/edr-freight-api/src/modules/file-upload-settings/entities/file-upload-setting.entity.ts @@ -0,0 +1,52 @@ +import { BaseEntity } from "@edr/api-common"; +import { + Column, + Entity, + Index, + OneToMany, +} from "typeorm"; + +import { FileUploadField } from "./file-upload-field.entity"; + +@Entity({ name: "file_upload_settings" }) +@Index(["code"], { unique: true }) +export class FileUploadSetting extends BaseEntity { + @Column({ + name: "code", + type: "varchar", + length: 128, + unique: true, + }) + code!: string; + + @Column({ + name: "label", + type: "varchar", + length: 256, + }) + label!: string; + + @Column({ + name: "description", + type: "text", + nullable: true, + }) + description?: string | null; + + @Column({ + name: "entity", + type: "varchar", + length: 32, + default: "other", + }) + entity!: string; + + @OneToMany( + () => FileUploadField, + (field) => field.setting, + { + cascade: true, + } + ) + fields!: FileUploadField[]; +} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/file-upload-settings/file-upload-settings.controller.ts b/apps/edr-freight-api/src/modules/file-upload-settings/file-upload-settings.controller.ts new file mode 100644 index 000000000..3613d9444 --- /dev/null +++ b/apps/edr-freight-api/src/modules/file-upload-settings/file-upload-settings.controller.ts @@ -0,0 +1,102 @@ +import { + Body, + Controller, + Delete, + Get, + HttpCode, + HttpStatus, + Param, + ParseUUIDPipe, + Patch, + Post, + Put, +} from "@nestjs/common"; +import { ApiOperation, ApiTags } from "@nestjs/swagger"; + +import { CreateFileUploadFieldDto } from "./dto/create-file-upload-field.dto"; +import { CreateFileUploadSettingDto } from "./dto/create-file-upload-setting.dto"; +import { UpdateFileUploadFieldDto } from "./dto/update-file-upload-field.dto"; +import { UpdateFileUploadSettingDto } from "./dto/update-file-upload-setting.dto"; +import { FileUploadSettingsService } from "./file-upload-settings.service"; + +@ApiTags("file-upload-settings") +@Controller("file-upload-settings") +export class FileUploadSettingsController { + constructor(private readonly service: FileUploadSettingsService) {} + + @Get() + @ApiOperation({ summary: "List all file upload settings" }) + list() { + return this.service.list(); + } + + @Get(":id") + @ApiOperation({ summary: "Get a file upload setting by ID" }) + getById(@Param("id", ParseUUIDPipe) id: string) { + return this.service.getById(id); + } + + @Get("by-code/:code") + @ApiOperation({ summary: "Get a file upload setting by its stable code" }) + getByCode(@Param("code") code: string) { + return this.service.getByCode(code); + } + + @Post() + @ApiOperation({ summary: "Create a new file upload setting" }) + create(@Body() dto: CreateFileUploadSettingDto) { + return this.service.create(dto); + } + + @Patch(":id") + @ApiOperation({ summary: "Update a file upload setting's metadata" }) + update( + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: UpdateFileUploadSettingDto, + ) { + return this.service.update(id, dto); + } + + @Delete(":id") + @ApiOperation({ summary: "Soft-delete a file upload setting" }) + @HttpCode(HttpStatus.NO_CONTENT) + remove(@Param("id", ParseUUIDPipe) id: string) { + return this.service.remove(id); + } + + /* ------------------------- field routes ------------------------- */ + + @Put(":id/fields") + @ApiOperation({ summary: "Replace the full field list for a setting" }) + replaceFields( + @Param("id", ParseUUIDPipe) id: string, + @Body() fields: CreateFileUploadFieldDto[], + ) { + return this.service.replaceFields(id, fields); + } + + @Post(":id/fields") + @ApiOperation({ summary: "Append a single field to a setting" }) + addField( + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: CreateFileUploadFieldDto, + ) { + return this.service.addField(id, dto); + } + + @Patch("fields/:fieldId") + @ApiOperation({ summary: "Update a single field" }) + updateField( + @Param("fieldId", ParseUUIDPipe) fieldId: string, + @Body() dto: UpdateFileUploadFieldDto, + ) { + return this.service.updateField(fieldId, dto); + } + + @Delete("fields/:fieldId") + @ApiOperation({ summary: "Soft-delete a single field" }) + @HttpCode(HttpStatus.NO_CONTENT) + removeField(@Param("fieldId", ParseUUIDPipe) fieldId: string) { + return this.service.removeField(fieldId); + } +} diff --git a/apps/edr-freight-api/src/modules/file-upload-settings/file-upload-settings.module.ts b/apps/edr-freight-api/src/modules/file-upload-settings/file-upload-settings.module.ts new file mode 100644 index 000000000..e32762394 --- /dev/null +++ b/apps/edr-freight-api/src/modules/file-upload-settings/file-upload-settings.module.ts @@ -0,0 +1,26 @@ +import { Module } from "@nestjs/common"; +import { TypeOrmModule } from "@nestjs/typeorm"; + +import { FileUploadField } from "./entities/file-upload-field.entity"; +import { FileUploadSetting } from "./entities/file-upload-setting.entity"; +import { FileUploadSettingsController } from "./file-upload-settings.controller"; +import { FileUploadSettingsRepository } from "./file-upload-settings.repository"; +import { FileUploadSettingsService } from "./file-upload-settings.service"; +import { FILE_UPLOAD_SETTINGS_REPOSITORY } from "./interfaces/file-upload-settings.repository.interface"; + +@Module({ + imports: [TypeOrmModule.forFeature([FileUploadSetting, FileUploadField])], + controllers: [FileUploadSettingsController], + providers: [ + FileUploadSettingsRepository, + { + // Bind the interface token to the concrete TypeORM repository so the + // service can inject the abstraction (handy for tests / swap-out). + provide: FILE_UPLOAD_SETTINGS_REPOSITORY, + useExisting: FileUploadSettingsRepository, + }, + FileUploadSettingsService, + ], + exports: [FileUploadSettingsService], +}) +export class FileUploadSettingsModule {} diff --git a/apps/edr-freight-api/src/modules/file-upload-settings/file-upload-settings.repository.ts b/apps/edr-freight-api/src/modules/file-upload-settings/file-upload-settings.repository.ts new file mode 100644 index 000000000..558a7bb74 --- /dev/null +++ b/apps/edr-freight-api/src/modules/file-upload-settings/file-upload-settings.repository.ts @@ -0,0 +1,75 @@ +import { BaseRepository } from "@edr/api-common"; +import { Injectable } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { Repository } from "typeorm"; + +import { FileUploadField } from "./entities/file-upload-field.entity"; +import { FileUploadSetting } from "./entities/file-upload-setting.entity"; +import type { IFileUploadSettingsRepository } from "./interfaces/file-upload-settings.repository.interface"; + +@Injectable() +export class FileUploadSettingsRepository + extends BaseRepository + implements IFileUploadSettingsRepository +{ + constructor( + @InjectRepository(FileUploadSetting) + repository: Repository, + @InjectRepository(FileUploadField) + private readonly fieldsRepository: Repository, + ) { + super(repository); + } + + /** Look up a setting by its stable code. */ + findByCode(code: string): Promise { + return this.repository.findOne({ + where: { code }, + relations: { fields: true }, + }); + } + + override findAll(): Promise { + return this.repository.find({ + order: { label: "ASC" }, + relations: { fields: true }, + }); + } + + /** Replace the whole field list for a setting. Returns the saved rows. */ + async replaceFields( + settingId: string, + fields: Array>, + ): Promise { + await this.fieldsRepository.delete({ settingId }); + if (fields.length === 0) return []; + const entities = fields.map((f, idx) => + this.fieldsRepository.create({ + ...f, + settingId, + displayOrder: f.displayOrder ?? idx + 1, + }), + ); + return this.fieldsRepository.save(entities); + } + + async addField( + settingId: string, + field: Partial, + ): Promise { + const entity = this.fieldsRepository.create({ ...field, settingId }); + return this.fieldsRepository.save(entity); + } + + async updateField( + fieldId: string, + data: Partial, + ): Promise { + await this.fieldsRepository.update(fieldId, data as never); + return this.fieldsRepository.findOne({ where: { id: fieldId } }); + } + + async removeField(fieldId: string): Promise { + await this.fieldsRepository.softDelete(fieldId); + } +} diff --git a/apps/edr-freight-api/src/modules/file-upload-settings/file-upload-settings.service.ts b/apps/edr-freight-api/src/modules/file-upload-settings/file-upload-settings.service.ts new file mode 100644 index 000000000..8ae5395d7 --- /dev/null +++ b/apps/edr-freight-api/src/modules/file-upload-settings/file-upload-settings.service.ts @@ -0,0 +1,109 @@ +import { + ConflictException, + Inject, + Injectable, + NotFoundException, +} from "@nestjs/common"; + +import { CreateFileUploadFieldDto } from "./dto/create-file-upload-field.dto"; +import { CreateFileUploadSettingDto } from "./dto/create-file-upload-setting.dto"; +import { UpdateFileUploadFieldDto } from "./dto/update-file-upload-field.dto"; +import { UpdateFileUploadSettingDto } from "./dto/update-file-upload-setting.dto"; +import { FileUploadField } from "./entities/file-upload-field.entity"; +import { FileUploadSetting } from "./entities/file-upload-setting.entity"; +import { + FILE_UPLOAD_SETTINGS_REPOSITORY, + IFileUploadSettingsRepository, +} from "./interfaces/file-upload-settings.repository.interface"; + +@Injectable() +export class FileUploadSettingsService { + constructor( + @Inject(FILE_UPLOAD_SETTINGS_REPOSITORY) + private readonly repository: IFileUploadSettingsRepository, + ) {} + + list(): Promise { + return this.repository.findAll(); + } + + async getById(id: string): Promise { + const setting = await this.repository.findById(id); + if (!setting) throw new NotFoundException(`Setting ${id} not found`); + return setting; + } + + async getByCode(code: string): Promise { + const setting = await this.repository.findByCode(code); + if (!setting) throw new NotFoundException(`Setting "${code}" not found`); + return setting; + } + + async create(dto: CreateFileUploadSettingDto): Promise { + const existing = await this.repository.findByCode(dto.code); + if (existing) { + throw new ConflictException( + `File upload setting with code "${dto.code}" already exists`, + ); + } + + const setting = await this.repository.create({ + code: dto.code, + label: dto.label, + description: dto.description ?? null, + entity: dto.entity, + }); + + if (dto.fields && dto.fields.length > 0) { + await this.repository.replaceFields(setting.id, dto.fields); + } + + return this.getById(setting.id); + } + + async update( + id: string, + dto: UpdateFileUploadSettingDto, + ): Promise { + await this.getById(id); // existence check + const updated = await this.repository.update(id, dto); + if (!updated) throw new NotFoundException(`Setting ${id} not found`); + return updated; + } + + async remove(id: string): Promise { + await this.getById(id); + await this.repository.softDelete(id); + } + + /* ------------------------ field operations ------------------------ */ + + async replaceFields( + settingId: string, + fields: CreateFileUploadFieldDto[], + ): Promise { + await this.getById(settingId); + return this.repository.replaceFields(settingId, fields); + } + + async addField( + settingId: string, + dto: CreateFileUploadFieldDto, + ): Promise { + await this.getById(settingId); + return this.repository.addField(settingId, dto); + } + + async updateField( + fieldId: string, + dto: UpdateFileUploadFieldDto, + ): Promise { + const updated = await this.repository.updateField(fieldId, dto); + if (!updated) throw new NotFoundException(`Field ${fieldId} not found`); + return updated; + } + + async removeField(fieldId: string): Promise { + await this.repository.removeField(fieldId); + } +} diff --git a/apps/edr-freight-api/src/modules/file-upload-settings/interfaces/file-upload-settings.repository.interface.ts b/apps/edr-freight-api/src/modules/file-upload-settings/interfaces/file-upload-settings.repository.interface.ts new file mode 100644 index 000000000..7e833de02 --- /dev/null +++ b/apps/edr-freight-api/src/modules/file-upload-settings/interfaces/file-upload-settings.repository.interface.ts @@ -0,0 +1,38 @@ +import { FileUploadField } from "../entities/file-upload-field.entity"; +import { FileUploadSetting } from "../entities/file-upload-setting.entity"; + +/** + * Contract every FileUploadSettings repository must satisfy. Lets services + * depend on the abstraction and lets tests swap in an in-memory fake. + */ +export const FILE_UPLOAD_SETTINGS_REPOSITORY = Symbol( + "FILE_UPLOAD_SETTINGS_REPOSITORY", +); + +export interface IFileUploadSettingsRepository { + findAll(): Promise; + findById(id: string): Promise; + findByCode(code: string): Promise; + + create(data: Partial): Promise; + update( + id: string, + data: Partial, + ): Promise; + softDelete(id: string): Promise; + + /* Field-level helpers */ + replaceFields( + settingId: string, + fields: Array>, + ): Promise; + addField( + settingId: string, + field: Partial, + ): Promise; + updateField( + fieldId: string, + data: Partial, + ): Promise; + removeField(fieldId: string): Promise; +} diff --git a/apps/edr-freight-web/portal/src/App.tsx b/apps/edr-freight-web/portal/src/App.tsx index 20d327528..591f2ec38 100644 --- a/apps/edr-freight-web/portal/src/App.tsx +++ b/apps/edr-freight-web/portal/src/App.tsx @@ -17,6 +17,7 @@ import { FileText, Settings, UserCircle, + FileUp, } from "lucide-react"; import BookingsPage from "./pages/bookings/BookingsPage"; @@ -39,6 +40,7 @@ import CustomerDetailPage from "./pages/customers/CustomerDetailPage"; import NewCustomerPage from "./pages/customers/NewCustomerPage"; import DocumentsPage from "./pages/documents/DocumentsPage"; import DropdownSettingsPage from "./pages/admin/DropdownSettingsPage"; +import FileUploadSettingsPage from "./pages/admin/FileUploadSettingsPage"; import MyPortalPage from "./pages/portal/MyPortalPage"; const sidebarItems: SidebarItem[] = [ @@ -52,6 +54,11 @@ const sidebarItems: SidebarItem[] = [ { label: "Billing", href: "/billing", icon: }, { label: "Documents", href: "/documents", icon: }, { label: "Dropdown Settings", href: "/admin/dropdowns", icon: }, + { + label: "File Upload Settings", + href: "/admin/file-uploads", + icon: , + }, ]; const App = () => { @@ -121,6 +128,10 @@ const App = () => { path="/admin/dropdowns" element={} /> + } + /> } /> } /> diff --git a/apps/edr-freight-web/portal/src/constants/URLS.ts b/apps/edr-freight-web/portal/src/constants/URLS.ts new file mode 100644 index 000000000..912bde107 --- /dev/null +++ b/apps/edr-freight-web/portal/src/constants/URLS.ts @@ -0,0 +1,63 @@ +export const URL_CONSTANTS = { + AUTH: { + LOGIN: "/auth/login", + REGISTER: "/auth/register", + REFRESH_TOKEN: "/auth/refresh-token", + LOGOUT: "/auth/logout", + PROFILE: "/auth/profile", + }, + + USERS: { + BASE: "/users", + BY_ID: (id: string | number) => `/users/${id}`, + }, + + ROLES: { + BASE: "/roles", + BY_ID: (id: string | number) => `/roles/${id}`, + }, + + PERMISSIONS: { + BASE: "/permissions", + BY_ID: (id: string | number) => `/permissions/${id}`, + }, + + PRODUCTS: { + BASE: "/products", + BY_ID: (id: string | number) => `/products/${id}`, + }, + + ORDERS: { + BASE: "/orders", + BY_ID: (id: string | number) => `/orders/${id}`, + }, + + FILES: { + BASE: "/files", + UPLOAD: "/files/upload", + FILE_UPLOAD_SETTINGS: "/files/upload", + DOWNLOAD: (id: string | number) => `/files/${id}/download`, + DELETE: (id: string | number) => `/files/${id}`, + BY_ID: (id: string | number) => `/files/${id}`, + }, + + SETTINGS: { + BASE: "/settings", + GENERAL: "/settings/general", + SECURITY: "/settings/security", + NOTIFICATIONS: "/settings/notifications", + }, + + CUSTOMERS: { + BASE: "/customers", + BY_ID: (id: string | number) => `/customers/${id}`, + BOOKINGS: (id: string | number) => `/customers/${id}/bookings`, + }, + + BOOKINGS: { + BASE: "/bookings", + BY_ID: (id: string | number) => `/bookings/${id}`, + CANCEL: (id: string | number) => `/bookings/${id}/cancel`, + CONFIRM: (id: string | number) => `/bookings/${id}/confirm`, + }, +}; \ No newline at end of file diff --git a/apps/edr-freight-web/portal/src/hooks/useFileUploadSettings.ts b/apps/edr-freight-web/portal/src/hooks/useFileUploadSettings.ts new file mode 100644 index 000000000..f916915f9 --- /dev/null +++ b/apps/edr-freight-web/portal/src/hooks/useFileUploadSettings.ts @@ -0,0 +1,126 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +import { fileUploadSettingsService } from "@/services/fileUploadSettings.service"; +import type { + CreateFileUploadFieldDto, + CreateFileUploadSettingDto, + UpdateFileUploadFieldDto, + UpdateFileUploadSettingDto, +} from "@/types/fileUploadSettings"; + +const KEY = ["file-upload-settings"] as const; + +/* ------------------------------ Queries ------------------------------ */ + +export const useFileUploadSettings = () => + useQuery({ + queryKey: KEY, + queryFn: fileUploadSettingsService.list, + }); + +export const useFileUploadSetting = (id: string) => + useQuery({ + queryKey: [...KEY, "id", id], + queryFn: () => fileUploadSettingsService.getById(id), + enabled: Boolean(id), + }); + +export const useFileUploadSettingByCode = (code: string) => + useQuery({ + queryKey: [...KEY, "code", code], + queryFn: () => fileUploadSettingsService.getByCode(code), + enabled: Boolean(code), + }); + +/* ----------------------------- Mutations ----------------------------- */ + +export const useCreateFileUploadSetting = () => { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (dto: CreateFileUploadSettingDto) => + fileUploadSettingsService.create(dto), + onSuccess: () => qc.invalidateQueries({ queryKey: KEY }), + }); +}; + +export const useUpdateFileUploadSetting = () => { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ + id, + dto, + }: { + id: string; + dto: UpdateFileUploadSettingDto; + }) => fileUploadSettingsService.update(id, dto), + onSuccess: (_data, { id }) => { + qc.invalidateQueries({ queryKey: KEY }); + qc.invalidateQueries({ queryKey: [...KEY, "id", id] }); + }, + }); +}; + +export const useDeleteFileUploadSetting = () => { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => fileUploadSettingsService.remove(id), + onSuccess: () => qc.invalidateQueries({ queryKey: KEY }), + }); +}; + +export const useReplaceFileUploadFields = () => { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ + settingId, + fields, + }: { + settingId: string; + fields: CreateFileUploadFieldDto[]; + }) => fileUploadSettingsService.replaceFields(settingId, fields), + onSuccess: (_data, { settingId }) => { + qc.invalidateQueries({ queryKey: KEY }); + qc.invalidateQueries({ queryKey: [...KEY, "id", settingId] }); + }, + }); +}; + +export const useAddFileUploadField = () => { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ + settingId, + dto, + }: { + settingId: string; + dto: CreateFileUploadFieldDto; + }) => fileUploadSettingsService.addField(settingId, dto), + onSuccess: (_data, { settingId }) => { + qc.invalidateQueries({ queryKey: KEY }); + qc.invalidateQueries({ queryKey: [...KEY, "id", settingId] }); + }, + }); +}; + +export const useUpdateFileUploadField = () => { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ + fieldId, + dto, + }: { + fieldId: string; + dto: UpdateFileUploadFieldDto; + }) => fileUploadSettingsService.updateField(fieldId, dto), + onSuccess: () => qc.invalidateQueries({ queryKey: KEY }), + }); +}; + +export const useRemoveFileUploadField = () => { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (fieldId: string) => + fileUploadSettingsService.removeField(fieldId), + onSuccess: () => qc.invalidateQueries({ queryKey: KEY }), + }); +}; diff --git a/apps/edr-freight-web/portal/src/pages/admin/DeleteFileUploadSettingDialog.tsx b/apps/edr-freight-web/portal/src/pages/admin/DeleteFileUploadSettingDialog.tsx new file mode 100644 index 000000000..84693fa28 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/admin/DeleteFileUploadSettingDialog.tsx @@ -0,0 +1,65 @@ +import type { ReactNode } from "react"; + +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; + +import { Button } from "@/components/ui/button"; + +export interface DeleteFileUploadSettingDialogProps { + settingLabel: string; + settingCode: string; + onConfirm?: () => void; + children: ReactNode; +} + +export default function DeleteFileUploadSettingDialog({ + settingLabel, + settingCode, + onConfirm, + children, +}: DeleteFileUploadSettingDialogProps) { + return ( + + {children} + + + + + Delete file upload setting? + + + + This will remove{" "} + {settingLabel}{" "} + ({settingCode}) and all + of its fields. Forms referencing this code will fall back to no + uploads. + + + + + + + + + + + + + + + ); +} diff --git a/apps/edr-freight-web/portal/src/pages/admin/EditFileUploadSettingDialog.tsx b/apps/edr-freight-web/portal/src/pages/admin/EditFileUploadSettingDialog.tsx new file mode 100644 index 000000000..491d1faaf --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/admin/EditFileUploadSettingDialog.tsx @@ -0,0 +1,229 @@ +import { useState, type ReactNode } from "react"; +import { Hash, Loader2 } from "lucide-react"; + +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Button } from "@/components/ui/button"; +import { Textarea } from "@/components/ui/textarea"; + +import type { + FileUploadEntity, + FileUploadSetting, +} from "@/types/fileUploadSettings"; +import { + useCreateFileUploadSetting, + useUpdateFileUploadSetting, +} from "@/hooks/useFileUploadSettings"; + +export interface EditFileUploadSettingDialogProps { + mode?: "create" | "edit"; + setting?: FileUploadSetting; + children: ReactNode; +} + +const selectClass = + "flex h-10 w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-700 shadow-xs outline-none transition hover:border-slate-300 focus:border-[#10B981]/50 focus:ring-2 focus:ring-[#10B981]/20"; + +const ENTITIES: FileUploadEntity[] = [ + "customer", + "booking", + "consignment", + "shipment", + "invoice", + "train", + "other", +]; + +export default function EditFileUploadSettingDialog({ + mode = "create", + setting, + children, +}: EditFileUploadSettingDialogProps) { + const isEdit = mode === "edit"; + + const [open, setOpen] = useState(false); + const [code, setCode] = useState(setting?.code ?? ""); + const [label, setLabel] = useState(setting?.label ?? ""); + const [entity, setEntity] = useState( + setting?.entity ?? "other", + ); + const [description, setDescription] = useState(setting?.description ?? ""); + const [error, setError] = useState(null); + + const createMutation = useCreateFileUploadSetting(); + const updateMutation = useUpdateFileUploadSetting(); + const pending = createMutation.isPending || updateMutation.isPending; + + const reset = () => { + setCode(setting?.code ?? ""); + setLabel(setting?.label ?? ""); + setEntity(setting?.entity ?? "other"); + setDescription(setting?.description ?? ""); + setError(null); + }; + + const handleSubmit = () => { + setError(null); + if (!code.trim() || !label.trim()) { + setError("Code and label are required."); + return; + } + + const payload = { + code: code.trim(), + label: label.trim(), + entity, + description: description.trim() || undefined, + }; + + const onDone = () => { + setOpen(false); + if (!isEdit) reset(); + }; + const onError = (err: unknown) => { + setError( + err instanceof Error ? err.message : "Something went wrong. Try again.", + ); + }; + + if (isEdit && setting) { + updateMutation.mutate( + { id: setting.id, dto: payload }, + { onSuccess: onDone, onError }, + ); + } else { + createMutation.mutate(payload, { onSuccess: onDone, onError }); + } + }; + + return ( + { + setOpen(next); + if (!next) reset(); + }} + > + {children} + + + + + {isEdit ? "Edit File Upload Setting" : "New File Upload Setting"} + + + {isEdit + ? "Update the metadata for this file upload group." + : "Define a new file upload group that a form can reference by code."} + + + +
+
+ +
+ + setCode(e.target.value)} + placeholder="e.g. customer_registration" + className="pl-10 font-mono" + disabled={isEdit} + /> +
+

+ {isEdit + ? "Code is immutable after creation." + : "Stable identifier used in code. Use snake_case."} +

+
+ +
+ + setLabel(e.target.value)} + placeholder="e.g. Customer Registration" + /> +
+ +
+ + +

+ Domain the upload group applies to. +

+
+ +
+ + +

+ Manage fields from the "Fields" action on the list. +

+
+ +
+ +