Merge branch 'develop' into ui-comps

This commit is contained in:
Nathnael Wondisha
2026-05-20 11:40:54 +03:00
committed by GitHub
58 changed files with 3251 additions and 227 deletions

View File

@@ -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:
- **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`).
### Auth integration (planned)

View File

@@ -14,6 +14,7 @@ import { CustomersModule } from "./modules/customers/customers.module";
import { TrackingModule } from "./modules/tracking/tracking.module";
import { BillingModule } from "./modules/billing/billing.module";
import { NotificationsModule } from "./modules/notifications/notifications.module";
import { FileUploadSettingsModule } from "./modules/file-upload-settings/file-upload-settings.module";
@Module({
imports: [
@@ -35,6 +36,7 @@ import { NotificationsModule } from "./modules/notifications/notifications.modul
TrackingModule,
BillingModule,
NotificationsModule,
FileUploadSettingsModule,
],
})
export class AppModule implements OnApplicationBootstrap {

View File

@@ -12,7 +12,30 @@ import {
import { AppModule } from "./app.module";
async function bootstrap() {
const app = await NestFactory.create(AppModule, { cors: true });
const app = await NestFactory.create(AppModule);
// Dev CORS: reflect any localhost origin and allow credentials so the
// freight portal (5173), passenger portal (5174), backoffices (5183/5184)
// and any other dev port can call the API with cookies + Authorization.
// For production, restrict `origin` to known FQDNs.
app.enableCors({
origin: true, // reflect request origin
credentials: true,
methods: ["GET", "HEAD", "PUT", "PATCH", "POST", "DELETE", "OPTIONS"],
allowedHeaders: [
"Content-Type",
"Accept",
"Authorization",
"X-Requested-With",
// IAM context headers required by @tria-plc/api-common's JwtGuard
"organization-unit-id",
"delegator-position-id",
"current-project-id",
"current-position-id",
],
exposedHeaders: ["Content-Disposition"],
maxAge: 86400, // cache preflight for 24h to cut chatter in dev
});
app.setGlobalPrefix("api");
app.useGlobalPipes(createValidationPipe());

View File

@@ -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;
}

View File

@@ -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[];
}

View File

@@ -0,0 +1,7 @@
import { PartialType } from "@nestjs/swagger";
import { CreateFileUploadFieldDto } from "./create-file-upload-field.dto";
export class UpdateFileUploadFieldDto extends PartialType(
CreateFileUploadFieldDto,
) {}

View File

@@ -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),
) {}

View File

@@ -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;
}

View File

@@ -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[];
}

View File

@@ -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);
}
}

View File

@@ -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 {}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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>;
}

View File

@@ -16,6 +16,8 @@ import {
Receipt,
FileText,
Settings,
UserCircle,
FileUp,
} from "lucide-react";
import BookingsPage from "./pages/bookings/BookingsPage";
@@ -38,8 +40,11 @@ import CustomerDetailPage from "./pages/customers/CustomerDetailPage";
import NewCustomerPage from "./pages/customers/NewCustomerPage";
import DocumentsPage from "./pages/documents/DocumentsPage";
import DropdownSettingsPage from "./pages/admin/DropdownSettingsPage";
import FileUploadSettingsPage from "./pages/admin/FileUploadSettingsPage";
import MyPortalPage from "./pages/portal/MyPortalPage";
const sidebarItems: SidebarItem[] = [
{ label: "My Portal", href: "/portal", icon: <UserCircle /> },
{ label: "Dashboard", href: "/", icon: <LayoutDashboard /> },
{ label: "Customers", href: "/customers", icon: <Users /> },
{ label: "Bookings", href: "/bookings", icon: <CalendarCheck /> },
@@ -49,6 +54,11 @@ const sidebarItems: SidebarItem[] = [
{ label: "Billing", href: "/billing", icon: <Receipt /> },
{ label: "Documents", href: "/documents", icon: <FileText /> },
{ label: "Dropdown Settings", href: "/admin/dropdowns", icon: <Settings /> },
{
label: "File Upload Settings",
href: "/admin/file-uploads",
icon: <FileUp />,
},
];
const App = () => {
@@ -101,6 +111,7 @@ const App = () => {
>
<Routes>
<Route path="/" element={<DashboardPage />} />
<Route path="/portal" element={<MyPortalPage />} />
<Route path="/bookings" element={<BookingsPage />} />
<Route path="/customers" element={<CustomersPage />} />
<Route path="/customers/:id" element={<CustomerDetailPage />} />
@@ -117,6 +128,10 @@ const App = () => {
path="/admin/dropdowns"
element={<DropdownSettingsPage />}
/>
<Route
path="/admin/file-uploads"
element={<FileUploadSettingsPage />}
/>
<Route path="/user-management" element={<Navigate to="/" replace />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>

View File

@@ -20,7 +20,7 @@ export default function Breadcrumbs({ items }: BreadcrumbsProps) {
<Link
to="/"
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" /> */}
Dashboard
@@ -36,7 +36,7 @@ export default function Breadcrumbs({ items }: BreadcrumbsProps) {
{item.href && !isLast ? (
<Link
to={item.href}
className="transition hover:text-[#33578D]"
className="transition hover:text-[#10B981]"
>
{item.label}
</Link>

View File

@@ -10,7 +10,7 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
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",
"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",
className,
)}

View File

@@ -9,7 +9,7 @@ function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
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",
"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",
className,
)}

View 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`,
},
};

View 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 }),
});
};

View 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);
}

View File

@@ -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>
);
}

View File

@@ -72,14 +72,14 @@ export default function DropdownSettingsPage() {
value={query}
onChange={(e) => setQuery(e.target.value)}
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>
<EditDropdownSettingDialog mode="create">
<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" />
New Setting
@@ -124,7 +124,7 @@ export default function DropdownSettingsPage() {
</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-[#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
</button>
@@ -157,11 +157,11 @@ export default function DropdownSettingsPage() {
filtered.map((setting) => (
<tr
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">
<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" />
</div>
<div>
@@ -183,7 +183,7 @@ export default function DropdownSettingsPage() {
<td className="px-6 py-4">
<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">
{setting.children.length}
</span>
@@ -214,7 +214,7 @@ export default function DropdownSettingsPage() {
(setting.meta?.permissions ?? []).map((p) => (
<span
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" />
{p}
@@ -229,7 +229,7 @@ export default function DropdownSettingsPage() {
<ManageDropdownOptionsDialog 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-[#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" />
Options
@@ -242,7 +242,7 @@ export default function DropdownSettingsPage() {
>
<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" />
</button>
@@ -285,7 +285,7 @@ function BehaviorChip({
className={
muted
? "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}
@@ -303,13 +303,13 @@ function StatCard({
icon: React.ReactNode;
}) {
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>
<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-[#33578D]/10 text-[#33578D]">
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-[#10B981] text-white">
{icon}
</div>
</div>

View File

@@ -97,7 +97,7 @@ export default function EditDropdownSettingDialog({
<Input
type="text"
defaultValue={setting?.meta?.color ?? ""}
placeholder="#33578D"
placeholder="#10B981"
/>
</div>
@@ -144,7 +144,7 @@ export default function EditDropdownSettingDialog({
<div className="flex justify-end gap-3">
<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"}
</Button>
</div>
@@ -168,15 +168,15 @@ function ToggleChip({
<label
className={
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-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-[#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-[#10B981]/30 hover:bg-[#10B981]/5"
}
>
<input
type="checkbox"
checked={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>
<p className="font-medium text-slate-900">{label}</p>

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -100,7 +100,7 @@ export default function ManageDropdownOptionsDialog({
<button
type="button"
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" />
Add Option
@@ -126,7 +126,7 @@ export default function ManageDropdownOptionsDialog({
onClick={() => move(i, -1)}
aria-label="Move up"
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>
@@ -135,7 +135,7 @@ export default function ManageDropdownOptionsDialog({
onClick={() => move(i, 1)}
aria-label="Move down"
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>
@@ -199,7 +199,7 @@ export default function ManageDropdownOptionsDialog({
onChange={(e) =>
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
</label>
@@ -220,7 +220,7 @@ export default function ManageDropdownOptionsDialog({
<div className="flex justify-end gap-3 border-t border-slate-100 pt-3">
<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
</Button>
</div>

View File

@@ -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>
);
}

View File

@@ -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),
);
}

View File

@@ -104,14 +104,14 @@ export default function BillingPage() {
setPage(1);
}}
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>
<NewInvoicePage>
<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" />
New Invoice
@@ -161,8 +161,8 @@ export default function BillingPage() {
}}
className={
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 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 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-[#10B981]/10 hover:text-[#10B981]"
}
>
{f}
@@ -193,7 +193,7 @@ export default function BillingPage() {
</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-[#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
</button>
@@ -227,11 +227,11 @@ export default function BillingPage() {
paginated.map((invoice) => (
<tr
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">
<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" />
</div>
<div>
@@ -270,7 +270,7 @@ export default function BillingPage() {
<button
type="button"
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" />
</button>
@@ -291,7 +291,7 @@ export default function BillingPage() {
>
<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" />
</button>
@@ -364,7 +364,7 @@ function Pagination({
id="invoice-page-size"
value={pageSize}
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) => (
<option key={size} value={size}>
@@ -382,7 +382,7 @@ function Pagination({
type="button"
onClick={() => onPageChange(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" />
Prev
@@ -398,8 +398,8 @@ function Pagination({
aria-current={isActive ? "page" : undefined}
className={
isActive
? "h-9 w-9 rounded-xl bg-[#33578D] 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 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-[#10B981]/30 hover:bg-[#10B981]/10 hover:text-[#10B981]"
}
>
{p}
@@ -411,7 +411,7 @@ function Pagination({
type="button"
onClick={() => onPageChange(page + 1)}
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
<ChevronRight className="h-4 w-4" />
@@ -435,10 +435,10 @@ function StatCard({
const iconWrap =
tone === "danger"
? "bg-red-100 text-red-600"
: "bg-[#33578D]/10 text-[#33578D]";
: "bg-[#10B981] text-white";
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>
<p className="text-sm text-slate-500">{title}</p>

View File

@@ -37,7 +37,7 @@ export interface NewInvoicePageProps {
}
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({
mode = "create",
@@ -192,7 +192,7 @@ export default function NewInvoicePage({
<div className="flex justify-end gap-3">
<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}
</Button>
</div>

View File

@@ -42,7 +42,7 @@ export default function BookingDetailPage() {
</p>
<Link
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" />
Back to Bookings
@@ -67,7 +67,7 @@ export default function BookingDetailPage() {
<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 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" />
</div>
<div>
@@ -105,7 +105,7 @@ export default function BookingDetailPage() {
>
<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
</button>
@@ -135,7 +135,7 @@ export default function BookingDetailPage() {
station={booking.originStation}
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" />
<ArrowRight className="h-5 w-5" />
</div>
@@ -151,11 +151,11 @@ export default function BookingDetailPage() {
{booking.transportMode === "Multimodal" && booking.legs && booking.legs.length > 0 ? (
<div className="rounded-3xl bg-white p-6 shadow-sm">
<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">
Transport Legs
</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
</span>
</div>
@@ -163,10 +163,10 @@ export default function BookingDetailPage() {
{booking.legs.map((leg, i) => (
<div
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 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}
</div>
<div>
@@ -175,7 +175,7 @@ export default function BookingDetailPage() {
</p>
<p className="font-semibold text-slate-900">
{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 || "—"}
</p>
</div>
@@ -231,14 +231,14 @@ export default function BookingDetailPage() {
<DetailCard title="Cargo Description">
<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>
</div>
</DetailCard>
<DetailCard title="Special Instructions">
<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>
</div>
</DetailCard>
@@ -259,7 +259,7 @@ function RouteEndpoint({
}) {
return (
<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}
</div>
<div>
@@ -298,7 +298,7 @@ function DetailRow({
}) {
return (
<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">
<p className="text-xs font-medium text-slate-500">{label}</p>
<p className="mt-0.5 text-sm text-slate-900">{value}</p>

View File

@@ -58,14 +58,14 @@ export default function BookingsPage() {
<input
type="search"
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>
<NewBookingPage>
<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" />
New Booking
@@ -109,7 +109,7 @@ export default function BookingsPage() {
</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-[#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
</button>
@@ -132,11 +132,11 @@ export default function BookingsPage() {
{paginated.map((booking) => (
<tr
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">
<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" />
</div>
<div>
@@ -180,7 +180,7 @@ export default function BookingsPage() {
<div className="flex justify-end gap-2">
<Link
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" />
</Link>
@@ -205,7 +205,7 @@ export default function BookingsPage() {
>
<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" />
</button>
@@ -279,7 +279,7 @@ function Pagination({
id="booking-page-size"
value={pageSize}
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) => (
<option key={size} value={size}>
@@ -297,7 +297,7 @@ function Pagination({
type="button"
onClick={() => onPageChange(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" />
Prev
@@ -313,8 +313,8 @@ function Pagination({
aria-current={isActive ? "page" : undefined}
className={
isActive
? "h-9 w-9 rounded-xl bg-[#33578D] 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 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-[#10B981]/30 hover:bg-[#10B981]/10 hover:text-[#10B981]"
}
>
{p}
@@ -326,7 +326,7 @@ function Pagination({
type="button"
onClick={() => onPageChange(page + 1)}
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
<ChevronRight className="h-4 w-4" />
@@ -346,13 +346,13 @@ function StatCard({
icon: React.ReactNode;
}) {
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>
<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-[#33578D]/10 text-[#33578D]">
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-[#10B981] text-white">
{icon}
</div>
</div>

View File

@@ -47,7 +47,7 @@ export interface NewBookingPageProps {
}
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: "" };
@@ -188,7 +188,7 @@ export default function NewBookingPage({
{/* Multimodal Transport Legs */}
{transportMode === "Multimodal" ? (
<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>
<p className="text-sm font-semibold text-slate-900">
@@ -201,7 +201,7 @@ export default function NewBookingPage({
<button
type="button"
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" />
Add Leg
@@ -215,7 +215,7 @@ export default function NewBookingPage({
className="rounded-xl border border-slate-200 bg-white p-3"
>
<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}
</span>
{legs.length > 1 ? (
@@ -356,7 +356,7 @@ export default function NewBookingPage({
<div className="flex justify-end gap-3">
<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}
</Button>
</div>

View File

@@ -47,7 +47,7 @@ export default function ConsignmentDetailPage() {
</p>
<Link
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" />
Back to Consignments
@@ -72,7 +72,7 @@ export default function ConsignmentDetailPage() {
<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 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" />
</div>
<div>
@@ -115,7 +115,7 @@ export default function ConsignmentDetailPage() {
>
<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
</button>
@@ -144,7 +144,7 @@ export default function ConsignmentDetailPage() {
label="Origin"
station={consignment.originStation}
/>
<ArrowRight className="h-5 w-5 text-[#33578D]" />
<ArrowRight className="h-5 w-5 text-[#10B981]" />
<RouteEndpoint
label="Destination"
station={consignment.destinationStation}
@@ -202,14 +202,14 @@ export default function ConsignmentDetailPage() {
<DetailCard title="Description">
<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>
</div>
</DetailCard>
<DetailCard title="Special Handling">
<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>
</div>
</DetailCard>
@@ -228,7 +228,7 @@ function RouteEndpoint({
}) {
return (
<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" />
</div>
<div>
@@ -267,7 +267,7 @@ function DetailRow({
}) {
return (
<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">
<p className="text-xs font-medium text-slate-500">{label}</p>
<p className="mt-0.5 text-sm text-slate-900">{value}</p>

View File

@@ -104,14 +104,14 @@ export default function ConsignmentsPage() {
setPage(1);
}}
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>
<NewConsignmentPage>
<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" />
New Consignment
@@ -164,8 +164,8 @@ export default function ConsignmentsPage() {
}}
className={
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 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 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-[#10B981]/10 hover:text-[#10B981]"
}
>
{f}
@@ -196,7 +196,7 @@ export default function ConsignmentsPage() {
</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-[#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
</button>
@@ -230,11 +230,11 @@ export default function ConsignmentsPage() {
paginated.map((consignment) => (
<tr
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">
<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" />
</div>
<div>
@@ -292,7 +292,7 @@ export default function ConsignmentsPage() {
<div className="flex justify-end gap-2">
<Link
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" />
</Link>
@@ -316,7 +316,7 @@ export default function ConsignmentsPage() {
>
<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" />
</button>
@@ -394,7 +394,7 @@ function Pagination({
id="consignment-page-size"
value={pageSize}
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) => (
<option key={size} value={size}>
@@ -412,7 +412,7 @@ function Pagination({
type="button"
onClick={() => onPageChange(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" />
Prev
@@ -428,8 +428,8 @@ function Pagination({
aria-current={isActive ? "page" : undefined}
className={
isActive
? "h-9 w-9 rounded-xl bg-[#33578D] 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 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-[#10B981]/30 hover:bg-[#10B981]/10 hover:text-[#10B981]"
}
>
{p}
@@ -441,7 +441,7 @@ function Pagination({
type="button"
onClick={() => onPageChange(page + 1)}
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
<ChevronRight className="h-4 w-4" />
@@ -465,10 +465,10 @@ function StatCard({
const iconWrap =
tone === "danger"
? "bg-red-100 text-red-600"
: "bg-[#33578D]/10 text-[#33578D]";
: "bg-[#10B981] text-white";
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>
<p className="text-sm text-slate-500">{title}</p>

View File

@@ -42,7 +42,7 @@ export interface NewConsignmentPageProps {
}
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({
mode = "create",
@@ -198,7 +198,7 @@ export default function NewConsignmentPage({
<input
type="checkbox"
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>
</label>
@@ -225,7 +225,7 @@ export default function NewConsignmentPage({
<div className="flex justify-end gap-3">
<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}
</Button>
</div>

View File

@@ -42,7 +42,7 @@ export default function CustomerDetailPage() {
</p>
<Link
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" />
Back to Customers
@@ -67,7 +67,7 @@ export default function CustomerDetailPage() {
<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 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" />
</div>
@@ -103,7 +103,7 @@ export default function CustomerDetailPage() {
>
<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
</button>
@@ -183,7 +183,7 @@ export default function CustomerDetailPage() {
<DetailCard title="Notes">
<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>
</div>
</DetailCard>
@@ -219,7 +219,7 @@ function DetailRow({
}) {
return (
<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">
<p className="text-xs font-medium text-slate-500">{label}</p>
<p className="mt-0.5 text-sm text-slate-900">{value}</p>

View File

@@ -243,7 +243,7 @@ function StatCard({
<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-[#33578D]/10 text-[#33578D]">
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-[#10B981] text-white">
{icon}
</div>
</CardContent>

View File

@@ -90,7 +90,7 @@ export default function NewCustomerPage({
<select
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>Exporter</option>
@@ -213,7 +213,7 @@ export default function NewCustomerPage({
<div className="flex justify-end gap-3">
<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}
</Button>
</div>

View File

@@ -37,9 +37,11 @@ import { invoices } from "../billing/invoices.mock";
import { shipments } from "../tracking/shipments.mock";
import { trains } from "../trains/trains.mock";
const BRAND = "#33578D";
const BRAND_LIGHT = "#6B8AB8";
const BRAND_LIGHTER = "#B7C7DE";
// Main brand color: #10B981 (emerald-500). Lighter variants for chart
// hierarchy so multi-series lines/areas/bars stay visually distinct.
const BRAND = "#10B981"; // emerald-500
const BRAND_LIGHT = "#6EE7B7"; // emerald-300
const BRAND_LIGHTER = "#D1FAE5"; // emerald-100
const STATUS_PALETTE: Record<string, string> = {
Pending: "#d97706",
@@ -179,10 +181,10 @@ export default function DashboardPage() {
</p>
</div>
<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
</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
</span>
</div>
@@ -417,7 +419,7 @@ export default function DashboardPage() {
width={100}
/>
<Tooltip
cursor={{ fill: "#33578D14" }}
cursor={{ fill: "#10B98114" }}
contentStyle={{
backgroundColor: "white",
border: "1px solid #e2e8f0",
@@ -454,7 +456,7 @@ export default function DashboardPage() {
/>
<YAxis stroke="#94a3b8" style={{ fontSize: "12px" }} />
<Tooltip
cursor={{ fill: "#33578D14" }}
cursor={{ fill: "#10B98114" }}
contentStyle={{
backgroundColor: "white",
border: "1px solid #e2e8f0",
@@ -474,7 +476,7 @@ export default function DashboardPage() {
</ResponsiveContainer>
</ChartCard>
<ChartCard title="Fleet Status" subtitle={`${trains.length} units`}>
<ChartCard title="Train Status" subtitle={`${trains.length} units`}>
<ResponsiveContainer width="100%" height={240}>
<PieChart>
<Pie
@@ -517,7 +519,7 @@ export default function DashboardPage() {
</div>
<Link
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
<ArrowRight className="h-3 w-3" />
@@ -528,7 +530,7 @@ export default function DashboardPage() {
{recentShipments.map((s) => (
<li
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">
<CircleDot
@@ -613,7 +615,7 @@ function KpiCard({
const TrendIcon = trend === "up" ? ArrowUpRight : ArrowDownRight;
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>
<p className="text-sm text-slate-500">{label}</p>
@@ -626,7 +628,7 @@ function KpiCard({
<span className="text-slate-400">vs last period</span>
</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}
</div>
</div>
@@ -674,10 +676,10 @@ function SummaryCard({
return (
<Link
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 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}
</div>
<div>

View File

@@ -110,14 +110,14 @@ export default function DocumentsPage() {
setPage(1);
}}
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>
<NewDocumentPage>
<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" />
Upload Document
@@ -169,8 +169,8 @@ export default function DocumentsPage() {
}}
className={
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 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 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-[#10B981]/10 hover:text-[#10B981]"
}
>
{f}
@@ -266,8 +266,8 @@ function ViewToggleButton({
aria-pressed={active}
className={
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 px-3 py-1.5 text-sm font-medium text-slate-600 transition hover:text-[#33578D]"
? "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-[#10B981]"
}
>
{children}
@@ -276,7 +276,7 @@ function ViewToggleButton({
}
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 === "DOCX") return <FileText className={className} />;
if (format === "XLSX") return <FileSpreadsheet className={className} />;
@@ -287,10 +287,10 @@ function FormatIcon({ format }: { format: DocumentFormat }) {
function DocumentCard({ doc }: { doc: DocumentRecord }) {
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 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} />
</div>
<div className="min-w-0 flex-1">
@@ -316,14 +316,14 @@ function DocumentCard({ doc }: { doc: DocumentRecord }) {
<button
type="button"
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" />
</button>
<button
type="button"
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" />
</button>
@@ -340,7 +340,7 @@ function DocumentCard({ doc }: { doc: DocumentRecord }) {
>
<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" />
</button>
@@ -380,7 +380,7 @@ function DocumentTable({ documents: rows }: { documents: DocumentRecord[] }) {
</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-[#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
</button>
@@ -404,11 +404,11 @@ function DocumentTable({ documents: rows }: { documents: DocumentRecord[] }) {
{rows.map((doc) => (
<tr
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">
<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} />
</div>
<div className="min-w-0">
@@ -448,14 +448,14 @@ function DocumentTable({ documents: rows }: { documents: DocumentRecord[] }) {
<button
type="button"
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" />
</button>
<button
type="button"
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" />
</button>
@@ -472,7 +472,7 @@ function DocumentTable({ documents: rows }: { documents: DocumentRecord[] }) {
>
<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" />
</button>
@@ -530,7 +530,7 @@ function Pagination({
id="document-page-size"
value={pageSize}
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) => (
<option key={size} value={size}>
@@ -548,7 +548,7 @@ function Pagination({
type="button"
onClick={() => onPageChange(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" />
Prev
@@ -564,8 +564,8 @@ function Pagination({
aria-current={isActive ? "page" : undefined}
className={
isActive
? "h-9 w-9 rounded-xl bg-[#33578D] 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 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-[#10B981]/30 hover:bg-[#10B981]/10 hover:text-[#10B981]"
}
>
{p}
@@ -577,7 +577,7 @@ function Pagination({
type="button"
onClick={() => onPageChange(page + 1)}
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
<ChevronRight className="h-4 w-4" />
@@ -597,13 +597,13 @@ function StatCard({
icon: React.ReactNode;
}) {
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>
<p className="text-sm text-slate-500">{title}</p>
<h3 className="mt-2 text-2xl font-bold text-slate-900">{value}</h3>
</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}
</div>
</div>

View File

@@ -39,7 +39,7 @@ export interface NewDocumentPageProps {
}
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({
mode = "create",
@@ -99,9 +99,9 @@ export default function NewDocumentPage({
<Label>File</Label>
<label
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" />
</div>
<p className="text-sm font-medium text-slate-900">
@@ -232,7 +232,7 @@ export default function NewDocumentPage({
<div className="flex justify-end gap-3">
<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}
</Button>
</div>

View 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>
);
}

View File

@@ -13,12 +13,12 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
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";
export interface ShipmentFormData {
bookingId?: number;
bookingReference?: string;
consignmentId?: number;
consignmentReference?: string;
originStation?: string;
destinationStation?: string;
mode?: ShipmentMode;
@@ -34,7 +34,7 @@ export interface NewShipmentPageProps {
}
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({
mode = "create",
@@ -61,23 +61,26 @@ export default function NewShipmentPage({
</DialogHeader>
<div className="grid gap-5 py-4 md:grid-cols-2">
{/* Freight Booking */}
{/* Consignment */}
<div className="space-y-2 md:col-span-2">
<Label>Freight Booking *</Label>
<Label>Consignment *</Label>
<select
defaultValue={shipment?.bookingId ?? ""}
defaultValue={shipment?.consignmentId ?? ""}
className={selectClass}
>
<option value="" disabled>
Select freight booking
Select consignment
</option>
{bookings.map((b) => (
<option key={b.id} value={b.id}>
{b.reference} {b.customer} ({b.originStation} {" "}
{b.destinationStation})
{consignments.map((c) => (
<option key={c.id} value={c.id}>
{c.trackingNumber} {c.customer} ({c.originStation} {" "}
{c.destinationStation})
</option>
))}
</select>
<p className="text-xs text-slate-500">
Origin and destination auto-fill from the linked consignment.
</p>
</div>
{/* Status */}
@@ -113,7 +116,7 @@ export default function NewShipmentPage({
<MapPin className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
<Input
defaultValue={shipment?.originStation ?? ""}
placeholder="Auto-filled from booking"
placeholder="Auto-filled from consignment"
className="pl-10"
/>
</div>
@@ -126,7 +129,7 @@ export default function NewShipmentPage({
<MapPin className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
<Input
defaultValue={shipment?.destinationStation ?? ""}
placeholder="Auto-filled from booking"
placeholder="Auto-filled from consignment"
className="pl-10"
/>
</div>
@@ -161,7 +164,7 @@ export default function NewShipmentPage({
<div className="flex justify-end gap-3">
<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}
</Button>
</div>

View File

@@ -41,6 +41,7 @@ export default function TrackingPage() {
if (!q) return true;
return (
s.reference.toLowerCase().includes(q) ||
s.consignmentReference.toLowerCase().includes(q) ||
s.bookingReference.toLowerCase().includes(q) ||
s.customer.toLowerCase().includes(q) ||
s.originStation.toLowerCase().includes(q) ||
@@ -74,14 +75,14 @@ export default function TrackingPage() {
value={query}
onChange={(e) => setQuery(e.target.value)}
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>
<NewShipmentPage>
<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" />
New Shipment
@@ -106,8 +107,8 @@ export default function TrackingPage() {
onClick={() => setFilter(f)}
className={
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 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 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-[#10B981]/10 hover:text-[#10B981]"
}
>
{f}
@@ -185,8 +186,8 @@ function ViewToggleButton({
aria-pressed={active}
className={
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 px-3 py-1.5 text-sm font-medium text-slate-600 transition hover:text-[#33578D]"
? "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-[#10B981]"
}
>
{children}
@@ -196,21 +197,21 @@ function ViewToggleButton({
function ShipmentCard({ shipment }: { shipment: Shipment }) {
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 flex-col gap-0.5">
<span className="text-base font-bold text-slate-900">
{shipment.reference}
</span>
<span className="text-xs text-slate-500">
Booking {shipment.bookingReference}
Consignment {shipment.consignmentReference}
</span>
</div>
<StatusBadge status={shipment.status} />
</div>
{/* 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">
<p className="text-xs font-medium uppercase tracking-wide text-slate-500">
From
@@ -219,7 +220,7 @@ function ShipmentCard({ shipment }: { shipment: Shipment }) {
{shipment.originStation}
</p>
</div>
<ArrowRight className="h-5 w-5 text-[#33578D]" />
<ArrowRight className="h-5 w-5 text-[#10B981]" />
<div className="text-sm text-right">
<p className="text-xs font-medium uppercase tracking-wide text-slate-500">
To
@@ -240,7 +241,7 @@ function ShipmentCard({ shipment }: { shipment: Shipment }) {
</div>
<div className="h-1.5 w-full overflow-hidden rounded-full bg-slate-100">
<div
className="h-full rounded-full bg-[#33578D] transition-all"
className="h-full rounded-full bg-[#10B981] transition-all"
style={{ width: `${shipment.progress}%` }}
/>
</div>
@@ -249,7 +250,7 @@ function ShipmentCard({ shipment }: { shipment: Shipment }) {
{/* Meta */}
<div className="space-y-2 text-sm text-slate-700">
<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>
</div>
<div className="flex items-center gap-2">
@@ -257,7 +258,7 @@ function ShipmentCard({ shipment }: { shipment: Shipment }) {
<span className="capitalize">{shipment.mode}</span>
</div>
<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>
</div>
<div className="flex items-center gap-2 text-xs text-slate-500">
@@ -285,7 +286,7 @@ function ShipmentCard({ shipment }: { shipment: Shipment }) {
>
<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" />
Edit
@@ -329,11 +330,11 @@ function ShipmentTable({ shipments: rows }: { shipments: Shipment[] }) {
{rows.map((shipment) => (
<tr
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">
<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" />
</div>
<div>
@@ -374,7 +375,7 @@ function ShipmentTable({ shipments: rows }: { shipments: Shipment[] }) {
<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-full rounded-full bg-[#33578D]"
className="h-full rounded-full bg-[#10B981]"
style={{ width: `${shipment.progress}%` }}
/>
</div>
@@ -386,7 +387,7 @@ function ShipmentTable({ shipments: rows }: { shipments: Shipment[] }) {
<td className="px-6 py-4 text-sm text-slate-700">
<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}
</div>
</td>
@@ -412,7 +413,7 @@ function ShipmentTable({ shipments: rows }: { shipments: Shipment[] }) {
>
<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" />
</button>
@@ -440,9 +441,9 @@ function ShipmentTable({ shipments: rows }: { shipments: Shipment[] }) {
}
function ModeIcon({ mode }: { mode: ShipmentMode }) {
if (mode === "rail") return <Train className="h-4 w-4 text-[#33578D]" />;
if (mode === "truck") return <Truck className="h-4 w-4 text-[#33578D]" />;
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-[#10B981]" />;
return <Train className="h-4 w-4 text-[#10B981]" />;
}
function StatusBadge({ status }: { status: ShipmentStatus }) {

View File

@@ -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 ShipmentMode = "rail" | "truck" | "multimodal";
@@ -6,7 +6,8 @@ export type ShipmentMode = "rail" | "truck" | "multimodal";
export interface Shipment {
id: number;
reference: string;
bookingId: number;
consignmentId: number;
consignmentReference: string;
bookingReference: string;
customer: string;
originStation: string;
@@ -126,16 +127,19 @@ const seedShipments: Array<{
];
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;
return {
id,
reference: `SH-${String(id).padStart(3, "0")}`,
bookingId: booking.id,
bookingReference: booking.reference,
customer: booking.customer,
originStation: booking.originStation,
destinationStation: booking.destinationStation,
consignmentId: consignment.id,
consignmentReference: consignment.trackingNumber,
bookingReference: consignment.bookingReference,
customer: consignment.customer,
originStation: consignment.originStation,
destinationStation: consignment.destinationStation,
mode: entry.mode,
status: entry.status,
currentLocation: entry.currentLocation,

View File

@@ -45,7 +45,7 @@ export interface NewTrainPageProps {
}
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({
mode = "create",
@@ -230,7 +230,7 @@ export default function NewTrainPage({
<div className="flex justify-end gap-3">
<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}
</Button>
</div>

View File

@@ -93,14 +93,14 @@ export default function TrainsPage() {
setPage(1);
}}
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>
<NewTrainPage>
<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" />
New Train
@@ -147,8 +147,8 @@ export default function TrainsPage() {
}}
className={
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 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 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-[#10B981]/10 hover:text-[#10B981]"
}
>
{f}
@@ -179,7 +179,7 @@ export default function TrainsPage() {
</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-[#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
</button>
@@ -213,11 +213,11 @@ export default function TrainsPage() {
paginated.map((train) => (
<tr
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">
<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" />
</div>
<div>
@@ -272,7 +272,7 @@ export default function TrainsPage() {
>
<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" />
</button>
@@ -345,7 +345,7 @@ function Pagination({
id="train-page-size"
value={pageSize}
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) => (
<option key={size} value={size}>
@@ -363,7 +363,7 @@ function Pagination({
type="button"
onClick={() => onPageChange(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" />
Prev
@@ -379,8 +379,8 @@ function Pagination({
aria-current={isActive ? "page" : undefined}
className={
isActive
? "h-9 w-9 rounded-xl bg-[#33578D] 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 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-[#10B981]/30 hover:bg-[#10B981]/10 hover:text-[#10B981]"
}
>
{p}
@@ -392,7 +392,7 @@ function Pagination({
type="button"
onClick={() => onPageChange(page + 1)}
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
<ChevronRight className="h-4 w-4" />
@@ -412,13 +412,13 @@ function StatCard({
icon: React.ReactNode;
}) {
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>
<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-[#33578D]/10 text-[#33578D]">
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-[#10B981] text-white">
{icon}
</div>
</div>

View 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();

View File

@@ -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}`
);
},
};

View File

@@ -0,0 +1,3 @@
import { api } from "../crud";
import { URL_CONSTANTS } from "../../constants/URLS"

View 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;
}

View File

@@ -1,10 +1,10 @@
import axios from "axios";
export const api = axios.create({
export const client = axios.create({
baseURL: import.meta.env.VITE_API_URL,
});
api.interceptors.request.use((config) => {
client.interceptors.request.use((config) => {
const token = document.cookie
.split("; ")
.find((row) => row.startsWith("auth-token="))
@@ -15,7 +15,7 @@ api.interceptors.request.use((config) => {
return config;
});
api.interceptors.response.use(
client.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {

View 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;
}

View File

@@ -1,5 +1,7 @@
import type { BaseEntity } from "../common";
export * from "./file_upload_settings";
export enum BookingStatus {
Draft = "DRAFT",
Confirmed = "CONFIRMED",

View File

@@ -37,7 +37,7 @@ function getInitialTheme(): Theme {
}
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 = ({
title,
@@ -112,7 +112,7 @@ const DashboardLayout = ({
aria-label={
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" ? (
<Sun className="h-4 w-4" />
@@ -159,9 +159,9 @@ const DashboardLayout = ({
aria-haspopup="menu"
aria-expanded={isUserMenuOpen}
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}
</div>
<span className="hidden text-sm font-medium text-slate-700 md:block dark:text-slate-200">
@@ -192,7 +192,7 @@ const DashboardLayout = ({
href="#profile"
role="menuitem"
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" />
Profile

View File

@@ -15,6 +15,12 @@ export interface SidebarProps {
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 = ({
title,
items,
@@ -65,7 +71,7 @@ const Sidebar = ({
"flex h-5 w-5 items-center justify-center [&_svg]:h-5 [&_svg]:w-5",
isActive
? "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}