mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
file upload settings
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { PartialType } from "@nestjs/swagger";
|
||||
|
||||
import { CreateFileUploadFieldDto } from "./create-file-upload-field.dto";
|
||||
|
||||
export class UpdateFileUploadFieldDto extends PartialType(
|
||||
CreateFileUploadFieldDto,
|
||||
) {}
|
||||
@@ -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),
|
||||
) {}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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<FileUploadSetting>
|
||||
implements IFileUploadSettingsRepository
|
||||
{
|
||||
constructor(
|
||||
@InjectRepository(FileUploadSetting)
|
||||
repository: Repository<FileUploadSetting>,
|
||||
@InjectRepository(FileUploadField)
|
||||
private readonly fieldsRepository: Repository<FileUploadField>,
|
||||
) {
|
||||
super(repository);
|
||||
}
|
||||
|
||||
/** Look up a setting by its stable code. */
|
||||
findByCode(code: string): Promise<FileUploadSetting | null> {
|
||||
return this.repository.findOne({
|
||||
where: { code },
|
||||
relations: { fields: true },
|
||||
});
|
||||
}
|
||||
|
||||
override findAll(): Promise<FileUploadSetting[]> {
|
||||
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<Partial<FileUploadField>>,
|
||||
): Promise<FileUploadField[]> {
|
||||
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<FileUploadField>,
|
||||
): Promise<FileUploadField> {
|
||||
const entity = this.fieldsRepository.create({ ...field, settingId });
|
||||
return this.fieldsRepository.save(entity);
|
||||
}
|
||||
|
||||
async updateField(
|
||||
fieldId: string,
|
||||
data: Partial<FileUploadField>,
|
||||
): Promise<FileUploadField | null> {
|
||||
await this.fieldsRepository.update(fieldId, data as never);
|
||||
return this.fieldsRepository.findOne({ where: { id: fieldId } });
|
||||
}
|
||||
|
||||
async removeField(fieldId: string): Promise<void> {
|
||||
await this.fieldsRepository.softDelete(fieldId);
|
||||
}
|
||||
}
|
||||
@@ -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<FileUploadSetting[]> {
|
||||
return this.repository.findAll();
|
||||
}
|
||||
|
||||
async getById(id: string): Promise<FileUploadSetting> {
|
||||
const setting = await this.repository.findById(id);
|
||||
if (!setting) throw new NotFoundException(`Setting ${id} not found`);
|
||||
return setting;
|
||||
}
|
||||
|
||||
async getByCode(code: string): Promise<FileUploadSetting> {
|
||||
const setting = await this.repository.findByCode(code);
|
||||
if (!setting) throw new NotFoundException(`Setting "${code}" not found`);
|
||||
return setting;
|
||||
}
|
||||
|
||||
async create(dto: CreateFileUploadSettingDto): Promise<FileUploadSetting> {
|
||||
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<FileUploadSetting> {
|
||||
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<void> {
|
||||
await this.getById(id);
|
||||
await this.repository.softDelete(id);
|
||||
}
|
||||
|
||||
/* ------------------------ field operations ------------------------ */
|
||||
|
||||
async replaceFields(
|
||||
settingId: string,
|
||||
fields: CreateFileUploadFieldDto[],
|
||||
): Promise<FileUploadField[]> {
|
||||
await this.getById(settingId);
|
||||
return this.repository.replaceFields(settingId, fields);
|
||||
}
|
||||
|
||||
async addField(
|
||||
settingId: string,
|
||||
dto: CreateFileUploadFieldDto,
|
||||
): Promise<FileUploadField> {
|
||||
await this.getById(settingId);
|
||||
return this.repository.addField(settingId, dto);
|
||||
}
|
||||
|
||||
async updateField(
|
||||
fieldId: string,
|
||||
dto: UpdateFileUploadFieldDto,
|
||||
): Promise<FileUploadField> {
|
||||
const updated = await this.repository.updateField(fieldId, dto);
|
||||
if (!updated) throw new NotFoundException(`Field ${fieldId} not found`);
|
||||
return updated;
|
||||
}
|
||||
|
||||
async removeField(fieldId: string): Promise<void> {
|
||||
await this.repository.removeField(fieldId);
|
||||
}
|
||||
}
|
||||
@@ -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<FileUploadSetting[]>;
|
||||
findById(id: string): Promise<FileUploadSetting | null>;
|
||||
findByCode(code: string): Promise<FileUploadSetting | null>;
|
||||
|
||||
create(data: Partial<FileUploadSetting>): Promise<FileUploadSetting>;
|
||||
update(
|
||||
id: string,
|
||||
data: Partial<FileUploadSetting>,
|
||||
): Promise<FileUploadSetting | null>;
|
||||
softDelete(id: string): Promise<void>;
|
||||
|
||||
/* Field-level helpers */
|
||||
replaceFields(
|
||||
settingId: string,
|
||||
fields: Array<Partial<FileUploadField>>,
|
||||
): Promise<FileUploadField[]>;
|
||||
addField(
|
||||
settingId: string,
|
||||
field: Partial<FileUploadField>,
|
||||
): Promise<FileUploadField>;
|
||||
updateField(
|
||||
fieldId: string,
|
||||
data: Partial<FileUploadField>,
|
||||
): Promise<FileUploadField | null>;
|
||||
removeField(fieldId: string): Promise<void>;
|
||||
}
|
||||
@@ -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: <Receipt /> },
|
||||
{ label: "Documents", href: "/documents", icon: <FileText /> },
|
||||
{ label: "Dropdown Settings", href: "/admin/dropdowns", icon: <Settings /> },
|
||||
{
|
||||
label: "File Upload Settings",
|
||||
href: "/admin/file-uploads",
|
||||
icon: <FileUp />,
|
||||
},
|
||||
];
|
||||
|
||||
const App = () => {
|
||||
@@ -121,6 +128,10 @@ const App = () => {
|
||||
path="/admin/dropdowns"
|
||||
element={<DropdownSettingsPage />}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/file-uploads"
|
||||
element={<FileUploadSettingsPage />}
|
||||
/>
|
||||
<Route path="/user-management" element={<Navigate to="/" replace />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
|
||||
63
apps/edr-freight-web/portal/src/constants/URLS.ts
Normal file
63
apps/edr-freight-web/portal/src/constants/URLS.ts
Normal file
@@ -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`,
|
||||
},
|
||||
};
|
||||
126
apps/edr-freight-web/portal/src/hooks/useFileUploadSettings.ts
Normal file
126
apps/edr-freight-web/portal/src/hooks/useFileUploadSettings.ts
Normal file
@@ -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 }),
|
||||
});
|
||||
};
|
||||
@@ -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 (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||
|
||||
<DialogContent className="sm:max-w-md rounded-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-xl font-bold">
|
||||
Delete file upload setting?
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription>
|
||||
This will remove{" "}
|
||||
<span className="font-semibold text-slate-900">{settingLabel}</span>{" "}
|
||||
(<span className="font-mono text-xs">{settingCode}</span>) and all
|
||||
of its fields. Forms referencing this code will fall back to no
|
||||
uploads.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogFooter className="mt-2">
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline">Cancel</Button>
|
||||
</DialogClose>
|
||||
|
||||
<DialogClose asChild>
|
||||
<Button
|
||||
onClick={onConfirm}
|
||||
className="bg-red-600 text-white hover:bg-red-700"
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</DialogClose>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -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<FileUploadEntity>(
|
||||
setting?.entity ?? "other",
|
||||
);
|
||||
const [description, setDescription] = useState(setting?.description ?? "");
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
setOpen(next);
|
||||
if (!next) reset();
|
||||
}}
|
||||
>
|
||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-2xl rounded-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-2xl font-bold">
|
||||
{isEdit ? "Edit File Upload Setting" : "New File Upload Setting"}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isEdit
|
||||
? "Update the metadata for this file upload group."
|
||||
: "Define a new file upload group that a form can reference by code."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-5 py-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>Code *</Label>
|
||||
<div className="relative">
|
||||
<Hash className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
|
||||
<Input
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
placeholder="e.g. customer_registration"
|
||||
className="pl-10 font-mono"
|
||||
disabled={isEdit}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-slate-500">
|
||||
{isEdit
|
||||
? "Code is immutable after creation."
|
||||
: "Stable identifier used in code. Use snake_case."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Label *</Label>
|
||||
<Input
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
placeholder="e.g. Customer Registration"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Entity</Label>
|
||||
<select
|
||||
value={entity}
|
||||
onChange={(e) => setEntity(e.target.value as FileUploadEntity)}
|
||||
className={selectClass}
|
||||
>
|
||||
{ENTITIES.map((e) => (
|
||||
<option key={e} value={e} className="capitalize">
|
||||
{e[0]!.toUpperCase() + e.slice(1)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-xs text-slate-500">
|
||||
Domain the upload group applies to.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Field Count</Label>
|
||||
<Input
|
||||
disabled
|
||||
value={String(setting?.fields.length ?? 0)}
|
||||
className="bg-slate-50 text-slate-600"
|
||||
/>
|
||||
<p className="text-xs text-slate-500">
|
||||
Manage fields from the "Fields" action on the list.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 md:col-span-2">
|
||||
<Label>Description</Label>
|
||||
<Textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="What this upload group represents and where it's used..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<p className="rounded-xl bg-red-50 px-3 py-2 text-sm text-red-700">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="mt-2 flex justify-end gap-3">
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline" disabled={pending}>
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
disabled={pending}
|
||||
className="bg-[#10B981] text-white hover:bg-[#10B981]/90 disabled:opacity-60"
|
||||
>
|
||||
{pending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : isEdit ? (
|
||||
"Save Changes"
|
||||
) : (
|
||||
"Create Setting"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,460 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
AlertCircle,
|
||||
Filter,
|
||||
FileUp,
|
||||
HardDrive,
|
||||
Layers,
|
||||
Loader2,
|
||||
Paperclip,
|
||||
Pencil,
|
||||
Plus,
|
||||
Search,
|
||||
Settings,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
|
||||
import Breadcrumbs from "@/components/Breadcrumbs";
|
||||
import EditFileUploadSettingDialog from "./EditFileUploadSettingDialog";
|
||||
import ManageFileUploadFieldsDialog from "./ManageFileUploadFieldsDialog";
|
||||
import DeleteFileUploadSettingDialog from "./DeleteFileUploadSettingDialog";
|
||||
import {
|
||||
useDeleteFileUploadSetting,
|
||||
useFileUploadSettings,
|
||||
} from "@/hooks/useFileUploadSettings";
|
||||
import { getMinFiles } from "@/types/fileUploadSettings";
|
||||
|
||||
export default function FileUploadSettingsPage() {
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
const { data, isLoading, isError, error } = useFileUploadSettings();
|
||||
const deleteMutation = useDeleteFileUploadSetting();
|
||||
|
||||
const fileUploadSettings = useMemo(
|
||||
() => (Array.isArray(data) ? data : []),
|
||||
[data],
|
||||
);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return fileUploadSettings;
|
||||
return fileUploadSettings.filter(
|
||||
(s) =>
|
||||
s.code.toLowerCase().includes(q) ||
|
||||
s.label.toLowerCase().includes(q) ||
|
||||
(s.description ?? "").toLowerCase().includes(q) ||
|
||||
s.fields.some(
|
||||
(f) =>
|
||||
f.fileKey.toLowerCase().includes(q) ||
|
||||
f.fileLabel.toLowerCase().includes(q),
|
||||
),
|
||||
);
|
||||
}, [fileUploadSettings, query]);
|
||||
|
||||
const totalFields = fileUploadSettings.reduce(
|
||||
(sum, s) => sum + s.fields.length,
|
||||
0,
|
||||
);
|
||||
const requiredFields = fileUploadSettings.reduce(
|
||||
(sum, s) => sum + s.fields.filter((f) => f.isRequired).length,
|
||||
0,
|
||||
);
|
||||
const multiFields = fileUploadSettings.reduce(
|
||||
(sum, s) => sum + s.fields.filter((f) => f.isMultiple).length,
|
||||
0,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 p-6">
|
||||
<div className="mx-auto max-w-7xl space-y-6">
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ label: "Admin", href: "/admin" },
|
||||
{ label: "File Upload Settings" },
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex flex-col gap-4 rounded-3xl bg-white p-6 shadow-sm md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight text-slate-900">
|
||||
File Upload Settings
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-slate-500">
|
||||
Define the file inputs every form in the platform should render —
|
||||
required/optional, single/multiple, allowed types and size.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-stretch gap-3 sm:flex-row sm:items-center">
|
||||
<div className="relative w-full sm:w-80">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
|
||||
<input
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search by code, label, or file key..."
|
||||
className="h-10 w-full rounded-2xl border border-slate-200 bg-white pl-10 pr-4 text-sm text-slate-700 outline-none transition placeholder:text-slate-400 focus:border-[#10B981]/50 focus:ring-2 focus:ring-[#10B981]/20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<EditFileUploadSettingDialog mode="create">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center justify-center gap-2 rounded-2xl bg-[#10B981] px-4 py-2 text-sm font-medium text-white transition hover:bg-[#10B981]/90"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
New Setting
|
||||
</button>
|
||||
</EditFileUploadSettingDialog>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid gap-4 md:grid-cols-4">
|
||||
<StatCard
|
||||
title="Settings"
|
||||
value={String(fileUploadSettings.length)}
|
||||
icon={<Settings className="h-5 w-5" />}
|
||||
/>
|
||||
<StatCard
|
||||
title="Total Fields"
|
||||
value={String(totalFields)}
|
||||
icon={<Paperclip className="h-5 w-5" />}
|
||||
/>
|
||||
<StatCard
|
||||
title="Required"
|
||||
value={String(requiredFields)}
|
||||
icon={<FileUp className="h-5 w-5" />}
|
||||
/>
|
||||
<StatCard
|
||||
title="Multi-file"
|
||||
value={String(multiFields)}
|
||||
icon={<Layers className="h-5 w-5" />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="overflow-hidden rounded-3xl bg-white shadow-sm">
|
||||
<div className="flex items-center justify-between border-b border-slate-100 px-6 py-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-slate-900">
|
||||
Registered File Upload Groups
|
||||
</h2>
|
||||
<p className="text-sm text-slate-500">
|
||||
Every group a form can reference by code.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button className="inline-flex items-center gap-2 rounded-2xl border border-slate-200 px-4 py-2 text-sm font-medium text-slate-700 transition hover:border-[#10B981]/30 hover:bg-[#10B981]/10 hover:text-[#10B981]">
|
||||
<Filter className="h-4 w-4" />
|
||||
Filter
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[1100px] whitespace-nowrap text-left">
|
||||
<thead className="bg-slate-50 text-sm text-slate-500">
|
||||
<tr>
|
||||
<th className="px-6 py-4 font-medium">Setting</th>
|
||||
<th className="px-6 py-4 font-medium">Code</th>
|
||||
<th className="px-6 py-4 font-medium">Entity</th>
|
||||
<th className="px-6 py-4 font-medium">Fields</th>
|
||||
<th className="px-6 py-4 font-medium">Required / Multi</th>
|
||||
<th className="px-6 py-4 font-medium">Max Size</th>
|
||||
<th className="px-6 py-4 font-medium text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
{isLoading ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-6 py-12 text-center">
|
||||
<Loader2 className="mx-auto h-6 w-6 animate-spin text-[#10B981]" />
|
||||
<p className="mt-2 text-sm text-slate-500">
|
||||
Loading file upload settings…
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
) : isError ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-6 py-12 text-center">
|
||||
<AlertCircle className="mx-auto h-6 w-6 text-red-500" />
|
||||
<p className="mt-2 text-sm text-red-600">
|
||||
Failed to load settings.{" "}
|
||||
{error instanceof Error ? error.message : "Unknown error."}
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
) : filtered.length === 0 ? (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={7}
|
||||
className="px-6 py-12 text-center text-sm text-slate-500"
|
||||
>
|
||||
{fileUploadSettings.length === 0
|
||||
? "No file upload settings yet. Click \"New Setting\" to add one."
|
||||
: "No file upload settings match your search."}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
filtered.map((setting) => {
|
||||
const required = setting.fields.filter(
|
||||
(f) => f.isRequired,
|
||||
).length;
|
||||
const multi = setting.fields.filter(
|
||||
(f) => f.isMultiple,
|
||||
).length;
|
||||
const maxSize = Math.max(
|
||||
0,
|
||||
...setting.fields.map((f) => f.maxSizeMb),
|
||||
);
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={setting.id}
|
||||
className="border-t border-slate-100 transition hover:bg-[#10B981]/5"
|
||||
>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-[#10B981] text-white">
|
||||
<FileUp className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-slate-900">
|
||||
{setting.label}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500">
|
||||
{setting.description ?? "No description"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td className="px-6 py-4">
|
||||
<span className="rounded-md bg-slate-100 px-2 py-1 font-mono text-xs text-slate-700">
|
||||
{setting.code}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
<td className="px-6 py-4">
|
||||
<span className="inline-flex rounded-full bg-slate-100 px-2.5 py-0.5 text-xs font-medium capitalize text-slate-600">
|
||||
{setting.entity ?? "—"}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex items-center gap-2 text-sm text-slate-700">
|
||||
<Paperclip className="h-4 w-4 text-[#10B981]" />
|
||||
<span className="font-medium">
|
||||
{setting.fields.length}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td className="px-6 py-4 text-sm text-slate-700">
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
<Chip>{required} required</Chip>
|
||||
<Chip muted>{multi} multi</Chip>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td className="px-6 py-4 text-sm text-slate-700">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<HardDrive className="h-4 w-4 text-slate-400" />
|
||||
{maxSize ? `${maxSize} MB` : "—"}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex justify-end gap-2">
|
||||
<ManageFileUploadFieldsDialog setting={setting}>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1 rounded-xl border border-slate-200 px-3 py-1.5 text-xs font-medium text-slate-600 transition hover:border-[#10B981]/30 hover:bg-[#10B981]/10 hover:text-[#10B981]"
|
||||
>
|
||||
<Paperclip className="h-3.5 w-3.5" />
|
||||
Fields
|
||||
</button>
|
||||
</ManageFileUploadFieldsDialog>
|
||||
|
||||
<EditFileUploadSettingDialog
|
||||
mode="edit"
|
||||
setting={setting}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-xl border border-slate-200 p-2 text-slate-600 transition hover:border-[#10B981]/30 hover:bg-[#10B981]/10 hover:text-[#10B981]"
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</button>
|
||||
</EditFileUploadSettingDialog>
|
||||
|
||||
<DeleteFileUploadSettingDialog
|
||||
settingLabel={setting.label}
|
||||
settingCode={setting.code}
|
||||
onConfirm={() =>
|
||||
deleteMutation.mutate(setting.id)
|
||||
}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
disabled={deleteMutation.isPending}
|
||||
className="rounded-xl border border-red-200 p-2 text-red-600 transition hover:bg-red-50 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</DeleteFileUploadSettingDialog>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Behavior reference card */}
|
||||
<div className="rounded-3xl bg-white p-6 shadow-sm">
|
||||
<h2 className="text-lg font-semibold text-slate-900">
|
||||
Required × Multiple behavior
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-slate-500">
|
||||
Min and max file counts are derived from these two toggles. The
|
||||
"Max Files" you set on a field is only used when{" "}
|
||||
<span className="font-medium">Multiple</span> is on.
|
||||
</p>
|
||||
<div className="mt-4 overflow-x-auto">
|
||||
<table className="w-full whitespace-nowrap text-left text-sm">
|
||||
<thead className="text-xs text-slate-500">
|
||||
<tr>
|
||||
<th className="py-2 font-medium">Required</th>
|
||||
<th className="py-2 font-medium">Multiple</th>
|
||||
<th className="py-2 font-medium">min_files</th>
|
||||
<th className="py-2 font-medium">max_files</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<BehaviorRow
|
||||
required={false}
|
||||
multiple={false}
|
||||
min="0"
|
||||
max="1"
|
||||
/>
|
||||
<BehaviorRow
|
||||
required={true}
|
||||
multiple={false}
|
||||
min="1"
|
||||
max="1"
|
||||
/>
|
||||
<BehaviorRow
|
||||
required={false}
|
||||
multiple={true}
|
||||
min="0"
|
||||
max="field.maxFiles"
|
||||
/>
|
||||
<BehaviorRow
|
||||
required={true}
|
||||
multiple={true}
|
||||
min="1"
|
||||
max="field.maxFiles"
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p className="mt-3 text-xs text-slate-500">
|
||||
Helpers <span className="font-mono">getMinFiles</span> and{" "}
|
||||
<span className="font-mono">getEffectiveMaxFiles</span> live in{" "}
|
||||
<span className="font-mono">@/types/fileUploadSettings</span> — use
|
||||
them when wiring real uploaders. Example: a field with{" "}
|
||||
<span className="font-mono">isRequired=false</span>,{" "}
|
||||
<span className="font-mono">isMultiple=true</span>,{" "}
|
||||
<span className="font-mono">maxFiles=5</span> gives{" "}
|
||||
<span className="font-mono">{getMinFiles({
|
||||
id: "demo",
|
||||
fileKey: "demo",
|
||||
fileLabel: "demo",
|
||||
isRequired: false,
|
||||
isMultiple: true,
|
||||
maxFiles: 5,
|
||||
allowedExtensions: [],
|
||||
maxSizeMb: 1,
|
||||
})}</span>
|
||||
…5.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BehaviorRow({
|
||||
required,
|
||||
multiple,
|
||||
min,
|
||||
max,
|
||||
}: {
|
||||
required: boolean;
|
||||
multiple: boolean;
|
||||
min: string;
|
||||
max: string;
|
||||
}) {
|
||||
return (
|
||||
<tr className="border-t border-slate-100">
|
||||
<td className="py-2.5">
|
||||
<Chip muted={!required}>{required ? "Required" : "Optional"}</Chip>
|
||||
</td>
|
||||
<td className="py-2.5">
|
||||
<Chip muted={!multiple}>{multiple ? "Multiple" : "Single"}</Chip>
|
||||
</td>
|
||||
<td className="py-2.5 font-mono text-slate-700">{min}</td>
|
||||
<td className="py-2.5 font-mono text-slate-700">{max}</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
function Chip({
|
||||
children,
|
||||
muted = false,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
muted?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className={
|
||||
muted
|
||||
? "rounded-full bg-slate-100 px-2 py-0.5 text-xs font-medium text-slate-600"
|
||||
: "rounded-full bg-[#10B981]/10 px-2 py-0.5 text-xs font-medium text-[#10B981]"
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({
|
||||
title,
|
||||
value,
|
||||
icon,
|
||||
}: {
|
||||
title: string;
|
||||
value: string;
|
||||
icon: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-3xl bg-white p-6 shadow-sm transition hover:shadow-md hover:ring-1 hover:ring-[#10B981]/20">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-slate-500">{title}</p>
|
||||
<h3 className="mt-2 text-3xl font-bold text-slate-900">{value}</h3>
|
||||
</div>
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-[#10B981] text-white">
|
||||
{icon}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,416 @@
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { GripVertical, Loader2, Plus, Trash2 } 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 type {
|
||||
CreateFileUploadFieldDto,
|
||||
FileUploadSetting,
|
||||
} from "@/types/fileUploadSettings";
|
||||
import { getMinFiles } from "@/types/fileUploadSettings";
|
||||
import { useReplaceFileUploadFields } from "@/hooks/useFileUploadSettings";
|
||||
|
||||
export interface ManageFileUploadFieldsDialogProps {
|
||||
setting: FileUploadSetting;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Local draft used by the editor — does NOT need to satisfy IFileUploadField
|
||||
* (which carries server-only props like createdAt). On save, we strip the
|
||||
* client-only `key` and post the rest as CreateFileUploadFieldDto[].
|
||||
*/
|
||||
interface DraftField extends CreateFileUploadFieldDto {
|
||||
key: string;
|
||||
}
|
||||
|
||||
let draftCounter = 0;
|
||||
const nextKey = () => `draft-${Date.now()}-${++draftCounter}`;
|
||||
|
||||
function makeEmptyDraft(idx: number): DraftField {
|
||||
return {
|
||||
key: nextKey(),
|
||||
fileKey: "",
|
||||
fileLabel: "",
|
||||
isRequired: false,
|
||||
isMultiple: false,
|
||||
maxFiles: 1,
|
||||
allowedExtensions: ["pdf"],
|
||||
maxSizeMb: 10,
|
||||
order: idx + 1,
|
||||
};
|
||||
}
|
||||
|
||||
export default function ManageFileUploadFieldsDialog({
|
||||
setting,
|
||||
children,
|
||||
}: ManageFileUploadFieldsDialogProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const seed = (): DraftField[] =>
|
||||
[...setting.fields]
|
||||
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
|
||||
.map((f, idx) => ({
|
||||
key: f.id,
|
||||
fileKey: f.fileKey,
|
||||
fileLabel: f.fileLabel,
|
||||
helpText: f.helpText ?? undefined,
|
||||
isRequired: f.isRequired,
|
||||
isMultiple: f.isMultiple,
|
||||
maxFiles: f.maxFiles,
|
||||
allowedExtensions: f.allowedExtensions,
|
||||
maxSizeMb: f.maxSizeMb,
|
||||
order: f.order ?? idx + 1,
|
||||
}));
|
||||
|
||||
const [fields, setFields] = useState<DraftField[]>(seed);
|
||||
|
||||
const replaceMutation = useReplaceFileUploadFields();
|
||||
|
||||
const update = (i: number, patch: Partial<DraftField>) =>
|
||||
setFields((prev) =>
|
||||
prev.map((f, idx) => {
|
||||
if (idx !== i) return f;
|
||||
const next = { ...f, ...patch };
|
||||
if (patch.isMultiple === false) next.maxFiles = 1;
|
||||
if (patch.isMultiple === true && next.maxFiles <= 1) next.maxFiles = 5;
|
||||
return next;
|
||||
}),
|
||||
);
|
||||
|
||||
const updateExtensions = (i: number, raw: string) => {
|
||||
const list = raw
|
||||
.split(",")
|
||||
.map((s) => s.trim().toLowerCase().replace(/^\./, ""))
|
||||
.filter(Boolean);
|
||||
update(i, { allowedExtensions: list });
|
||||
};
|
||||
|
||||
const remove = (i: number) =>
|
||||
setFields((prev) => prev.filter((_, idx) => idx !== i));
|
||||
|
||||
const add = () =>
|
||||
setFields((prev) => [...prev, makeEmptyDraft(prev.length)]);
|
||||
|
||||
const move = (i: number, dir: -1 | 1) =>
|
||||
setFields((prev) => {
|
||||
const next = [...prev];
|
||||
const target = i + dir;
|
||||
if (target < 0 || target >= next.length) return prev;
|
||||
const a = next[i] as DraftField;
|
||||
const b = next[target] as DraftField;
|
||||
next[i] = { ...b, order: i + 1 };
|
||||
next[target] = { ...a, order: target + 1 };
|
||||
return next;
|
||||
});
|
||||
|
||||
const handleSave = () => {
|
||||
setError(null);
|
||||
|
||||
const invalid = fields.findIndex(
|
||||
(f) =>
|
||||
!f.fileKey.trim() ||
|
||||
!f.fileLabel.trim() ||
|
||||
f.allowedExtensions.length === 0,
|
||||
);
|
||||
if (invalid >= 0) {
|
||||
setError(
|
||||
`Field ${invalid + 1} is missing file key, label, or extensions.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const payload: CreateFileUploadFieldDto[] = fields.map((f, idx) => ({
|
||||
fileKey: f.fileKey.trim(),
|
||||
fileLabel: f.fileLabel.trim(),
|
||||
helpText: f.helpText?.trim() || undefined,
|
||||
isRequired: f.isRequired,
|
||||
isMultiple: f.isMultiple,
|
||||
maxFiles: f.isMultiple ? Math.max(1, f.maxFiles) : 1,
|
||||
allowedExtensions: f.allowedExtensions,
|
||||
maxSizeMb: f.maxSizeMb,
|
||||
order: idx + 1,
|
||||
}));
|
||||
|
||||
replaceMutation.mutate(
|
||||
{ settingId: setting.id, fields: payload },
|
||||
{
|
||||
onSuccess: () => setOpen(false),
|
||||
onError: (err) =>
|
||||
setError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "Failed to save fields. Try again.",
|
||||
),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
setOpen(next);
|
||||
if (next) {
|
||||
setFields(seed());
|
||||
setError(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-5xl rounded-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-2xl font-bold">
|
||||
Manage Fields · {setting.label}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Add, edit, reorder, or remove upload fields for{" "}
|
||||
<span className="font-mono text-slate-700">{setting.code}</span>.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-slate-500">
|
||||
{fields.length} field{fields.length === 1 ? "" : "s"}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={add}
|
||||
className="inline-flex items-center gap-1.5 rounded-xl bg-[#10B981] px-3 py-1.5 text-xs font-medium text-white transition hover:bg-[#10B981]/90"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
Add Field
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{fields.length === 0 ? (
|
||||
<div className="rounded-2xl border border-dashed border-slate-200 p-8 text-center text-sm text-slate-500">
|
||||
No fields yet. Click{" "}
|
||||
<span className="font-medium">Add Field</span> to start.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{fields.map((f, i) => (
|
||||
<FieldEditor
|
||||
key={f.key}
|
||||
field={f}
|
||||
index={i}
|
||||
total={fields.length}
|
||||
onChange={(patch) => update(i, patch)}
|
||||
onChangeExtensions={(raw) => updateExtensions(i, raw)}
|
||||
onMove={(dir) => move(i, dir)}
|
||||
onRemove={() => remove(i)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<p className="rounded-xl bg-red-50 px-3 py-2 text-sm text-red-700">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="mt-2 flex justify-end gap-3 border-t border-slate-100 pt-3">
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline" disabled={replaceMutation.isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={replaceMutation.isPending}
|
||||
className="bg-[#10B981] text-white hover:bg-[#10B981]/90 disabled:opacity-60"
|
||||
>
|
||||
{replaceMutation.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
"Save Fields"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldEditor({
|
||||
field,
|
||||
index,
|
||||
total,
|
||||
onChange,
|
||||
onChangeExtensions,
|
||||
onMove,
|
||||
onRemove,
|
||||
}: {
|
||||
field: DraftField;
|
||||
index: number;
|
||||
total: number;
|
||||
onChange: (patch: Partial<DraftField>) => void;
|
||||
onChangeExtensions: (raw: string) => void;
|
||||
onMove: (dir: -1 | 1) => void;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
const minFiles = getMinFiles(field);
|
||||
const effectiveMax = field.isMultiple ? field.maxFiles : 1;
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-slate-200 bg-white p-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-slate-400">
|
||||
<GripVertical className="h-4 w-4" />
|
||||
<div className="flex flex-col">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onMove(-1)}
|
||||
aria-label="Move up"
|
||||
disabled={index === 0}
|
||||
className="text-[10px] leading-none text-slate-400 transition hover:text-[#10B981] disabled:opacity-30"
|
||||
>
|
||||
▲
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onMove(1)}
|
||||
aria-label="Move down"
|
||||
disabled={index === total - 1}
|
||||
className="text-[10px] leading-none text-slate-400 transition hover:text-[#10B981] disabled:opacity-30"
|
||||
>
|
||||
▼
|
||||
</button>
|
||||
</div>
|
||||
<span className="text-xs font-semibold uppercase tracking-wide text-[#10B981]">
|
||||
Field {index + 1}
|
||||
</span>
|
||||
<span className="rounded-full bg-slate-100 px-2 py-0.5 text-[10px] font-medium text-slate-600">
|
||||
min {minFiles} · max {effectiveMax}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
aria-label="Remove field"
|
||||
className="rounded-lg p-1 text-red-500 transition hover:bg-red-50"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">File Key *</Label>
|
||||
<Input
|
||||
value={field.fileKey}
|
||||
onChange={(e) => onChange({ fileKey: e.target.value })}
|
||||
placeholder="supporting_doc"
|
||||
className="font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5 md:col-span-2">
|
||||
<Label className="text-xs">File Label *</Label>
|
||||
<Input
|
||||
value={field.fileLabel}
|
||||
onChange={(e) => onChange({ fileLabel: e.target.value })}
|
||||
placeholder="Supporting Document"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Max Size (MB)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={field.maxSizeMb}
|
||||
onChange={(e) =>
|
||||
onChange({ maxSizeMb: Number(e.target.value) })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5 md:col-span-2">
|
||||
<Label className="text-xs">Allowed Extensions</Label>
|
||||
<Input
|
||||
value={field.allowedExtensions.join(", ")}
|
||||
onChange={(e) => onChangeExtensions(e.target.value)}
|
||||
placeholder="pdf, docx, jpg"
|
||||
className="font-mono"
|
||||
/>
|
||||
<p className="text-xs text-slate-500">
|
||||
Comma-separated, no leading dot.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Max Files</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={50}
|
||||
value={field.maxFiles}
|
||||
disabled={!field.isMultiple}
|
||||
onChange={(e) =>
|
||||
onChange({ maxFiles: Number(e.target.value) })
|
||||
}
|
||||
className={!field.isMultiple ? "bg-slate-50 text-slate-400" : ""}
|
||||
/>
|
||||
{!field.isMultiple ? (
|
||||
<p className="text-xs text-slate-400">
|
||||
Locked to 1 when single-file.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-end md:gap-4">
|
||||
<label className="flex items-center gap-2 text-sm text-slate-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={field.isRequired}
|
||||
onChange={(e) =>
|
||||
onChange({ isRequired: e.target.checked })
|
||||
}
|
||||
className="h-4 w-4 rounded border-slate-300 text-[#10B981] focus:ring-[#10B981]/20"
|
||||
/>
|
||||
Required
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm text-slate-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={field.isMultiple}
|
||||
onChange={(e) =>
|
||||
onChange({ isMultiple: e.target.checked })
|
||||
}
|
||||
className="h-4 w-4 rounded border-slate-300 text-[#10B981] focus:ring-[#10B981]/20"
|
||||
/>
|
||||
Multiple
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5 md:col-span-4">
|
||||
<Label className="text-xs">Help Text (optional)</Label>
|
||||
<Input
|
||||
value={field.helpText ?? ""}
|
||||
onChange={(e) => onChange({ helpText: e.target.value })}
|
||||
placeholder="e.g. PDF or photo of the original document."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import type {
|
||||
FileUploadField,
|
||||
FileUploadSetting,
|
||||
} from "@/types/fileUploadSettings";
|
||||
|
||||
const field = (
|
||||
settingCode: string,
|
||||
idx: number,
|
||||
data: Omit<FileUploadField, "id" | "order">,
|
||||
): FileUploadField => ({
|
||||
id: `${settingCode}-${idx + 1}`,
|
||||
order: idx + 1,
|
||||
...data,
|
||||
});
|
||||
|
||||
export const fileUploadSettings: FileUploadSetting[] = [
|
||||
{
|
||||
id: "fu-customer_registration",
|
||||
code: "customer_registration",
|
||||
label: "Customer Registration",
|
||||
description: "Documents required when onboarding a new customer.",
|
||||
entity: "customer",
|
||||
fields: [
|
||||
field("customer_registration", 0, {
|
||||
fileKey: "tin_certificate",
|
||||
fileLabel: "TIN Certificate",
|
||||
helpText: "Tax Identification Number certificate.",
|
||||
isRequired: true,
|
||||
isMultiple: false,
|
||||
maxFiles: 1,
|
||||
allowedExtensions: ["pdf", "jpg", "png"],
|
||||
maxSizeMb: 5,
|
||||
}),
|
||||
field("customer_registration", 1, {
|
||||
fileKey: "trade_license",
|
||||
fileLabel: "Trade License",
|
||||
helpText: "Current, non-expired trade license.",
|
||||
isRequired: true,
|
||||
isMultiple: false,
|
||||
maxFiles: 1,
|
||||
allowedExtensions: ["pdf", "jpg", "png"],
|
||||
maxSizeMb: 5,
|
||||
}),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "fu-booking",
|
||||
code: "booking",
|
||||
label: "Freight Booking",
|
||||
description: "Documents attached to a freight booking submission.",
|
||||
entity: "booking",
|
||||
fields: [
|
||||
field("booking", 0, {
|
||||
fileKey: "packing_list",
|
||||
fileLabel: "Packing List",
|
||||
isRequired: true,
|
||||
isMultiple: false,
|
||||
maxFiles: 1,
|
||||
allowedExtensions: ["pdf", "xlsx"],
|
||||
maxSizeMb: 10,
|
||||
}),
|
||||
field("booking", 1, {
|
||||
fileKey: "commercial_invoice",
|
||||
fileLabel: "Commercial Invoice",
|
||||
isRequired: true,
|
||||
isMultiple: false,
|
||||
maxFiles: 1,
|
||||
allowedExtensions: ["pdf"],
|
||||
maxSizeMb: 10,
|
||||
}),
|
||||
field("booking", 2, {
|
||||
fileKey: "certificate_of_origin",
|
||||
fileLabel: "Certificate of Origin",
|
||||
helpText: "Optional. Required for international shipments.",
|
||||
isRequired: false,
|
||||
isMultiple: false,
|
||||
maxFiles: 1,
|
||||
allowedExtensions: ["pdf", "jpg", "png"],
|
||||
maxSizeMb: 5,
|
||||
}),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "fu-consignment",
|
||||
code: "consignment",
|
||||
label: "Consignment",
|
||||
description: "Cargo-level documents.",
|
||||
entity: "consignment",
|
||||
fields: [
|
||||
field("consignment", 0, {
|
||||
fileKey: "bill_of_lading",
|
||||
fileLabel: "Bill of Lading",
|
||||
isRequired: true,
|
||||
isMultiple: false,
|
||||
maxFiles: 1,
|
||||
allowedExtensions: ["pdf"],
|
||||
maxSizeMb: 10,
|
||||
}),
|
||||
field("consignment", 1, {
|
||||
fileKey: "supporting_doc",
|
||||
fileLabel: "Supporting Documents",
|
||||
helpText: "Customs declarations, inspection reports, etc.",
|
||||
isRequired: false,
|
||||
isMultiple: true,
|
||||
maxFiles: 5,
|
||||
allowedExtensions: ["pdf", "docx", "jpg", "png"],
|
||||
maxSizeMb: 10,
|
||||
}),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "fu-invoice",
|
||||
code: "invoice",
|
||||
label: "Invoice",
|
||||
description: "Attachments for billing invoices.",
|
||||
entity: "invoice",
|
||||
fields: [
|
||||
field("invoice", 0, {
|
||||
fileKey: "proof_of_payment",
|
||||
fileLabel: "Proof of Payment",
|
||||
helpText: "Bank transfer receipt or wire confirmation.",
|
||||
isRequired: false,
|
||||
isMultiple: false,
|
||||
maxFiles: 1,
|
||||
allowedExtensions: ["pdf", "jpg", "png"],
|
||||
maxSizeMb: 5,
|
||||
}),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "fu-train",
|
||||
code: "train_maintenance",
|
||||
label: "Train Maintenance",
|
||||
description: "Maintenance and inspection records for rolling stock.",
|
||||
entity: "train",
|
||||
fields: [
|
||||
field("train_maintenance", 0, {
|
||||
fileKey: "inspection_report",
|
||||
fileLabel: "Inspection Report",
|
||||
isRequired: true,
|
||||
isMultiple: false,
|
||||
maxFiles: 1,
|
||||
allowedExtensions: ["pdf"],
|
||||
maxSizeMb: 10,
|
||||
}),
|
||||
field("train_maintenance", 1, {
|
||||
fileKey: "photos",
|
||||
fileLabel: "Inspection Photos",
|
||||
helpText: "Photos of the condition.",
|
||||
isRequired: false,
|
||||
isMultiple: true,
|
||||
maxFiles: 10,
|
||||
allowedExtensions: ["jpg", "png", "heic"],
|
||||
maxSizeMb: 8,
|
||||
}),
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export function getFileUploadSettingByCode(
|
||||
code: string,
|
||||
): FileUploadSetting | undefined {
|
||||
return fileUploadSettings.find((s) => s.code === code);
|
||||
}
|
||||
|
||||
export function getFileUploadFieldsByCode(
|
||||
code: string,
|
||||
): FileUploadField[] {
|
||||
const setting = getFileUploadSettingByCode(code);
|
||||
if (!setting) return [];
|
||||
return [...setting.fields].sort(
|
||||
(a, b) => (a.order ?? 0) - (b.order ?? 0),
|
||||
);
|
||||
}
|
||||
60
apps/edr-freight-web/portal/src/services/crud.ts
Normal file
60
apps/edr-freight-web/portal/src/services/crud.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { client } from "@/utils/api";
|
||||
import { AxiosRequestConfig, AxiosResponse } from "axios";
|
||||
|
||||
|
||||
class ApiService {
|
||||
async get<T = any>(
|
||||
url: string,
|
||||
config?: AxiosRequestConfig
|
||||
): Promise<T> {
|
||||
const response: AxiosResponse<T> =
|
||||
await client.get(url, config);
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async post<T = any>(
|
||||
url: string,
|
||||
data?: any,
|
||||
config?: AxiosRequestConfig
|
||||
): Promise<T> {
|
||||
const response: AxiosResponse<T> =
|
||||
await client.post(url, data, config);
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async put<T = any>(
|
||||
url: string,
|
||||
data?: any,
|
||||
config?: AxiosRequestConfig
|
||||
): Promise<T> {
|
||||
const response: AxiosResponse<T> =
|
||||
await client.put(url, data, config);
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async patch<T = any>(
|
||||
url: string,
|
||||
data?: any,
|
||||
config?: AxiosRequestConfig
|
||||
): Promise<T> {
|
||||
const response: AxiosResponse<T> =
|
||||
await client.patch(url, data, config);
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async delete<T = any>(
|
||||
url: string,
|
||||
config?: AxiosRequestConfig
|
||||
): Promise<T> {
|
||||
const response: AxiosResponse<T> =
|
||||
await client.delete(url, config);
|
||||
|
||||
return response.data;
|
||||
}
|
||||
}
|
||||
|
||||
export const api = new ApiService();
|
||||
@@ -0,0 +1,148 @@
|
||||
import { api } from "./crud";
|
||||
|
||||
import type {
|
||||
CreateFileUploadFieldDto,
|
||||
CreateFileUploadSettingDto,
|
||||
FileUploadField,
|
||||
FileUploadSetting,
|
||||
UpdateFileUploadFieldDto,
|
||||
UpdateFileUploadSettingDto,
|
||||
} from "@/types/fileUploadSettings";
|
||||
|
||||
const BASE = "/api/file-upload-settings";
|
||||
|
||||
/**
|
||||
* Handles both:
|
||||
* 1. raw payload
|
||||
* 2. { data: payload }
|
||||
*/
|
||||
type Envelope<T> = { data: T } | T;
|
||||
|
||||
function unwrap<T>(payload: Envelope<T>): T {
|
||||
if (
|
||||
payload &&
|
||||
typeof payload === "object" &&
|
||||
"data" in (payload as object)
|
||||
) {
|
||||
return (payload as { data: T }).data;
|
||||
}
|
||||
|
||||
return payload as T;
|
||||
}
|
||||
|
||||
export const fileUploadSettingsService = {
|
||||
// GET /file-upload-settings
|
||||
list: async (): Promise<FileUploadSetting[]> => {
|
||||
const response =
|
||||
await api.get<Envelope<FileUploadSetting[]>>(BASE);
|
||||
|
||||
return unwrap(response);
|
||||
},
|
||||
|
||||
// GET /file-upload-settings/:id
|
||||
getById: async (
|
||||
id: string
|
||||
): Promise<FileUploadSetting> => {
|
||||
const response =
|
||||
await api.get<Envelope<FileUploadSetting>>(
|
||||
`${BASE}/${id}`
|
||||
);
|
||||
|
||||
return unwrap(response);
|
||||
},
|
||||
|
||||
// GET /file-upload-settings/by-code/:code
|
||||
getByCode: async (
|
||||
code: string
|
||||
): Promise<FileUploadSetting> => {
|
||||
const response =
|
||||
await api.get<Envelope<FileUploadSetting>>(
|
||||
`${BASE}/by-code/${encodeURIComponent(code)}`
|
||||
);
|
||||
|
||||
return unwrap(response);
|
||||
},
|
||||
|
||||
// POST /file-upload-settings
|
||||
create: async (
|
||||
payload: CreateFileUploadSettingDto
|
||||
): Promise<FileUploadSetting> => {
|
||||
const response =
|
||||
await api.post<Envelope<FileUploadSetting>>(
|
||||
BASE,
|
||||
payload
|
||||
);
|
||||
|
||||
return unwrap(response);
|
||||
},
|
||||
|
||||
// PATCH /file-upload-settings/:id
|
||||
update: async (
|
||||
id: string,
|
||||
payload: UpdateFileUploadSettingDto
|
||||
): Promise<FileUploadSetting> => {
|
||||
const response =
|
||||
await api.patch<Envelope<FileUploadSetting>>(
|
||||
`${BASE}/${id}`,
|
||||
payload
|
||||
);
|
||||
|
||||
return unwrap(response);
|
||||
},
|
||||
|
||||
// DELETE /file-upload-settings/:id
|
||||
remove: async (id: string): Promise<void> => {
|
||||
await api.delete(`${BASE}/${id}`);
|
||||
},
|
||||
|
||||
// PUT /file-upload-settings/:id/fields
|
||||
replaceFields: async (
|
||||
id: string,
|
||||
fields: CreateFileUploadFieldDto[]
|
||||
): Promise<FileUploadField[]> => {
|
||||
const response =
|
||||
await api.put<Envelope<FileUploadField[]>>(
|
||||
`${BASE}/${id}/fields`,
|
||||
fields
|
||||
);
|
||||
|
||||
return unwrap(response);
|
||||
},
|
||||
|
||||
// POST /file-upload-settings/:id/fields
|
||||
addField: async (
|
||||
id: string,
|
||||
payload: CreateFileUploadFieldDto
|
||||
): Promise<FileUploadField> => {
|
||||
const response =
|
||||
await api.post<Envelope<FileUploadField>>(
|
||||
`${BASE}/${id}/fields`,
|
||||
payload
|
||||
);
|
||||
|
||||
return unwrap(response);
|
||||
},
|
||||
|
||||
// PATCH /file-upload-settings/fields/:fieldId
|
||||
updateField: async (
|
||||
fieldId: string,
|
||||
payload: UpdateFileUploadFieldDto
|
||||
): Promise<FileUploadField> => {
|
||||
const response =
|
||||
await api.patch<Envelope<FileUploadField>>(
|
||||
`${BASE}/fields/${fieldId}`,
|
||||
payload
|
||||
);
|
||||
|
||||
return unwrap(response);
|
||||
},
|
||||
|
||||
// DELETE /file-upload-settings/fields/:fieldId
|
||||
removeField: async (
|
||||
fieldId: string
|
||||
): Promise<void> => {
|
||||
await api.delete(
|
||||
`${BASE}/fields/${fieldId}`
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
import { api } from "../crud";
|
||||
|
||||
import { URL_CONSTANTS } from "../../constants/URLS"
|
||||
39
apps/edr-freight-web/portal/src/types/fileUploadSettings.ts
Normal file
39
apps/edr-freight-web/portal/src/types/fileUploadSettings.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
// Re-export the shared types from @edr/types so existing local imports keep
|
||||
// working. Canonical source: packages/types/src/freight/file_upload_settings.ts
|
||||
//
|
||||
// NOTE: @edr/types is compiled to CommonJS (see packages/types/tsconfig.json),
|
||||
// so its dist/index.js uses `Object.defineProperty(exports, ...)` instead of
|
||||
// real ESM exports. Vite can't pull runtime values out of it — only TypeScript
|
||||
// type-only imports (which are erased at build time) work cleanly. That's why
|
||||
// `getMinFiles` / `getEffectiveMaxFiles` are defined locally below instead of
|
||||
// re-exported from the package. They mirror the canonical logic in
|
||||
// packages/types/src/freight/file_upload_settings.ts exactly.
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
export type FileUploadEntity = Freight.FileUploadEntity;
|
||||
export type FileUploadField = Freight.IFileUploadField;
|
||||
export type FileUploadSetting = Freight.IFileUploadSetting;
|
||||
export type CreateFileUploadFieldDto = Freight.CreateFileUploadFieldDto;
|
||||
export type CreateFileUploadSettingDto = Freight.CreateFileUploadSettingDto;
|
||||
export type UpdateFileUploadFieldDto = Freight.UpdateFileUploadFieldDto;
|
||||
export type UpdateFileUploadSettingDto = Freight.UpdateFileUploadSettingDto;
|
||||
|
||||
/**
|
||||
* required | multiple | min | max
|
||||
* ---------|----------|-----|-----------------
|
||||
* no | no | 0 | 1
|
||||
* yes | no | 1 | 1
|
||||
* no | yes | 0 | field.maxFiles
|
||||
* yes | yes | 1 | field.maxFiles
|
||||
*/
|
||||
export function getMinFiles(
|
||||
field: Pick<FileUploadField, "isRequired">,
|
||||
): number {
|
||||
return field.isRequired ? 1 : 0;
|
||||
}
|
||||
|
||||
export function getEffectiveMaxFiles(
|
||||
field: Pick<FileUploadField, "isMultiple" | "maxFiles">,
|
||||
): number {
|
||||
return field.isMultiple ? Math.max(1, field.maxFiles) : 1;
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import axios from "axios";
|
||||
|
||||
export const api = axios.create({
|
||||
export const client = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_URL,
|
||||
});
|
||||
|
||||
api.interceptors.request.use((config) => {
|
||||
client.interceptors.request.use((config) => {
|
||||
const token = document.cookie
|
||||
.split("; ")
|
||||
.find((row) => row.startsWith("auth-token="))
|
||||
@@ -15,7 +15,7 @@ api.interceptors.request.use((config) => {
|
||||
return config;
|
||||
});
|
||||
|
||||
api.interceptors.response.use(
|
||||
client.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.response?.status === 401) {
|
||||
|
||||
Reference in New Issue
Block a user