fix tracker

This commit is contained in:
natib21
2026-07-07 06:39:02 +00:00
parent 8328543686
commit 75427ea63a
15 changed files with 1148 additions and 337 deletions

View File

@@ -83,6 +83,7 @@ import { MaintenanceModule } from "./modules/maintenance/maintenance.module";
import { ComplianceModule } from "./modules/compliance/compliance.module";
import { IncidentsModule } from "./modules/incidents/incidents.module";
import { ProcurementModule } from "./modules/procurement/procurement.module";
import { GpsTrackingModule } from "./modules/gps-tracking/gps-tracking.module";
import { FirstMileModule } from "./modules/first-mile/first-mile.module";
import { LastMileModule } from "./modules/last-mile/last-mile.module";
import { InterchangeDocumentsModule } from "./modules/interchange-documents/interchange-documents.module";
@@ -154,6 +155,7 @@ import { LoggerMiddleware } from "./logger.middleware";
ComplianceModule,
IncidentsModule,
ProcurementModule,
GpsTrackingModule,
FirstMileModule,
LastMileModule,
InterchangeDocumentsModule,

View File

@@ -0,0 +1,69 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* GPS tracking: physical trackers (gps_devices, one denormalized latest fix per
* device for the live map) + append-only fix history (gps_positions).
*/
export class AddGpsTracking2000000000000 implements MigrationInterface {
name = "AddGpsTracking2000000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.gps_devices (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
imei varchar(20) NOT NULL UNIQUE,
name varchar,
vehicle_id uuid REFERENCES freight.vehicles(id),
status varchar(16) NOT NULL DEFAULT 'REGISTERED',
last_seen_at timestamptz,
last_lat numeric(10,6),
last_lng numeric(10,6),
last_speed numeric(6,2),
last_course int,
last_fix_at timestamptz,
voltage_level int,
gsm_level int,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz
)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_GPS_DEVICES_VEHICLE"
ON freight.gps_devices (vehicle_id)
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.gps_positions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
device_id uuid NOT NULL,
imei varchar(20) NOT NULL,
vehicle_id uuid,
lat numeric(10,6) NOT NULL,
lng numeric(10,6) NOT NULL,
speed numeric(6,2) NOT NULL DEFAULT 0,
course int NOT NULL DEFAULT 0,
satellites int NOT NULL DEFAULT 0,
positioned boolean NOT NULL DEFAULT false,
gps_time timestamptz NOT NULL,
alarm int NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz
)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_GPS_POSITIONS_DEVICE_TIME"
ON freight.gps_positions (device_id, gps_time)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_GPS_POSITIONS_VEHICLE_TIME"
ON freight.gps_positions (vehicle_id, gps_time)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.gps_positions`);
await queryRunner.query(`DROP TABLE IF EXISTS freight.gps_devices`);
}
}

View File

@@ -0,0 +1,24 @@
import { IsOptional, IsString, IsUUID } from 'class-validator';
export class RegisterDeviceDto {
@IsString()
imei!: string;
@IsOptional()
@IsString()
name?: string;
@IsOptional()
@IsUUID()
vehicleId?: string;
}
export class UpdateDeviceDto {
@IsOptional()
@IsString()
name?: string;
@IsOptional()
@IsUUID()
vehicleId?: string | null;
}

View File

@@ -0,0 +1,55 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { Vehicle } from '../../vehicles/entities/vehicle.entity';
/**
* A physical GPS tracker (GT06). Identified by IMEI, optionally bound to a
* vehicle. Carries the denormalized latest fix so the live map reads one row
* per device without scanning position history.
*/
@Entity({ name: 'gps_devices', schema: 'freight' })
@Index(['vehicleId'])
export class GpsDevice extends BaseEntity {
@Column({ name: 'imei', type: 'varchar', length: 20, unique: true })
imei!: string;
@Column({ name: 'name', type: 'varchar', nullable: true })
name?: string | null;
@Column({ name: 'vehicle_id', type: 'uuid', nullable: true })
vehicleId?: string | null;
@ManyToOne(() => Vehicle, { nullable: true, eager: false })
@JoinColumn({ name: 'vehicle_id' })
vehicle?: Vehicle | null;
/** ONLINE once a packet arrives; OFFLINE when stale (derived on read). */
@Column({ name: 'status', type: 'varchar', length: 16, default: 'REGISTERED' })
status!: string;
@Column({ name: 'last_seen_at', type: 'timestamptz', nullable: true })
lastSeenAt?: Date | null;
// ── Denormalized latest fix ──
@Column({ name: 'last_lat', type: 'numeric', precision: 10, scale: 6, nullable: true })
lastLat?: number | null;
@Column({ name: 'last_lng', type: 'numeric', precision: 10, scale: 6, nullable: true })
lastLng?: number | null;
@Column({ name: 'last_speed', type: 'numeric', precision: 6, scale: 2, nullable: true })
lastSpeed?: number | null;
@Column({ name: 'last_course', type: 'int', nullable: true })
lastCourse?: number | null;
@Column({ name: 'last_fix_at', type: 'timestamptz', nullable: true })
lastFixAt?: Date | null;
@Column({ name: 'voltage_level', type: 'int', nullable: true })
voltageLevel?: number | null;
@Column({ name: 'gsm_level', type: 'int', nullable: true })
gsmLevel?: number | null;
}

View File

@@ -0,0 +1,43 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index } from 'typeorm';
/** One GPS fix from a tracker (append-only history). */
@Entity({ name: 'gps_positions', schema: 'freight' })
@Index(['deviceId', 'gpsTime'])
@Index(['vehicleId', 'gpsTime'])
export class GpsPosition extends BaseEntity {
@Column({ name: 'device_id', type: 'uuid' })
deviceId!: string;
@Column({ name: 'imei', type: 'varchar', length: 20 })
imei!: string;
@Column({ name: 'vehicle_id', type: 'uuid', nullable: true })
vehicleId?: string | null;
@Column({ name: 'lat', type: 'numeric', precision: 10, scale: 6 })
lat!: number;
@Column({ name: 'lng', type: 'numeric', precision: 10, scale: 6 })
lng!: number;
@Column({ name: 'speed', type: 'numeric', precision: 6, scale: 2, default: 0 })
speed!: number;
@Column({ name: 'course', type: 'int', default: 0 })
course!: number;
@Column({ name: 'satellites', type: 'int', default: 0 })
satellites!: number;
@Column({ name: 'positioned', type: 'boolean', default: false })
positioned!: boolean;
/** Fix time reported by the device (UTC). */
@Column({ name: 'gps_time', type: 'timestamptz' })
gpsTime!: Date;
/** Non-zero when the fix came in via an alarm packet. */
@Column({ name: 'alarm', type: 'int', default: 0 })
alarm!: number;
}

View File

@@ -0,0 +1,66 @@
import {
Body,
Controller,
Delete,
Get,
Param,
ParseUUIDPipe,
Patch,
Post,
Query,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { FleetManage, FleetView } from '../../common/booking-guards';
import { GpsTrackingService } from './gps-tracking.service';
import { RegisterDeviceDto, UpdateDeviceDto } from './dto/gps-device.dto';
@ApiTags('gps-tracking')
@ApiBearerAuth()
@Controller('gps')
@FleetView()
export class GpsTrackingController {
constructor(private readonly gps: GpsTrackingService) {}
@Get('positions/latest')
@ApiOperation({ summary: 'Latest fix per device (live map feed)' })
latest() {
return this.gps.latest();
}
@Get('positions/:vehicleId/history')
@ApiOperation({ summary: 'Position history for a vehicle' })
history(
@Param('vehicleId', ParseUUIDPipe) vehicleId: string,
@Query('limit') limit?: string,
) {
return this.gps.history(vehicleId, limit ? parseInt(limit, 10) : undefined);
}
@Get('devices')
@ApiOperation({ summary: 'List GPS trackers' })
listDevices() {
return this.gps.listDevices();
}
@Post('devices')
@FleetManage()
@ApiOperation({ summary: 'Register a GPS tracker' })
register(@Body() dto: RegisterDeviceDto) {
return this.gps.registerDevice(dto);
}
@Patch('devices/:id')
@FleetManage()
@ApiOperation({ summary: 'Update a GPS tracker (name / assigned vehicle)' })
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateDeviceDto) {
return this.gps.updateDevice(id, dto);
}
@Delete('devices/:id')
@FleetManage()
@ApiOperation({ summary: 'Delete a GPS tracker' })
remove(@Param('id', ParseUUIDPipe) id: string) {
return this.gps.removeDevice(id);
}
}

View File

@@ -0,0 +1,17 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { GpsDevice } from './entities/gps-device.entity';
import { GpsPosition } from './entities/gps-position.entity';
import { GpsDeviceRepository, GpsPositionRepository } from './gps-tracking.repository';
import { GpsTrackingService } from './gps-tracking.service';
import { GpsTrackingController } from './gps-tracking.controller';
import { Gt06Server } from './gt06/gt06.server';
@Module({
imports: [TypeOrmModule.forFeature([GpsDevice, GpsPosition])],
controllers: [GpsTrackingController],
providers: [GpsDeviceRepository, GpsPositionRepository, GpsTrackingService, Gt06Server],
exports: [GpsTrackingService],
})
export class GpsTrackingModule {}

View File

@@ -0,0 +1,29 @@
import { BaseRepository } from '@edr/api-common';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { GpsDevice } from './entities/gps-device.entity';
import { GpsPosition } from './entities/gps-position.entity';
@Injectable()
export class GpsDeviceRepository extends BaseRepository<GpsDevice> {
constructor(
@InjectRepository(GpsDevice) repository: Repository<GpsDevice>,
) {
super(repository);
}
findByImei(imei: string): Promise<GpsDevice | null> {
return this.repository.findOne({ where: { imei } });
}
}
@Injectable()
export class GpsPositionRepository extends BaseRepository<GpsPosition> {
constructor(
@InjectRepository(GpsPosition) repository: Repository<GpsPosition>,
) {
super(repository);
}
}

View File

@@ -0,0 +1,124 @@
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { GpsDeviceRepository, GpsPositionRepository } from './gps-tracking.repository';
import { GpsDevice } from './entities/gps-device.entity';
import { Gt06Gps, Gt06Status } from './gt06/gt06.codec';
/** A device is considered ONLINE if seen within this window. */
const ONLINE_WINDOW_MS = 5 * 60 * 1000;
@Injectable()
export class GpsTrackingService {
private readonly logger = new Logger(GpsTrackingService.name);
constructor(
private readonly devices: GpsDeviceRepository,
private readonly positions: GpsPositionRepository,
) {}
private isOnline(d: GpsDevice): boolean {
return Boolean(d.lastSeenAt && Date.now() - new Date(d.lastSeenAt).getTime() < ONLINE_WINDOW_MS);
}
/** Find the device for an IMEI, auto-registering it on first contact. */
private async ensureDevice(imei: string): Promise<GpsDevice> {
const existing = await this.devices.findByImei(imei);
if (existing) return existing;
this.logger.log(`Auto-registering new GPS tracker ${imei}`);
return this.devices.create({ imei, status: 'REGISTERED', lastSeenAt: new Date() });
}
// ── Ingestion (called by the TCP server) ──
async handleLogin(imei: string): Promise<void> {
const device = await this.ensureDevice(imei);
await this.devices.update(device.id, { lastSeenAt: new Date(), status: 'ONLINE' });
}
async handleHeartbeat(imei: string, status: Gt06Status): Promise<void> {
const device = await this.ensureDevice(imei);
await this.devices.update(device.id, {
lastSeenAt: new Date(),
status: 'ONLINE',
voltageLevel: status.voltageLevel,
gsmLevel: status.gsmLevel,
});
}
async handleFix(imei: string, gps: Gt06Gps, alarm = 0, status?: Gt06Status): Promise<void> {
const device = await this.ensureDevice(imei);
const now = new Date();
await this.devices.update(device.id, {
lastSeenAt: now,
status: 'ONLINE',
lastLat: gps.latitude,
lastLng: gps.longitude,
lastSpeed: gps.speed,
lastCourse: gps.course,
lastFixAt: new Date(gps.time),
...(status ? { voltageLevel: status.voltageLevel, gsmLevel: status.gsmLevel } : {}),
});
await this.positions.create({
deviceId: device.id,
imei,
vehicleId: device.vehicleId ?? null,
lat: gps.latitude,
lng: gps.longitude,
speed: gps.speed,
course: gps.course,
satellites: gps.satellites,
positioned: gps.positioned,
gpsTime: new Date(gps.time),
alarm,
});
}
// ── Queries / management (REST) ──
private decorate(d: GpsDevice) {
return { ...d, online: this.isOnline(d) };
}
async listDevices() {
const rows = await this.devices.findAll({ relations: { vehicle: true }, order: { createdAt: 'DESC' } });
return rows.map((d) => this.decorate(d));
}
/** Live map feed — devices that have at least one fix. */
async latest() {
const rows = await this.devices.findAll({ relations: { vehicle: true } });
return rows.filter((d) => d.lastLat != null && d.lastLng != null).map((d) => this.decorate(d));
}
async history(vehicleId: string, limit = 200) {
return this.positions.findAll({
where: { vehicleId },
order: { gpsTime: 'DESC' },
take: Math.min(limit, 1000),
});
}
async registerDevice(dto: { imei: string; name?: string; vehicleId?: string | null }) {
const existing = await this.devices.findByImei(dto.imei);
if (existing) throw new BadRequestException(`A device with IMEI ${dto.imei} already exists`);
return this.devices.create({
imei: dto.imei,
name: dto.name ?? null,
vehicleId: dto.vehicleId ?? null,
status: 'REGISTERED',
});
}
async updateDevice(id: string, dto: { name?: string; vehicleId?: string | null }) {
const updated = await this.devices.update(id, {
...(dto.name !== undefined ? { name: dto.name } : {}),
...(dto.vehicleId !== undefined ? { vehicleId: dto.vehicleId } : {}),
});
if (!updated) throw new NotFoundException(`GPS device ${id} not found`);
return updated;
}
async removeDevice(id: string): Promise<void> {
await this.devices.softDelete(id);
}
}

View File

@@ -0,0 +1,207 @@
/**
* GT06 GPS-tracker protocol codec.
*
* Frame: 0x78 0x78 | len(1) | protocol(1) | content(N) | serial(2) | crc(2) | 0x0D 0x0A
* `len` counts protocol..crc (= 5 + N). CRC-ITU (CRC-16/X.25) is computed over
* len..serial (inclusive) and equals the 2 crc bytes.
*/
const START = 0x7878;
const STOP = 0x0d0a;
export const GT06_PROTOCOL = {
LOGIN: 0x01,
LOCATION: 0x12,
HEARTBEAT: 0x13,
STRING: 0x15,
ALARM: 0x16,
ADDRESS_BY_PHONE: 0x1a,
SERVER_COMMAND: 0x80,
} as const;
/** CRC-16/X.25 (a.k.a. CRC-ITU) used by GT06 — reflected, poly 0x8408, init/xorout 0xFFFF. */
export function crcItu(bytes: Buffer): number {
let fcs = 0xffff;
for (const b of bytes) {
fcs ^= b;
for (let i = 0; i < 8; i++) {
fcs = fcs & 1 ? (fcs >> 1) ^ 0x8408 : fcs >> 1;
}
}
return (~fcs) & 0xffff;
}
export interface Gt06Gps {
time: string; // ISO (UTC)
satellites: number;
latitude: number;
longitude: number;
speed: number; // km/h
course: number; // 0-360
positioned: boolean;
}
export interface Gt06Lbs {
mcc: number;
mnc: number;
lac: number;
cellId: number;
}
export interface Gt06Status {
terminalInfo: number;
voltageLevel: number;
gsmLevel: number;
alarm: number; // former byte of alarm/language
charging: boolean;
accOn: boolean;
gpsTracking: boolean;
oilCut: boolean;
}
export type Gt06Packet =
| { type: 'login'; protocol: number; serial: number; imei: string }
| { type: 'location'; protocol: number; serial: number; gps: Gt06Gps; lbs: Gt06Lbs }
| { type: 'heartbeat'; protocol: number; serial: number; status: Gt06Status }
| { type: 'alarm'; protocol: number; serial: number; gps: Gt06Gps; lbs: Gt06Lbs; status: Gt06Status }
| { type: 'unknown'; protocol: number; serial: number };
/** Terminal ID (8 BCD bytes) → 15-digit IMEI (drops the leading pad nibble). */
function decodeImei(buf: Buffer): string {
return buf.toString('hex').replace(/^0/, '');
}
function decodeDateTime(buf: Buffer, off: number): string {
const year = 2000 + buf[off];
const month = buf[off + 1];
const day = buf[off + 2];
const hour = buf[off + 3];
const min = buf[off + 4];
const sec = buf[off + 5];
return new Date(Date.UTC(year, month - 1, day, hour, min, sec)).toISOString();
}
/** Convert a GT06 lat/long raw uint32 to decimal degrees (magnitude only). */
function rawToDegrees(raw: number): number {
return raw / 30000 / 60;
}
function decodeGps(buf: Buffer, off: number): Gt06Gps {
const time = decodeDateTime(buf, off);
const lenSat = buf[off + 6];
const satellites = lenSat & 0x0f;
const latRaw = buf.readUInt32BE(off + 7);
const lonRaw = buf.readUInt32BE(off + 11);
const speed = buf[off + 15];
const cs = buf.readUInt16BE(off + 16);
const hi = (cs >> 8) & 0xff;
const positioned = Boolean(hi & 0x10); // BYTE_1 Bit4
const isWest = Boolean(hi & 0x08); // BYTE_1 Bit3 (1 = West)
const isNorth = Boolean(hi & 0x04); // BYTE_1 Bit2 (1 = North)
const course = cs & 0x03ff; // BYTE_1 Bit1-0 + BYTE_2
let latitude = rawToDegrees(latRaw);
let longitude = rawToDegrees(lonRaw);
if (!isNorth) latitude = -latitude;
if (isWest) longitude = -longitude;
return { time, satellites, latitude, longitude, speed, course, positioned };
}
function decodeStatus(buf: Buffer, off: number): Gt06Status {
const terminalInfo = buf[off];
const voltageLevel = buf[off + 1];
const gsmLevel = buf[off + 2];
const alarm = buf[off + 3]; // alarm/language former byte
return {
terminalInfo,
voltageLevel,
gsmLevel,
alarm,
oilCut: Boolean(terminalInfo & 0x80),
gpsTracking: Boolean(terminalInfo & 0x40),
charging: Boolean(terminalInfo & 0x04),
accOn: Boolean(terminalInfo & 0x02),
};
}
function decodeLbs(buf: Buffer, off: number): Gt06Lbs {
return {
mcc: buf.readUInt16BE(off),
mnc: buf[off + 2],
lac: buf.readUInt16BE(off + 3),
cellId: buf.readUIntBE(off + 5, 3),
};
}
function decodeFrame(frame: Buffer): Gt06Packet | null {
// frame = 78 78 len ...content... serial(2) crc(2) 0D 0A
const len = frame[2];
const protocol = frame[3];
const serialOff = 3 + (len - 4); // after protocol + content, before serial(2)+crc(2)
const serial = frame.readUInt16BE(serialOff);
const contentOff = 4; // start of content (after protocol)
switch (protocol) {
case GT06_PROTOCOL.LOGIN:
return { type: 'login', protocol, serial, imei: decodeImei(frame.subarray(contentOff, contentOff + 8)) };
case GT06_PROTOCOL.LOCATION:
return { type: 'location', protocol, serial, gps: decodeGps(frame, contentOff), lbs: decodeLbs(frame, contentOff + 18) };
case GT06_PROTOCOL.HEARTBEAT:
return { type: 'heartbeat', protocol, serial, status: decodeStatus(frame, contentOff) };
case GT06_PROTOCOL.ALARM: {
const gps = decodeGps(frame, contentOff);
// content: date(6)+lenSat(1)+lat(4)+lng(4)+speed(1)+course(2)=18, lbsLen(1), lbs(8), status(1+1+1+2)
const lbs = decodeLbs(frame, contentOff + 18 + 1);
const status = decodeStatus(frame, contentOff + 18 + 1 + 8);
return { type: 'alarm', protocol, serial, gps, lbs, status };
}
default:
return { type: 'unknown', protocol, serial };
}
}
/**
* Pull all complete frames out of a stream buffer. Returns the decoded packets
* (skipping CRC-failed ones) and the trailing bytes that form a partial frame.
*/
export function parseStream(buffer: Buffer): { packets: Gt06Packet[]; rest: Buffer } {
const packets: Gt06Packet[] = [];
let i = 0;
while (i + 5 <= buffer.length) {
if (buffer.readUInt16BE(i) !== START) {
i += 1; // resync
continue;
}
const len = buffer[i + 2];
const frameLen = 2 + 1 + len + 2; // start + lenByte + (protocol..crc) + stop
if (i + frameLen > buffer.length) break; // incomplete
const frame = buffer.subarray(i, i + frameLen);
if (frame.readUInt16BE(frameLen - 2) === STOP) {
// CRC over len..serial (frame[2 .. frameLen-4]); crc bytes are frameLen-4..frameLen-3.
const crcCalc = crcItu(frame.subarray(2, frameLen - 4));
const crcRecv = frame.readUInt16BE(frameLen - 4);
if (crcCalc === crcRecv) {
const pkt = decodeFrame(frame);
if (pkt) packets.push(pkt);
}
i += frameLen;
} else {
i += 1; // bad frame, resync
}
}
return { packets, rest: buffer.subarray(i) };
}
/** Build a server → terminal ACK (login/heartbeat/alarm) echoing the serial. */
export function buildAck(protocol: number, serial: number): Buffer {
const body = Buffer.alloc(3); // protocol + serial(2)
body[0] = protocol;
body.writeUInt16BE(serial, 1);
const len = body.length + 2; // + crc(2)
const forCrc = Buffer.concat([Buffer.from([len]), body]);
const crc = crcItu(forCrc);
return Buffer.concat([
Buffer.from([0x78, 0x78, len]),
body,
Buffer.from([(crc >> 8) & 0xff, crc & 0xff, 0x0d, 0x0a]),
]);
}

View File

@@ -0,0 +1,96 @@
import { Injectable, Logger, OnApplicationBootstrap, OnModuleDestroy } from '@nestjs/common';
import * as net from 'net';
import { GpsTrackingService } from '../gps-tracking.service';
import { buildAck, GT06_PROTOCOL, parseStream } from './gt06.codec';
interface Session {
buffer: Buffer;
imei: string | null;
}
const MAX_BUFFER = 64 * 1024;
/**
* Raw TCP listener for GT06 GPS trackers. Trackers open a socket, send a login
* (IMEI), then stream location/heartbeat/alarm packets; we decode, persist via
* {@link GpsTrackingService}, and ACK login/heartbeat/alarm so the device keeps
* the connection alive. Disabled when GT06_TCP_PORT=0.
*/
@Injectable()
export class Gt06Server implements OnApplicationBootstrap, OnModuleDestroy {
private readonly logger = new Logger(Gt06Server.name);
private server?: net.Server;
private readonly sessions = new Map<net.Socket, Session>();
constructor(private readonly gps: GpsTrackingService) {}
onApplicationBootstrap(): void {
const port = Number(process.env.GT06_TCP_PORT ?? 5023);
if (!port) {
this.logger.log('GT06 TCP listener disabled (GT06_TCP_PORT=0)');
return;
}
this.server = net.createServer((socket) => this.onConnection(socket));
this.server.on('error', (err) => this.logger.error(`GT06 server error: ${String(err)}`));
this.server.listen(port, () => this.logger.log(`GT06 GPS tracker listener on tcp/${port}`));
}
onModuleDestroy(): void {
for (const socket of this.sessions.keys()) socket.destroy();
this.sessions.clear();
this.server?.close();
}
private onConnection(socket: net.Socket): void {
this.sessions.set(socket, { buffer: Buffer.alloc(0), imei: null });
socket.on('data', (chunk) => void this.onData(socket, chunk));
socket.on('error', () => this.sessions.delete(socket));
socket.on('close', () => this.sessions.delete(socket));
}
private async onData(socket: net.Socket, chunk: Buffer): Promise<void> {
const session = this.sessions.get(socket);
if (!session) return;
session.buffer = Buffer.concat([session.buffer, chunk]);
if (session.buffer.length > MAX_BUFFER) session.buffer = Buffer.alloc(0); // drop garbage
const { packets, rest } = parseStream(session.buffer);
session.buffer = rest;
for (const pkt of packets) {
try {
await this.handle(socket, session, pkt);
} catch (err) {
this.logger.error(`Failed to handle GT06 packet (${pkt.type}): ${String(err)}`);
}
}
}
private async handle(
socket: net.Socket,
session: Session,
pkt: ReturnType<typeof parseStream>['packets'][number],
): Promise<void> {
switch (pkt.type) {
case 'login':
session.imei = pkt.imei;
await this.gps.handleLogin(pkt.imei);
socket.write(buildAck(GT06_PROTOCOL.LOGIN, pkt.serial));
break;
case 'heartbeat':
if (session.imei) await this.gps.handleHeartbeat(session.imei, pkt.status);
socket.write(buildAck(GT06_PROTOCOL.HEARTBEAT, pkt.serial));
break;
case 'location':
if (session.imei) await this.gps.handleFix(session.imei, pkt.gps);
break;
case 'alarm':
if (session.imei) await this.gps.handleFix(session.imei, pkt.gps, pkt.status.alarm, pkt.status);
socket.write(buildAck(GT06_PROTOCOL.ALARM, pkt.serial));
break;
default:
break;
}
}
}

View File

@@ -22,6 +22,7 @@
"@tabler/icons-react": "^3.44.0",
"@tanstack/react-query": "^5.100.11",
"@tria-plc/iamui": "file:../../../local-packages/tria-plc-iamui-0.1.1.tgz",
"@vis.gl/react-google-maps": "^1.8.3",
"axios": "^1.7.7",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -44,6 +45,7 @@
"@edr/eslint-config": "workspace:*",
"@edr/tsconfig": "workspace:*",
"@tailwindcss/vite": "^4.3.0",
"@types/google.maps": "^3.65.2",
"@types/react": "^18.3.11",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.2",

View File

@@ -1,368 +1,391 @@
import { useState, useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Container, Grid, Card, Stack, Group, Select, Text, Badge, Button, Box, Table, SimpleGrid } from '@mantine/core';
import { MapPin, Navigation, Radio, Activity } from 'lucide-react';
import Breadcrumbs from '@/components/ui/Breadcrumbs';
import { QUERY_KEYS } from '@/constants/QUERY_KEYS';
import { vehiclesService } from '@/services/vehicles.service';
import { freightBrand } from '@/theme/freight-brand';
import { useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
ActionIcon,
Badge,
Box,
Button,
Card,
Container,
Grid,
Group,
Modal,
Select,
SimpleGrid,
Stack,
Table,
Text,
TextInput,
} from "@mantine/core";
import {
APIProvider,
Map as GoogleMap,
Marker,
useMap,
} from "@vis.gl/react-google-maps";
import { Activity, Plus, Radio, Trash2 } from "lucide-react";
import Breadcrumbs from "@/components/ui/Breadcrumbs";
import { useToast } from "@/hooks/use-toast";
import { vehiclesService } from "@/services/vehicles.service";
import { gpsTrackingService, type GpsDevice } from "@/services/gps-tracking.service";
import { freightBrand } from "@/theme/freight-brand";
interface Vehicle {
id: string;
registrationNumber: string;
plateNumber: string;
manufacturer: string;
model: string;
status?: string;
// Same default key + env override the portal's LocationPicker uses.
const GOOGLE_MAPS_API_KEY =
import.meta.env.VITE_GOOGLE_MAPS_API_KEY ||
"AIzaSyBg4tN31-fgvH_2Ix_TPo6VSfOA2uA5CCI";
const DEFAULT_CENTER = { lat: 9.03, lng: 38.74 }; // Addis Ababa
const toNum = (v: number | string | null | undefined): number | null =>
v == null || v === "" ? null : Number(v);
const deviceLabel = (d: GpsDevice) =>
d.vehicle
? [d.vehicle.code, d.vehicle.plateNumber].filter(Boolean).join(" · ")
: d.name || d.imei;
const fmtTime = (iso?: string | null) => {
if (!iso) return "—";
const d = new Date(iso);
return Number.isNaN(d.getTime()) ? "—" : d.toLocaleString();
};
const StatBox = ({ label, value }: { label: string; value: string }) => (
<Box p="sm" style={{ backgroundColor: "#f8f9fa", borderRadius: 8 }}>
<Text size="xs" c="dimmed">{label}</Text>
<Text fw={600} size="sm">{value}</Text>
</Box>
);
type LatLng = { lat: number; lng: number };
/** Fit the map to the current markers (or center on a single one). */
function FitBounds({ points }: { points: LatLng[] }) {
const map = useMap();
useEffect(() => {
if (!map || points.length === 0 || typeof google === "undefined") return;
if (points.length === 1) {
map.setCenter(points[0]);
map.setZoom(14);
return;
}
const b = new google.maps.LatLngBounds();
points.forEach((p) => b.extend(p));
map.fitBounds(b, 60);
}, [map, points]);
return null;
}
interface GPSLocation {
lat: number;
lng: number;
speed?: number;
heading?: number;
lastUpdate?: string;
/** Draw the selected vehicle's recent path as a polyline. */
function RouteTrail({ path }: { path: LatLng[] }) {
const map = useMap();
useEffect(() => {
if (!map || path.length < 2 || typeof google === "undefined") return;
const line = new google.maps.Polyline({
path,
strokeColor: freightBrand.primary,
strokeOpacity: 0.85,
strokeWeight: 4,
});
line.setMap(map);
return () => line.setMap(null);
}, [map, path]);
return null;
}
// Mock GPS data for demo (no real GPS backend exists — these are simulated values)
const generateMockGPS = (): GPSLocation => ({
lat: 9.0 + Math.random() * 0.5,
lng: 38.7 + Math.random() * 0.5,
speed: Math.floor(Math.random() * 120),
heading: Math.floor(Math.random() * 360),
lastUpdate: new Date(Date.now() - Math.random() * 300000).toLocaleTimeString(),
});
export function TrackingPage() {
const [selectedVehicleId, setSelectedVehicleId] = useState<string | null>(null);
const [mapCenter] = useState({ lat: 9.0, lng: 38.8 });
const { toast } = useToast();
const qc = useQueryClient();
const [selectedId, setSelectedId] = useState<string | null>(null);
const [registerOpen, setRegisterOpen] = useState(false);
const [form, setForm] = useState({ imei: "", name: "", vehicleId: "" });
const { data: vehicles = [] } = useQuery({
queryKey: QUERY_KEYS.VEHICLES.list(),
queryFn: async () => {
const res = await vehiclesService.getAll({ limit: 1000 });
return res.data || [];
// Poll every 10s so the map tracks live movement.
const { data: devices = [] } = useQuery({
queryKey: ["gps", "devices"],
queryFn: async () => (await gpsTrackingService.listDevices()).data ?? [],
refetchInterval: 10_000,
});
const { data: vehiclesData } = useQuery({
queryKey: ["vehicles", "all"],
queryFn: async () => (await vehiclesService.getAll({ limit: 1000 })).data ?? [],
});
const vehicleOptions = useMemo(
() =>
(vehiclesData ?? []).map((v) => ({
value: v.id,
label: `${v.plateNumber} - ${v.manufacturer} ${v.model}`,
})),
[vehiclesData],
);
const positioned = useMemo(
() =>
devices
.map((d) => ({ d, lat: toNum(d.lastLat), lng: toNum(d.lastLng) }))
.filter((x): x is { d: GpsDevice; lat: number; lng: number } => x.lat != null && x.lng != null),
[devices],
);
const selected = devices.find((d) => d.id === selectedId) ?? null;
const onlineCount = devices.filter((d) => d.online).length;
// Route history for the selected device's vehicle (chronological trail).
const { data: history = [] } = useQuery({
queryKey: ["gps", "history", selected?.vehicleId],
queryFn: async () => (await gpsTrackingService.history(selected!.vehicleId!, 300)).data ?? [],
enabled: Boolean(selected?.vehicleId),
});
const trail = useMemo(
() => [...history].reverse().map((h) => ({ lat: Number(h.lat), lng: Number(h.lng) })),
[history],
);
const markerIcon = (d: GpsDevice, selectedFlag: boolean) => {
if (typeof google === "undefined") return undefined;
return {
path: "M 0,-9 L 6,9 L 0,4 L -6,9 Z", // arrow
rotation: d.lastCourse ?? 0,
fillColor: selectedFlag ? freightBrand.primary : d.online ? "#2f80ed" : "#95a5a6",
fillOpacity: 1,
strokeColor: "#ffffff",
strokeWeight: 1.5,
scale: 1.4,
} as google.maps.Symbol;
};
const registerMutation = useMutation({
mutationFn: () =>
gpsTrackingService.register({
imei: form.imei.trim(),
name: form.name.trim() || undefined,
vehicleId: form.vehicleId || null,
}),
onSuccess: () => {
toast({ title: "Tracker registered" });
setRegisterOpen(false);
setForm({ imei: "", name: "", vehicleId: "" });
void qc.invalidateQueries({ queryKey: ["gps", "devices"] });
},
onError: (err: unknown) => {
const description =
(err as { response?: { data?: { message?: string } } })?.response?.data?.message ?? "Failed";
toast({ title: "Registration failed", description, variant: "destructive" });
},
});
// Generate mock GPS data for each vehicle
const vehiclesWithGPS = useMemo(() => {
return (vehicles as Vehicle[]).map((v) => ({
...v,
gps: generateMockGPS(),
}));
}, [vehicles]);
const assignMutation = useMutation({
mutationFn: ({ id, vehicleId }: { id: string; vehicleId: string | null }) =>
gpsTrackingService.update(id, { vehicleId }),
onSuccess: () => {
toast({ title: "Tracker updated" });
void qc.invalidateQueries({ queryKey: ["gps", "devices"] });
},
onError: () => toast({ title: "Update failed", variant: "destructive" }),
});
// For demo: show all vehicles as trackable (or filter by ACTIVE if status data available)
const trackableVehicles = useMemo(
() => vehiclesWithGPS.slice(0, 10), // Limit to first 10 for demo
[vehiclesWithGPS]
);
const selectedVehicle = trackableVehicles.find(v => v.id === selectedVehicleId);
const vehicleOptions = useMemo(
() => trackableVehicles.map(v => ({ label: v.registrationNumber, value: v.id })),
[trackableVehicles]
);
// Map dimensions
const mapWidth = 800;
const mapHeight = 500;
const pixelsPerLat = mapHeight / 0.6;
const pixelsPerLng = mapWidth / 0.6;
const getMapCoords = (lat: number, lng: number) => ({
x: ((lng - (mapCenter.lng - 0.3)) * pixelsPerLng),
y: ((mapCenter.lat + 0.3 - lat) * pixelsPerLat),
const deleteMutation = useMutation({
mutationFn: (id: string) => gpsTrackingService.remove(id),
onSuccess: () => {
toast({ title: "Tracker removed" });
setSelectedId(null);
void qc.invalidateQueries({ queryKey: ["gps", "devices"] });
},
onError: () => toast({ title: "Delete failed", variant: "destructive" }),
});
return (
<Container size="xl" py="xl" px="lg">
<Breadcrumbs items={[{ label: 'Fleet' }, { label: 'Vehicle Tracking' }]} />
<Breadcrumbs items={[{ label: "Fleet" }, { label: "Vehicle Tracking" }]} />
<Stack gap="xl">
<Group justify="space-between">
<div>
<Group gap="xs" align="center">
<Text fw={700} size="xl">
Real-Time Vehicle Tracking
</Text>
<Badge color="yellow" variant="light">
Simulated GPS
</Badge>
</Group>
<Text c="dimmed" size="sm">
Monitor vehicle locations, speed, and status
</Text>
</div>
</Group>
<Group justify="space-between" mb="xl">
<div>
<Text fw={700} size="xl">Real-Time Vehicle Tracking</Text>
<Text c="dimmed" size="sm">Live GPS positions from GT06 trackers</Text>
</div>
<Button leftSection={<Plus size={16} />} color="edr-green" onClick={() => setRegisterOpen(true)}>
Register tracker
</Button>
</Group>
<Grid>
{/* Map Section */}
<Grid.Col span={{ base: 12, lg: 8 }}>
<Card withBorder p="lg">
<Card.Section p="md" withBorder>
<Group justify="space-between">
<Text fw={500}>Map View</Text>
<Group gap="xs">
<Badge color="edr-green" leftSection={<Radio size={12} />}>
{trackableVehicles.length} Tracked
</Badge>
</Group>
</Group>
</Card.Section>
<Card.Section p="md">
<Box style={{ overflowX: 'auto', maxWidth: '100%' }}>
<Box
pos="relative"
style={{
width: mapWidth,
height: mapHeight,
backgroundColor: '#f0f8f7',
border: `2px solid ${freightBrand.primary}`,
borderRadius: '8px',
overflow: 'hidden',
}}
<Grid>
{/* Map */}
<Grid.Col span={{ base: 12, lg: 8 }}>
<Card withBorder p="lg">
<Card.Section p="md" withBorder>
<Group justify="space-between">
<Text fw={500}>Live Map</Text>
<Badge color="edr-green" leftSection={<Radio size={12} />}>
{onlineCount} online · {positioned.length} located
</Badge>
</Group>
</Card.Section>
<Card.Section p="md">
<Box style={{ height: 500, width: "100%", borderRadius: 8, overflow: "hidden" }}>
<APIProvider apiKey={GOOGLE_MAPS_API_KEY}>
<GoogleMap
defaultCenter={DEFAULT_CENTER}
defaultZoom={7}
gestureHandling="greedy"
disableDefaultUI={false}
style={{ width: "100%", height: "100%" }}
>
{/* Grid background */}
<svg
width={mapWidth}
height={mapHeight}
style={{ position: 'absolute', top: 0, left: 0 }}
>
{/* Latitude lines */}
{[0, 1, 2, 3, 4, 5, 6].map(i => (
<line
key={`lat-${i}`}
x1={0}
y1={(i / 6) * mapHeight}
x2={mapWidth}
y2={(i / 6) * mapHeight}
stroke="#e0e0e0"
strokeWidth={1}
{positioned.map(({ d, lat, lng }) => (
<Marker
key={d.id}
position={{ lat, lng }}
title={deviceLabel(d)}
icon={markerIcon(d, d.id === selectedId)}
onClick={() => setSelectedId(d.id)}
/>
))}
{/* Longitude lines */}
{[0, 1, 2, 3, 4, 5, 6].map(i => (
<line
key={`lng-${i}`}
x1={(i / 6) * mapWidth}
y1={0}
x2={(i / 6) * mapWidth}
y2={mapHeight}
stroke="#e0e0e0"
strokeWidth={1}
/>
))}
</svg>
{/* Vehicle markers */}
{trackableVehicles.map((vehicle) => {
const coords = getMapCoords(vehicle.gps.lat, vehicle.gps.lng);
const isSelected = vehicle.id === selectedVehicleId;
return (
<Box
key={vehicle.id}
pos="absolute"
style={{
left: coords.x - 15,
top: coords.y - 15,
width: 30,
height: 30,
cursor: 'pointer',
zIndex: isSelected ? 100 : 10,
}}
onClick={() => setSelectedVehicleId(vehicle.id)}
title={vehicle.registrationNumber}
>
<Box
pos="absolute"
inset={0}
style={{
backgroundColor: isSelected ? freightBrand.primary : '#3498db',
borderRadius: '50%',
border: isSelected ? `3px solid ${freightBrand.primaryDark}` : 'none',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'white',
fontSize: '16px',
boxShadow: isSelected ? `0 0 0 8px ${freightBrand.ring}` : 'none',
}}
>
<Navigation size={16} />
</Box>
</Box>
);
})}
{/* Map labels */}
<Box pos="absolute" bottom={8} left={8} style={{ zIndex: 50 }}>
<Text size="xs" c="dimmed">
📍 Addis Ababa, Ethiopia
</Text>
</Box>
</Box>
</Box>
<Text size="xs" c="dimmed" mt="xs">
Simulated map coordinates, speed, and heading are demo values, not live GPS.
<FitBounds points={positioned.map((p) => ({ lat: p.lat, lng: p.lng }))} />
{selected?.vehicleId && trail.length > 1 && <RouteTrail path={trail} />}
</GoogleMap>
</APIProvider>
</Box>
{positioned.length === 0 && (
<Text size="sm" c="dimmed" ta="center" mt="sm">
No located trackers yet waiting for GPS fixes.
</Text>
</Card.Section>
</Card>
</Grid.Col>
{/* Sidebar */}
<Grid.Col span={{ base: 12, lg: 4 }}>
<Stack gap="md">
{/* Vehicle Selector */}
<Card withBorder p="lg">
<Stack gap="md">
<Select
label="Track Vehicle"
placeholder="Select a vehicle to track"
data={vehicleOptions}
value={selectedVehicleId}
onChange={setSelectedVehicleId}
searchable
/>
{selectedVehicle && (
<Box p="md" style={{ backgroundColor: freightBrand.mutedBg, borderRadius: '8px' }}>
<Stack gap="sm">
<div>
<Text size="sm" c="dimmed">
Registration
</Text>
<Text fw={600}>{selectedVehicle.registrationNumber}</Text>
</div>
<div>
<Text size="sm" c="dimmed">
Vehicle
</Text>
<Text fw={600}>
{selectedVehicle.manufacturer} {selectedVehicle.model}
</Text>
</div>
<div>
<Text size="sm" c="dimmed">
Status
</Text>
<Badge color={selectedVehicle.status === 'ACTIVE' ? 'edr-green' : 'gray'}>
{selectedVehicle.status || 'Unknown'}
</Badge>
</div>
</Stack>
</Box>
)}
</Stack>
</Card>
{/* GPS Details */}
{selectedVehicle && (
<Card withBorder p="lg">
<Stack gap="md">
<Group justify="space-between">
<Text fw={500}>GPS Location</Text>
<Badge color="edr-green" leftSection={<Activity size={12} />}>
Live
</Badge>
</Group>
<SimpleGrid cols={2} spacing="sm">
<Box p="sm" style={{ backgroundColor: '#f8f9fa', borderRadius: '8px' }}>
<Text size="xs" c="dimmed">
Latitude
</Text>
<Text fw={600} size="sm">
{selectedVehicle.gps.lat.toFixed(4)}°
</Text>
</Box>
<Box p="sm" style={{ backgroundColor: '#f8f9fa', borderRadius: '8px' }}>
<Text size="xs" c="dimmed">
Longitude
</Text>
<Text fw={600} size="sm">
{selectedVehicle.gps.lng.toFixed(4)}°
</Text>
</Box>
<Box p="sm" style={{ backgroundColor: '#f8f9fa', borderRadius: '8px' }}>
<Text size="xs" c="dimmed">
Speed
</Text>
<Text fw={600} size="sm">
{selectedVehicle.gps.speed} km/h
</Text>
</Box>
<Box p="sm" style={{ backgroundColor: '#f8f9fa', borderRadius: '8px' }}>
<Text size="xs" c="dimmed">
Heading
</Text>
<Text fw={600} size="sm">
{selectedVehicle.gps.heading}°
</Text>
</Box>
</SimpleGrid>
<div>
<Text size="xs" c="dimmed">
Last Update
</Text>
<Text fw={500}>{selectedVehicle.gps.lastUpdate}</Text>
</div>
<Button color="edr-green" fullWidth leftSection={<MapPin size={16} />}>
View Full History
</Button>
</Stack>
</Card>
)}
</Card.Section>
</Card>
</Grid.Col>
{/* Tracked Vehicles List */}
{/* Sidebar */}
<Grid.Col span={{ base: 12, lg: 4 }}>
<Stack gap="md">
{selected && (
<Card withBorder p="lg">
<Stack gap="md">
<Text fw={500}>Tracked Vehicles ({trackableVehicles.length})</Text>
<div style={{ maxHeight: '300px', overflowY: 'auto' }}>
<Table>
<Table.Tbody>
{trackableVehicles.map(v => (
<Table.Tr
key={v.id}
style={{
cursor: 'pointer',
backgroundColor: v.id === selectedVehicleId ? freightBrand.mutedBg : 'transparent',
}}
onClick={() => setSelectedVehicleId(v.id)}
>
<Table.Td>
<Stack gap={0}>
<Text size="sm" fw={600}>
{v.registrationNumber}
</Text>
<Text size="xs" c="dimmed">
{v.gps.speed} km/h
</Text>
</Stack>
</Table.Td>
<Table.Td align="right">
<Badge
color={v.status === 'ACTIVE' ? 'edr-green' : 'gray'}
size="sm"
>
{v.status || 'N/A'}
</Badge>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
<Group justify="space-between">
<Text fw={500}>{deviceLabel(selected)}</Text>
<Group gap="xs">
<Badge color={selected.online ? "edr-green" : "gray"} leftSection={<Activity size={12} />}>
{selected.online ? "Live" : "Offline"}
</Badge>
<ActionIcon variant="subtle" color="red" aria-label="Remove tracker" onClick={() => deleteMutation.mutate(selected.id)}>
<Trash2 size={16} />
</ActionIcon>
</Group>
</Group>
<SimpleGrid cols={2} spacing="sm">
<StatBox label="Latitude" value={toNum(selected.lastLat)?.toFixed(5) ?? "—"} />
<StatBox label="Longitude" value={toNum(selected.lastLng)?.toFixed(5) ?? "—"} />
<StatBox label="Speed" value={`${toNum(selected.lastSpeed) ?? 0} km/h`} />
<StatBox label="Course" value={`${selected.lastCourse ?? 0}°`} />
<StatBox label="Voltage" value={selected.voltageLevel != null ? `${selected.voltageLevel}/6` : "—"} />
<StatBox label="GSM" value={selected.gsmLevel != null ? `${selected.gsmLevel}/4` : "—"} />
</SimpleGrid>
<div>
<Text size="xs" c="dimmed">IMEI</Text>
<Text fw={500} size="sm">{selected.imei}</Text>
</div>
<div>
<Text size="xs" c="dimmed">Last fix</Text>
<Text fw={500} size="sm">{fmtTime(selected.lastFixAt)}</Text>
</div>
{selected.vehicleId && (
<Text size="xs" c="dimmed">
Showing last {trail.length} fixes as a route trail.
</Text>
)}
<Select
label="Assigned vehicle"
placeholder="Unassigned"
data={vehicleOptions}
value={selected.vehicleId ?? null}
onChange={(v) => assignMutation.mutate({ id: selected.id, vehicleId: v })}
searchable
clearable
/>
</Stack>
</Card>
</Stack>
</Grid.Col>
</Grid>
</Stack>
)}
<Card withBorder p="lg">
<Stack gap="md">
<Text fw={500}>Trackers ({devices.length})</Text>
<div style={{ maxHeight: 340, overflowY: "auto" }}>
<Table>
<Table.Tbody>
{devices.map((d) => (
<Table.Tr
key={d.id}
style={{ cursor: "pointer", backgroundColor: d.id === selectedId ? freightBrand.mutedBg : "transparent" }}
onClick={() => setSelectedId(d.id)}
>
<Table.Td>
<Stack gap={0}>
<Text size="sm" fw={600}>{deviceLabel(d)}</Text>
<Text size="xs" c="dimmed">{toNum(d.lastSpeed) ?? 0} km/h · {fmtTime(d.lastFixAt)}</Text>
</Stack>
</Table.Td>
<Table.Td align="right">
<Badge color={d.online ? "edr-green" : "gray"} size="sm">{d.online ? "Live" : "Offline"}</Badge>
</Table.Td>
</Table.Tr>
))}
{devices.length === 0 && (
<Table.Tr>
<Table.Td colSpan={2}>
<Text size="sm" c="dimmed" ta="center" py="md">No trackers registered yet.</Text>
</Table.Td>
</Table.Tr>
)}
</Table.Tbody>
</Table>
</div>
</Stack>
</Card>
</Stack>
</Grid.Col>
</Grid>
{/* Register modal */}
<Modal opened={registerOpen} onClose={() => setRegisterOpen(false)} title="Register GPS tracker" radius="lg" centered>
<Stack gap="md">
<TextInput
label="IMEI"
placeholder="15-digit device IMEI"
required
value={form.imei}
onChange={(e) => setForm({ ...form, imei: e.currentTarget.value })}
/>
<TextInput
label="Name"
placeholder="Optional label"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.currentTarget.value })}
/>
<Select
label="Vehicle"
placeholder="Assign to a vehicle (optional)"
data={vehicleOptions}
value={form.vehicleId || null}
onChange={(v) => setForm({ ...form, vehicleId: v ?? "" })}
searchable
clearable
/>
<Group justify="flex-end">
<Button variant="default" onClick={() => setRegisterOpen(false)}>Cancel</Button>
<Button
loading={registerMutation.isPending}
disabled={!form.imei.trim()}
onClick={() => registerMutation.mutate()}
>
Register
</Button>
</Group>
</Stack>
</Modal>
</Container>
);
}
export default TrackingPage;

View File

@@ -0,0 +1,48 @@
import { api } from "@/auth/http";
export interface GpsDevice {
id: string;
imei: string;
name?: string | null;
vehicleId?: string | null;
vehicle?: {
id: string;
plateNumber?: string;
code?: string | null;
manufacturer?: string;
model?: string;
} | null;
status: string;
online: boolean;
lastSeenAt?: string | null;
lastLat?: number | string | null;
lastLng?: number | string | null;
lastSpeed?: number | string | null;
lastCourse?: number | null;
lastFixAt?: string | null;
voltageLevel?: number | null;
gsmLevel?: number | null;
}
export interface GpsPosition {
id: string;
lat: number | string;
lng: number | string;
speed: number | string;
course: number;
satellites: number;
gpsTime: string;
alarm: number;
}
export const gpsTrackingService = {
latest: () => api.get<GpsDevice[]>("/gps/positions/latest"),
listDevices: () => api.get<GpsDevice[]>("/gps/devices"),
history: (vehicleId: string, limit = 200) =>
api.get<GpsPosition[]>(`/gps/positions/${vehicleId}/history?limit=${limit}`),
register: (data: { imei: string; name?: string; vehicleId?: string | null }) =>
api.post<GpsDevice>("/gps/devices", data),
update: (id: string, data: { name?: string; vehicleId?: string | null }) =>
api.patch<GpsDevice>(`/gps/devices/${id}`, data),
remove: (id: string) => api.delete<void>(`/gps/devices/${id}`),
};

6
pnpm-lock.yaml generated
View File

@@ -244,6 +244,9 @@ importers:
'@tria-plc/iamui':
specifier: file:../../../local-packages/tria-plc-iamui-0.1.1.tgz
version: file:local-packages/tria-plc-iamui-0.1.1.tgz(0ce39b7e349029277dcd938d06eeb0f7)
'@vis.gl/react-google-maps':
specifier: ^1.8.3
version: 1.8.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
axios:
specifier: ^1.7.7
version: 1.17.0
@@ -305,6 +308,9 @@ importers:
'@tailwindcss/vite':
specifier: ^4.3.0
version: 4.3.0(vite@5.4.21(@types/node@24.13.1)(lightningcss@1.32.0)(terser@5.48.0))
'@types/google.maps':
specifier: ^3.65.2
version: 3.65.2
'@types/react':
specifier: ^18.3.11
version: 18.3.31