mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge pull request #591 from Tria-plc/Truckdetantion
Truckdetantion Receive to warehouse form cleanup and trimming Wiring GRN with bookings Fixed Slow GRN plus fixed Freight order genration and rendering
This commit is contained in:
@@ -11,6 +11,7 @@ import { RuleEngineModule } from '../rule-engine/rule-engine.module';
|
||||
import { FileUploadSettingsModule } from '../file-upload-settings/file-upload-settings.module';
|
||||
import { SignaturesModule } from '../signatures/signatures.module';
|
||||
import { BillingModule } from '../billing/billing.module';
|
||||
import { DocumentsModule } from '../billing/documents/documents.module';
|
||||
import { FirstMileModule } from '../first-mile/first-mile.module';
|
||||
import { LastMileModule } from '../last-mile/last-mile.module';
|
||||
import { BookingContractService } from './booking-contract.service';
|
||||
@@ -68,6 +69,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
|
||||
CustomerTruckContainer,
|
||||
]),
|
||||
BillingModule,
|
||||
DocumentsModule,
|
||||
NotificationsModule,
|
||||
NotificationInboxModule,
|
||||
forwardRef(() => FirstMileModule),
|
||||
|
||||
@@ -50,7 +50,8 @@ import { Booking } from './entities/booking.entity';
|
||||
import { BookingContainerAllocation } from './entities/booking-container-allocation.entity';
|
||||
import { FileRecord } from '../files/entities/file.entity';
|
||||
import { CustomerTruckAssignmentDto } from './dto/customer-truck-assignment.dto';
|
||||
import { ContractPdfService } from '../../contracts/contract-pdf.service';
|
||||
import { PdfRenderService } from '../billing/documents/pdf-render.service';
|
||||
import { buildTabularFallbackPdf } from '../billing/documents/styled-pdf.util';
|
||||
|
||||
/** Paginated booking list: flat `total` (backoffice) + `meta` block (portal). */
|
||||
export interface PaginatedBookings {
|
||||
@@ -99,7 +100,7 @@ export class BookingsService {
|
||||
private readonly containerTypesService: ContainerTypesService,
|
||||
private readonly consolidationService: ConsolidationService,
|
||||
private readonly vehiclesService: VehiclesService,
|
||||
private readonly contractPdfService: ContractPdfService,
|
||||
private readonly pdfRender: PdfRenderService,
|
||||
private readonly events: EventEmitter2,
|
||||
) {}
|
||||
|
||||
@@ -170,7 +171,12 @@ export class BookingsService {
|
||||
);
|
||||
|
||||
const html = this.buildCustomerTruckFreightOrderHtml(booking, trucks);
|
||||
const buffer = await this.contractPdfService.htmlToPdfBuffer(html);
|
||||
// Chromium when available; otherwise the styled tabular fallback (never the
|
||||
// generic text dump — the freight order is an outward-facing gate document).
|
||||
const buffer = await this.pdfRender.htmlToPdfBuffer(html, {
|
||||
label: 'freight order',
|
||||
fallback: (prepared) => buildTabularFallbackPdf(prepared),
|
||||
});
|
||||
return {
|
||||
filename: `freight-order-${booking.reference.replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`,
|
||||
buffer,
|
||||
@@ -262,21 +268,10 @@ export class BookingsService {
|
||||
containers: string | null;
|
||||
}>,
|
||||
): string {
|
||||
const esc = (v: unknown) => this.escapeHtml(String(v ?? '-'));
|
||||
const assignedAt = booking.customerTruckAssignedAt
|
||||
? new Date(booking.customerTruckAssignedAt).toLocaleString('en-GB')
|
||||
: '-';
|
||||
const bookingRows: Array<[string, string | null | undefined]> = [
|
||||
['Booking Reference', booking.reference],
|
||||
['Client Name', booking.company?.name],
|
||||
['Client ID', booking.companyId],
|
||||
['Trade Direction', booking.tradeDirection],
|
||||
['Freight Type', booking.freightType],
|
||||
['Assigned At', assignedAt],
|
||||
['Booking Status', booking.status],
|
||||
];
|
||||
const bookingRowHtml = bookingRows
|
||||
.map(([label, value]) => `<tr><th>${this.escapeHtml(label)}</th><td>${this.escapeHtml(value || '-')}</td></tr>`)
|
||||
.join('');
|
||||
|
||||
// Fall back to the legacy single-truck booking columns when there are no
|
||||
// multi-truck rows (bookings assigned before the multi-truck feature).
|
||||
@@ -297,44 +292,63 @@ export class BookingsService {
|
||||
]
|
||||
: [];
|
||||
|
||||
const truckBlocks = truckList
|
||||
.map((t, i) => {
|
||||
const rows: Array<[string, string | null | undefined]> = [
|
||||
['Truck Plate Number', t.plateNumber],
|
||||
['Driver Name', t.driverName],
|
||||
['Truck Type', t.truckType],
|
||||
['Containers Loaded', t.containers],
|
||||
[
|
||||
'Arrival',
|
||||
t.arrivedAt ? new Date(t.arrivedAt).toLocaleString('en-GB') : 'Awaiting arrival',
|
||||
],
|
||||
];
|
||||
const html = rows
|
||||
.map(
|
||||
([label, value]) =>
|
||||
`<tr><th>${this.escapeHtml(label)}</th><td>${this.escapeHtml(value || '-')}</td></tr>`,
|
||||
)
|
||||
.join('');
|
||||
return `<div class="truck"><h2>Truck ${i + 1}</h2><table>${html}</table></div>`;
|
||||
})
|
||||
const truckRows = truckList
|
||||
.map(
|
||||
(t, i) => `<tr>
|
||||
<td class="num">${i + 1}</td>
|
||||
<td>${esc(t.plateNumber)}</td>
|
||||
<td>${esc(t.driverName)}</td>
|
||||
<td>${esc(t.truckType)}</td>
|
||||
<td>${esc(t.containers)}</td>
|
||||
<td>${t.arrivedAt ? esc(new Date(t.arrivedAt).toLocaleString('en-GB')) : 'Awaiting arrival'}</td>
|
||||
</tr>`,
|
||||
)
|
||||
.join('');
|
||||
|
||||
const copy = (watermark: string) => `
|
||||
<section class="copy">
|
||||
<div class="watermark">${this.escapeHtml(watermark)}</div>
|
||||
<header>
|
||||
<div class="watermark">${esc(watermark)}</div>
|
||||
<div class="top">
|
||||
<div>
|
||||
<div class="brand">Ethio-Djibouti Railway S.C.</div>
|
||||
<h1>Freight Order</h1>
|
||||
<p>Customer external truck assignment — ${truckList.length} truck${truckList.length !== 1 ? 's' : ''}</p>
|
||||
<div class="subtitle">Customer external truck assignment — ${truckList.length} truck${truckList.length !== 1 ? 's' : ''}</div>
|
||||
</div>
|
||||
<strong>${this.escapeHtml(booking.reference)}</strong>
|
||||
</header>
|
||||
<table>${bookingRowHtml}</table>
|
||||
${truckBlocks}
|
||||
<div class="meta">
|
||||
Booking
|
||||
<strong>${esc(booking.reference)}</strong>
|
||||
Generated: ${esc(new Date().toLocaleString('en-GB'))}
|
||||
</div>
|
||||
</div>
|
||||
<div class="summary">
|
||||
<div class="tile"><span>Client</span><strong>${esc(booking.company?.name)}</strong></div>
|
||||
<div class="tile"><span>Client ID</span><strong>${esc(booking.companyId)}</strong></div>
|
||||
<div class="tile"><span>Trade direction</span><strong>${esc(booking.tradeDirection)}</strong></div>
|
||||
<div class="tile"><span>Freight type</span><strong>${esc(booking.freightType)}</strong></div>
|
||||
<div class="tile"><span>Assigned at</span><strong>${esc(assignedAt)}</strong></div>
|
||||
<div class="tile"><span>Booking status</span><strong>${esc(booking.status)}</strong></div>
|
||||
</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="num">#</th>
|
||||
<th>Truck plate</th>
|
||||
<th>Driver</th>
|
||||
<th>Truck type</th>
|
||||
<th>Containers loaded</th>
|
||||
<th>Arrival</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>${truckRows}</tbody>
|
||||
</table>
|
||||
<div class="notice">
|
||||
Present this freight order at the warehouse gate. Each truck may only collect the
|
||||
containers listed against it; the handover must be signed before any truck leaves.
|
||||
</div>
|
||||
<div class="signatures">
|
||||
<div>Customer / Carrier Signature</div>
|
||||
<div>Port Operations Verification</div>
|
||||
<div>Gate Security Verification</div>
|
||||
<div class="line">Customer / Carrier signature — date</div>
|
||||
<div class="line">Port operations verification — date</div>
|
||||
<div class="line">Gate security verification — date</div>
|
||||
</div>
|
||||
</section>`;
|
||||
|
||||
@@ -342,21 +356,30 @@ export class BookingsService {
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Freight Order</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; color: #10202f; margin: 0; }
|
||||
.copy { position: relative; min-height: 46vh; padding: 28px 32px; page-break-inside: avoid; border-bottom: 1px dashed #94a3b8; }
|
||||
.watermark { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; font-size: 34px; font-weight: 800; color: rgba(16, 32, 47, 0.08); transform: rotate(-18deg); pointer-events: none; }
|
||||
header { display: flex; justify-content: space-between; align-items: flex-start; border-bottom: 3px solid #0a9f6a; padding-bottom: 14px; margin-bottom: 18px; }
|
||||
h1 { margin: 0; font-size: 28px; letter-spacing: 0; }
|
||||
h2 { margin: 18px 0 8px; font-size: 14px; color: #0a6f4d; }
|
||||
p { margin: 4px 0 0; color: #64748b; }
|
||||
strong { font-size: 16px; color: #0a9f6a; }
|
||||
table { width: 100%; border-collapse: collapse; position: relative; z-index: 1; margin-bottom: 6px; }
|
||||
th, td { border: 1px solid #cbd5e1; padding: 9px 10px; text-align: left; font-size: 12px; }
|
||||
th { width: 34%; background: #f1f5f9; }
|
||||
.truck { page-break-inside: avoid; }
|
||||
.signatures { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; margin-top: 34px; font-size: 11px; color: #475569; position: relative; z-index: 1; }
|
||||
.signatures div { border-top: 1px solid #334155; padding-top: 8px; min-height: 28px; }
|
||||
@page { size: A4 portrait; margin: 10mm; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; color: #0f172a; font-family: Arial, sans-serif; }
|
||||
.copy { position: relative; padding: 24px 28px; page-break-inside: avoid; border-bottom: 1px dashed #94a3b8; }
|
||||
.watermark { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; font-size: 30px; font-weight: 800; color: rgba(15, 23, 42, 0.07); transform: rotate(-18deg); pointer-events: none; }
|
||||
.top { display: flex; justify-content: space-between; border-bottom: 3px solid #0f766e; padding-bottom: 10px; gap: 24px; }
|
||||
.brand { font-size: 11px; color: #475569; text-transform: uppercase; letter-spacing: .08em; font-weight: 700; }
|
||||
h1 { margin: 6px 0 0; font-size: 25px; line-height: 1.05; }
|
||||
.subtitle { margin-top: 4px; color: #64748b; font-size: 12px; }
|
||||
.meta { text-align: right; font-size: 11px; color: #475569; min-width: 210px; }
|
||||
.meta strong { display: block; margin: 4px 0; color: #0f172a; font-size: 15px; }
|
||||
.summary { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin: 14px 0; }
|
||||
.tile { border: 1px solid #cbd5e1; padding: 8px; min-height: 48px; }
|
||||
.tile span { display: block; color: #64748b; font-size: 9px; text-transform: uppercase; letter-spacing: .05em; margin-bottom: 4px; }
|
||||
.tile strong { font-size: 11px; }
|
||||
table { width: 100%; border-collapse: collapse; position: relative; z-index: 1; }
|
||||
th { background: #f8fafc; color: #475569; text-align: left; }
|
||||
th, td { border: 1px solid #cbd5e1; padding: 6px 7px; font-size: 10.5px; vertical-align: top; }
|
||||
.num { text-align: right; width: 26px; }
|
||||
.notice { margin-top: 10px; border-left: 4px solid #0f766e; background: #f0fdfa; padding: 8px 10px; font-size: 10px; color: #134e4a; }
|
||||
.signatures { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; margin-top: 30px; position: relative; z-index: 1; }
|
||||
.line { border-top: 1px solid #334155; padding-top: 7px; font-size: 9px; color: #475569; min-height: 30px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -207,6 +207,7 @@ export interface EligibleBookingRow {
|
||||
customerTin: string | null;
|
||||
customerPhone: string | null;
|
||||
containerNumber: string | null;
|
||||
sealNumbers: string | null;
|
||||
containerQuantity: number | null;
|
||||
containerPackagingType: string | null;
|
||||
cargoDescription: string | null;
|
||||
@@ -774,7 +775,8 @@ export class WarehouseInventoryService {
|
||||
company.name AS "customer",
|
||||
company.tin AS "customerTin",
|
||||
COALESCE(company.contact_person_phone, company.phone, company.general_manager_phone, company.etrade_phone) AS "customerPhone",
|
||||
bc.container_numbers AS "containerNumber",
|
||||
COALESCE(bcu.unit_numbers, bc.container_numbers) AS "containerNumber",
|
||||
bcu.seal_numbers AS "sealNumbers",
|
||||
bc.container_quantity AS "containerQuantity",
|
||||
bc.container_packaging_type AS "containerPackagingType",
|
||||
(NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NOT NULL
|
||||
@@ -831,6 +833,14 @@ export class WarehouseInventoryService {
|
||||
LEFT JOIN freight.container_types container_type ON container_type.id = booking_container.container_type_id
|
||||
WHERE booking_container.booking_id = b.id AND booking_container.deleted_at IS NULL
|
||||
) bc ON true
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT string_agg(NULLIF(unit.container_number, ''), ', ' ORDER BY unit.container_number) AS unit_numbers,
|
||||
string_agg(DISTINCT NULLIF(unit.seal_number, ''), ', ') AS seal_numbers
|
||||
FROM freight.booking_container_units unit
|
||||
JOIN freight.booking_container line
|
||||
ON line.id = unit.booking_container_id AND line.deleted_at IS NULL
|
||||
WHERE line.booking_id = b.id AND unit.deleted_at IS NULL
|
||||
) bcu ON true
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT first_mile.id, first_mile.status, first_mile.vehicle_id
|
||||
FROM freight.first_mile first_mile
|
||||
@@ -881,11 +891,18 @@ export class WarehouseInventoryService {
|
||||
}> = [];
|
||||
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
await this.validateLocation(manager, {
|
||||
const { warehouse, yard, zone } = await this.validateLocation(manager, {
|
||||
warehouseId: dto.warehouseId,
|
||||
yardId: dto.yardId,
|
||||
zoneId: dto.zoneId,
|
||||
});
|
||||
// The receive location is whatever the operator selected above — never a
|
||||
// hand-typed string. Stamp it on the truck entrance for the GRN/notes.
|
||||
if (dto.truckEntrance && !dto.truckEntrance.warehouseCodeLocation) {
|
||||
dto.truckEntrance.warehouseCodeLocation = [warehouse.code, yard.code, zone.code]
|
||||
.filter(Boolean)
|
||||
.join(' / ');
|
||||
}
|
||||
|
||||
for (const bookingId of dto.bookingIds) {
|
||||
const skip = (reason: string) => {
|
||||
|
||||
@@ -150,9 +150,6 @@ interface TruckEntranceFormState {
|
||||
assignedEquipmentNumber: string;
|
||||
customsSealNumber: string;
|
||||
declarationNumber: string;
|
||||
incoterms: string;
|
||||
hsCodes: string;
|
||||
itemCode: string;
|
||||
itemDescription: string;
|
||||
packagingType: string;
|
||||
unitCount: number | '';
|
||||
@@ -162,7 +159,6 @@ interface TruckEntranceFormState {
|
||||
volumeDimensions: string;
|
||||
conditionAtReceipt: string;
|
||||
damagedRejectedQuantity: number | '';
|
||||
warehouseCodeLocation: string;
|
||||
driverName: string;
|
||||
driverPhone: string;
|
||||
driverLicenseNumber: string;
|
||||
@@ -205,9 +201,6 @@ const emptyTruckEntrance = (): TruckEntranceFormState => ({
|
||||
assignedEquipmentNumber: '',
|
||||
customsSealNumber: '',
|
||||
declarationNumber: '',
|
||||
incoterms: '',
|
||||
hsCodes: '',
|
||||
itemCode: '',
|
||||
itemDescription: '',
|
||||
packagingType: '',
|
||||
unitCount: '',
|
||||
@@ -217,7 +210,6 @@ const emptyTruckEntrance = (): TruckEntranceFormState => ({
|
||||
volumeDimensions: '',
|
||||
conditionAtReceipt: '',
|
||||
damagedRejectedQuantity: '',
|
||||
warehouseCodeLocation: '',
|
||||
driverName: '',
|
||||
driverPhone: '',
|
||||
driverLicenseNumber: '',
|
||||
@@ -239,9 +231,6 @@ const toTruckEntrancePayload = (form: TruckEntranceFormState): TruckEntrancePayl
|
||||
assignedEquipmentNumber: form.assignedEquipmentNumber.trim() || undefined,
|
||||
customsSealNumber: form.customsSealNumber.trim() || undefined,
|
||||
declarationNumber: form.declarationNumber.trim() || undefined,
|
||||
incoterms: form.incoterms.trim() || undefined,
|
||||
hsCodes: form.hsCodes.trim() || undefined,
|
||||
itemCode: form.itemCode.trim() || undefined,
|
||||
itemDescription: form.itemDescription.trim() || undefined,
|
||||
packagingType: form.packagingType.trim() || undefined,
|
||||
unitCount: form.unitCount === '' ? undefined : Number(form.unitCount),
|
||||
@@ -251,7 +240,6 @@ const toTruckEntrancePayload = (form: TruckEntranceFormState): TruckEntrancePayl
|
||||
volumeDimensions: form.volumeDimensions.trim() || undefined,
|
||||
conditionAtReceipt: form.conditionAtReceipt.trim() || undefined,
|
||||
damagedRejectedQuantity: form.damagedRejectedQuantity === '' ? undefined : Number(form.damagedRejectedQuantity),
|
||||
warehouseCodeLocation: form.warehouseCodeLocation.trim() || undefined,
|
||||
driverName: form.driverName.trim(),
|
||||
driverPhone: form.driverPhone.trim(),
|
||||
driverLicenseNumber: form.driverLicenseNumber.trim() || undefined,
|
||||
@@ -296,6 +284,10 @@ const truckEntranceFromBookings = (bookings: EligibleBooking[]): {
|
||||
const truckType = commonNonEmptyValue(
|
||||
bookings.map((booking) => booking.firstMileTruckType || booking.customerTruckType),
|
||||
);
|
||||
const customsSealNumber = commonNonEmptyValue(bookings.map((booking) => booking.sealNumbers));
|
||||
// Booking's declared cargo weight (tonnes) — the receive-time net until re-weighed.
|
||||
const bookingWeight = bookings.length === 1 ? Number(bookings[0]?.weight ?? '') : NaN;
|
||||
const netWeightKg: number | '' = Number.isFinite(bookingWeight) && bookingWeight > 0 ? bookingWeight : '';
|
||||
const edrDigitalBookingId =
|
||||
bookings.length === 1
|
||||
? bookings[0]?.reference ?? bookings[0]?.id ?? ''
|
||||
@@ -321,9 +313,11 @@ const truckEntranceFromBookings = (bookings: EligibleBooking[]): {
|
||||
customerPhone,
|
||||
edrDigitalBookingId,
|
||||
assignedEquipmentNumber,
|
||||
customsSealNumber,
|
||||
itemDescription,
|
||||
packagingType,
|
||||
unitCount,
|
||||
netWeightKg,
|
||||
grossWeightKg: '',
|
||||
truckPlateNumber,
|
||||
trailerPlateNumber,
|
||||
@@ -331,6 +325,7 @@ const truckEntranceFromBookings = (bookings: EligibleBooking[]): {
|
||||
driverPhone,
|
||||
driverLicenseNumber,
|
||||
truckType,
|
||||
driverSignatoryName: driverName,
|
||||
},
|
||||
lockedFields: {
|
||||
ownerName: Boolean(ownerName),
|
||||
@@ -550,38 +545,19 @@ function TruckEntranceFields({
|
||||
)}
|
||||
|
||||
<Text size="sm" fw={600} mt="xs">Customs and compliance</Text>
|
||||
<Group grow>
|
||||
<TextInput
|
||||
label="Declaration / Bill of Entry number"
|
||||
value={value.declarationNumber}
|
||||
onChange={(e) => onChange({ ...value, declarationNumber: e.currentTarget.value })}
|
||||
/>
|
||||
<TextInput
|
||||
label="Incoterms"
|
||||
value={value.incoterms}
|
||||
onChange={(e) => onChange({ ...value, incoterms: e.currentTarget.value })}
|
||||
/>
|
||||
</Group>
|
||||
<TextInput
|
||||
label="HS codes"
|
||||
value={value.hsCodes}
|
||||
onChange={(e) => onChange({ ...value, hsCodes: e.currentTarget.value })}
|
||||
label="Declaration / Bill of Entry number"
|
||||
value={value.declarationNumber}
|
||||
onChange={(e) => onChange({ ...value, declarationNumber: e.currentTarget.value })}
|
||||
/>
|
||||
|
||||
<Text size="sm" fw={600} mt="xs">Physical cargo specifications</Text>
|
||||
<Group grow>
|
||||
<TextInput
|
||||
label="Item code"
|
||||
value={value.itemCode}
|
||||
onChange={(e) => onChange({ ...value, itemCode: e.currentTarget.value })}
|
||||
/>
|
||||
<TextInput
|
||||
label="Item description"
|
||||
value={value.itemDescription}
|
||||
readOnly={lockedFields?.itemDescription}
|
||||
onChange={(e) => onChange({ ...value, itemDescription: e.currentTarget.value })}
|
||||
/>
|
||||
</Group>
|
||||
<TextInput
|
||||
label="Item description"
|
||||
value={value.itemDescription}
|
||||
readOnly={lockedFields?.itemDescription}
|
||||
onChange={(e) => onChange({ ...value, itemDescription: e.currentTarget.value })}
|
||||
/>
|
||||
<Group grow>
|
||||
<Select
|
||||
label="Packaging type"
|
||||
@@ -626,11 +602,6 @@ function TruckEntranceFields({
|
||||
onChange={(v) => onChange({ ...value, damagedRejectedQuantity: v === '' ? '' : Number(v) })}
|
||||
/>
|
||||
</Group>
|
||||
<TextInput
|
||||
label="Warehouse code and location"
|
||||
value={value.warehouseCodeLocation}
|
||||
onChange={(e) => onChange({ ...value, warehouseCodeLocation: e.currentTarget.value })}
|
||||
/>
|
||||
<Group grow>
|
||||
<TextInput
|
||||
label="Driver signatory"
|
||||
|
||||
@@ -388,6 +388,8 @@ export interface EligibleBooking {
|
||||
customerTin: string | null;
|
||||
customerPhone: string | null;
|
||||
containerNumber: string | null;
|
||||
/** Distinct seal numbers from the booking's container units, comma-joined. */
|
||||
sealNumbers: string | null;
|
||||
containerQuantity: number | null;
|
||||
containerPackagingType: string | null;
|
||||
cargoDescription: string | null;
|
||||
|
||||
Reference in New Issue
Block a user