diff --git a/apps/edr-freight-api/src/migrations/1900000000000-AddLoadingStatusToTrainScheduleBookings.ts b/apps/edr-freight-api/src/migrations/1900000000000-AddLoadingStatusToTrainScheduleBookings.ts new file mode 100644 index 000000000..6c7235e3b --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1900000000000-AddLoadingStatusToTrainScheduleBookings.ts @@ -0,0 +1,25 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Track per-booking loading confirmation (LOADED/UNLOADED) on train_schedule_bookings. + * Tracking only — does not gate dispatch. + */ +export class AddLoadingStatusToTrainScheduleBookings1900000000000 + implements MigrationInterface +{ + name = "AddLoadingStatusToTrainScheduleBookings1900000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.train_schedule_bookings + ADD COLUMN IF NOT EXISTS loading_status varchar(20) NOT NULL DEFAULT 'UNLOADED' + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.train_schedule_bookings + DROP COLUMN IF EXISTS loading_status + `); + } +} diff --git a/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule-booking.entity.ts b/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule-booking.entity.ts index 4ffecea26..352704eea 100644 --- a/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule-booking.entity.ts +++ b/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule-booking.entity.ts @@ -1,9 +1,15 @@ import { BaseEntity } from '@edr/api-common'; +import { LoadingStatus } from '@edr/types'; import { Entity, Index, JoinColumn, ManyToOne, Column } from 'typeorm'; import { Booking } from '../../bookings/entities/booking.entity'; import { TrainSchedule } from './train-schedule.entity'; +export const TRAIN_SCHEDULE_BOOKING_LOADING_STATUSES = [ + LoadingStatus.Unloaded, + LoadingStatus.Loaded, +] as const; + @Entity({ schema: 'freight', name: 'train_schedule_bookings' }) @Index(['trainScheduleId', 'bookingId'], { unique: true }) @Index(['bookingId'], { unique: true }) @@ -23,4 +29,7 @@ export class TrainScheduleBooking extends BaseEntity { @ManyToOne(() => Booking) @JoinColumn({ name: 'booking_id' }) booking?: Booking; + + @Column({ name: 'loading_status', type: 'varchar', length: 20, default: 'UNLOADED' }) + loadingStatus!: string; } diff --git a/apps/edr-freight-api/src/modules/train-schedules/train-schedule-bookings.repository.ts b/apps/edr-freight-api/src/modules/train-schedules/train-schedule-bookings.repository.ts index 4ccfcd469..64607ecb8 100644 --- a/apps/edr-freight-api/src/modules/train-schedules/train-schedule-bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/train-schedules/train-schedule-bookings.repository.ts @@ -47,4 +47,27 @@ export class TrainScheduleBookingsRepository extends BaseRepository { + return this.repo(manager).find({ + where: { trainScheduleId }, + select: { id: true, bookingId: true, trainScheduleId: true, loadingStatus: true }, + }); + } + + async updateLoadingStatusMany( + trainScheduleId: string, + bookingIds: string[], + loadingStatus: string, + manager?: EntityManager, + ): Promise { + if (!bookingIds.length) return; + await this.repo(manager).update( + { trainScheduleId, bookingId: In(bookingIds) }, + { loadingStatus }, + ); + } } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/update-import-loading-status.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/update-import-loading-status.dto.ts new file mode 100644 index 000000000..25943050c --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/update-import-loading-status.dto.ts @@ -0,0 +1,15 @@ +import { LoadingStatus } from '@edr/types'; +import { ApiProperty } from '@nestjs/swagger'; +import { ArrayMinSize, IsArray, IsEnum, IsUUID } from 'class-validator'; + +export class UpdateImportLoadingStatusDto { + @ApiProperty({ type: [String] }) + @IsArray() + @ArrayMinSize(1) + @IsUUID('4', { each: true }) + bookingIds!: string[]; + + @ApiProperty({ enum: LoadingStatus }) + @IsEnum(LoadingStatus) + loadingStatus!: LoadingStatus; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts index 8887fb4c6..d0595c0da 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts @@ -28,6 +28,7 @@ import { GetEligibleBulkBookingsDto } from "./dto/get-eligible-bulk-bookings.dto import { GetEligibleContainerBookingsDto } from "./dto/get-eligible-container-bookings.dto"; import { PinWagonsDto } from "./dto/pin-wagons.dto"; import { UpdateContainerItemDto } from "./dto/update-container-item.dto"; +import { UpdateImportLoadingStatusDto } from "./dto/update-import-loading-status.dto"; import { PreviewBulkTrainScheduleDto } from "./dto/preview-bulk-train-schedule.dto"; import { PreviewContainerTrainScheduleDto } from "./dto/preview-container-train-schedule.dto"; import { PreviewTrainScheduleDto } from "./dto/preview-train-schedule.dto"; @@ -325,6 +326,28 @@ export class TrainSchedulingController { return this.trainSchedulingService.getCompositionRemovals(id); } + @Get("schedules/:id/import-loading-bookings") + @TrainSchedulingView() + @ApiOperation({ + summary: "List import bookings eligible for loading confirmation on this schedule", + }) + getImportLoadingBookings(@Param("id", ParseUUIDPipe) id: string) { + return this.trainSchedulingService.getImportLoadingBookings(id); + } + + @Patch("schedules/:id/import-loading-status") + @TrainSchedulingManage() + @ApiOperation({ + summary: + "Mark import bookings loaded/unloaded on this schedule (tracking only, does not affect dispatch)", + }) + updateImportLoadingStatus( + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: UpdateImportLoadingStatusDto, + ) { + return this.trainSchedulingService.updateImportLoadingStatus(id, dto); + } + @Post("schedules/:id/pin-wagons") @TrainSchedulingManage() @ApiOperation({ summary: "Pin physical wagons to train set slots" }) diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index fbcb82142..2c678ea44 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -1,5 +1,6 @@ import { AllocationLoadType, + LoadingStatus, SchedulingStatus, TrainCheckpointKind, TrainScheduleStatus as TrainScheduleStatusEnum, @@ -45,6 +46,7 @@ import { GetEligibleBulkBookingsDto } from './dto/get-eligible-bulk-bookings.dto import { GetEligibleContainerBookingsDto } from './dto/get-eligible-container-bookings.dto'; import { PinWagonsDto } from './dto/pin-wagons.dto'; import { UpdateContainerItemDto } from './dto/update-container-item.dto'; +import { UpdateImportLoadingStatusDto } from './dto/update-import-loading-status.dto'; import { PreviewBulkTrainScheduleDto } from './dto/preview-bulk-train-schedule.dto'; import { PreviewContainerTrainScheduleDto } from './dto/preview-container-train-schedule.dto'; import { PreviewTrainScheduleDto } from './dto/preview-train-schedule.dto'; @@ -744,6 +746,87 @@ export class TrainSchedulingService { ); } + async getImportLoadingBookings(scheduleId: string) { + const schedule = await this.trainSchedulesRepository.findById(scheduleId); + if (!schedule) { + throw new NotFoundException(`Train schedule ${scheduleId} not found`); + } + + const [scheduleBookings, allocations] = await Promise.all([ + this.trainScheduleBookingsRepository.findByScheduleId(scheduleId), + this.wagonBookingAllocationsRepository.findByScheduleId(scheduleId), + ]); + if (!scheduleBookings.length) { + return { count: 0, items: [] }; + } + + const allocatedBookingIds = new Set(allocations.map((a) => a.bookingId)); + const statusByBookingId = new Map( + scheduleBookings.map((sb) => [sb.bookingId, sb.loadingStatus]), + ); + const candidateIds = scheduleBookings + .map((sb) => sb.bookingId) + .filter((id) => allocatedBookingIds.has(id)); + if (!candidateIds.length) { + return { count: 0, items: [] }; + } + + const bookings = await this.bookingsRepository.findByIdsForScheduling(candidateIds); + const items = bookings + .filter((b) => b.tradeDirection === 'IMPORT' && b.paymentStatus === 'PAID') + .map((b) => ({ + id: b.id, + reference: b.reference ?? null, + customer: b.company?.name ?? null, + weightTons: b.cargoTotalWeightVgm, + loadingStatus: statusByBookingId.get(b.id) ?? LoadingStatus.Unloaded, + })); + return { count: items.length, items }; + } + + async updateImportLoadingStatus(scheduleId: string, dto: UpdateImportLoadingStatusDto) { + const schedule = await this.trainSchedulesRepository.findById(scheduleId); + if (!schedule) { + throw new NotFoundException(`Train schedule ${scheduleId} not found`); + } + + const [scheduleBookings, allocations, bookings] = await Promise.all([ + this.trainScheduleBookingsRepository.findByScheduleId(scheduleId), + this.wagonBookingAllocationsRepository.findByScheduleId(scheduleId), + this.bookingsRepository.findByIdsForScheduling(dto.bookingIds), + ]); + + const scheduledIds = new Set(scheduleBookings.map((sb) => sb.bookingId)); + const allocatedIds = new Set(allocations.map((a) => a.bookingId)); + const bookingById = new Map(bookings.map((b) => [b.id, b])); + + const invalid: string[] = []; + for (const id of dto.bookingIds) { + const booking = bookingById.get(id); + if ( + !scheduledIds.has(id) || + !allocatedIds.has(id) || + !booking || + booking.tradeDirection !== 'IMPORT' || + booking.paymentStatus !== 'PAID' + ) { + invalid.push(id); + } + } + if (invalid.length) { + throw new BadRequestException( + `Not eligible for import loading confirmation on this schedule: ${invalid.join(', ')}`, + ); + } + + await this.trainScheduleBookingsRepository.updateLoadingStatusMany( + scheduleId, + dto.bookingIds, + dto.loadingStatus, + ); + return this.getImportLoadingBookings(scheduleId); + } + async pinWagons(scheduleId: string, dto: PinWagonsDto) { const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); if (!schedule) { diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/filter-inventory.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/filter-inventory.dto.ts index 9f89e7c2c..49fb22fde 100644 --- a/apps/edr-freight-api/src/modules/warehouses/dto/filter-inventory.dto.ts +++ b/apps/edr-freight-api/src/modules/warehouses/dto/filter-inventory.dto.ts @@ -52,6 +52,11 @@ export class FilterWarehouseInventoryDto { @IsEnum(WAREHOUSE_INVENTORY_STATUSES) status?: WarehouseInventoryStatus; + @ApiPropertyOptional({ enum: ['IMPORT', 'EXPORT'] }) + @IsOptional() + @IsEnum(['IMPORT', 'EXPORT']) + direction?: 'IMPORT' | 'EXPORT'; + @ApiPropertyOptional() @IsOptional() @IsString() diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts index 11f34c892..ac6b71c89 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts @@ -430,6 +430,7 @@ export class WarehouseInventoryService { ...(filter.status ? { status: filter.status } : {}), ...(createdAt ? { createdAt } : {}), ...(filter.facilityId ? { warehouse: { stationId: filter.facilityId } } : {}), + ...(filter.direction ? { booking: { tradeDirection: filter.direction } } : {}), }; const search = filter.search?.trim(); @@ -2032,6 +2033,22 @@ export class WarehouseInventoryService { const isTruckLeaving = dto.grossWeight !== undefined && Boolean(dto.gateOutTime); if (isTruckLeaving) { await this.invoices.assertClearanceAllowed(id); + + if (item.bookingId) { + const [truckInfo]: Array<{ customerTruckAssignedAt: string | null }> = + await this.dataSource.query( + `SELECT customer_truck_assigned_at AS "customerTruckAssignedAt" + FROM freight.bookings + WHERE id = $1 AND deleted_at IS NULL`, + [item.bookingId], + ); + const usesCustomerTruck = Boolean(truckInfo?.customerTruckAssignedAt); + if (usesCustomerTruck && !this.extractCustomerDeliveryApproval(item.notes)) { + throw new BadRequestException( + 'Customer must approve delivery (sign the handover) before the exit paper can be generated', + ); + } + } } const releaseDate = isTruckLeaving ? dto.releaseDate ? new Date(dto.releaseDate) : new Date() diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index e7e567c7a..966a84132 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -328,7 +328,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ }, { label: "Terminal Inventory", - href: "/dashboard/warehouse-inventory", + href: "/dashboard/warehouse-inventory?direction=IMPORT", icon: , }, { @@ -375,7 +375,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ }, { label: "Terminal Inventory", - href: "/dashboard/warehouse-inventory", + href: "/dashboard/warehouse-inventory?direction=EXPORT", icon: , }, ], diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/ImportLoadingConfirmationPanel.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/ImportLoadingConfirmationPanel.tsx new file mode 100644 index 000000000..ab1d907e6 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/ImportLoadingConfirmationPanel.tsx @@ -0,0 +1,172 @@ +import { useMemo, useState } from "react"; +import { PackageCheck } from "lucide-react"; +import { Badge, Button, Checkbox, Group, Loader, Paper, Stack, Text } from "@mantine/core"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import toast from "react-hot-toast"; + +import { api } from "@/services/api"; +import type { + ImportLoadingBooking, + ImportLoadingBookingsResponse, + LoadingStatus, +} from "@/types/trainScheduling"; + +function ImportLoadingBookingRow({ + booking, + selected, + onToggle, +}: { + booking: ImportLoadingBooking; + selected: boolean; + onToggle: () => void; +}) { + return ( + + + + + + + {booking.reference ?? booking.id} + + + {booking.loadingStatus} + + + + {booking.customer ?? "Unknown customer"} + + + {booking.weightTons}T + + + + ); +} + +export function ImportLoadingConfirmationPanel({ + scheduleId, + items, + isLoading, +}: { + scheduleId: string; + items: ImportLoadingBooking[]; + isLoading?: boolean; +}) { + const [selectedIds, setSelectedIds] = useState([]); + const queryClient = useQueryClient(); + + const updateStatus = useMutation< + ImportLoadingBookingsResponse, + Error, + { id: string; bookingIds: string[]; loadingStatus: LoadingStatus } + >({ + ...api.trainScheduling.updateImportLoadingStatus.mutationOptions(), + onSuccess: () => { + setSelectedIds([]); + queryClient.invalidateQueries({ + queryKey: api.trainScheduling.importLoadingBookings.queryKey({ id: scheduleId }), + }); + }, + onError: (error) => { + toast.error(error instanceof Error ? error.message : "Could not update loading status"); + }, + }); + + const toggle = (id: string) => { + setSelectedIds((prev) => + prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id], + ); + }; + + const allIds = useMemo(() => items.map((b) => b.id), [items]); + + if (isLoading) { + return ( + + + + Loading import bookings… + + + ); + } + + if (!items.length) { + return ( + + + No paid import bookings with wagons allocated on this schedule + + + ); + } + + return ( + + + + Import bookings ({items.length}) + + + + + + + + + {items.map((booking) => ( + toggle(booking.id)} + /> + ))} + + + + + + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts b/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts index 70a134396..3275ef662 100644 --- a/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts @@ -109,6 +109,8 @@ export const QUERY_KEYS = { ["train-scheduling", "unassigned", id] as const, compositionRemovals: (id: string) => ["train-scheduling", "removals", id] as const, + importLoadingBookings: (id: string) => + ["train-scheduling", "import-loading-bookings", id] as const, }, FLEET: { diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index 083dd0efc..9cc2ad685 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -322,6 +322,10 @@ export const URL_CONSTANTS = { PIN_WAGONS: (id: string) => `/train-scheduling/schedules/${id}/pin-wagons`, FINALIZE: (id: string) => `/train-scheduling/schedules/${id}/finalize`, DISPATCH: (id: string) => `/train-scheduling/schedules/${id}/dispatch`, + IMPORT_LOADING_BOOKINGS: (id: string) => + `/train-scheduling/schedules/${id}/import-loading-bookings`, + IMPORT_LOADING_STATUS: (id: string) => + `/train-scheduling/schedules/${id}/import-loading-status`, IMPORT_DJIBOUTI: (id: string) => `/train-scheduling/schedules/${id}/import-djibouti`, IMPORT_DJIBOUTI_DOCUMENTS: (id: string) => diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx index 9319e6a01..c02eb2c39 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx @@ -42,6 +42,7 @@ import { } from "@/components/trainScheduling/containerPlacement.util"; import { ContainerPlacementGrid } from "@/components/trainScheduling/ContainerPlacementGrid"; import { FleetAvailabilitySummary } from "@/components/trainScheduling/FleetAvailabilitySummary"; +import { ImportLoadingConfirmationPanel } from "@/components/trainScheduling/ImportLoadingConfirmationPanel"; import { RescheduleTrainDialog } from "@/components/trainScheduling/RescheduleTrainDialog"; import { ScheduleBatchPanel } from "@/components/trainScheduling/ScheduleBatchPanel"; import { ScheduleBookingsStep } from "@/components/trainScheduling/ScheduleBookingsStep"; @@ -141,6 +142,13 @@ export default function TrainScheduleV2DetailPage() { }, }); + const importLoadingQuery = useQuery( + api.trainScheduling.importLoadingBookings.queryOptions({ + input: { id: scheduleId ?? "" }, + enabled: Boolean(scheduleId && schedule?.direction === "IMPORT"), + }), + ); + const eligibleFilters = useMemo( () => schedule @@ -951,6 +959,23 @@ export default function TrainScheduleV2DetailPage() { ]} /> + {schedule?.direction === "IMPORT" ? ( + + + Import loading confirmation + + Paid import bookings with a wagon allocated on this schedule. Marking loaded/unloaded + is tracking only — it does not block dispatch. + + + + + ) : null} + {gatepassApplies ? ( diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/ExportDjiboutiUnloadingQueuePage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/ExportDjiboutiUnloadingQueuePage.tsx index 31ee5ea36..609995e3e 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/ExportDjiboutiUnloadingQueuePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/ExportDjiboutiUnloadingQueuePage.tsx @@ -14,7 +14,17 @@ import { Text, } from '@mantine/core'; import { useNavigate } from 'react-router-dom'; -import { ChevronDown, ChevronRight, Eye, FileText, History, PackageOpen, Truck } from 'lucide-react'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { + ChevronDown, + ChevronRight, + Eye, + FileText, + History, + PackageOpen, + ShieldCheck, + Truck, +} from 'lucide-react'; import { PageHeader } from '@/components/page'; import Breadcrumbs from '@/components/ui/Breadcrumbs'; @@ -36,6 +46,7 @@ import { useInterchangeDocuments, } from '@/hooks/useInterchangeDocuments'; import { useToast } from '@/hooks/use-toast'; +import { trainSchedulingService } from '@/services/trainScheduling.service'; import type { AutoUnloadExportDjiboutiResult, ExportTrain, @@ -179,6 +190,14 @@ export default function ExportDjiboutiUnloadingQueuePage() { const { data: interchangeDocuments = [] } = useInterchangeDocuments({ direction: 'EXPORT' }); const autoUnload = useAutoUnloadExportAtDjibouti(); const generateInterchange = useGenerateInterchangeDocument(); + const qc = useQueryClient(); + const secureGatePass = useMutation({ + mutationFn: (scheduleId: string) => trainSchedulingService.grantImportDjiboutiGatepass(scheduleId), + onSuccess: () => + qc.invalidateQueries({ + queryKey: ['warehouse-inventory', 'export-djibouti-arrival-queue'], + }), + }); const [openScheduleId, setOpenScheduleId] = useState(null); const [busyScheduleId, setBusyScheduleId] = useState(null); const [historyInventoryId, setHistoryInventoryId] = useState(null); @@ -189,6 +208,25 @@ export default function ExportDjiboutiUnloadingQueuePage() { .map((doc) => [doc.scheduleId as string, doc]), ); + const secureGate = async (train: ExportTrain) => { + setBusyScheduleId(train.scheduleId); + try { + await secureGatePass.mutateAsync(train.scheduleId); + toast({ + title: 'Gate pass secured', + description: `Djibouti Port entry allowed for ${train.trainNumber ?? 'the train'}. You can now auto unload.`, + }); + } catch (error) { + toast({ + variant: 'destructive', + title: 'Could not secure gate pass', + description: getErrorMessage(error), + }); + } finally { + setBusyScheduleId(null); + } + }; + const unloadTrain = async (train: ExportTrain) => { setBusyScheduleId(train.scheduleId); try { @@ -346,6 +384,16 @@ export default function ExportDjiboutiUnloadingQueuePage() { > Open +