add company stamp upload functionality for contract signing

- Introduced StampUpload component for uploading company stamp images.
- Integrated stamp upload in contract signing modal, supporting PNG and JPG formats.
- Implemented validation for file type and size (max 5 MB).
- Added visual feedback for drag-and-drop functionality.
- Updated contract-related pages to handle duplicate contract alerts and pricing notices.
- Enhanced contract expiry management with a nightly sweep service.
- Added unit tests for new features and updated existing tests for contract handling.
This commit is contained in:
Marshal
2026-07-25 17:14:58 +00:00
parent 54b5882355
commit fde5e6de4b
68 changed files with 1858 additions and 289 deletions

View File

@@ -30,6 +30,28 @@ import {
} from './batch-window.util';
import { type BookingWindowConfig } from './booking-window.config';
/**
* The most urgent document-review deadline that still has un-accepted booking
* requests behind it. Backoffice counts down to it and warns staff, because
* everything still pending when the phase ends is expired automatically.
*/
export interface DocReviewAlert {
/** A schedule of the route-day group under review (deep-link target). */
scheduleId: string;
originYardId: string;
destinationYardId: string;
/** EAT booking day of the group, YYYY-MM-DD. */
day: string;
/** IMPORT (the usual) or DOMESTIC — both run a review phase; export does not. */
tradeDirection: string;
/** ISO deadline the review phase ends at. */
docReviewEndsAt: string;
/** Full length of the review phase — the client warns past its halfway mark. */
docReviewMinutes: number;
/** Requests neither accepted nor rejected — they expire at the deadline. */
pendingCount: number;
}
/**
* Drives the one-booking-day window cycle for IMPORT schedules and the FCFS
* booking window for EXPORT schedules. All state lives in DB timestamps on the
@@ -130,6 +152,65 @@ export class BookingWindowService implements OnModuleInit {
}
}
/**
* The route-day currently in document review whose deadline is nearest and
* which still has un-accepted requests. Null when nothing is under review or
* every request has been decided — the backoffice header shows nothing then.
*
* One card, one deadline, one count: route-days are checked in deadline order
* and the first with pending work wins, so the number always belongs to the
* clock beside it.
*/
async getDocReviewAlert(): Promise<DocReviewAlert | null> {
const reviewing = (
await this.trainSchedulesRepository.findAll({
where: [
{ status: TrainScheduleStatusEnum.Draft },
{ status: TrainScheduleStatusEnum.Scheduled },
],
})
)
.filter(
(s) =>
s.windowPhase === 'DOC_REVIEW' &&
s.docReviewCompletedAt == null &&
s.docReviewEndsAt != null &&
s.scheduledDepartureDate != null,
)
.sort((a, b) => a.docReviewEndsAt!.getTime() - b.docReviewEndsAt!.getTime());
if (reviewing.length === 0) return null;
const liveCfg = await this.trainSchedulingService.getWindowConfig();
const seen = new Set<string>();
for (const schedule of reviewing) {
const group = {
originYardId: schedule.originStationId,
destinationYardId: schedule.destinationStationId,
day: eatDay(schedule.scheduledDepartureDate),
};
// Sibling trains share one review phase for the route-day pool — count it once.
const key = `${group.originYardId}|${group.destinationYardId}|${group.day}`;
if (seen.has(key)) continue;
seen.add(key);
const pendingCount =
await this.bookingBatchService.countUnacceptedForRouteDay(group);
if (pendingCount === 0) continue;
return {
scheduleId: schedule.id,
...group,
// Carried so the backoffice list opens on the same direction the
// at-risk requests belong to (import corridor, or a domestic day).
tradeDirection: schedule.direction ?? 'IMPORT',
docReviewEndsAt: schedule.docReviewEndsAt!.toISOString(),
docReviewMinutes: effectiveWindowConfig(schedule, liveCfg).docReviewMinutes,
pendingCount,
};
}
return null;
}
/** Staff finished document review early — start the batch/payment phase now. */
async completeDocReview(scheduleId: string): Promise<TrainSchedule> {
const schedule = await this.trainSchedulesRepository.findById(scheduleId);