mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 15:48:11 +00:00
Merge pull request #12 from Tria-plc/feat/customer_registration
Feat/customer registration
This commit is contained in:
@@ -147,7 +147,7 @@ Each feature folder typically contains: `*Page.tsx` (list), `*DetailPage.tsx`, `
|
|||||||
|
|
||||||
`DashboardLayout` provides the sidebar + header shell shared across all freight and passenger web apps:
|
`DashboardLayout` provides the sidebar + header shell shared across all freight and passenger web apps:
|
||||||
|
|
||||||
- **Sidebar** — brand-tinted, icon-led navigation; the brand color (`#33578D`) marks the active item.
|
- **Sidebar** — brand-tinted, icon-led navigation. Main brand color: `#10B981` (`rgb(16, 185, 129)` — emerald-500). Icon containers use the filled style: brand-color background with a white icon.
|
||||||
- **Header** — language picker, notifications, user dropdown (click-driven, click-outside / Escape close); host apps opt into a light/dark theme toggle via `enableThemeToggle` (Tailwind class-based, persisted in `localStorage`).
|
- **Header** — language picker, notifications, user dropdown (click-driven, click-outside / Escape close); host apps opt into a light/dark theme toggle via `enableThemeToggle` (Tailwind class-based, persisted in `localStorage`).
|
||||||
|
|
||||||
### Auth integration (planned)
|
### Auth integration (planned)
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { CustomersModule } from "./modules/customers/customers.module";
|
|||||||
import { TrackingModule } from "./modules/tracking/tracking.module";
|
import { TrackingModule } from "./modules/tracking/tracking.module";
|
||||||
import { BillingModule } from "./modules/billing/billing.module";
|
import { BillingModule } from "./modules/billing/billing.module";
|
||||||
import { NotificationsModule } from "./modules/notifications/notifications.module";
|
import { NotificationsModule } from "./modules/notifications/notifications.module";
|
||||||
|
import { FileUploadSettingsModule } from "./modules/file-upload-settings/file-upload-settings.module";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -35,6 +36,7 @@ import { NotificationsModule } from "./modules/notifications/notifications.modul
|
|||||||
TrackingModule,
|
TrackingModule,
|
||||||
BillingModule,
|
BillingModule,
|
||||||
NotificationsModule,
|
NotificationsModule,
|
||||||
|
FileUploadSettingsModule,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppModule implements OnApplicationBootstrap {
|
export class AppModule implements OnApplicationBootstrap {
|
||||||
|
|||||||
@@ -12,7 +12,30 @@ import {
|
|||||||
import { AppModule } from "./app.module";
|
import { AppModule } from "./app.module";
|
||||||
|
|
||||||
async function bootstrap() {
|
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.setGlobalPrefix("api");
|
||||||
app.useGlobalPipes(createValidationPipe());
|
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>;
|
||||||
|
}
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
*::-webkit-scrollbar-thumb:hover {
|
*::-webkit-scrollbar-thumb:hover {
|
||||||
background-color: rgb(51 87 141 / 0.5);
|
background-color: rgb(16 185 129 / 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
*::-webkit-scrollbar-corner {
|
*::-webkit-scrollbar-corner {
|
||||||
@@ -38,5 +38,5 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dark *::-webkit-scrollbar-thumb:hover {
|
.dark *::-webkit-scrollbar-thumb:hover {
|
||||||
background-color: rgb(51 87 141 / 0.7);
|
background-color: rgb(16 185 129 / 0.7);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ import {
|
|||||||
Receipt,
|
Receipt,
|
||||||
FileText,
|
FileText,
|
||||||
Settings,
|
Settings,
|
||||||
|
UserCircle,
|
||||||
|
FileUp,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
import BookingsPage from "./pages/bookings/BookingsPage";
|
import BookingsPage from "./pages/bookings/BookingsPage";
|
||||||
@@ -38,8 +40,11 @@ import CustomerDetailPage from "./pages/customers/CustomerDetailPage";
|
|||||||
import NewCustomerPage from "./pages/customers/NewCustomerPage";
|
import NewCustomerPage from "./pages/customers/NewCustomerPage";
|
||||||
import DocumentsPage from "./pages/documents/DocumentsPage";
|
import DocumentsPage from "./pages/documents/DocumentsPage";
|
||||||
import DropdownSettingsPage from "./pages/admin/DropdownSettingsPage";
|
import DropdownSettingsPage from "./pages/admin/DropdownSettingsPage";
|
||||||
|
import FileUploadSettingsPage from "./pages/admin/FileUploadSettingsPage";
|
||||||
|
import MyPortalPage from "./pages/portal/MyPortalPage";
|
||||||
|
|
||||||
const sidebarItems: SidebarItem[] = [
|
const sidebarItems: SidebarItem[] = [
|
||||||
|
{ label: "My Portal", href: "/portal", icon: <UserCircle /> },
|
||||||
{ label: "Dashboard", href: "/", icon: <LayoutDashboard /> },
|
{ label: "Dashboard", href: "/", icon: <LayoutDashboard /> },
|
||||||
{ label: "Customers", href: "/customers", icon: <Users /> },
|
{ label: "Customers", href: "/customers", icon: <Users /> },
|
||||||
{ label: "Bookings", href: "/bookings", icon: <CalendarCheck /> },
|
{ label: "Bookings", href: "/bookings", icon: <CalendarCheck /> },
|
||||||
@@ -49,6 +54,11 @@ const sidebarItems: SidebarItem[] = [
|
|||||||
{ label: "Billing", href: "/billing", icon: <Receipt /> },
|
{ label: "Billing", href: "/billing", icon: <Receipt /> },
|
||||||
{ label: "Documents", href: "/documents", icon: <FileText /> },
|
{ label: "Documents", href: "/documents", icon: <FileText /> },
|
||||||
{ label: "Dropdown Settings", href: "/admin/dropdowns", icon: <Settings /> },
|
{ label: "Dropdown Settings", href: "/admin/dropdowns", icon: <Settings /> },
|
||||||
|
{
|
||||||
|
label: "File Upload Settings",
|
||||||
|
href: "/admin/file-uploads",
|
||||||
|
icon: <FileUp />,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const App = () => {
|
const App = () => {
|
||||||
@@ -101,6 +111,7 @@ const App = () => {
|
|||||||
>
|
>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<DashboardPage />} />
|
<Route path="/" element={<DashboardPage />} />
|
||||||
|
<Route path="/portal" element={<MyPortalPage />} />
|
||||||
<Route path="/bookings" element={<BookingsPage />} />
|
<Route path="/bookings" element={<BookingsPage />} />
|
||||||
<Route path="/customers" element={<CustomersPage />} />
|
<Route path="/customers" element={<CustomersPage />} />
|
||||||
<Route path="/customers/:id" element={<CustomerDetailPage />} />
|
<Route path="/customers/:id" element={<CustomerDetailPage />} />
|
||||||
@@ -117,6 +128,10 @@ const App = () => {
|
|||||||
path="/admin/dropdowns"
|
path="/admin/dropdowns"
|
||||||
element={<DropdownSettingsPage />}
|
element={<DropdownSettingsPage />}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/admin/file-uploads"
|
||||||
|
element={<FileUploadSettingsPage />}
|
||||||
|
/>
|
||||||
<Route path="/user-management" element={<Navigate to="/" replace />} />
|
<Route path="/user-management" element={<Navigate to="/" replace />} />
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export default function Breadcrumbs({ items }: BreadcrumbsProps) {
|
|||||||
<Link
|
<Link
|
||||||
to="/"
|
to="/"
|
||||||
aria-label="Home"
|
aria-label="Home"
|
||||||
className="flex items-center transition hover:text-[#33578D]"
|
className="flex items-center transition hover:text-[#10B981]"
|
||||||
>
|
>
|
||||||
{/* <Home className="h-4 w-4" /> */}
|
{/* <Home className="h-4 w-4" /> */}
|
||||||
Dashboard
|
Dashboard
|
||||||
@@ -36,7 +36,7 @@ export default function Breadcrumbs({ items }: BreadcrumbsProps) {
|
|||||||
{item.href && !isLast ? (
|
{item.href && !isLast ? (
|
||||||
<Link
|
<Link
|
||||||
to={item.href}
|
to={item.href}
|
||||||
className="transition hover:text-[#33578D]"
|
className="transition hover:text-[#10B981]"
|
||||||
>
|
>
|
||||||
{item.label}
|
{item.label}
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
|||||||
className={cn(
|
className={cn(
|
||||||
"flex h-10 w-full min-w-0 rounded-md border border-slate-200 bg-white px-3 py-1 text-sm text-slate-700 shadow-xs outline-none transition placeholder:text-slate-400 file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
|
"flex h-10 w-full min-w-0 rounded-md border border-slate-200 bg-white px-3 py-1 text-sm text-slate-700 shadow-xs outline-none transition placeholder:text-slate-400 file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
"hover:border-slate-300",
|
"hover:border-slate-300",
|
||||||
"focus:border-[#33578D]/50 focus:ring-2 focus:ring-[#33578D]/20",
|
"focus:border-[#10B981]/50 focus:ring-2 focus:ring-[#10B981]/20",
|
||||||
"aria-invalid:border-red-500 aria-invalid:ring-2 aria-invalid:ring-red-500/20",
|
"aria-invalid:border-red-500 aria-invalid:ring-2 aria-invalid:ring-red-500/20",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
|||||||
className={cn(
|
className={cn(
|
||||||
"field-sizing-content flex min-h-16 w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-700 shadow-xs outline-none transition placeholder:text-slate-400 disabled:cursor-not-allowed disabled:opacity-50",
|
"field-sizing-content flex min-h-16 w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-700 shadow-xs outline-none transition placeholder:text-slate-400 disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
"hover:border-slate-300",
|
"hover:border-slate-300",
|
||||||
"focus:border-[#33578D]/50 focus:ring-2 focus:ring-[#33578D]/20",
|
"focus:border-[#10B981]/50 focus:ring-2 focus:ring-[#10B981]/20",
|
||||||
"aria-invalid:border-red-500 aria-invalid:ring-2 aria-invalid:ring-red-500/20",
|
"aria-invalid:border-red-500 aria-invalid:ring-2 aria-invalid:ring-red-500/20",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
|
|||||||
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 }),
|
||||||
|
});
|
||||||
|
};
|
||||||
45
apps/edr-freight-web/portal/src/lib/currentCustomer.ts
Normal file
45
apps/edr-freight-web/portal/src/lib/currentCustomer.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { customers, type Customer } from "@/pages/customers/customers.mock";
|
||||||
|
import { bookings, type Booking } from "@/pages/bookings/bookings.mock";
|
||||||
|
import {
|
||||||
|
consignments,
|
||||||
|
type Consignment,
|
||||||
|
} from "@/pages/consignments/consignments.mock";
|
||||||
|
import {
|
||||||
|
shipments,
|
||||||
|
type Shipment,
|
||||||
|
} from "@/pages/tracking/shipments.mock";
|
||||||
|
import { invoices, type Invoice } from "@/pages/billing/invoices.mock";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mock "logged-in customer". When auth integrates, replace this with the value
|
||||||
|
* pulled from `@edr/iamui-common` / the JWT context.
|
||||||
|
*/
|
||||||
|
const CURRENT_CUSTOMER_ID = 1;
|
||||||
|
|
||||||
|
export function getCurrentCustomer(): Customer {
|
||||||
|
return (
|
||||||
|
customers.find((c) => c.id === CURRENT_CUSTOMER_ID) ??
|
||||||
|
(customers[0] as Customer)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMyBookings(): Booking[] {
|
||||||
|
const me = getCurrentCustomer();
|
||||||
|
return bookings.filter((b) => b.customerId === me.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMyConsignments(): Consignment[] {
|
||||||
|
const me = getCurrentCustomer();
|
||||||
|
const myBookingIds = new Set(getMyBookings().map((b) => b.id));
|
||||||
|
return consignments.filter((c) => myBookingIds.has(c.bookingId));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMyShipments(): Shipment[] {
|
||||||
|
const myBookingIds = new Set(getMyBookings().map((b) => b.id));
|
||||||
|
return shipments.filter((s) => myBookingIds.has(s.bookingId));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMyInvoices(): Invoice[] {
|
||||||
|
const me = getCurrentCustomer();
|
||||||
|
return invoices.filter((inv) => inv.customerId === me.id);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -72,14 +72,14 @@ export default function DropdownSettingsPage() {
|
|||||||
value={query}
|
value={query}
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
placeholder="Search by code, label, description..."
|
placeholder="Search by code, label, description..."
|
||||||
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-[#33578D]/50 focus:ring-2 focus:ring-[#33578D]/20"
|
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>
|
</div>
|
||||||
|
|
||||||
<EditDropdownSettingDialog mode="create">
|
<EditDropdownSettingDialog mode="create">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="inline-flex items-center justify-center gap-2 rounded-2xl bg-[#33578D] px-4 py-2 text-sm font-medium text-white transition hover:bg-[#33578D]/90"
|
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" />
|
<Plus className="h-4 w-4" />
|
||||||
New Setting
|
New Setting
|
||||||
@@ -124,7 +124,7 @@ export default function DropdownSettingsPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</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-[#33578D]/30 hover:bg-[#33578D]/10 hover:text-[#33578D]">
|
<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 className="h-4 w-4" />
|
||||||
Filter
|
Filter
|
||||||
</button>
|
</button>
|
||||||
@@ -157,11 +157,11 @@ export default function DropdownSettingsPage() {
|
|||||||
filtered.map((setting) => (
|
filtered.map((setting) => (
|
||||||
<tr
|
<tr
|
||||||
key={setting.id}
|
key={setting.id}
|
||||||
className="border-t border-slate-100 transition hover:bg-[#33578D]/5"
|
className="border-t border-slate-100 transition hover:bg-[#10B981]/5"
|
||||||
>
|
>
|
||||||
<td className="px-6 py-4">
|
<td className="px-6 py-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-[#33578D]/10 text-[#33578D]">
|
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-[#10B981] text-white">
|
||||||
<Settings className="h-5 w-5" />
|
<Settings className="h-5 w-5" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -183,7 +183,7 @@ export default function DropdownSettingsPage() {
|
|||||||
|
|
||||||
<td className="px-6 py-4">
|
<td className="px-6 py-4">
|
||||||
<div className="flex items-center gap-2 text-sm text-slate-700">
|
<div className="flex items-center gap-2 text-sm text-slate-700">
|
||||||
<Boxes className="h-4 w-4 text-[#33578D]" />
|
<Boxes className="h-4 w-4 text-[#10B981]" />
|
||||||
<span className="font-medium">
|
<span className="font-medium">
|
||||||
{setting.children.length}
|
{setting.children.length}
|
||||||
</span>
|
</span>
|
||||||
@@ -214,7 +214,7 @@ export default function DropdownSettingsPage() {
|
|||||||
(setting.meta?.permissions ?? []).map((p) => (
|
(setting.meta?.permissions ?? []).map((p) => (
|
||||||
<span
|
<span
|
||||||
key={p}
|
key={p}
|
||||||
className="inline-flex items-center gap-1 rounded-full bg-[#33578D]/10 px-2 py-0.5 text-xs font-medium text-[#33578D]"
|
className="inline-flex items-center gap-1 rounded-full bg-[#10B981]/10 px-2 py-0.5 text-xs font-medium text-[#10B981]"
|
||||||
>
|
>
|
||||||
<Shield className="h-3 w-3" />
|
<Shield className="h-3 w-3" />
|
||||||
{p}
|
{p}
|
||||||
@@ -229,7 +229,7 @@ export default function DropdownSettingsPage() {
|
|||||||
<ManageDropdownOptionsDialog setting={setting}>
|
<ManageDropdownOptionsDialog setting={setting}>
|
||||||
<button
|
<button
|
||||||
type="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-[#33578D]/30 hover:bg-[#33578D]/10 hover:text-[#33578D]"
|
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]"
|
||||||
>
|
>
|
||||||
<CheckCircle2 className="h-3.5 w-3.5" />
|
<CheckCircle2 className="h-3.5 w-3.5" />
|
||||||
Options
|
Options
|
||||||
@@ -242,7 +242,7 @@ export default function DropdownSettingsPage() {
|
|||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="rounded-xl border border-slate-200 p-2 text-slate-600 transition hover:border-[#33578D]/30 hover:bg-[#33578D]/10 hover:text-[#33578D]"
|
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" />
|
<Pencil className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
@@ -285,7 +285,7 @@ function BehaviorChip({
|
|||||||
className={
|
className={
|
||||||
muted
|
muted
|
||||||
? "rounded-full bg-slate-100 px-2 py-0.5 text-xs font-medium text-slate-600"
|
? "rounded-full bg-slate-100 px-2 py-0.5 text-xs font-medium text-slate-600"
|
||||||
: "rounded-full bg-[#33578D]/10 px-2 py-0.5 text-xs font-medium text-[#33578D]"
|
: "rounded-full bg-[#10B981]/10 px-2 py-0.5 text-xs font-medium text-[#10B981]"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
@@ -303,13 +303,13 @@ function StatCard({
|
|||||||
icon: React.ReactNode;
|
icon: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-3xl bg-white p-6 shadow-sm transition hover:shadow-md hover:ring-1 hover:ring-[#33578D]/20">
|
<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 className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-slate-500">{title}</p>
|
<p className="text-sm text-slate-500">{title}</p>
|
||||||
<h3 className="mt-2 text-3xl font-bold text-slate-900">{value}</h3>
|
<h3 className="mt-2 text-3xl font-bold text-slate-900">{value}</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-[#33578D]/10 text-[#33578D]">
|
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-[#10B981] text-white">
|
||||||
{icon}
|
{icon}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ export default function EditDropdownSettingDialog({
|
|||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
defaultValue={setting?.meta?.color ?? ""}
|
defaultValue={setting?.meta?.color ?? ""}
|
||||||
placeholder="#33578D"
|
placeholder="#10B981"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -144,7 +144,7 @@ export default function EditDropdownSettingDialog({
|
|||||||
|
|
||||||
<div className="flex justify-end gap-3">
|
<div className="flex justify-end gap-3">
|
||||||
<Button variant="outline">Cancel</Button>
|
<Button variant="outline">Cancel</Button>
|
||||||
<Button className="bg-[#33578D] text-white hover:bg-[#33578D]/90">
|
<Button className="bg-[#10B981] text-white hover:bg-[#10B981]/90">
|
||||||
{isEdit ? "Save Changes" : "Create Setting"}
|
{isEdit ? "Save Changes" : "Create Setting"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -168,15 +168,15 @@ function ToggleChip({
|
|||||||
<label
|
<label
|
||||||
className={
|
className={
|
||||||
checked
|
checked
|
||||||
? "flex cursor-pointer items-start gap-2 rounded-2xl border border-[#33578D]/40 bg-[#33578D]/10 px-3 py-2 text-sm"
|
? "flex cursor-pointer items-start gap-2 rounded-2xl border border-[#10B981]/40 bg-[#10B981]/10 px-3 py-2 text-sm"
|
||||||
: "flex cursor-pointer items-start gap-2 rounded-2xl border border-slate-200 bg-white px-3 py-2 text-sm transition hover:border-[#33578D]/30 hover:bg-[#33578D]/5"
|
: "flex cursor-pointer items-start gap-2 rounded-2xl border border-slate-200 bg-white px-3 py-2 text-sm transition hover:border-[#10B981]/30 hover:bg-[#10B981]/5"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={checked}
|
checked={checked}
|
||||||
onChange={(e) => onChange(e.target.checked)}
|
onChange={(e) => onChange(e.target.checked)}
|
||||||
className="mt-0.5 h-4 w-4 rounded border-slate-300 text-[#33578D] focus:ring-[#33578D]/20"
|
className="mt-0.5 h-4 w-4 rounded border-slate-300 text-[#10B981] focus:ring-[#10B981]/20"
|
||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-slate-900">{label}</p>
|
<p className="font-medium text-slate-900">{label}</p>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -100,7 +100,7 @@ export default function ManageDropdownOptionsDialog({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={add}
|
onClick={add}
|
||||||
className="inline-flex items-center gap-1.5 rounded-xl bg-[#33578D] px-3 py-1.5 text-xs font-medium text-white transition hover:bg-[#33578D]/90"
|
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" />
|
<Plus className="h-3.5 w-3.5" />
|
||||||
Add Option
|
Add Option
|
||||||
@@ -126,7 +126,7 @@ export default function ManageDropdownOptionsDialog({
|
|||||||
onClick={() => move(i, -1)}
|
onClick={() => move(i, -1)}
|
||||||
aria-label="Move up"
|
aria-label="Move up"
|
||||||
disabled={i === 0}
|
disabled={i === 0}
|
||||||
className="text-[10px] leading-none text-slate-400 transition hover:text-[#33578D] disabled:opacity-30"
|
className="text-[10px] leading-none text-slate-400 transition hover:text-[#10B981] disabled:opacity-30"
|
||||||
>
|
>
|
||||||
▲
|
▲
|
||||||
</button>
|
</button>
|
||||||
@@ -135,7 +135,7 @@ export default function ManageDropdownOptionsDialog({
|
|||||||
onClick={() => move(i, 1)}
|
onClick={() => move(i, 1)}
|
||||||
aria-label="Move down"
|
aria-label="Move down"
|
||||||
disabled={i === options.length - 1}
|
disabled={i === options.length - 1}
|
||||||
className="text-[10px] leading-none text-slate-400 transition hover:text-[#33578D] disabled:opacity-30"
|
className="text-[10px] leading-none text-slate-400 transition hover:text-[#10B981] disabled:opacity-30"
|
||||||
>
|
>
|
||||||
▼
|
▼
|
||||||
</button>
|
</button>
|
||||||
@@ -199,7 +199,7 @@ export default function ManageDropdownOptionsDialog({
|
|||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
update(i, { disabled: e.target.checked })
|
update(i, { disabled: e.target.checked })
|
||||||
}
|
}
|
||||||
className="h-3.5 w-3.5 rounded border-slate-300 text-[#33578D] focus:ring-[#33578D]/20"
|
className="h-3.5 w-3.5 rounded border-slate-300 text-[#10B981] focus:ring-[#10B981]/20"
|
||||||
/>
|
/>
|
||||||
Off
|
Off
|
||||||
</label>
|
</label>
|
||||||
@@ -220,7 +220,7 @@ export default function ManageDropdownOptionsDialog({
|
|||||||
|
|
||||||
<div className="flex justify-end gap-3 border-t border-slate-100 pt-3">
|
<div className="flex justify-end gap-3 border-t border-slate-100 pt-3">
|
||||||
<Button variant="outline">Cancel</Button>
|
<Button variant="outline">Cancel</Button>
|
||||||
<Button className="bg-[#33578D] text-white hover:bg-[#33578D]/90">
|
<Button className="bg-[#10B981] text-white hover:bg-[#10B981]/90">
|
||||||
Save Options
|
Save Options
|
||||||
</Button>
|
</Button>
|
||||||
</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),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -104,14 +104,14 @@ export default function BillingPage() {
|
|||||||
setPage(1);
|
setPage(1);
|
||||||
}}
|
}}
|
||||||
placeholder="Search invoices..."
|
placeholder="Search invoices..."
|
||||||
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-[#33578D]/50 focus:ring-2 focus:ring-[#33578D]/20"
|
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>
|
</div>
|
||||||
|
|
||||||
<NewInvoicePage>
|
<NewInvoicePage>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="inline-flex items-center justify-center gap-2 rounded-2xl bg-[#33578D] px-4 py-2 text-sm font-medium text-white transition hover:bg-[#33578D]/90"
|
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" />
|
<Plus className="h-4 w-4" />
|
||||||
New Invoice
|
New Invoice
|
||||||
@@ -161,8 +161,8 @@ export default function BillingPage() {
|
|||||||
}}
|
}}
|
||||||
className={
|
className={
|
||||||
isActive
|
isActive
|
||||||
? "inline-flex items-center gap-2 rounded-2xl bg-[#33578D] px-4 py-2 text-sm font-medium text-white"
|
? "inline-flex items-center gap-2 rounded-2xl bg-[#10B981] px-4 py-2 text-sm font-medium text-white"
|
||||||
: "inline-flex items-center gap-2 rounded-2xl px-4 py-2 text-sm font-medium text-slate-600 transition hover:bg-[#33578D]/10 hover:text-[#33578D]"
|
: "inline-flex items-center gap-2 rounded-2xl px-4 py-2 text-sm font-medium text-slate-600 transition hover:bg-[#10B981]/10 hover:text-[#10B981]"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{f}
|
{f}
|
||||||
@@ -193,7 +193,7 @@ export default function BillingPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</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-[#33578D]/30 hover:bg-[#33578D]/10 hover:text-[#33578D]">
|
<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 className="h-4 w-4" />
|
||||||
Filter
|
Filter
|
||||||
</button>
|
</button>
|
||||||
@@ -227,11 +227,11 @@ export default function BillingPage() {
|
|||||||
paginated.map((invoice) => (
|
paginated.map((invoice) => (
|
||||||
<tr
|
<tr
|
||||||
key={invoice.id}
|
key={invoice.id}
|
||||||
className="border-t border-slate-100 transition hover:bg-[#33578D]/5"
|
className="border-t border-slate-100 transition hover:bg-[#10B981]/5"
|
||||||
>
|
>
|
||||||
<td className="px-6 py-4">
|
<td className="px-6 py-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-[#33578D]/10 text-[#33578D]">
|
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-[#10B981] text-white">
|
||||||
<Receipt className="h-5 w-5" />
|
<Receipt className="h-5 w-5" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -270,7 +270,7 @@ export default function BillingPage() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Download invoice"
|
aria-label="Download invoice"
|
||||||
className="rounded-xl border border-slate-200 p-2 text-slate-600 transition hover:border-[#33578D]/30 hover:bg-[#33578D]/10 hover:text-[#33578D]"
|
className="rounded-xl border border-slate-200 p-2 text-slate-600 transition hover:border-[#10B981]/30 hover:bg-[#10B981]/10 hover:text-[#10B981]"
|
||||||
>
|
>
|
||||||
<Download className="h-4 w-4" />
|
<Download className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
@@ -291,7 +291,7 @@ export default function BillingPage() {
|
|||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="rounded-xl border border-slate-200 p-2 text-slate-600 transition hover:border-[#33578D]/30 hover:bg-[#33578D]/10 hover:text-[#33578D]"
|
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" />
|
<Pencil className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
@@ -364,7 +364,7 @@ function Pagination({
|
|||||||
id="invoice-page-size"
|
id="invoice-page-size"
|
||||||
value={pageSize}
|
value={pageSize}
|
||||||
onChange={(e) => onPageSizeChange(Number(e.target.value))}
|
onChange={(e) => onPageSizeChange(Number(e.target.value))}
|
||||||
className="h-9 rounded-xl border border-slate-200 bg-white px-3 text-sm text-slate-700 outline-none transition focus:border-[#33578D]/50 focus:ring-2 focus:ring-[#33578D]/20"
|
className="h-9 rounded-xl border border-slate-200 bg-white px-3 text-sm text-slate-700 outline-none transition focus:border-[#10B981]/50 focus:ring-2 focus:ring-[#10B981]/20"
|
||||||
>
|
>
|
||||||
{PAGE_SIZE_OPTIONS.map((size) => (
|
{PAGE_SIZE_OPTIONS.map((size) => (
|
||||||
<option key={size} value={size}>
|
<option key={size} value={size}>
|
||||||
@@ -382,7 +382,7 @@ function Pagination({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => onPageChange(page - 1)}
|
onClick={() => onPageChange(page - 1)}
|
||||||
disabled={page <= 1}
|
disabled={page <= 1}
|
||||||
className="inline-flex h-9 items-center gap-1 rounded-xl border border-slate-200 px-3 text-sm font-medium text-slate-700 transition hover:border-[#33578D]/30 hover:bg-[#33578D]/10 hover:text-[#33578D] disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent disabled:hover:text-slate-700"
|
className="inline-flex h-9 items-center gap-1 rounded-xl border border-slate-200 px-3 text-sm font-medium text-slate-700 transition hover:border-[#10B981]/30 hover:bg-[#10B981]/10 hover:text-[#10B981] disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent disabled:hover:text-slate-700"
|
||||||
>
|
>
|
||||||
<ChevronLeft className="h-4 w-4" />
|
<ChevronLeft className="h-4 w-4" />
|
||||||
Prev
|
Prev
|
||||||
@@ -398,8 +398,8 @@ function Pagination({
|
|||||||
aria-current={isActive ? "page" : undefined}
|
aria-current={isActive ? "page" : undefined}
|
||||||
className={
|
className={
|
||||||
isActive
|
isActive
|
||||||
? "h-9 w-9 rounded-xl bg-[#33578D] text-sm font-medium text-white"
|
? "h-9 w-9 rounded-xl bg-[#10B981] text-sm font-medium text-white"
|
||||||
: "h-9 w-9 rounded-xl border border-slate-200 text-sm font-medium text-slate-700 transition hover:border-[#33578D]/30 hover:bg-[#33578D]/10 hover:text-[#33578D]"
|
: "h-9 w-9 rounded-xl border border-slate-200 text-sm font-medium text-slate-700 transition hover:border-[#10B981]/30 hover:bg-[#10B981]/10 hover:text-[#10B981]"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{p}
|
{p}
|
||||||
@@ -411,7 +411,7 @@ function Pagination({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => onPageChange(page + 1)}
|
onClick={() => onPageChange(page + 1)}
|
||||||
disabled={page >= totalPages}
|
disabled={page >= totalPages}
|
||||||
className="inline-flex h-9 items-center gap-1 rounded-xl border border-slate-200 px-3 text-sm font-medium text-slate-700 transition hover:border-[#33578D]/30 hover:bg-[#33578D]/10 hover:text-[#33578D] disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent disabled:hover:text-slate-700"
|
className="inline-flex h-9 items-center gap-1 rounded-xl border border-slate-200 px-3 text-sm font-medium text-slate-700 transition hover:border-[#10B981]/30 hover:bg-[#10B981]/10 hover:text-[#10B981] disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent disabled:hover:text-slate-700"
|
||||||
>
|
>
|
||||||
Next
|
Next
|
||||||
<ChevronRight className="h-4 w-4" />
|
<ChevronRight className="h-4 w-4" />
|
||||||
@@ -435,10 +435,10 @@ function StatCard({
|
|||||||
const iconWrap =
|
const iconWrap =
|
||||||
tone === "danger"
|
tone === "danger"
|
||||||
? "bg-red-100 text-red-600"
|
? "bg-red-100 text-red-600"
|
||||||
: "bg-[#33578D]/10 text-[#33578D]";
|
: "bg-[#10B981] text-white";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-3xl bg-white p-6 shadow-sm transition hover:shadow-md hover:ring-1 hover:ring-[#33578D]/20">
|
<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 className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-slate-500">{title}</p>
|
<p className="text-sm text-slate-500">{title}</p>
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export interface NewInvoicePageProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const selectClass =
|
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-[#33578D]/50 focus:ring-2 focus:ring-[#33578D]/20";
|
"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";
|
||||||
|
|
||||||
export default function NewInvoicePage({
|
export default function NewInvoicePage({
|
||||||
mode = "create",
|
mode = "create",
|
||||||
@@ -192,7 +192,7 @@ export default function NewInvoicePage({
|
|||||||
|
|
||||||
<div className="flex justify-end gap-3">
|
<div className="flex justify-end gap-3">
|
||||||
<Button variant="outline">Cancel</Button>
|
<Button variant="outline">Cancel</Button>
|
||||||
<Button className="bg-[#33578D] text-white hover:bg-[#33578D]/90">
|
<Button className="bg-[#10B981] text-white hover:bg-[#10B981]/90">
|
||||||
{submitLabel}
|
{submitLabel}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export default function BookingDetailPage() {
|
|||||||
</p>
|
</p>
|
||||||
<Link
|
<Link
|
||||||
to="/bookings"
|
to="/bookings"
|
||||||
className="mt-6 inline-flex items-center gap-2 rounded-2xl bg-[#33578D] px-4 py-2 text-sm font-medium text-white transition hover:bg-[#33578D]/90"
|
className="mt-6 inline-flex items-center gap-2 rounded-2xl bg-[#10B981] px-4 py-2 text-sm font-medium text-white transition hover:bg-[#10B981]/90"
|
||||||
>
|
>
|
||||||
<ArrowLeft className="h-4 w-4" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
Back to Bookings
|
Back to Bookings
|
||||||
@@ -67,7 +67,7 @@ export default function BookingDetailPage() {
|
|||||||
<div className="rounded-3xl bg-white p-6 shadow-sm">
|
<div className="rounded-3xl bg-white p-6 shadow-sm">
|
||||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-[#33578D]/10 text-[#33578D]">
|
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-[#10B981] text-white">
|
||||||
<Package className="h-8 w-8" />
|
<Package className="h-8 w-8" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -105,7 +105,7 @@ export default function BookingDetailPage() {
|
|||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="inline-flex items-center gap-2 rounded-2xl bg-[#33578D] px-4 py-2 text-sm font-medium text-white transition hover:bg-[#33578D]/90"
|
className="inline-flex items-center gap-2 rounded-2xl bg-[#10B981] px-4 py-2 text-sm font-medium text-white transition hover:bg-[#10B981]/90"
|
||||||
>
|
>
|
||||||
Edit Booking
|
Edit Booking
|
||||||
</button>
|
</button>
|
||||||
@@ -135,7 +135,7 @@ export default function BookingDetailPage() {
|
|||||||
station={booking.originStation}
|
station={booking.originStation}
|
||||||
icon={<MapPin className="h-5 w-5" />}
|
icon={<MapPin className="h-5 w-5" />}
|
||||||
/>
|
/>
|
||||||
<div className="flex items-center gap-2 text-[#33578D]">
|
<div className="flex items-center gap-2 text-[#10B981]">
|
||||||
<Train className="h-5 w-5" />
|
<Train className="h-5 w-5" />
|
||||||
<ArrowRight className="h-5 w-5" />
|
<ArrowRight className="h-5 w-5" />
|
||||||
</div>
|
</div>
|
||||||
@@ -151,11 +151,11 @@ export default function BookingDetailPage() {
|
|||||||
{booking.transportMode === "Multimodal" && booking.legs && booking.legs.length > 0 ? (
|
{booking.transportMode === "Multimodal" && booking.legs && booking.legs.length > 0 ? (
|
||||||
<div className="rounded-3xl bg-white p-6 shadow-sm">
|
<div className="rounded-3xl bg-white p-6 shadow-sm">
|
||||||
<div className="mb-4 flex items-center gap-2">
|
<div className="mb-4 flex items-center gap-2">
|
||||||
<Train className="h-5 w-5 text-[#33578D]" />
|
<Train className="h-5 w-5 text-[#10B981]" />
|
||||||
<h2 className="text-lg font-semibold text-slate-900">
|
<h2 className="text-lg font-semibold text-slate-900">
|
||||||
Transport Legs
|
Transport Legs
|
||||||
</h2>
|
</h2>
|
||||||
<span className="rounded-full bg-[#33578D]/10 px-2 py-0.5 text-xs font-medium text-[#33578D]">
|
<span className="rounded-full bg-[#10B981]/10 px-2 py-0.5 text-xs font-medium text-[#10B981]">
|
||||||
{booking.legs.length} legs
|
{booking.legs.length} legs
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -163,10 +163,10 @@ export default function BookingDetailPage() {
|
|||||||
{booking.legs.map((leg, i) => (
|
{booking.legs.map((leg, i) => (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
className="flex flex-col gap-3 rounded-2xl border border-slate-200 bg-[#33578D]/5 p-4 md:flex-row md:items-center md:justify-between"
|
className="flex flex-col gap-3 rounded-2xl border border-slate-200 bg-[#10B981]/5 p-4 md:flex-row md:items-center md:justify-between"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-[#33578D] text-sm font-bold text-white">
|
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-[#10B981] text-sm font-bold text-white">
|
||||||
{i + 1}
|
{i + 1}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -175,7 +175,7 @@ export default function BookingDetailPage() {
|
|||||||
</p>
|
</p>
|
||||||
<p className="font-semibold text-slate-900">
|
<p className="font-semibold text-slate-900">
|
||||||
{leg.from || "—"}
|
{leg.from || "—"}
|
||||||
<ArrowRight className="mx-2 inline h-4 w-4 text-[#33578D]" />
|
<ArrowRight className="mx-2 inline h-4 w-4 text-[#10B981]" />
|
||||||
{leg.to || "—"}
|
{leg.to || "—"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -231,14 +231,14 @@ export default function BookingDetailPage() {
|
|||||||
|
|
||||||
<DetailCard title="Cargo Description">
|
<DetailCard title="Cargo Description">
|
||||||
<div className="flex items-start gap-3 text-sm text-slate-700">
|
<div className="flex items-start gap-3 text-sm text-slate-700">
|
||||||
<Package className="mt-0.5 h-4 w-4 text-[#33578D]" />
|
<Package className="mt-0.5 h-4 w-4 text-[#10B981]" />
|
||||||
<p className="leading-relaxed">{booking.cargoDescription}</p>
|
<p className="leading-relaxed">{booking.cargoDescription}</p>
|
||||||
</div>
|
</div>
|
||||||
</DetailCard>
|
</DetailCard>
|
||||||
|
|
||||||
<DetailCard title="Special Instructions">
|
<DetailCard title="Special Instructions">
|
||||||
<div className="flex items-start gap-3 text-sm text-slate-700">
|
<div className="flex items-start gap-3 text-sm text-slate-700">
|
||||||
<StickyNote className="mt-0.5 h-4 w-4 text-[#33578D]" />
|
<StickyNote className="mt-0.5 h-4 w-4 text-[#10B981]" />
|
||||||
<p className="leading-relaxed">{booking.specialInstructions}</p>
|
<p className="leading-relaxed">{booking.specialInstructions}</p>
|
||||||
</div>
|
</div>
|
||||||
</DetailCard>
|
</DetailCard>
|
||||||
@@ -259,7 +259,7 @@ function RouteEndpoint({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-[#33578D]/10 text-[#33578D]">
|
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-[#10B981] text-white">
|
||||||
{icon}
|
{icon}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -298,7 +298,7 @@ function DetailRow({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<div className="mt-0.5 text-[#33578D]">{icon}</div>
|
<div className="mt-0.5 text-[#10B981]">{icon}</div>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<p className="text-xs font-medium text-slate-500">{label}</p>
|
<p className="text-xs font-medium text-slate-500">{label}</p>
|
||||||
<p className="mt-0.5 text-sm text-slate-900">{value}</p>
|
<p className="mt-0.5 text-sm text-slate-900">{value}</p>
|
||||||
|
|||||||
@@ -58,14 +58,14 @@ export default function BookingsPage() {
|
|||||||
<input
|
<input
|
||||||
type="search"
|
type="search"
|
||||||
placeholder="Search bookings..."
|
placeholder="Search bookings..."
|
||||||
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-[#33578D]/50 focus:ring-2 focus:ring-[#33578D]/20"
|
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>
|
</div>
|
||||||
|
|
||||||
<NewBookingPage>
|
<NewBookingPage>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="inline-flex items-center justify-center gap-2 rounded-2xl bg-[#33578D] px-4 py-2 text-sm font-medium text-white transition hover:bg-[#33578D]/90"
|
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" />
|
<Plus className="h-4 w-4" />
|
||||||
New Booking
|
New Booking
|
||||||
@@ -109,7 +109,7 @@ export default function BookingsPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</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-[#33578D]/30 hover:bg-[#33578D]/10 hover:text-[#33578D]">
|
<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 className="h-4 w-4" />
|
||||||
Filter
|
Filter
|
||||||
</button>
|
</button>
|
||||||
@@ -132,11 +132,11 @@ export default function BookingsPage() {
|
|||||||
{paginated.map((booking) => (
|
{paginated.map((booking) => (
|
||||||
<tr
|
<tr
|
||||||
key={booking.id}
|
key={booking.id}
|
||||||
className="border-t border-slate-100 transition hover:bg-[#33578D]/5"
|
className="border-t border-slate-100 transition hover:bg-[#10B981]/5"
|
||||||
>
|
>
|
||||||
<td className="px-6 py-4">
|
<td className="px-6 py-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-[#33578D]/10 text-[#33578D]">
|
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-[#10B981] text-white">
|
||||||
<Package className="h-5 w-5" />
|
<Package className="h-5 w-5" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -180,7 +180,7 @@ export default function BookingsPage() {
|
|||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
<Link
|
<Link
|
||||||
to={`/bookings/${booking.id}`}
|
to={`/bookings/${booking.id}`}
|
||||||
className="rounded-xl border border-slate-200 p-2 text-slate-600 transition hover:border-[#33578D]/30 hover:bg-[#33578D]/10 hover:text-[#33578D]"
|
className="rounded-xl border border-slate-200 p-2 text-slate-600 transition hover:border-[#10B981]/30 hover:bg-[#10B981]/10 hover:text-[#10B981]"
|
||||||
>
|
>
|
||||||
<Eye className="h-4 w-4" />
|
<Eye className="h-4 w-4" />
|
||||||
</Link>
|
</Link>
|
||||||
@@ -205,7 +205,7 @@ export default function BookingsPage() {
|
|||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="rounded-xl border border-slate-200 p-2 text-slate-600 transition hover:border-[#33578D]/30 hover:bg-[#33578D]/10 hover:text-[#33578D]"
|
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" />
|
<Pencil className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
@@ -279,7 +279,7 @@ function Pagination({
|
|||||||
id="booking-page-size"
|
id="booking-page-size"
|
||||||
value={pageSize}
|
value={pageSize}
|
||||||
onChange={(e) => onPageSizeChange(Number(e.target.value))}
|
onChange={(e) => onPageSizeChange(Number(e.target.value))}
|
||||||
className="h-9 rounded-xl border border-slate-200 bg-white px-3 text-sm text-slate-700 outline-none transition focus:border-[#33578D]/50 focus:ring-2 focus:ring-[#33578D]/20"
|
className="h-9 rounded-xl border border-slate-200 bg-white px-3 text-sm text-slate-700 outline-none transition focus:border-[#10B981]/50 focus:ring-2 focus:ring-[#10B981]/20"
|
||||||
>
|
>
|
||||||
{PAGE_SIZE_OPTIONS.map((size) => (
|
{PAGE_SIZE_OPTIONS.map((size) => (
|
||||||
<option key={size} value={size}>
|
<option key={size} value={size}>
|
||||||
@@ -297,7 +297,7 @@ function Pagination({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => onPageChange(page - 1)}
|
onClick={() => onPageChange(page - 1)}
|
||||||
disabled={page <= 1}
|
disabled={page <= 1}
|
||||||
className="inline-flex h-9 items-center gap-1 rounded-xl border border-slate-200 px-3 text-sm font-medium text-slate-700 transition hover:border-[#33578D]/30 hover:bg-[#33578D]/10 hover:text-[#33578D] disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent disabled:hover:text-slate-700"
|
className="inline-flex h-9 items-center gap-1 rounded-xl border border-slate-200 px-3 text-sm font-medium text-slate-700 transition hover:border-[#10B981]/30 hover:bg-[#10B981]/10 hover:text-[#10B981] disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent disabled:hover:text-slate-700"
|
||||||
>
|
>
|
||||||
<ChevronLeft className="h-4 w-4" />
|
<ChevronLeft className="h-4 w-4" />
|
||||||
Prev
|
Prev
|
||||||
@@ -313,8 +313,8 @@ function Pagination({
|
|||||||
aria-current={isActive ? "page" : undefined}
|
aria-current={isActive ? "page" : undefined}
|
||||||
className={
|
className={
|
||||||
isActive
|
isActive
|
||||||
? "h-9 w-9 rounded-xl bg-[#33578D] text-sm font-medium text-white"
|
? "h-9 w-9 rounded-xl bg-[#10B981] text-sm font-medium text-white"
|
||||||
: "h-9 w-9 rounded-xl border border-slate-200 text-sm font-medium text-slate-700 transition hover:border-[#33578D]/30 hover:bg-[#33578D]/10 hover:text-[#33578D]"
|
: "h-9 w-9 rounded-xl border border-slate-200 text-sm font-medium text-slate-700 transition hover:border-[#10B981]/30 hover:bg-[#10B981]/10 hover:text-[#10B981]"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{p}
|
{p}
|
||||||
@@ -326,7 +326,7 @@ function Pagination({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => onPageChange(page + 1)}
|
onClick={() => onPageChange(page + 1)}
|
||||||
disabled={page >= totalPages}
|
disabled={page >= totalPages}
|
||||||
className="inline-flex h-9 items-center gap-1 rounded-xl border border-slate-200 px-3 text-sm font-medium text-slate-700 transition hover:border-[#33578D]/30 hover:bg-[#33578D]/10 hover:text-[#33578D] disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent disabled:hover:text-slate-700"
|
className="inline-flex h-9 items-center gap-1 rounded-xl border border-slate-200 px-3 text-sm font-medium text-slate-700 transition hover:border-[#10B981]/30 hover:bg-[#10B981]/10 hover:text-[#10B981] disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent disabled:hover:text-slate-700"
|
||||||
>
|
>
|
||||||
Next
|
Next
|
||||||
<ChevronRight className="h-4 w-4" />
|
<ChevronRight className="h-4 w-4" />
|
||||||
@@ -346,13 +346,13 @@ function StatCard({
|
|||||||
icon: React.ReactNode;
|
icon: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-3xl bg-white p-6 shadow-sm transition hover:shadow-md hover:ring-1 hover:ring-[#33578D]/20">
|
<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 className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-slate-500">{title}</p>
|
<p className="text-sm text-slate-500">{title}</p>
|
||||||
<h3 className="mt-2 text-3xl font-bold text-slate-900">{value}</h3>
|
<h3 className="mt-2 text-3xl font-bold text-slate-900">{value}</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-[#33578D]/10 text-[#33578D]">
|
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-[#10B981] text-white">
|
||||||
{icon}
|
{icon}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ export interface NewBookingPageProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const selectClass =
|
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-[#33578D]/50 focus:ring-2 focus:ring-[#33578D]/20";
|
"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 emptyLeg: TransportLeg = { mode: "Rail", from: "", to: "" };
|
const emptyLeg: TransportLeg = { mode: "Rail", from: "", to: "" };
|
||||||
|
|
||||||
@@ -188,7 +188,7 @@ export default function NewBookingPage({
|
|||||||
{/* Multimodal Transport Legs */}
|
{/* Multimodal Transport Legs */}
|
||||||
{transportMode === "Multimodal" ? (
|
{transportMode === "Multimodal" ? (
|
||||||
<div className="md:col-span-2">
|
<div className="md:col-span-2">
|
||||||
<div className="rounded-2xl border border-slate-200 bg-[#33578D]/5 p-4">
|
<div className="rounded-2xl border border-slate-200 bg-[#10B981]/5 p-4">
|
||||||
<div className="mb-3 flex items-center justify-between">
|
<div className="mb-3 flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-semibold text-slate-900">
|
<p className="text-sm font-semibold text-slate-900">
|
||||||
@@ -201,7 +201,7 @@ export default function NewBookingPage({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={addLeg}
|
onClick={addLeg}
|
||||||
className="inline-flex items-center gap-1.5 rounded-xl bg-[#33578D] px-3 py-1.5 text-xs font-medium text-white transition hover:bg-[#33578D]/90"
|
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" />
|
<Plus className="h-3.5 w-3.5" />
|
||||||
Add Leg
|
Add Leg
|
||||||
@@ -215,7 +215,7 @@ export default function NewBookingPage({
|
|||||||
className="rounded-xl border border-slate-200 bg-white p-3"
|
className="rounded-xl border border-slate-200 bg-white p-3"
|
||||||
>
|
>
|
||||||
<div className="mb-2 flex items-center justify-between">
|
<div className="mb-2 flex items-center justify-between">
|
||||||
<span className="text-xs font-semibold uppercase tracking-wide text-[#33578D]">
|
<span className="text-xs font-semibold uppercase tracking-wide text-[#10B981]">
|
||||||
Leg {i + 1}
|
Leg {i + 1}
|
||||||
</span>
|
</span>
|
||||||
{legs.length > 1 ? (
|
{legs.length > 1 ? (
|
||||||
@@ -356,7 +356,7 @@ export default function NewBookingPage({
|
|||||||
|
|
||||||
<div className="flex justify-end gap-3">
|
<div className="flex justify-end gap-3">
|
||||||
<Button variant="outline">Cancel</Button>
|
<Button variant="outline">Cancel</Button>
|
||||||
<Button className="bg-[#33578D] text-white hover:bg-[#33578D]/90">
|
<Button className="bg-[#10B981] text-white hover:bg-[#10B981]/90">
|
||||||
{submitLabel}
|
{submitLabel}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ export default function ConsignmentDetailPage() {
|
|||||||
</p>
|
</p>
|
||||||
<Link
|
<Link
|
||||||
to="/consignments"
|
to="/consignments"
|
||||||
className="mt-6 inline-flex items-center gap-2 rounded-2xl bg-[#33578D] px-4 py-2 text-sm font-medium text-white transition hover:bg-[#33578D]/90"
|
className="mt-6 inline-flex items-center gap-2 rounded-2xl bg-[#10B981] px-4 py-2 text-sm font-medium text-white transition hover:bg-[#10B981]/90"
|
||||||
>
|
>
|
||||||
<ArrowLeft className="h-4 w-4" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
Back to Consignments
|
Back to Consignments
|
||||||
@@ -72,7 +72,7 @@ export default function ConsignmentDetailPage() {
|
|||||||
<div className="rounded-3xl bg-white p-6 shadow-sm">
|
<div className="rounded-3xl bg-white p-6 shadow-sm">
|
||||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-[#33578D]/10 text-[#33578D]">
|
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-[#10B981] text-white">
|
||||||
<Package className="h-8 w-8" />
|
<Package className="h-8 w-8" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -115,7 +115,7 @@ export default function ConsignmentDetailPage() {
|
|||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="inline-flex items-center gap-2 rounded-2xl bg-[#33578D] px-4 py-2 text-sm font-medium text-white transition hover:bg-[#33578D]/90"
|
className="inline-flex items-center gap-2 rounded-2xl bg-[#10B981] px-4 py-2 text-sm font-medium text-white transition hover:bg-[#10B981]/90"
|
||||||
>
|
>
|
||||||
Edit Consignment
|
Edit Consignment
|
||||||
</button>
|
</button>
|
||||||
@@ -144,7 +144,7 @@ export default function ConsignmentDetailPage() {
|
|||||||
label="Origin"
|
label="Origin"
|
||||||
station={consignment.originStation}
|
station={consignment.originStation}
|
||||||
/>
|
/>
|
||||||
<ArrowRight className="h-5 w-5 text-[#33578D]" />
|
<ArrowRight className="h-5 w-5 text-[#10B981]" />
|
||||||
<RouteEndpoint
|
<RouteEndpoint
|
||||||
label="Destination"
|
label="Destination"
|
||||||
station={consignment.destinationStation}
|
station={consignment.destinationStation}
|
||||||
@@ -202,14 +202,14 @@ export default function ConsignmentDetailPage() {
|
|||||||
|
|
||||||
<DetailCard title="Description">
|
<DetailCard title="Description">
|
||||||
<div className="flex items-start gap-3 text-sm text-slate-700">
|
<div className="flex items-start gap-3 text-sm text-slate-700">
|
||||||
<Package className="mt-0.5 h-4 w-4 text-[#33578D]" />
|
<Package className="mt-0.5 h-4 w-4 text-[#10B981]" />
|
||||||
<p className="leading-relaxed">{consignment.description}</p>
|
<p className="leading-relaxed">{consignment.description}</p>
|
||||||
</div>
|
</div>
|
||||||
</DetailCard>
|
</DetailCard>
|
||||||
|
|
||||||
<DetailCard title="Special Handling">
|
<DetailCard title="Special Handling">
|
||||||
<div className="flex items-start gap-3 text-sm text-slate-700">
|
<div className="flex items-start gap-3 text-sm text-slate-700">
|
||||||
<StickyNote className="mt-0.5 h-4 w-4 text-[#33578D]" />
|
<StickyNote className="mt-0.5 h-4 w-4 text-[#10B981]" />
|
||||||
<p className="leading-relaxed">{consignment.specialHandling}</p>
|
<p className="leading-relaxed">{consignment.specialHandling}</p>
|
||||||
</div>
|
</div>
|
||||||
</DetailCard>
|
</DetailCard>
|
||||||
@@ -228,7 +228,7 @@ function RouteEndpoint({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-[#33578D]/10 text-[#33578D]">
|
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-[#10B981] text-white">
|
||||||
<MapPin className="h-5 w-5" />
|
<MapPin className="h-5 w-5" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -267,7 +267,7 @@ function DetailRow({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<div className="mt-0.5 text-[#33578D]">{icon}</div>
|
<div className="mt-0.5 text-[#10B981]">{icon}</div>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<p className="text-xs font-medium text-slate-500">{label}</p>
|
<p className="text-xs font-medium text-slate-500">{label}</p>
|
||||||
<p className="mt-0.5 text-sm text-slate-900">{value}</p>
|
<p className="mt-0.5 text-sm text-slate-900">{value}</p>
|
||||||
|
|||||||
@@ -104,14 +104,14 @@ export default function ConsignmentsPage() {
|
|||||||
setPage(1);
|
setPage(1);
|
||||||
}}
|
}}
|
||||||
placeholder="Search consignments..."
|
placeholder="Search consignments..."
|
||||||
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-[#33578D]/50 focus:ring-2 focus:ring-[#33578D]/20"
|
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>
|
</div>
|
||||||
|
|
||||||
<NewConsignmentPage>
|
<NewConsignmentPage>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="inline-flex items-center justify-center gap-2 rounded-2xl bg-[#33578D] px-4 py-2 text-sm font-medium text-white transition hover:bg-[#33578D]/90"
|
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" />
|
<Plus className="h-4 w-4" />
|
||||||
New Consignment
|
New Consignment
|
||||||
@@ -164,8 +164,8 @@ export default function ConsignmentsPage() {
|
|||||||
}}
|
}}
|
||||||
className={
|
className={
|
||||||
isActive
|
isActive
|
||||||
? "inline-flex items-center gap-2 rounded-2xl bg-[#33578D] px-4 py-2 text-sm font-medium text-white"
|
? "inline-flex items-center gap-2 rounded-2xl bg-[#10B981] px-4 py-2 text-sm font-medium text-white"
|
||||||
: "inline-flex items-center gap-2 rounded-2xl px-4 py-2 text-sm font-medium text-slate-600 transition hover:bg-[#33578D]/10 hover:text-[#33578D]"
|
: "inline-flex items-center gap-2 rounded-2xl px-4 py-2 text-sm font-medium text-slate-600 transition hover:bg-[#10B981]/10 hover:text-[#10B981]"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{f}
|
{f}
|
||||||
@@ -196,7 +196,7 @@ export default function ConsignmentsPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</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-[#33578D]/30 hover:bg-[#33578D]/10 hover:text-[#33578D]">
|
<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 className="h-4 w-4" />
|
||||||
Filter
|
Filter
|
||||||
</button>
|
</button>
|
||||||
@@ -230,11 +230,11 @@ export default function ConsignmentsPage() {
|
|||||||
paginated.map((consignment) => (
|
paginated.map((consignment) => (
|
||||||
<tr
|
<tr
|
||||||
key={consignment.id}
|
key={consignment.id}
|
||||||
className="border-t border-slate-100 transition hover:bg-[#33578D]/5"
|
className="border-t border-slate-100 transition hover:bg-[#10B981]/5"
|
||||||
>
|
>
|
||||||
<td className="px-6 py-4">
|
<td className="px-6 py-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-[#33578D]/10 text-[#33578D]">
|
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-[#10B981] text-white">
|
||||||
<Package className="h-5 w-5" />
|
<Package className="h-5 w-5" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -292,7 +292,7 @@ export default function ConsignmentsPage() {
|
|||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
<Link
|
<Link
|
||||||
to={`/consignments/${consignment.id}`}
|
to={`/consignments/${consignment.id}`}
|
||||||
className="rounded-xl border border-slate-200 p-2 text-slate-600 transition hover:border-[#33578D]/30 hover:bg-[#33578D]/10 hover:text-[#33578D]"
|
className="rounded-xl border border-slate-200 p-2 text-slate-600 transition hover:border-[#10B981]/30 hover:bg-[#10B981]/10 hover:text-[#10B981]"
|
||||||
>
|
>
|
||||||
<Eye className="h-4 w-4" />
|
<Eye className="h-4 w-4" />
|
||||||
</Link>
|
</Link>
|
||||||
@@ -316,7 +316,7 @@ export default function ConsignmentsPage() {
|
|||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="rounded-xl border border-slate-200 p-2 text-slate-600 transition hover:border-[#33578D]/30 hover:bg-[#33578D]/10 hover:text-[#33578D]"
|
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" />
|
<Pencil className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
@@ -394,7 +394,7 @@ function Pagination({
|
|||||||
id="consignment-page-size"
|
id="consignment-page-size"
|
||||||
value={pageSize}
|
value={pageSize}
|
||||||
onChange={(e) => onPageSizeChange(Number(e.target.value))}
|
onChange={(e) => onPageSizeChange(Number(e.target.value))}
|
||||||
className="h-9 rounded-xl border border-slate-200 bg-white px-3 text-sm text-slate-700 outline-none transition focus:border-[#33578D]/50 focus:ring-2 focus:ring-[#33578D]/20"
|
className="h-9 rounded-xl border border-slate-200 bg-white px-3 text-sm text-slate-700 outline-none transition focus:border-[#10B981]/50 focus:ring-2 focus:ring-[#10B981]/20"
|
||||||
>
|
>
|
||||||
{PAGE_SIZE_OPTIONS.map((size) => (
|
{PAGE_SIZE_OPTIONS.map((size) => (
|
||||||
<option key={size} value={size}>
|
<option key={size} value={size}>
|
||||||
@@ -412,7 +412,7 @@ function Pagination({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => onPageChange(page - 1)}
|
onClick={() => onPageChange(page - 1)}
|
||||||
disabled={page <= 1}
|
disabled={page <= 1}
|
||||||
className="inline-flex h-9 items-center gap-1 rounded-xl border border-slate-200 px-3 text-sm font-medium text-slate-700 transition hover:border-[#33578D]/30 hover:bg-[#33578D]/10 hover:text-[#33578D] disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent disabled:hover:text-slate-700"
|
className="inline-flex h-9 items-center gap-1 rounded-xl border border-slate-200 px-3 text-sm font-medium text-slate-700 transition hover:border-[#10B981]/30 hover:bg-[#10B981]/10 hover:text-[#10B981] disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent disabled:hover:text-slate-700"
|
||||||
>
|
>
|
||||||
<ChevronLeft className="h-4 w-4" />
|
<ChevronLeft className="h-4 w-4" />
|
||||||
Prev
|
Prev
|
||||||
@@ -428,8 +428,8 @@ function Pagination({
|
|||||||
aria-current={isActive ? "page" : undefined}
|
aria-current={isActive ? "page" : undefined}
|
||||||
className={
|
className={
|
||||||
isActive
|
isActive
|
||||||
? "h-9 w-9 rounded-xl bg-[#33578D] text-sm font-medium text-white"
|
? "h-9 w-9 rounded-xl bg-[#10B981] text-sm font-medium text-white"
|
||||||
: "h-9 w-9 rounded-xl border border-slate-200 text-sm font-medium text-slate-700 transition hover:border-[#33578D]/30 hover:bg-[#33578D]/10 hover:text-[#33578D]"
|
: "h-9 w-9 rounded-xl border border-slate-200 text-sm font-medium text-slate-700 transition hover:border-[#10B981]/30 hover:bg-[#10B981]/10 hover:text-[#10B981]"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{p}
|
{p}
|
||||||
@@ -441,7 +441,7 @@ function Pagination({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => onPageChange(page + 1)}
|
onClick={() => onPageChange(page + 1)}
|
||||||
disabled={page >= totalPages}
|
disabled={page >= totalPages}
|
||||||
className="inline-flex h-9 items-center gap-1 rounded-xl border border-slate-200 px-3 text-sm font-medium text-slate-700 transition hover:border-[#33578D]/30 hover:bg-[#33578D]/10 hover:text-[#33578D] disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent disabled:hover:text-slate-700"
|
className="inline-flex h-9 items-center gap-1 rounded-xl border border-slate-200 px-3 text-sm font-medium text-slate-700 transition hover:border-[#10B981]/30 hover:bg-[#10B981]/10 hover:text-[#10B981] disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent disabled:hover:text-slate-700"
|
||||||
>
|
>
|
||||||
Next
|
Next
|
||||||
<ChevronRight className="h-4 w-4" />
|
<ChevronRight className="h-4 w-4" />
|
||||||
@@ -465,10 +465,10 @@ function StatCard({
|
|||||||
const iconWrap =
|
const iconWrap =
|
||||||
tone === "danger"
|
tone === "danger"
|
||||||
? "bg-red-100 text-red-600"
|
? "bg-red-100 text-red-600"
|
||||||
: "bg-[#33578D]/10 text-[#33578D]";
|
: "bg-[#10B981] text-white";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-3xl bg-white p-6 shadow-sm transition hover:shadow-md hover:ring-1 hover:ring-[#33578D]/20">
|
<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 className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-slate-500">{title}</p>
|
<p className="text-sm text-slate-500">{title}</p>
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export interface NewConsignmentPageProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const selectClass =
|
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-[#33578D]/50 focus:ring-2 focus:ring-[#33578D]/20";
|
"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";
|
||||||
|
|
||||||
export default function NewConsignmentPage({
|
export default function NewConsignmentPage({
|
||||||
mode = "create",
|
mode = "create",
|
||||||
@@ -198,7 +198,7 @@ export default function NewConsignmentPage({
|
|||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
defaultChecked={consignment?.hazardous ?? false}
|
defaultChecked={consignment?.hazardous ?? false}
|
||||||
className="h-4 w-4 rounded border-slate-300 text-[#33578D] focus:ring-[#33578D]/20"
|
className="h-4 w-4 rounded border-slate-300 text-[#10B981] focus:ring-[#10B981]/20"
|
||||||
/>
|
/>
|
||||||
<span>Mark as hazardous (DG)</span>
|
<span>Mark as hazardous (DG)</span>
|
||||||
</label>
|
</label>
|
||||||
@@ -225,7 +225,7 @@ export default function NewConsignmentPage({
|
|||||||
|
|
||||||
<div className="flex justify-end gap-3">
|
<div className="flex justify-end gap-3">
|
||||||
<Button variant="outline">Cancel</Button>
|
<Button variant="outline">Cancel</Button>
|
||||||
<Button className="bg-[#33578D] text-white hover:bg-[#33578D]/90">
|
<Button className="bg-[#10B981] text-white hover:bg-[#10B981]/90">
|
||||||
{submitLabel}
|
{submitLabel}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export default function CustomerDetailPage() {
|
|||||||
</p>
|
</p>
|
||||||
<Link
|
<Link
|
||||||
to="/customers"
|
to="/customers"
|
||||||
className="mt-6 inline-flex items-center gap-2 rounded-2xl bg-[#33578D] px-4 py-2 text-sm font-medium text-white transition hover:bg-[#33578D]/90"
|
className="mt-6 inline-flex items-center gap-2 rounded-2xl bg-[#10B981] px-4 py-2 text-sm font-medium text-white transition hover:bg-[#10B981]/90"
|
||||||
>
|
>
|
||||||
<ArrowLeft className="h-4 w-4" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
Back to Customers
|
Back to Customers
|
||||||
@@ -67,7 +67,7 @@ export default function CustomerDetailPage() {
|
|||||||
<div className="rounded-3xl bg-white p-6 shadow-sm">
|
<div className="rounded-3xl bg-white p-6 shadow-sm">
|
||||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-[#33578D]/10 text-[#33578D]">
|
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-[#10B981] text-white">
|
||||||
<User className="h-8 w-8" />
|
<User className="h-8 w-8" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -103,7 +103,7 @@ export default function CustomerDetailPage() {
|
|||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="inline-flex items-center gap-2 rounded-2xl bg-[#33578D] px-4 py-2 text-sm font-medium text-white transition hover:bg-[#33578D]/90"
|
className="inline-flex items-center gap-2 rounded-2xl bg-[#10B981] px-4 py-2 text-sm font-medium text-white transition hover:bg-[#10B981]/90"
|
||||||
>
|
>
|
||||||
Edit Customer
|
Edit Customer
|
||||||
</button>
|
</button>
|
||||||
@@ -183,7 +183,7 @@ export default function CustomerDetailPage() {
|
|||||||
|
|
||||||
<DetailCard title="Notes">
|
<DetailCard title="Notes">
|
||||||
<div className="flex items-start gap-3 text-sm text-slate-700">
|
<div className="flex items-start gap-3 text-sm text-slate-700">
|
||||||
<StickyNote className="mt-0.5 h-4 w-4 text-[#33578D]" />
|
<StickyNote className="mt-0.5 h-4 w-4 text-[#10B981]" />
|
||||||
<p className="leading-relaxed">{customer.notes}</p>
|
<p className="leading-relaxed">{customer.notes}</p>
|
||||||
</div>
|
</div>
|
||||||
</DetailCard>
|
</DetailCard>
|
||||||
@@ -219,7 +219,7 @@ function DetailRow({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<div className="mt-0.5 text-[#33578D]">{icon}</div>
|
<div className="mt-0.5 text-[#10B981]">{icon}</div>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<p className="text-xs font-medium text-slate-500">{label}</p>
|
<p className="text-xs font-medium text-slate-500">{label}</p>
|
||||||
<p className="mt-0.5 text-sm text-slate-900">{value}</p>
|
<p className="mt-0.5 text-sm text-slate-900">{value}</p>
|
||||||
|
|||||||
@@ -58,14 +58,14 @@ export default function CustomerPage() {
|
|||||||
<input
|
<input
|
||||||
type="search"
|
type="search"
|
||||||
placeholder="Search customers..."
|
placeholder="Search customers..."
|
||||||
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-[#33578D]/50 focus:ring-2 focus:ring-[#33578D]/20"
|
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>
|
</div>
|
||||||
|
|
||||||
<NewCustomerPage>
|
<NewCustomerPage>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="inline-flex items-center justify-center gap-2 rounded-2xl bg-[#33578D] px-4 py-2 text-sm font-medium text-white transition hover:bg-[#33578D]/90"
|
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" />
|
<Plus className="h-4 w-4" />
|
||||||
Add Customer
|
Add Customer
|
||||||
@@ -107,7 +107,7 @@ export default function CustomerPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</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-[#33578D]/30 hover:bg-[#33578D]/10 hover:text-[#33578D]">
|
<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 className="h-4 w-4" />
|
||||||
Filter
|
Filter
|
||||||
</button>
|
</button>
|
||||||
@@ -129,11 +129,11 @@ export default function CustomerPage() {
|
|||||||
{paginated.map((customer) => (
|
{paginated.map((customer) => (
|
||||||
<tr
|
<tr
|
||||||
key={customer.id}
|
key={customer.id}
|
||||||
className="border-t border-slate-100 transition hover:bg-[#33578D]/5"
|
className="border-t border-slate-100 transition hover:bg-[#10B981]/5"
|
||||||
>
|
>
|
||||||
<td className="px-6 py-4">
|
<td className="px-6 py-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-[#33578D]/10 text-[#33578D]">
|
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-[#10B981] text-white">
|
||||||
<User className="h-5 w-5" />
|
<User className="h-5 w-5" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -164,7 +164,7 @@ export default function CustomerPage() {
|
|||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
<Link
|
<Link
|
||||||
to={`/customers/${customer.id}`}
|
to={`/customers/${customer.id}`}
|
||||||
className="rounded-xl border border-slate-200 p-2 text-slate-600 transition hover:border-[#33578D]/30 hover:bg-[#33578D]/10 hover:text-[#33578D]"
|
className="rounded-xl border border-slate-200 p-2 text-slate-600 transition hover:border-[#10B981]/30 hover:bg-[#10B981]/10 hover:text-[#10B981]"
|
||||||
>
|
>
|
||||||
<Eye className="h-4 w-4" />
|
<Eye className="h-4 w-4" />
|
||||||
</Link>
|
</Link>
|
||||||
@@ -186,7 +186,7 @@ export default function CustomerPage() {
|
|||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="rounded-xl border border-slate-200 p-2 text-slate-600 transition hover:border-[#33578D]/30 hover:bg-[#33578D]/10 hover:text-[#33578D]"
|
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" />
|
<Pencil className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
@@ -258,7 +258,7 @@ function Pagination({
|
|||||||
id="page-size"
|
id="page-size"
|
||||||
value={pageSize}
|
value={pageSize}
|
||||||
onChange={(e) => onPageSizeChange(Number(e.target.value))}
|
onChange={(e) => onPageSizeChange(Number(e.target.value))}
|
||||||
className="h-9 rounded-xl border border-slate-200 bg-white px-3 text-sm text-slate-700 outline-none transition focus:border-[#33578D]/50 focus:ring-2 focus:ring-[#33578D]/20"
|
className="h-9 rounded-xl border border-slate-200 bg-white px-3 text-sm text-slate-700 outline-none transition focus:border-[#10B981]/50 focus:ring-2 focus:ring-[#10B981]/20"
|
||||||
>
|
>
|
||||||
{PAGE_SIZE_OPTIONS.map((size) => (
|
{PAGE_SIZE_OPTIONS.map((size) => (
|
||||||
<option key={size} value={size}>
|
<option key={size} value={size}>
|
||||||
@@ -276,7 +276,7 @@ function Pagination({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => onPageChange(page - 1)}
|
onClick={() => onPageChange(page - 1)}
|
||||||
disabled={page <= 1}
|
disabled={page <= 1}
|
||||||
className="inline-flex h-9 items-center gap-1 rounded-xl border border-slate-200 px-3 text-sm font-medium text-slate-700 transition hover:border-[#33578D]/30 hover:bg-[#33578D]/10 hover:text-[#33578D] disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent disabled:hover:text-slate-700"
|
className="inline-flex h-9 items-center gap-1 rounded-xl border border-slate-200 px-3 text-sm font-medium text-slate-700 transition hover:border-[#10B981]/30 hover:bg-[#10B981]/10 hover:text-[#10B981] disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent disabled:hover:text-slate-700"
|
||||||
>
|
>
|
||||||
<ChevronLeft className="h-4 w-4" />
|
<ChevronLeft className="h-4 w-4" />
|
||||||
Prev
|
Prev
|
||||||
@@ -292,8 +292,8 @@ function Pagination({
|
|||||||
aria-current={isActive ? "page" : undefined}
|
aria-current={isActive ? "page" : undefined}
|
||||||
className={
|
className={
|
||||||
isActive
|
isActive
|
||||||
? "h-9 w-9 rounded-xl bg-[#33578D] text-sm font-medium text-white"
|
? "h-9 w-9 rounded-xl bg-[#10B981] text-sm font-medium text-white"
|
||||||
: "h-9 w-9 rounded-xl border border-slate-200 text-sm font-medium text-slate-700 transition hover:border-[#33578D]/30 hover:bg-[#33578D]/10 hover:text-[#33578D]"
|
: "h-9 w-9 rounded-xl border border-slate-200 text-sm font-medium text-slate-700 transition hover:border-[#10B981]/30 hover:bg-[#10B981]/10 hover:text-[#10B981]"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{p}
|
{p}
|
||||||
@@ -305,7 +305,7 @@ function Pagination({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => onPageChange(page + 1)}
|
onClick={() => onPageChange(page + 1)}
|
||||||
disabled={page >= totalPages}
|
disabled={page >= totalPages}
|
||||||
className="inline-flex h-9 items-center gap-1 rounded-xl border border-slate-200 px-3 text-sm font-medium text-slate-700 transition hover:border-[#33578D]/30 hover:bg-[#33578D]/10 hover:text-[#33578D] disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent disabled:hover:text-slate-700"
|
className="inline-flex h-9 items-center gap-1 rounded-xl border border-slate-200 px-3 text-sm font-medium text-slate-700 transition hover:border-[#10B981]/30 hover:bg-[#10B981]/10 hover:text-[#10B981] disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent disabled:hover:text-slate-700"
|
||||||
>
|
>
|
||||||
Next
|
Next
|
||||||
<ChevronRight className="h-4 w-4" />
|
<ChevronRight className="h-4 w-4" />
|
||||||
@@ -325,14 +325,14 @@ function StatCard({
|
|||||||
icon: React.ReactNode;
|
icon: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-3xl bg-white p-6 shadow-sm transition hover:shadow-md hover:ring-1 hover:ring-[#33578D]/20">
|
<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 className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-slate-500">{title}</p>
|
<p className="text-sm text-slate-500">{title}</p>
|
||||||
<h3 className="mt-2 text-3xl font-bold text-slate-900">{value}</h3>
|
<h3 className="mt-2 text-3xl font-bold text-slate-900">{value}</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-[#33578D]/10 text-[#33578D]">
|
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-[#10B981] text-white">
|
||||||
{icon}
|
{icon}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ export default function NewCustomerPage({
|
|||||||
|
|
||||||
<select
|
<select
|
||||||
defaultValue={customer?.customerType ?? "Importer"}
|
defaultValue={customer?.customerType ?? "Importer"}
|
||||||
className="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-[#33578D]/50 focus:ring-2 focus:ring-[#33578D]/20"
|
className="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"
|
||||||
>
|
>
|
||||||
<option>Importer</option>
|
<option>Importer</option>
|
||||||
<option>Exporter</option>
|
<option>Exporter</option>
|
||||||
@@ -213,7 +213,7 @@ export default function NewCustomerPage({
|
|||||||
<div className="flex justify-end gap-3">
|
<div className="flex justify-end gap-3">
|
||||||
<Button variant="outline">Cancel</Button>
|
<Button variant="outline">Cancel</Button>
|
||||||
|
|
||||||
<Button className="bg-[#33578D] text-white hover:bg-[#33578D]/90">
|
<Button className="bg-[#10B981] text-white hover:bg-[#10B981]/90">
|
||||||
{submitLabel}
|
{submitLabel}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -37,9 +37,11 @@ import { invoices } from "../billing/invoices.mock";
|
|||||||
import { shipments } from "../tracking/shipments.mock";
|
import { shipments } from "../tracking/shipments.mock";
|
||||||
import { trains } from "../trains/trains.mock";
|
import { trains } from "../trains/trains.mock";
|
||||||
|
|
||||||
const BRAND = "#33578D";
|
// Main brand color: #10B981 (emerald-500). Lighter variants for chart
|
||||||
const BRAND_LIGHT = "#6B8AB8";
|
// hierarchy so multi-series lines/areas/bars stay visually distinct.
|
||||||
const BRAND_LIGHTER = "#B7C7DE";
|
const BRAND = "#10B981"; // emerald-500
|
||||||
|
const BRAND_LIGHT = "#6EE7B7"; // emerald-300
|
||||||
|
const BRAND_LIGHTER = "#D1FAE5"; // emerald-100
|
||||||
|
|
||||||
const STATUS_PALETTE: Record<string, string> = {
|
const STATUS_PALETTE: Record<string, string> = {
|
||||||
Pending: "#d97706",
|
Pending: "#d97706",
|
||||||
@@ -179,10 +181,10 @@ export default function DashboardPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center gap-2 text-xs">
|
<div className="flex flex-wrap items-center gap-2 text-xs">
|
||||||
<span className="rounded-full cursor-pointer hover:scale-110 text-white bg-gradient-to-r from-[#33578D] to-[#4a72ad] px-3 py-1 backdrop-blur">
|
<span className="rounded-full cursor-pointer hover:scale-110 text-white bg-gradient-to-r from-[#059669] to-[#10B981] px-3 py-1 backdrop-blur">
|
||||||
● Live
|
● Live
|
||||||
</span>
|
</span>
|
||||||
<span className="rounded-full cursor-pointer hover:scale-110 text-white bg-gradient-to-r from-[#33578D] to-[#4a72ad] px-3 py-1 backdrop-blur">
|
<span className="rounded-full cursor-pointer hover:scale-110 text-white bg-gradient-to-r from-[#059669] to-[#10B981] px-3 py-1 backdrop-blur">
|
||||||
Last 30 days
|
Last 30 days
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -417,7 +419,7 @@ export default function DashboardPage() {
|
|||||||
width={100}
|
width={100}
|
||||||
/>
|
/>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
cursor={{ fill: "#33578D14" }}
|
cursor={{ fill: "#10B98114" }}
|
||||||
contentStyle={{
|
contentStyle={{
|
||||||
backgroundColor: "white",
|
backgroundColor: "white",
|
||||||
border: "1px solid #e2e8f0",
|
border: "1px solid #e2e8f0",
|
||||||
@@ -454,7 +456,7 @@ export default function DashboardPage() {
|
|||||||
/>
|
/>
|
||||||
<YAxis stroke="#94a3b8" style={{ fontSize: "12px" }} />
|
<YAxis stroke="#94a3b8" style={{ fontSize: "12px" }} />
|
||||||
<Tooltip
|
<Tooltip
|
||||||
cursor={{ fill: "#33578D14" }}
|
cursor={{ fill: "#10B98114" }}
|
||||||
contentStyle={{
|
contentStyle={{
|
||||||
backgroundColor: "white",
|
backgroundColor: "white",
|
||||||
border: "1px solid #e2e8f0",
|
border: "1px solid #e2e8f0",
|
||||||
@@ -474,7 +476,7 @@ export default function DashboardPage() {
|
|||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</ChartCard>
|
</ChartCard>
|
||||||
|
|
||||||
<ChartCard title="Fleet Status" subtitle={`${trains.length} units`}>
|
<ChartCard title="Train Status" subtitle={`${trains.length} units`}>
|
||||||
<ResponsiveContainer width="100%" height={240}>
|
<ResponsiveContainer width="100%" height={240}>
|
||||||
<PieChart>
|
<PieChart>
|
||||||
<Pie
|
<Pie
|
||||||
@@ -517,7 +519,7 @@ export default function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
<Link
|
<Link
|
||||||
to="/tracking"
|
to="/tracking"
|
||||||
className="inline-flex items-center gap-1 text-xs font-medium text-[#33578D] transition hover:underline"
|
className="inline-flex items-center gap-1 text-xs font-medium text-[#10B981] transition hover:underline"
|
||||||
>
|
>
|
||||||
View all
|
View all
|
||||||
<ArrowRight className="h-3 w-3" />
|
<ArrowRight className="h-3 w-3" />
|
||||||
@@ -528,7 +530,7 @@ export default function DashboardPage() {
|
|||||||
{recentShipments.map((s) => (
|
{recentShipments.map((s) => (
|
||||||
<li
|
<li
|
||||||
key={s.id}
|
key={s.id}
|
||||||
className="flex items-center justify-between gap-3 rounded-2xl border border-slate-100 p-3 transition hover:border-[#33578D]/20 hover:bg-[#33578D]/5"
|
className="flex items-center justify-between gap-3 rounded-2xl border border-slate-100 p-3 transition hover:border-[#10B981]/20 hover:bg-[#10B981]/5"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3 overflow-hidden">
|
<div className="flex items-center gap-3 overflow-hidden">
|
||||||
<CircleDot
|
<CircleDot
|
||||||
@@ -613,7 +615,7 @@ function KpiCard({
|
|||||||
const TrendIcon = trend === "up" ? ArrowUpRight : ArrowDownRight;
|
const TrendIcon = trend === "up" ? ArrowUpRight : ArrowDownRight;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-3xl bg-white p-6 shadow-sm transition hover:shadow-md hover:ring-1 hover:ring-[#33578D]/20">
|
<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-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-slate-500">{label}</p>
|
<p className="text-sm text-slate-500">{label}</p>
|
||||||
@@ -626,7 +628,7 @@ function KpiCard({
|
|||||||
<span className="text-slate-400">vs last period</span>
|
<span className="text-slate-400">vs last period</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-2xl bg-[#33578D]/10 text-[#33578D]">
|
<div className="flex h-10 w-10 items-center justify-center rounded-2xl bg-[#10B981] text-white">
|
||||||
{icon}
|
{icon}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -674,10 +676,10 @@ function SummaryCard({
|
|||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
to={href}
|
to={href}
|
||||||
className="flex items-center justify-between rounded-3xl bg-white p-4 shadow-sm transition hover:shadow-md hover:ring-1 hover:ring-[#33578D]/20"
|
className="flex items-center justify-between rounded-3xl bg-white p-4 shadow-sm transition hover:shadow-md hover:ring-1 hover:ring-[#10B981]/20"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-[#33578D]/10 text-[#33578D]">
|
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-[#10B981] text-white">
|
||||||
{icon}
|
{icon}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -110,14 +110,14 @@ export default function DocumentsPage() {
|
|||||||
setPage(1);
|
setPage(1);
|
||||||
}}
|
}}
|
||||||
placeholder="Search documents..."
|
placeholder="Search documents..."
|
||||||
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-[#33578D]/50 focus:ring-2 focus:ring-[#33578D]/20"
|
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>
|
</div>
|
||||||
|
|
||||||
<NewDocumentPage>
|
<NewDocumentPage>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="inline-flex items-center justify-center gap-2 rounded-2xl bg-[#33578D] px-4 py-2 text-sm font-medium text-white transition hover:bg-[#33578D]/90"
|
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" />
|
<Plus className="h-4 w-4" />
|
||||||
Upload Document
|
Upload Document
|
||||||
@@ -169,8 +169,8 @@ export default function DocumentsPage() {
|
|||||||
}}
|
}}
|
||||||
className={
|
className={
|
||||||
isActive
|
isActive
|
||||||
? "inline-flex items-center gap-2 rounded-2xl bg-[#33578D] px-4 py-2 text-sm font-medium text-white"
|
? "inline-flex items-center gap-2 rounded-2xl bg-[#10B981] px-4 py-2 text-sm font-medium text-white"
|
||||||
: "inline-flex items-center gap-2 rounded-2xl px-4 py-2 text-sm font-medium text-slate-600 transition hover:bg-[#33578D]/10 hover:text-[#33578D]"
|
: "inline-flex items-center gap-2 rounded-2xl px-4 py-2 text-sm font-medium text-slate-600 transition hover:bg-[#10B981]/10 hover:text-[#10B981]"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{f}
|
{f}
|
||||||
@@ -266,8 +266,8 @@ function ViewToggleButton({
|
|||||||
aria-pressed={active}
|
aria-pressed={active}
|
||||||
className={
|
className={
|
||||||
active
|
active
|
||||||
? "inline-flex items-center gap-2 rounded-lg bg-white px-3 py-1.5 text-sm font-medium text-[#33578D] shadow-sm"
|
? "inline-flex items-center gap-2 rounded-lg bg-white px-3 py-1.5 text-sm font-medium text-[#10B981] shadow-sm"
|
||||||
: "inline-flex items-center gap-2 rounded-lg px-3 py-1.5 text-sm font-medium text-slate-600 transition hover:text-[#33578D]"
|
: "inline-flex items-center gap-2 rounded-lg px-3 py-1.5 text-sm font-medium text-slate-600 transition hover:text-[#10B981]"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
@@ -276,7 +276,7 @@ function ViewToggleButton({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function FormatIcon({ format }: { format: DocumentFormat }) {
|
function FormatIcon({ format }: { format: DocumentFormat }) {
|
||||||
const className = "h-5 w-5 text-[#33578D]";
|
const className = "h-5 w-5 text-[#10B981]";
|
||||||
if (format === "PDF") return <FileText className={className} />;
|
if (format === "PDF") return <FileText className={className} />;
|
||||||
if (format === "DOCX") return <FileText className={className} />;
|
if (format === "DOCX") return <FileText className={className} />;
|
||||||
if (format === "XLSX") return <FileSpreadsheet className={className} />;
|
if (format === "XLSX") return <FileSpreadsheet className={className} />;
|
||||||
@@ -287,10 +287,10 @@ function FormatIcon({ format }: { format: DocumentFormat }) {
|
|||||||
|
|
||||||
function DocumentCard({ doc }: { doc: DocumentRecord }) {
|
function DocumentCard({ doc }: { doc: DocumentRecord }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4 rounded-3xl bg-white p-5 shadow-sm transition hover:shadow-md hover:ring-1 hover:ring-[#33578D]/20">
|
<div className="flex flex-col gap-4 rounded-3xl bg-white p-5 shadow-sm transition hover:shadow-md hover:ring-1 hover:ring-[#10B981]/20">
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-[#33578D]/10">
|
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-[#10B981]/10">
|
||||||
<FormatIcon format={doc.format} />
|
<FormatIcon format={doc.format} />
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
@@ -316,14 +316,14 @@ function DocumentCard({ doc }: { doc: DocumentRecord }) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Preview"
|
aria-label="Preview"
|
||||||
className="rounded-xl border border-slate-200 p-2 text-slate-600 transition hover:border-[#33578D]/30 hover:bg-[#33578D]/10 hover:text-[#33578D]"
|
className="rounded-xl border border-slate-200 p-2 text-slate-600 transition hover:border-[#10B981]/30 hover:bg-[#10B981]/10 hover:text-[#10B981]"
|
||||||
>
|
>
|
||||||
<Eye className="h-4 w-4" />
|
<Eye className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Download"
|
aria-label="Download"
|
||||||
className="rounded-xl border border-slate-200 p-2 text-slate-600 transition hover:border-[#33578D]/30 hover:bg-[#33578D]/10 hover:text-[#33578D]"
|
className="rounded-xl border border-slate-200 p-2 text-slate-600 transition hover:border-[#10B981]/30 hover:bg-[#10B981]/10 hover:text-[#10B981]"
|
||||||
>
|
>
|
||||||
<Download className="h-4 w-4" />
|
<Download className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
@@ -340,7 +340,7 @@ function DocumentCard({ doc }: { doc: DocumentRecord }) {
|
|||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="rounded-xl border border-slate-200 p-2 text-slate-600 transition hover:border-[#33578D]/30 hover:bg-[#33578D]/10 hover:text-[#33578D]"
|
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" />
|
<Pencil className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
@@ -380,7 +380,7 @@ function DocumentTable({ documents: rows }: { documents: DocumentRecord[] }) {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</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-[#33578D]/30 hover:bg-[#33578D]/10 hover:text-[#33578D]">
|
<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 className="h-4 w-4" />
|
||||||
Filter
|
Filter
|
||||||
</button>
|
</button>
|
||||||
@@ -404,11 +404,11 @@ function DocumentTable({ documents: rows }: { documents: DocumentRecord[] }) {
|
|||||||
{rows.map((doc) => (
|
{rows.map((doc) => (
|
||||||
<tr
|
<tr
|
||||||
key={doc.id}
|
key={doc.id}
|
||||||
className="border-t border-slate-100 transition hover:bg-[#33578D]/5"
|
className="border-t border-slate-100 transition hover:bg-[#10B981]/5"
|
||||||
>
|
>
|
||||||
<td className="px-6 py-4">
|
<td className="px-6 py-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-[#33578D]/10">
|
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-[#10B981]/10">
|
||||||
<FormatIcon format={doc.format} />
|
<FormatIcon format={doc.format} />
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
@@ -448,14 +448,14 @@ function DocumentTable({ documents: rows }: { documents: DocumentRecord[] }) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Preview"
|
aria-label="Preview"
|
||||||
className="rounded-xl border border-slate-200 p-2 text-slate-600 transition hover:border-[#33578D]/30 hover:bg-[#33578D]/10 hover:text-[#33578D]"
|
className="rounded-xl border border-slate-200 p-2 text-slate-600 transition hover:border-[#10B981]/30 hover:bg-[#10B981]/10 hover:text-[#10B981]"
|
||||||
>
|
>
|
||||||
<Eye className="h-4 w-4" />
|
<Eye className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Download"
|
aria-label="Download"
|
||||||
className="rounded-xl border border-slate-200 p-2 text-slate-600 transition hover:border-[#33578D]/30 hover:bg-[#33578D]/10 hover:text-[#33578D]"
|
className="rounded-xl border border-slate-200 p-2 text-slate-600 transition hover:border-[#10B981]/30 hover:bg-[#10B981]/10 hover:text-[#10B981]"
|
||||||
>
|
>
|
||||||
<Download className="h-4 w-4" />
|
<Download className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
@@ -472,7 +472,7 @@ function DocumentTable({ documents: rows }: { documents: DocumentRecord[] }) {
|
|||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="rounded-xl border border-slate-200 p-2 text-slate-600 transition hover:border-[#33578D]/30 hover:bg-[#33578D]/10 hover:text-[#33578D]"
|
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" />
|
<Pencil className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
@@ -530,7 +530,7 @@ function Pagination({
|
|||||||
id="document-page-size"
|
id="document-page-size"
|
||||||
value={pageSize}
|
value={pageSize}
|
||||||
onChange={(e) => onPageSizeChange(Number(e.target.value))}
|
onChange={(e) => onPageSizeChange(Number(e.target.value))}
|
||||||
className="h-9 rounded-xl border border-slate-200 bg-white px-3 text-sm text-slate-700 outline-none transition focus:border-[#33578D]/50 focus:ring-2 focus:ring-[#33578D]/20"
|
className="h-9 rounded-xl border border-slate-200 bg-white px-3 text-sm text-slate-700 outline-none transition focus:border-[#10B981]/50 focus:ring-2 focus:ring-[#10B981]/20"
|
||||||
>
|
>
|
||||||
{PAGE_SIZE_OPTIONS.map((size) => (
|
{PAGE_SIZE_OPTIONS.map((size) => (
|
||||||
<option key={size} value={size}>
|
<option key={size} value={size}>
|
||||||
@@ -548,7 +548,7 @@ function Pagination({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => onPageChange(page - 1)}
|
onClick={() => onPageChange(page - 1)}
|
||||||
disabled={page <= 1}
|
disabled={page <= 1}
|
||||||
className="inline-flex h-9 items-center gap-1 rounded-xl border border-slate-200 px-3 text-sm font-medium text-slate-700 transition hover:border-[#33578D]/30 hover:bg-[#33578D]/10 hover:text-[#33578D] disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent disabled:hover:text-slate-700"
|
className="inline-flex h-9 items-center gap-1 rounded-xl border border-slate-200 px-3 text-sm font-medium text-slate-700 transition hover:border-[#10B981]/30 hover:bg-[#10B981]/10 hover:text-[#10B981] disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent disabled:hover:text-slate-700"
|
||||||
>
|
>
|
||||||
<ChevronLeft className="h-4 w-4" />
|
<ChevronLeft className="h-4 w-4" />
|
||||||
Prev
|
Prev
|
||||||
@@ -564,8 +564,8 @@ function Pagination({
|
|||||||
aria-current={isActive ? "page" : undefined}
|
aria-current={isActive ? "page" : undefined}
|
||||||
className={
|
className={
|
||||||
isActive
|
isActive
|
||||||
? "h-9 w-9 rounded-xl bg-[#33578D] text-sm font-medium text-white"
|
? "h-9 w-9 rounded-xl bg-[#10B981] text-sm font-medium text-white"
|
||||||
: "h-9 w-9 rounded-xl border border-slate-200 text-sm font-medium text-slate-700 transition hover:border-[#33578D]/30 hover:bg-[#33578D]/10 hover:text-[#33578D]"
|
: "h-9 w-9 rounded-xl border border-slate-200 text-sm font-medium text-slate-700 transition hover:border-[#10B981]/30 hover:bg-[#10B981]/10 hover:text-[#10B981]"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{p}
|
{p}
|
||||||
@@ -577,7 +577,7 @@ function Pagination({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => onPageChange(page + 1)}
|
onClick={() => onPageChange(page + 1)}
|
||||||
disabled={page >= totalPages}
|
disabled={page >= totalPages}
|
||||||
className="inline-flex h-9 items-center gap-1 rounded-xl border border-slate-200 px-3 text-sm font-medium text-slate-700 transition hover:border-[#33578D]/30 hover:bg-[#33578D]/10 hover:text-[#33578D] disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent disabled:hover:text-slate-700"
|
className="inline-flex h-9 items-center gap-1 rounded-xl border border-slate-200 px-3 text-sm font-medium text-slate-700 transition hover:border-[#10B981]/30 hover:bg-[#10B981]/10 hover:text-[#10B981] disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent disabled:hover:text-slate-700"
|
||||||
>
|
>
|
||||||
Next
|
Next
|
||||||
<ChevronRight className="h-4 w-4" />
|
<ChevronRight className="h-4 w-4" />
|
||||||
@@ -597,13 +597,13 @@ function StatCard({
|
|||||||
icon: React.ReactNode;
|
icon: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-3xl bg-white p-6 shadow-sm transition hover:shadow-md hover:ring-1 hover:ring-[#33578D]/20">
|
<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 className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-slate-500">{title}</p>
|
<p className="text-sm text-slate-500">{title}</p>
|
||||||
<h3 className="mt-2 text-2xl font-bold text-slate-900">{value}</h3>
|
<h3 className="mt-2 text-2xl font-bold text-slate-900">{value}</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-[#33578D]/10 text-[#33578D]">
|
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-[#10B981] text-white">
|
||||||
{icon}
|
{icon}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export interface NewDocumentPageProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const selectClass =
|
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-[#33578D]/50 focus:ring-2 focus:ring-[#33578D]/20";
|
"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";
|
||||||
|
|
||||||
export default function NewDocumentPage({
|
export default function NewDocumentPage({
|
||||||
mode = "create",
|
mode = "create",
|
||||||
@@ -99,9 +99,9 @@ export default function NewDocumentPage({
|
|||||||
<Label>File</Label>
|
<Label>File</Label>
|
||||||
<label
|
<label
|
||||||
htmlFor="document-file-input"
|
htmlFor="document-file-input"
|
||||||
className="mt-1 flex cursor-pointer flex-col items-center justify-center gap-2 rounded-2xl border-2 border-dashed border-slate-200 bg-[#33578D]/5 p-8 text-center transition hover:border-[#33578D]/40 hover:bg-[#33578D]/10"
|
className="mt-1 flex cursor-pointer flex-col items-center justify-center gap-2 rounded-2xl border-2 border-dashed border-slate-200 bg-[#10B981]/5 p-8 text-center transition hover:border-[#10B981]/40 hover:bg-[#10B981]/10"
|
||||||
>
|
>
|
||||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-[#33578D]/10 text-[#33578D]">
|
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-[#10B981] text-white">
|
||||||
<FileUp className="h-6 w-6" />
|
<FileUp className="h-6 w-6" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm font-medium text-slate-900">
|
<p className="text-sm font-medium text-slate-900">
|
||||||
@@ -232,7 +232,7 @@ export default function NewDocumentPage({
|
|||||||
|
|
||||||
<div className="flex justify-end gap-3">
|
<div className="flex justify-end gap-3">
|
||||||
<Button variant="outline">Cancel</Button>
|
<Button variant="outline">Cancel</Button>
|
||||||
<Button className="bg-[#33578D] text-white hover:bg-[#33578D]/90">
|
<Button className="bg-[#10B981] text-white hover:bg-[#10B981]/90">
|
||||||
{submitLabel}
|
{submitLabel}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
480
apps/edr-freight-web/portal/src/pages/portal/MyPortalPage.tsx
Normal file
480
apps/edr-freight-web/portal/src/pages/portal/MyPortalPage.tsx
Normal file
@@ -0,0 +1,480 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
ArrowRight,
|
||||||
|
Building2,
|
||||||
|
CheckCircle2,
|
||||||
|
Clock,
|
||||||
|
DollarSign,
|
||||||
|
Eye,
|
||||||
|
Mail,
|
||||||
|
MapPin,
|
||||||
|
Package,
|
||||||
|
Phone,
|
||||||
|
Plus,
|
||||||
|
Receipt,
|
||||||
|
Truck,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
import Breadcrumbs from "@/components/Breadcrumbs";
|
||||||
|
import NewBookingPage from "@/pages/bookings/NewBookingPage";
|
||||||
|
import {
|
||||||
|
getCurrentCustomer,
|
||||||
|
getMyBookings,
|
||||||
|
getMyInvoices,
|
||||||
|
getMyShipments,
|
||||||
|
} from "@/lib/currentCustomer";
|
||||||
|
import { formatCurrency } from "@/pages/billing/invoices.mock";
|
||||||
|
import type { ShipmentStatus } from "@/pages/tracking/shipments.mock";
|
||||||
|
import type { InvoiceStatus } from "@/pages/billing/invoices.mock";
|
||||||
|
import type { BookingStatus } from "@/pages/bookings/bookings.mock";
|
||||||
|
|
||||||
|
export default function MyPortalPage() {
|
||||||
|
const me = useMemo(() => getCurrentCustomer(), []);
|
||||||
|
const myBookings = useMemo(() => getMyBookings(), []);
|
||||||
|
const myShipments = useMemo(() => getMyShipments(), []);
|
||||||
|
const myInvoices = useMemo(() => getMyInvoices(), []);
|
||||||
|
|
||||||
|
const activeBookings = myBookings.filter(
|
||||||
|
(b) => b.status === "Confirmed" || b.status === "In Transit",
|
||||||
|
);
|
||||||
|
const activeShipments = myShipments.filter((s) => s.status === "In Transit");
|
||||||
|
const outstandingInvoices = myInvoices.filter(
|
||||||
|
(inv) => inv.status === "Sent" || inv.status === "Overdue",
|
||||||
|
);
|
||||||
|
const totalOutstanding = outstandingInvoices
|
||||||
|
.filter((inv) => inv.currency === "USD")
|
||||||
|
.reduce((sum, inv) => sum + inv.amount, 0);
|
||||||
|
const totalSpent = myInvoices
|
||||||
|
.filter((inv) => inv.status === "Paid" && inv.currency === "USD")
|
||||||
|
.reduce((sum, inv) => sum + inv.amount, 0);
|
||||||
|
|
||||||
|
const recentBookings = [...myBookings].slice(0, 5);
|
||||||
|
const recentInvoices = [...myInvoices].slice(0, 4);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-slate-50 p-6">
|
||||||
|
<div className="mx-auto max-w-7xl space-y-6">
|
||||||
|
<Breadcrumbs items={[{ label: "My Portal" }]} />
|
||||||
|
|
||||||
|
{/* Welcome banner */}
|
||||||
|
<div className="overflow-hidden rounded-3xl bg-gradient-to-r from-[#059669] to-[#10B981] p-6 text-white shadow-sm">
|
||||||
|
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-white/15 text-2xl font-bold backdrop-blur">
|
||||||
|
{me.company.charAt(0)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-white/80">Welcome back</p>
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight">
|
||||||
|
{me.name}
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 flex items-center gap-2 text-sm text-white/80">
|
||||||
|
<Building2 className="h-4 w-4" />
|
||||||
|
{me.company}
|
||||||
|
<span className="text-white/40">·</span>
|
||||||
|
<span className="rounded-full bg-white/15 px-2 py-0.5 text-xs">
|
||||||
|
{me.customerType}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<NewBookingPage>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex items-center gap-2 rounded-2xl bg-white px-4 py-2 text-sm font-semibold text-[#10B981] transition hover:bg-slate-100"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
New Booking
|
||||||
|
</button>
|
||||||
|
</NewBookingPage>
|
||||||
|
<Link
|
||||||
|
to="/tracking"
|
||||||
|
className="inline-flex items-center gap-2 rounded-2xl border border-white/40 px-4 py-2 text-sm font-medium text-white transition hover:bg-white/10"
|
||||||
|
>
|
||||||
|
<Truck className="h-4 w-4" />
|
||||||
|
Track Shipment
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* My KPIs */}
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||||
|
<KpiCard
|
||||||
|
label="My Active Bookings"
|
||||||
|
value={String(activeBookings.length)}
|
||||||
|
sub={`${myBookings.length} total`}
|
||||||
|
icon={<Package className="h-5 w-5" />}
|
||||||
|
href="/bookings"
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
label="In Transit"
|
||||||
|
value={String(activeShipments.length)}
|
||||||
|
sub={`${myShipments.length} shipments`}
|
||||||
|
icon={<Truck className="h-5 w-5" />}
|
||||||
|
href="/tracking"
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
label="Outstanding"
|
||||||
|
value={formatCurrency(totalOutstanding, "USD")}
|
||||||
|
sub={`${outstandingInvoices.length} invoices`}
|
||||||
|
icon={<DollarSign className="h-5 w-5" />}
|
||||||
|
href="/billing"
|
||||||
|
tone={outstandingInvoices.some((i) => i.status === "Overdue") ? "danger" : "brand"}
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
label="Total Spent"
|
||||||
|
value={formatCurrency(totalSpent, "USD")}
|
||||||
|
sub="All-time, paid invoices"
|
||||||
|
icon={<CheckCircle2 className="h-5 w-5" />}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Active Shipments */}
|
||||||
|
<div className="rounded-3xl bg-white p-6 shadow-sm">
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-slate-900">
|
||||||
|
Active Shipments
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-slate-500">
|
||||||
|
Live tracking for your in-flight cargo
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
to="/tracking"
|
||||||
|
className="inline-flex items-center gap-1 text-sm font-medium text-[#10B981] transition hover:underline"
|
||||||
|
>
|
||||||
|
View all
|
||||||
|
<ArrowRight className="h-4 w-4" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{activeShipments.length === 0 ? (
|
||||||
|
<p className="rounded-2xl border border-dashed border-slate-200 p-8 text-center text-sm text-slate-500">
|
||||||
|
No shipments currently in transit.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
|
{activeShipments.slice(0, 4).map((shipment) => (
|
||||||
|
<div
|
||||||
|
key={shipment.id}
|
||||||
|
className="rounded-2xl border border-slate-100 p-4 transition hover:border-[#10B981]/20 hover:bg-[#10B981]/5"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="font-semibold text-slate-900">
|
||||||
|
{shipment.reference}
|
||||||
|
</span>
|
||||||
|
<ShipmentBadge status={shipment.status} />
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-sm text-slate-700">
|
||||||
|
{shipment.originStation}
|
||||||
|
<ArrowRight className="mx-2 inline h-3 w-3 text-slate-400" />
|
||||||
|
{shipment.destinationStation}
|
||||||
|
</p>
|
||||||
|
<div className="mt-3 flex items-center justify-between text-xs text-slate-500">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<MapPin className="h-3 w-3 text-[#10B981]" />
|
||||||
|
{shipment.currentLocation}
|
||||||
|
</span>
|
||||||
|
<span>ETA {shipment.eta}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 h-1.5 w-full overflow-hidden rounded-full bg-slate-100">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-[#10B981] transition-all"
|
||||||
|
style={{ width: `${shipment.progress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recent Bookings + Invoices + Profile */}
|
||||||
|
<div className="grid gap-6 lg:grid-cols-3">
|
||||||
|
{/* Recent bookings */}
|
||||||
|
<div className="rounded-3xl bg-white p-6 shadow-sm lg:col-span-2">
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-slate-900">
|
||||||
|
Recent Bookings
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-slate-500">
|
||||||
|
Your latest freight requests
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
to="/bookings"
|
||||||
|
className="inline-flex items-center gap-1 text-sm font-medium text-[#10B981] transition hover:underline"
|
||||||
|
>
|
||||||
|
View all
|
||||||
|
<ArrowRight className="h-4 w-4" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{recentBookings.length === 0 ? (
|
||||||
|
<p className="rounded-2xl border border-dashed border-slate-200 p-8 text-center text-sm text-slate-500">
|
||||||
|
You haven't booked any freight yet.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full min-w-[600px] whitespace-nowrap text-left text-sm">
|
||||||
|
<thead className="text-xs text-slate-500">
|
||||||
|
<tr>
|
||||||
|
<th className="py-2 font-medium">Reference</th>
|
||||||
|
<th className="py-2 font-medium">Route</th>
|
||||||
|
<th className="py-2 font-medium">Cargo</th>
|
||||||
|
<th className="py-2 font-medium">Status</th>
|
||||||
|
<th className="py-2 text-right font-medium">Action</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{recentBookings.map((booking) => (
|
||||||
|
<tr
|
||||||
|
key={booking.id}
|
||||||
|
className="border-t border-slate-100 transition hover:bg-[#10B981]/5"
|
||||||
|
>
|
||||||
|
<td className="py-3 font-medium text-slate-900">
|
||||||
|
{booking.reference}
|
||||||
|
</td>
|
||||||
|
<td className="py-3 text-slate-700">
|
||||||
|
{booking.originStation} → {booking.destinationStation}
|
||||||
|
</td>
|
||||||
|
<td className="py-3 text-slate-700">
|
||||||
|
{booking.cargoType}
|
||||||
|
</td>
|
||||||
|
<td className="py-3">
|
||||||
|
<BookingBadge status={booking.status} />
|
||||||
|
</td>
|
||||||
|
<td className="py-3 text-right">
|
||||||
|
<Link
|
||||||
|
to={`/bookings/${booking.id}`}
|
||||||
|
aria-label="View booking"
|
||||||
|
className="inline-flex h-7 w-7 items-center justify-center rounded-lg text-slate-500 transition hover:bg-[#10B981]/10 hover:text-[#10B981]"
|
||||||
|
>
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Profile card */}
|
||||||
|
<div className="rounded-3xl bg-white p-6 shadow-sm">
|
||||||
|
<h2 className="text-lg font-semibold text-slate-900">
|
||||||
|
My Profile
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-slate-500">Account information</p>
|
||||||
|
|
||||||
|
<div className="mt-4 space-y-3 text-sm">
|
||||||
|
<ProfileRow
|
||||||
|
icon={<Building2 className="h-4 w-4" />}
|
||||||
|
label="Company"
|
||||||
|
value={me.company}
|
||||||
|
/>
|
||||||
|
<ProfileRow
|
||||||
|
icon={<Mail className="h-4 w-4" />}
|
||||||
|
label="Email"
|
||||||
|
value={me.email}
|
||||||
|
/>
|
||||||
|
<ProfileRow
|
||||||
|
icon={<Phone className="h-4 w-4" />}
|
||||||
|
label="Phone"
|
||||||
|
value={me.phone}
|
||||||
|
/>
|
||||||
|
<ProfileRow
|
||||||
|
icon={<MapPin className="h-4 w-4" />}
|
||||||
|
label="Location"
|
||||||
|
value={`${me.city}, ${me.country}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
to={`/customers/${me.id}`}
|
||||||
|
className="mt-4 inline-flex w-full items-center justify-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]"
|
||||||
|
>
|
||||||
|
View full profile
|
||||||
|
<ArrowRight className="h-4 w-4" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Invoices */}
|
||||||
|
<div className="rounded-3xl bg-white p-6 shadow-sm">
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-slate-900">
|
||||||
|
Recent Invoices
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-slate-500">
|
||||||
|
{outstandingInvoices.length} outstanding ·{" "}
|
||||||
|
{myInvoices.length} total
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
to="/billing"
|
||||||
|
className="inline-flex items-center gap-1 text-sm font-medium text-[#10B981] transition hover:underline"
|
||||||
|
>
|
||||||
|
View all
|
||||||
|
<ArrowRight className="h-4 w-4" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{recentInvoices.length === 0 ? (
|
||||||
|
<p className="rounded-2xl border border-dashed border-slate-200 p-8 text-center text-sm text-slate-500">
|
||||||
|
No invoices yet.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-4">
|
||||||
|
{recentInvoices.map((invoice) => (
|
||||||
|
<div
|
||||||
|
key={invoice.id}
|
||||||
|
className="rounded-2xl border border-slate-100 p-4 transition hover:border-[#10B981]/20 hover:bg-[#10B981]/5"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Receipt className="h-4 w-4 text-[#10B981]" />
|
||||||
|
<InvoiceBadge status={invoice.status} />
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-xs text-slate-500">
|
||||||
|
{invoice.number}
|
||||||
|
</p>
|
||||||
|
<p className="mt-0.5 text-lg font-bold text-slate-900">
|
||||||
|
{formatCurrency(invoice.amount, invoice.currency)}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 flex items-center gap-1 text-xs text-slate-500">
|
||||||
|
<Clock className="h-3 w-3" />
|
||||||
|
Due {invoice.dueDate}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function KpiCard({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
sub,
|
||||||
|
icon,
|
||||||
|
href,
|
||||||
|
tone = "brand",
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
sub: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
href?: string;
|
||||||
|
tone?: "brand" | "danger";
|
||||||
|
}) {
|
||||||
|
const iconWrap =
|
||||||
|
tone === "danger"
|
||||||
|
? "bg-red-100 text-red-600"
|
||||||
|
: "bg-[#10B981] text-white";
|
||||||
|
|
||||||
|
const inner = (
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-slate-500">{label}</p>
|
||||||
|
<h3 className="mt-2 text-2xl font-bold text-slate-900">{value}</h3>
|
||||||
|
<p className="mt-1 text-xs text-slate-500">{sub}</p>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`flex h-10 w-10 items-center justify-center rounded-2xl ${iconWrap}`}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const className =
|
||||||
|
"rounded-3xl bg-white p-6 shadow-sm transition hover:shadow-md hover:ring-1 hover:ring-[#10B981]/20";
|
||||||
|
|
||||||
|
return href ? (
|
||||||
|
<Link to={href} className={`block ${className}`}>
|
||||||
|
{inner}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<div className={className}>{inner}</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProfileRow({
|
||||||
|
icon,
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
}: {
|
||||||
|
icon: React.ReactNode;
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="mt-0.5 text-[#10B981]">{icon}</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-xs font-medium text-slate-500">{label}</p>
|
||||||
|
<p className="mt-0.5 text-sm text-slate-900">{value}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ShipmentBadge({ status }: { status: ShipmentStatus }) {
|
||||||
|
const styles: Record<ShipmentStatus, string> = {
|
||||||
|
"In Transit": "bg-indigo-100 text-indigo-700",
|
||||||
|
Delivered: "bg-emerald-100 text-emerald-700",
|
||||||
|
Delayed: "bg-red-100 text-red-700",
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`inline-flex rounded-full px-2.5 py-0.5 text-xs font-medium ${styles[status]}`}
|
||||||
|
>
|
||||||
|
{status}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BookingBadge({ status }: { status: BookingStatus }) {
|
||||||
|
const styles: Record<BookingStatus, string> = {
|
||||||
|
Pending: "bg-amber-100 text-amber-700",
|
||||||
|
Confirmed: "bg-sky-100 text-sky-700",
|
||||||
|
"In Transit": "bg-indigo-100 text-indigo-700",
|
||||||
|
Delivered: "bg-emerald-100 text-emerald-700",
|
||||||
|
Cancelled: "bg-red-100 text-red-700",
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`inline-flex rounded-full px-2.5 py-0.5 text-xs font-medium ${styles[status]}`}
|
||||||
|
>
|
||||||
|
{status}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InvoiceBadge({ status }: { status: InvoiceStatus }) {
|
||||||
|
const styles: Record<InvoiceStatus, string> = {
|
||||||
|
Draft: "bg-slate-100 text-slate-600",
|
||||||
|
Sent: "bg-sky-100 text-sky-700",
|
||||||
|
Paid: "bg-emerald-100 text-emerald-700",
|
||||||
|
Overdue: "bg-red-100 text-red-700",
|
||||||
|
Cancelled: "bg-amber-100 text-amber-700",
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`inline-flex rounded-full px-2.5 py-0.5 text-xs font-medium ${styles[status]}`}
|
||||||
|
>
|
||||||
|
{status}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -13,12 +13,12 @@ import { Input } from "@/components/ui/input";
|
|||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
import { bookings } from "../bookings/bookings.mock";
|
import { consignments } from "../consignments/consignments.mock";
|
||||||
import type { ShipmentMode, ShipmentStatus } from "./shipments.mock";
|
import type { ShipmentMode, ShipmentStatus } from "./shipments.mock";
|
||||||
|
|
||||||
export interface ShipmentFormData {
|
export interface ShipmentFormData {
|
||||||
bookingId?: number;
|
consignmentId?: number;
|
||||||
bookingReference?: string;
|
consignmentReference?: string;
|
||||||
originStation?: string;
|
originStation?: string;
|
||||||
destinationStation?: string;
|
destinationStation?: string;
|
||||||
mode?: ShipmentMode;
|
mode?: ShipmentMode;
|
||||||
@@ -34,7 +34,7 @@ export interface NewShipmentPageProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const selectClass =
|
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-[#33578D]/50 focus:ring-2 focus:ring-[#33578D]/20";
|
"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";
|
||||||
|
|
||||||
export default function NewShipmentPage({
|
export default function NewShipmentPage({
|
||||||
mode = "create",
|
mode = "create",
|
||||||
@@ -61,23 +61,26 @@ export default function NewShipmentPage({
|
|||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="grid gap-5 py-4 md:grid-cols-2">
|
<div className="grid gap-5 py-4 md:grid-cols-2">
|
||||||
{/* Freight Booking */}
|
{/* Consignment */}
|
||||||
<div className="space-y-2 md:col-span-2">
|
<div className="space-y-2 md:col-span-2">
|
||||||
<Label>Freight Booking *</Label>
|
<Label>Consignment *</Label>
|
||||||
<select
|
<select
|
||||||
defaultValue={shipment?.bookingId ?? ""}
|
defaultValue={shipment?.consignmentId ?? ""}
|
||||||
className={selectClass}
|
className={selectClass}
|
||||||
>
|
>
|
||||||
<option value="" disabled>
|
<option value="" disabled>
|
||||||
Select freight booking
|
Select consignment
|
||||||
</option>
|
</option>
|
||||||
{bookings.map((b) => (
|
{consignments.map((c) => (
|
||||||
<option key={b.id} value={b.id}>
|
<option key={c.id} value={c.id}>
|
||||||
{b.reference} — {b.customer} ({b.originStation} →{" "}
|
{c.trackingNumber} — {c.customer} ({c.originStation} →{" "}
|
||||||
{b.destinationStation})
|
{c.destinationStation})
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
Origin and destination auto-fill from the linked consignment.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Status */}
|
{/* Status */}
|
||||||
@@ -113,7 +116,7 @@ export default function NewShipmentPage({
|
|||||||
<MapPin className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
|
<MapPin className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
|
||||||
<Input
|
<Input
|
||||||
defaultValue={shipment?.originStation ?? ""}
|
defaultValue={shipment?.originStation ?? ""}
|
||||||
placeholder="Auto-filled from booking"
|
placeholder="Auto-filled from consignment"
|
||||||
className="pl-10"
|
className="pl-10"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -126,7 +129,7 @@ export default function NewShipmentPage({
|
|||||||
<MapPin className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
|
<MapPin className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
|
||||||
<Input
|
<Input
|
||||||
defaultValue={shipment?.destinationStation ?? ""}
|
defaultValue={shipment?.destinationStation ?? ""}
|
||||||
placeholder="Auto-filled from booking"
|
placeholder="Auto-filled from consignment"
|
||||||
className="pl-10"
|
className="pl-10"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -161,7 +164,7 @@ export default function NewShipmentPage({
|
|||||||
|
|
||||||
<div className="flex justify-end gap-3">
|
<div className="flex justify-end gap-3">
|
||||||
<Button variant="outline">Cancel</Button>
|
<Button variant="outline">Cancel</Button>
|
||||||
<Button className="bg-[#33578D] text-white hover:bg-[#33578D]/90">
|
<Button className="bg-[#10B981] text-white hover:bg-[#10B981]/90">
|
||||||
{submitLabel}
|
{submitLabel}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ export default function TrackingPage() {
|
|||||||
if (!q) return true;
|
if (!q) return true;
|
||||||
return (
|
return (
|
||||||
s.reference.toLowerCase().includes(q) ||
|
s.reference.toLowerCase().includes(q) ||
|
||||||
|
s.consignmentReference.toLowerCase().includes(q) ||
|
||||||
s.bookingReference.toLowerCase().includes(q) ||
|
s.bookingReference.toLowerCase().includes(q) ||
|
||||||
s.customer.toLowerCase().includes(q) ||
|
s.customer.toLowerCase().includes(q) ||
|
||||||
s.originStation.toLowerCase().includes(q) ||
|
s.originStation.toLowerCase().includes(q) ||
|
||||||
@@ -74,14 +75,14 @@ export default function TrackingPage() {
|
|||||||
value={query}
|
value={query}
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
placeholder="Search shipments..."
|
placeholder="Search shipments..."
|
||||||
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-[#33578D]/50 focus:ring-2 focus:ring-[#33578D]/20"
|
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>
|
</div>
|
||||||
|
|
||||||
<NewShipmentPage>
|
<NewShipmentPage>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="inline-flex items-center justify-center gap-2 rounded-2xl bg-[#33578D] px-4 py-2 text-sm font-medium text-white transition hover:bg-[#33578D]/90"
|
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" />
|
<Plus className="h-4 w-4" />
|
||||||
New Shipment
|
New Shipment
|
||||||
@@ -106,8 +107,8 @@ export default function TrackingPage() {
|
|||||||
onClick={() => setFilter(f)}
|
onClick={() => setFilter(f)}
|
||||||
className={
|
className={
|
||||||
isActive
|
isActive
|
||||||
? "inline-flex items-center gap-2 rounded-2xl bg-[#33578D] px-4 py-2 text-sm font-medium text-white"
|
? "inline-flex items-center gap-2 rounded-2xl bg-[#10B981] px-4 py-2 text-sm font-medium text-white"
|
||||||
: "inline-flex items-center gap-2 rounded-2xl px-4 py-2 text-sm font-medium text-slate-600 transition hover:bg-[#33578D]/10 hover:text-[#33578D]"
|
: "inline-flex items-center gap-2 rounded-2xl px-4 py-2 text-sm font-medium text-slate-600 transition hover:bg-[#10B981]/10 hover:text-[#10B981]"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{f}
|
{f}
|
||||||
@@ -185,8 +186,8 @@ function ViewToggleButton({
|
|||||||
aria-pressed={active}
|
aria-pressed={active}
|
||||||
className={
|
className={
|
||||||
active
|
active
|
||||||
? "inline-flex items-center gap-2 rounded-lg bg-white px-3 py-1.5 text-sm font-medium text-[#33578D] shadow-sm"
|
? "inline-flex items-center gap-2 rounded-lg bg-white px-3 py-1.5 text-sm font-medium text-[#10B981] shadow-sm"
|
||||||
: "inline-flex items-center gap-2 rounded-lg px-3 py-1.5 text-sm font-medium text-slate-600 transition hover:text-[#33578D]"
|
: "inline-flex items-center gap-2 rounded-lg px-3 py-1.5 text-sm font-medium text-slate-600 transition hover:text-[#10B981]"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
@@ -196,21 +197,21 @@ function ViewToggleButton({
|
|||||||
|
|
||||||
function ShipmentCard({ shipment }: { shipment: Shipment }) {
|
function ShipmentCard({ shipment }: { shipment: Shipment }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4 rounded-3xl bg-white p-5 shadow-sm transition hover:shadow-md hover:ring-1 hover:ring-[#33578D]/20">
|
<div className="flex flex-col gap-4 rounded-3xl bg-white p-5 shadow-sm transition hover:shadow-md hover:ring-1 hover:ring-[#10B981]/20">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex flex-col gap-0.5">
|
<div className="flex flex-col gap-0.5">
|
||||||
<span className="text-base font-bold text-slate-900">
|
<span className="text-base font-bold text-slate-900">
|
||||||
{shipment.reference}
|
{shipment.reference}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-slate-500">
|
<span className="text-xs text-slate-500">
|
||||||
Booking {shipment.bookingReference}
|
Consignment {shipment.consignmentReference}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<StatusBadge status={shipment.status} />
|
<StatusBadge status={shipment.status} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Route */}
|
{/* Route */}
|
||||||
<div className="flex items-center justify-between rounded-2xl bg-[#33578D]/5 p-3">
|
<div className="flex items-center justify-between rounded-2xl bg-[#10B981]/5 p-3">
|
||||||
<div className="text-sm">
|
<div className="text-sm">
|
||||||
<p className="text-xs font-medium uppercase tracking-wide text-slate-500">
|
<p className="text-xs font-medium uppercase tracking-wide text-slate-500">
|
||||||
From
|
From
|
||||||
@@ -219,7 +220,7 @@ function ShipmentCard({ shipment }: { shipment: Shipment }) {
|
|||||||
{shipment.originStation}
|
{shipment.originStation}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<ArrowRight className="h-5 w-5 text-[#33578D]" />
|
<ArrowRight className="h-5 w-5 text-[#10B981]" />
|
||||||
<div className="text-sm text-right">
|
<div className="text-sm text-right">
|
||||||
<p className="text-xs font-medium uppercase tracking-wide text-slate-500">
|
<p className="text-xs font-medium uppercase tracking-wide text-slate-500">
|
||||||
To
|
To
|
||||||
@@ -240,7 +241,7 @@ function ShipmentCard({ shipment }: { shipment: Shipment }) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="h-1.5 w-full overflow-hidden rounded-full bg-slate-100">
|
<div className="h-1.5 w-full overflow-hidden rounded-full bg-slate-100">
|
||||||
<div
|
<div
|
||||||
className="h-full rounded-full bg-[#33578D] transition-all"
|
className="h-full rounded-full bg-[#10B981] transition-all"
|
||||||
style={{ width: `${shipment.progress}%` }}
|
style={{ width: `${shipment.progress}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -249,7 +250,7 @@ function ShipmentCard({ shipment }: { shipment: Shipment }) {
|
|||||||
{/* Meta */}
|
{/* Meta */}
|
||||||
<div className="space-y-2 text-sm text-slate-700">
|
<div className="space-y-2 text-sm text-slate-700">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Building2 className="h-4 w-4 text-[#33578D]" />
|
<Building2 className="h-4 w-4 text-[#10B981]" />
|
||||||
<span>{shipment.customer}</span>
|
<span>{shipment.customer}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -257,7 +258,7 @@ function ShipmentCard({ shipment }: { shipment: Shipment }) {
|
|||||||
<span className="capitalize">{shipment.mode}</span>
|
<span className="capitalize">{shipment.mode}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<MapPin className="h-4 w-4 text-[#33578D]" />
|
<MapPin className="h-4 w-4 text-[#10B981]" />
|
||||||
<span>{shipment.currentLocation}</span>
|
<span>{shipment.currentLocation}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 text-xs text-slate-500">
|
<div className="flex items-center gap-2 text-xs text-slate-500">
|
||||||
@@ -285,7 +286,7 @@ function ShipmentCard({ shipment }: { shipment: Shipment }) {
|
|||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="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-[#33578D]/30 hover:bg-[#33578D]/10 hover:text-[#33578D]"
|
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]"
|
||||||
>
|
>
|
||||||
<Pencil className="h-3.5 w-3.5" />
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
Edit
|
Edit
|
||||||
@@ -329,11 +330,11 @@ function ShipmentTable({ shipments: rows }: { shipments: Shipment[] }) {
|
|||||||
{rows.map((shipment) => (
|
{rows.map((shipment) => (
|
||||||
<tr
|
<tr
|
||||||
key={shipment.id}
|
key={shipment.id}
|
||||||
className="border-t border-slate-100 transition hover:bg-[#33578D]/5"
|
className="border-t border-slate-100 transition hover:bg-[#10B981]/5"
|
||||||
>
|
>
|
||||||
<td className="px-6 py-4">
|
<td className="px-6 py-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-[#33578D]/10 text-[#33578D]">
|
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-[#10B981] text-white">
|
||||||
<Truck className="h-5 w-5" />
|
<Truck className="h-5 w-5" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -374,7 +375,7 @@ function ShipmentTable({ shipments: rows }: { shipments: Shipment[] }) {
|
|||||||
<div className="flex w-32 items-center gap-2">
|
<div className="flex w-32 items-center gap-2">
|
||||||
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-slate-100">
|
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-slate-100">
|
||||||
<div
|
<div
|
||||||
className="h-full rounded-full bg-[#33578D]"
|
className="h-full rounded-full bg-[#10B981]"
|
||||||
style={{ width: `${shipment.progress}%` }}
|
style={{ width: `${shipment.progress}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -386,7 +387,7 @@ function ShipmentTable({ shipments: rows }: { shipments: Shipment[] }) {
|
|||||||
|
|
||||||
<td className="px-6 py-4 text-sm text-slate-700">
|
<td className="px-6 py-4 text-sm text-slate-700">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<MapPin className="h-3.5 w-3.5 text-[#33578D]" />
|
<MapPin className="h-3.5 w-3.5 text-[#10B981]" />
|
||||||
{shipment.currentLocation}
|
{shipment.currentLocation}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@@ -412,7 +413,7 @@ function ShipmentTable({ shipments: rows }: { shipments: Shipment[] }) {
|
|||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="rounded-xl border border-slate-200 p-2 text-slate-600 transition hover:border-[#33578D]/30 hover:bg-[#33578D]/10 hover:text-[#33578D]"
|
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" />
|
<Pencil className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
@@ -440,9 +441,9 @@ function ShipmentTable({ shipments: rows }: { shipments: Shipment[] }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ModeIcon({ mode }: { mode: ShipmentMode }) {
|
function ModeIcon({ mode }: { mode: ShipmentMode }) {
|
||||||
if (mode === "rail") return <Train className="h-4 w-4 text-[#33578D]" />;
|
if (mode === "rail") return <Train className="h-4 w-4 text-[#10B981]" />;
|
||||||
if (mode === "truck") return <Truck className="h-4 w-4 text-[#33578D]" />;
|
if (mode === "truck") return <Truck className="h-4 w-4 text-[#10B981]" />;
|
||||||
return <Train className="h-4 w-4 text-[#33578D]" />;
|
return <Train className="h-4 w-4 text-[#10B981]" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function StatusBadge({ status }: { status: ShipmentStatus }) {
|
function StatusBadge({ status }: { status: ShipmentStatus }) {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { bookings } from "../bookings/bookings.mock";
|
import { consignments } from "../consignments/consignments.mock";
|
||||||
|
|
||||||
export type ShipmentStatus = "In Transit" | "Delivered" | "Delayed";
|
export type ShipmentStatus = "In Transit" | "Delivered" | "Delayed";
|
||||||
export type ShipmentMode = "rail" | "truck" | "multimodal";
|
export type ShipmentMode = "rail" | "truck" | "multimodal";
|
||||||
@@ -6,7 +6,8 @@ export type ShipmentMode = "rail" | "truck" | "multimodal";
|
|||||||
export interface Shipment {
|
export interface Shipment {
|
||||||
id: number;
|
id: number;
|
||||||
reference: string;
|
reference: string;
|
||||||
bookingId: number;
|
consignmentId: number;
|
||||||
|
consignmentReference: string;
|
||||||
bookingReference: string;
|
bookingReference: string;
|
||||||
customer: string;
|
customer: string;
|
||||||
originStation: string;
|
originStation: string;
|
||||||
@@ -126,16 +127,19 @@ const seedShipments: Array<{
|
|||||||
];
|
];
|
||||||
|
|
||||||
export const shipments: Shipment[] = seedShipments.map((entry, i) => {
|
export const shipments: Shipment[] = seedShipments.map((entry, i) => {
|
||||||
const booking = bookings[i % bookings.length] as (typeof bookings)[number];
|
const consignment = consignments[
|
||||||
|
i % consignments.length
|
||||||
|
] as (typeof consignments)[number];
|
||||||
const id = i + 1;
|
const id = i + 1;
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
reference: `SH-${String(id).padStart(3, "0")}`,
|
reference: `SH-${String(id).padStart(3, "0")}`,
|
||||||
bookingId: booking.id,
|
consignmentId: consignment.id,
|
||||||
bookingReference: booking.reference,
|
consignmentReference: consignment.trackingNumber,
|
||||||
customer: booking.customer,
|
bookingReference: consignment.bookingReference,
|
||||||
originStation: booking.originStation,
|
customer: consignment.customer,
|
||||||
destinationStation: booking.destinationStation,
|
originStation: consignment.originStation,
|
||||||
|
destinationStation: consignment.destinationStation,
|
||||||
mode: entry.mode,
|
mode: entry.mode,
|
||||||
status: entry.status,
|
status: entry.status,
|
||||||
currentLocation: entry.currentLocation,
|
currentLocation: entry.currentLocation,
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export interface NewTrainPageProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const selectClass =
|
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-[#33578D]/50 focus:ring-2 focus:ring-[#33578D]/20";
|
"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";
|
||||||
|
|
||||||
export default function NewTrainPage({
|
export default function NewTrainPage({
|
||||||
mode = "create",
|
mode = "create",
|
||||||
@@ -230,7 +230,7 @@ export default function NewTrainPage({
|
|||||||
|
|
||||||
<div className="flex justify-end gap-3">
|
<div className="flex justify-end gap-3">
|
||||||
<Button variant="outline">Cancel</Button>
|
<Button variant="outline">Cancel</Button>
|
||||||
<Button className="bg-[#33578D] text-white hover:bg-[#33578D]/90">
|
<Button className="bg-[#10B981] text-white hover:bg-[#10B981]/90">
|
||||||
{submitLabel}
|
{submitLabel}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -93,14 +93,14 @@ export default function TrainsPage() {
|
|||||||
setPage(1);
|
setPage(1);
|
||||||
}}
|
}}
|
||||||
placeholder="Search trains..."
|
placeholder="Search trains..."
|
||||||
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-[#33578D]/50 focus:ring-2 focus:ring-[#33578D]/20"
|
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>
|
</div>
|
||||||
|
|
||||||
<NewTrainPage>
|
<NewTrainPage>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="inline-flex items-center justify-center gap-2 rounded-2xl bg-[#33578D] px-4 py-2 text-sm font-medium text-white transition hover:bg-[#33578D]/90"
|
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" />
|
<Plus className="h-4 w-4" />
|
||||||
New Train
|
New Train
|
||||||
@@ -147,8 +147,8 @@ export default function TrainsPage() {
|
|||||||
}}
|
}}
|
||||||
className={
|
className={
|
||||||
isActive
|
isActive
|
||||||
? "inline-flex items-center gap-2 rounded-2xl bg-[#33578D] px-4 py-2 text-sm font-medium text-white"
|
? "inline-flex items-center gap-2 rounded-2xl bg-[#10B981] px-4 py-2 text-sm font-medium text-white"
|
||||||
: "inline-flex items-center gap-2 rounded-2xl px-4 py-2 text-sm font-medium text-slate-600 transition hover:bg-[#33578D]/10 hover:text-[#33578D]"
|
: "inline-flex items-center gap-2 rounded-2xl px-4 py-2 text-sm font-medium text-slate-600 transition hover:bg-[#10B981]/10 hover:text-[#10B981]"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{f}
|
{f}
|
||||||
@@ -179,7 +179,7 @@ export default function TrainsPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</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-[#33578D]/30 hover:bg-[#33578D]/10 hover:text-[#33578D]">
|
<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 className="h-4 w-4" />
|
||||||
Filter
|
Filter
|
||||||
</button>
|
</button>
|
||||||
@@ -213,11 +213,11 @@ export default function TrainsPage() {
|
|||||||
paginated.map((train) => (
|
paginated.map((train) => (
|
||||||
<tr
|
<tr
|
||||||
key={train.id}
|
key={train.id}
|
||||||
className="border-t border-slate-100 transition hover:bg-[#33578D]/5"
|
className="border-t border-slate-100 transition hover:bg-[#10B981]/5"
|
||||||
>
|
>
|
||||||
<td className="px-6 py-4">
|
<td className="px-6 py-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-[#33578D]/10 text-[#33578D]">
|
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-[#10B981] text-white">
|
||||||
<TrainIcon className="h-5 w-5" />
|
<TrainIcon className="h-5 w-5" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -272,7 +272,7 @@ export default function TrainsPage() {
|
|||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="rounded-xl border border-slate-200 p-2 text-slate-600 transition hover:border-[#33578D]/30 hover:bg-[#33578D]/10 hover:text-[#33578D]"
|
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" />
|
<Pencil className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
@@ -345,7 +345,7 @@ function Pagination({
|
|||||||
id="train-page-size"
|
id="train-page-size"
|
||||||
value={pageSize}
|
value={pageSize}
|
||||||
onChange={(e) => onPageSizeChange(Number(e.target.value))}
|
onChange={(e) => onPageSizeChange(Number(e.target.value))}
|
||||||
className="h-9 rounded-xl border border-slate-200 bg-white px-3 text-sm text-slate-700 outline-none transition focus:border-[#33578D]/50 focus:ring-2 focus:ring-[#33578D]/20"
|
className="h-9 rounded-xl border border-slate-200 bg-white px-3 text-sm text-slate-700 outline-none transition focus:border-[#10B981]/50 focus:ring-2 focus:ring-[#10B981]/20"
|
||||||
>
|
>
|
||||||
{PAGE_SIZE_OPTIONS.map((size) => (
|
{PAGE_SIZE_OPTIONS.map((size) => (
|
||||||
<option key={size} value={size}>
|
<option key={size} value={size}>
|
||||||
@@ -363,7 +363,7 @@ function Pagination({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => onPageChange(page - 1)}
|
onClick={() => onPageChange(page - 1)}
|
||||||
disabled={page <= 1}
|
disabled={page <= 1}
|
||||||
className="inline-flex h-9 items-center gap-1 rounded-xl border border-slate-200 px-3 text-sm font-medium text-slate-700 transition hover:border-[#33578D]/30 hover:bg-[#33578D]/10 hover:text-[#33578D] disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent disabled:hover:text-slate-700"
|
className="inline-flex h-9 items-center gap-1 rounded-xl border border-slate-200 px-3 text-sm font-medium text-slate-700 transition hover:border-[#10B981]/30 hover:bg-[#10B981]/10 hover:text-[#10B981] disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent disabled:hover:text-slate-700"
|
||||||
>
|
>
|
||||||
<ChevronLeft className="h-4 w-4" />
|
<ChevronLeft className="h-4 w-4" />
|
||||||
Prev
|
Prev
|
||||||
@@ -379,8 +379,8 @@ function Pagination({
|
|||||||
aria-current={isActive ? "page" : undefined}
|
aria-current={isActive ? "page" : undefined}
|
||||||
className={
|
className={
|
||||||
isActive
|
isActive
|
||||||
? "h-9 w-9 rounded-xl bg-[#33578D] text-sm font-medium text-white"
|
? "h-9 w-9 rounded-xl bg-[#10B981] text-sm font-medium text-white"
|
||||||
: "h-9 w-9 rounded-xl border border-slate-200 text-sm font-medium text-slate-700 transition hover:border-[#33578D]/30 hover:bg-[#33578D]/10 hover:text-[#33578D]"
|
: "h-9 w-9 rounded-xl border border-slate-200 text-sm font-medium text-slate-700 transition hover:border-[#10B981]/30 hover:bg-[#10B981]/10 hover:text-[#10B981]"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{p}
|
{p}
|
||||||
@@ -392,7 +392,7 @@ function Pagination({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => onPageChange(page + 1)}
|
onClick={() => onPageChange(page + 1)}
|
||||||
disabled={page >= totalPages}
|
disabled={page >= totalPages}
|
||||||
className="inline-flex h-9 items-center gap-1 rounded-xl border border-slate-200 px-3 text-sm font-medium text-slate-700 transition hover:border-[#33578D]/30 hover:bg-[#33578D]/10 hover:text-[#33578D] disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent disabled:hover:text-slate-700"
|
className="inline-flex h-9 items-center gap-1 rounded-xl border border-slate-200 px-3 text-sm font-medium text-slate-700 transition hover:border-[#10B981]/30 hover:bg-[#10B981]/10 hover:text-[#10B981] disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent disabled:hover:text-slate-700"
|
||||||
>
|
>
|
||||||
Next
|
Next
|
||||||
<ChevronRight className="h-4 w-4" />
|
<ChevronRight className="h-4 w-4" />
|
||||||
@@ -412,13 +412,13 @@ function StatCard({
|
|||||||
icon: React.ReactNode;
|
icon: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-3xl bg-white p-6 shadow-sm transition hover:shadow-md hover:ring-1 hover:ring-[#33578D]/20">
|
<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 className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-slate-500">{title}</p>
|
<p className="text-sm text-slate-500">{title}</p>
|
||||||
<h3 className="mt-2 text-3xl font-bold text-slate-900">{value}</h3>
|
<h3 className="mt-2 text-3xl font-bold text-slate-900">{value}</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-[#33578D]/10 text-[#33578D]">
|
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-[#10B981] text-white">
|
||||||
{icon}
|
{icon}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
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";
|
import axios from "axios";
|
||||||
|
|
||||||
export const api = axios.create({
|
export const client = axios.create({
|
||||||
baseURL: import.meta.env.VITE_API_URL,
|
baseURL: import.meta.env.VITE_API_URL,
|
||||||
});
|
});
|
||||||
|
|
||||||
api.interceptors.request.use((config) => {
|
client.interceptors.request.use((config) => {
|
||||||
const token = document.cookie
|
const token = document.cookie
|
||||||
.split("; ")
|
.split("; ")
|
||||||
.find((row) => row.startsWith("auth-token="))
|
.find((row) => row.startsWith("auth-token="))
|
||||||
@@ -15,7 +15,7 @@ api.interceptors.request.use((config) => {
|
|||||||
return config;
|
return config;
|
||||||
});
|
});
|
||||||
|
|
||||||
api.interceptors.response.use(
|
client.interceptors.response.use(
|
||||||
(response) => response,
|
(response) => response,
|
||||||
(error) => {
|
(error) => {
|
||||||
if (error.response?.status === 401) {
|
if (error.response?.status === 401) {
|
||||||
|
|||||||
89
packages/types/src/freight/file_upload_settings.ts
Normal file
89
packages/types/src/freight/file_upload_settings.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import type { BaseEntity } from "../common";
|
||||||
|
|
||||||
|
export type FileUploadEntity =
|
||||||
|
| "customer"
|
||||||
|
| "booking"
|
||||||
|
| "consignment"
|
||||||
|
| "shipment"
|
||||||
|
| "invoice"
|
||||||
|
| "train"
|
||||||
|
| "other";
|
||||||
|
|
||||||
|
export interface IFileUploadField extends BaseEntity {
|
||||||
|
settingId: string;
|
||||||
|
/** Stable identifier used by the API / object storage. */
|
||||||
|
fileKey: string;
|
||||||
|
/** Human-readable label rendered above the input. */
|
||||||
|
fileLabel: string;
|
||||||
|
helpText?: string | null;
|
||||||
|
isRequired: boolean;
|
||||||
|
isMultiple: boolean;
|
||||||
|
/** Upper bound on file count when isMultiple is true. */
|
||||||
|
maxFiles: number;
|
||||||
|
allowedExtensions: string[];
|
||||||
|
maxSizeMb: number;
|
||||||
|
order: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IFileUploadSetting extends BaseEntity {
|
||||||
|
/** Stable code referenced from forms (snake_case). */
|
||||||
|
code: string;
|
||||||
|
/** Display label for admins. */
|
||||||
|
label: string;
|
||||||
|
description?: string | null;
|
||||||
|
entity: FileUploadEntity;
|
||||||
|
fields: IFileUploadField[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ *
|
||||||
|
* Wire DTOs (shared between API and frontend)
|
||||||
|
* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
export interface CreateFileUploadFieldDto {
|
||||||
|
fileKey: string;
|
||||||
|
fileLabel: string;
|
||||||
|
helpText?: string;
|
||||||
|
isRequired: boolean;
|
||||||
|
isMultiple: boolean;
|
||||||
|
maxFiles: number;
|
||||||
|
allowedExtensions: string[];
|
||||||
|
maxSizeMb: number;
|
||||||
|
order?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UpdateFileUploadFieldDto = Partial<CreateFileUploadFieldDto>;
|
||||||
|
|
||||||
|
export interface CreateFileUploadSettingDto {
|
||||||
|
code: string;
|
||||||
|
label: string;
|
||||||
|
description?: string;
|
||||||
|
entity: FileUploadEntity;
|
||||||
|
fields?: CreateFileUploadFieldDto[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UpdateFileUploadSettingDto = Partial<
|
||||||
|
Omit<CreateFileUploadSettingDto, "fields">
|
||||||
|
>;
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ *
|
||||||
|
* Helpers — encode the required × multiple matrix.
|
||||||
|
*
|
||||||
|
* 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<IFileUploadField, "isRequired">,
|
||||||
|
): number {
|
||||||
|
return field.isRequired ? 1 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getEffectiveMaxFiles(
|
||||||
|
field: Pick<IFileUploadField, "isMultiple" | "maxFiles">,
|
||||||
|
): number {
|
||||||
|
return field.isMultiple ? Math.max(1, field.maxFiles) : 1;
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import type { BaseEntity } from "../common";
|
import type { BaseEntity } from "../common";
|
||||||
|
|
||||||
|
export * from "./file_upload_settings";
|
||||||
|
|
||||||
export enum BookingStatus {
|
export enum BookingStatus {
|
||||||
Draft = "DRAFT",
|
Draft = "DRAFT",
|
||||||
Confirmed = "CONFIRMED",
|
Confirmed = "CONFIRMED",
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ function getInitialTheme(): Theme {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const iconButtonClass =
|
const iconButtonClass =
|
||||||
"inline-flex h-10 w-10 items-center justify-center rounded-xl border border-slate-200 text-slate-600 transition hover:border-[#33578D]/30 hover:bg-[#33578D]/10 hover:text-[#33578D] dark:border-slate-700 dark:text-slate-300 dark:hover:border-[#33578D]/40 dark:hover:bg-[#33578D]/20 dark:hover:text-white";
|
"inline-flex h-10 w-10 items-center justify-center rounded-xl border border-slate-200 text-slate-600 transition hover:border-[#10B981]/30 hover:bg-[#10B981]/10 hover:text-[#10B981] dark:border-slate-700 dark:text-slate-300 dark:hover:border-[#10B981]/40 dark:hover:bg-[#10B981]/20 dark:hover:text-white";
|
||||||
|
|
||||||
const DashboardLayout = ({
|
const DashboardLayout = ({
|
||||||
title,
|
title,
|
||||||
@@ -112,7 +112,7 @@ const DashboardLayout = ({
|
|||||||
aria-label={
|
aria-label={
|
||||||
theme === "dark" ? "Switch to light mode" : "Switch to dark mode"
|
theme === "dark" ? "Switch to light mode" : "Switch to dark mode"
|
||||||
}
|
}
|
||||||
className="inline-flex h-8 w-8 items-center justify-center rounded-lg text-slate-600 transition hover:bg-[#33578D]/10 hover:text-[#33578D] dark:text-slate-300 dark:hover:bg-[#33578D]/20 dark:hover:text-white"
|
className="inline-flex h-8 w-8 items-center justify-center rounded-lg text-slate-600 transition hover:bg-[#10B981]/10 hover:text-[#10B981] dark:text-slate-300 dark:hover:bg-[#10B981]/20 dark:hover:text-white"
|
||||||
>
|
>
|
||||||
{theme === "dark" ? (
|
{theme === "dark" ? (
|
||||||
<Sun className="h-4 w-4" />
|
<Sun className="h-4 w-4" />
|
||||||
@@ -123,7 +123,7 @@ const DashboardLayout = ({
|
|||||||
) : null;
|
) : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen bg-slate-50 dark:bg-slate-950">
|
<div className="flex h-screen overflow-hidden bg-slate-50 dark:bg-slate-950">
|
||||||
<Sidebar
|
<Sidebar
|
||||||
title={title}
|
title={title}
|
||||||
items={sidebarItems}
|
items={sidebarItems}
|
||||||
@@ -131,7 +131,7 @@ const DashboardLayout = ({
|
|||||||
onNavigate={onNavigate}
|
onNavigate={onNavigate}
|
||||||
headerExtra={themeToggleButton}
|
headerExtra={themeToggleButton}
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-1 flex-col">
|
<div className="flex min-w-0 flex-1 flex-col">
|
||||||
<header className="flex h-16 items-center justify-between border-b border-slate-200 bg-white px-6 dark:border-slate-800 dark:bg-slate-900">
|
<header className="flex h-16 items-center justify-between border-b border-slate-200 bg-white px-6 dark:border-slate-800 dark:bg-slate-900">
|
||||||
<div className="text-base font-medium text-slate-700 dark:text-slate-200">
|
<div className="text-base font-medium text-slate-700 dark:text-slate-200">
|
||||||
{title}
|
{title}
|
||||||
@@ -161,9 +161,9 @@ const DashboardLayout = ({
|
|||||||
aria-haspopup="menu"
|
aria-haspopup="menu"
|
||||||
aria-expanded={isUserMenuOpen}
|
aria-expanded={isUserMenuOpen}
|
||||||
onClick={() => setIsUserMenuOpen((open) => !open)}
|
onClick={() => setIsUserMenuOpen((open) => !open)}
|
||||||
className="flex items-center gap-2 rounded-xl border border-transparent px-2 py-1.5 transition hover:border-[#33578D]/20 hover:bg-[#33578D]/5 aria-expanded:border-[#33578D]/30 aria-expanded:bg-[#33578D]/10 dark:hover:border-[#33578D]/30 dark:hover:bg-[#33578D]/10 dark:aria-expanded:border-[#33578D]/40 dark:aria-expanded:bg-[#33578D]/20"
|
className="flex items-center gap-2 rounded-xl border border-transparent px-2 py-1.5 transition hover:border-[#10B981]/20 hover:bg-[#10B981]/5 aria-expanded:border-[#10B981]/30 aria-expanded:bg-[#10B981]/10 dark:hover:border-[#10B981]/30 dark:hover:bg-[#10B981]/10 dark:aria-expanded:border-[#10B981]/40 dark:aria-expanded:bg-[#10B981]/20"
|
||||||
>
|
>
|
||||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-[#33578D] text-xs font-semibold text-white">
|
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-[#10B981] text-xs font-semibold text-white">
|
||||||
{initials}
|
{initials}
|
||||||
</div>
|
</div>
|
||||||
<span className="hidden text-sm font-medium text-slate-700 md:block dark:text-slate-200">
|
<span className="hidden text-sm font-medium text-slate-700 md:block dark:text-slate-200">
|
||||||
@@ -171,7 +171,7 @@ const DashboardLayout = ({
|
|||||||
</span>
|
</span>
|
||||||
<ChevronDown
|
<ChevronDown
|
||||||
className={`h-4 w-4 text-slate-400 transition dark:text-slate-500 ${
|
className={`h-4 w-4 text-slate-400 transition dark:text-slate-500 ${
|
||||||
isUserMenuOpen ? "rotate-180 text-[#33578D]" : ""
|
isUserMenuOpen ? "rotate-180 text-[#10B981]" : ""
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
@@ -195,7 +195,7 @@ const DashboardLayout = ({
|
|||||||
href="#profile"
|
href="#profile"
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
onClick={() => setIsUserMenuOpen(false)}
|
onClick={() => setIsUserMenuOpen(false)}
|
||||||
className="flex items-center gap-2 px-4 py-2 text-sm text-slate-700 transition hover:bg-[#33578D]/10 hover:text-[#33578D] dark:text-slate-300 dark:hover:bg-[#33578D]/20 dark:hover:text-white"
|
className="flex items-center gap-2 px-4 py-2 text-sm text-slate-700 transition hover:bg-[#10B981]/10 hover:text-[#10B981] dark:text-slate-300 dark:hover:bg-[#10B981]/20 dark:hover:text-white"
|
||||||
>
|
>
|
||||||
<User className="h-4 w-4" />
|
<User className="h-4 w-4" />
|
||||||
Profile
|
Profile
|
||||||
|
|||||||
@@ -15,6 +15,12 @@ export interface SidebarProps {
|
|||||||
headerExtra?: ReactNode;
|
headerExtra?: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Brand palette
|
||||||
|
* #10B981 — dominant dark green → brand mark, active state, hover text
|
||||||
|
* #10B981 — secondary green → hover background tint
|
||||||
|
* #10B981 — muted green tone → sidebar canvas
|
||||||
|
*/
|
||||||
const Sidebar = ({
|
const Sidebar = ({
|
||||||
title,
|
title,
|
||||||
items,
|
items,
|
||||||
@@ -22,11 +28,11 @@ const Sidebar = ({
|
|||||||
onNavigate,
|
onNavigate,
|
||||||
headerExtra,
|
headerExtra,
|
||||||
}: SidebarProps) => (
|
}: SidebarProps) => (
|
||||||
<aside className="flex w-64 flex-col gap-1 border-r border-slate-200 bg-[#33578D]/10 px-3 py-5 dark:border-slate-800 dark:bg-slate-900">
|
<aside className="flex h-full w-64 shrink-0 flex-col gap-1 overflow-y-auto border-r border-slate-200 bg-[#10B981]/10 px-3 py-5 dark:border-slate-800 dark:bg-slate-900">
|
||||||
{title ? (
|
{title ? (
|
||||||
<div className="flex items-center justify-between gap-2 px-3 pb-4">
|
<div className="flex items-center justify-between gap-2 px-3 pb-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-[#33578D] text-sm font-bold text-white">
|
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-[#10B981] text-sm font-bold text-white">
|
||||||
{title.charAt(0)}
|
{title.charAt(0)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-base font-semibold text-slate-800 dark:text-slate-100">
|
<div className="text-base font-semibold text-slate-800 dark:text-slate-100">
|
||||||
@@ -56,8 +62,8 @@ const Sidebar = ({
|
|||||||
className={clsx(
|
className={clsx(
|
||||||
"group flex items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium transition",
|
"group flex items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium transition",
|
||||||
isActive
|
isActive
|
||||||
? "bg-[#33578D] text-white shadow-sm shadow-[#33578D]/20"
|
? "bg-[#10B981] text-white shadow-sm shadow-[#10B981]/25"
|
||||||
: "text-slate-700 hover:bg-[#33578D]/10 hover:text-[#33578D] dark:text-slate-300 dark:hover:bg-[#33578D]/20 dark:hover:text-white",
|
: "text-slate-700 hover:bg-[#10B981]/10 hover:text-[#10B981] dark:text-slate-300 dark:hover:bg-[#10B981]/20 dark:hover:text-white",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{item.icon ? (
|
{item.icon ? (
|
||||||
@@ -66,7 +72,7 @@ const Sidebar = ({
|
|||||||
"flex h-5 w-5 items-center justify-center [&_svg]:h-5 [&_svg]:w-5",
|
"flex h-5 w-5 items-center justify-center [&_svg]:h-5 [&_svg]:w-5",
|
||||||
isActive
|
isActive
|
||||||
? "text-white"
|
? "text-white"
|
||||||
: "text-slate-500 group-hover:text-[#33578D] dark:text-slate-400 dark:group-hover:text-white",
|
: "text-slate-500 group-hover:text-[#10B981] dark:text-slate-400 dark:group-hover:text-white",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{item.icon}
|
{item.icon}
|
||||||
|
|||||||
Reference in New Issue
Block a user