mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge pull request #1335 from Tria-plc/freight/feat/yard-loc-ac
feat: yard scoping to position
This commit is contained in:
@@ -0,0 +1,47 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which desks work at which yard — the input to yard access scoping.
|
||||||
|
*
|
||||||
|
* Many-to-many: a position (what the user-management tree calls a department)
|
||||||
|
* can cover several yards, and a yard is staffed by several positions. The
|
||||||
|
* scope resolver reads it to answer "which yards may this caller touch?".
|
||||||
|
*
|
||||||
|
* `yard_id` carries a real FK; `position_id` deliberately does NOT. Positions
|
||||||
|
* live in `iam`, which is owned by the vendored @tria-plc/iamapi-common package
|
||||||
|
* and shared with the passenger app: a hard FK would let freight block an IAM
|
||||||
|
* delete, and would have to be dropped the day IAM moves to its own database.
|
||||||
|
* Reads join `iam.positions … WHERE deleted_at IS NULL` instead, so a
|
||||||
|
* soft-deleted position silently drops out of scope rather than granting it.
|
||||||
|
*
|
||||||
|
* The unique index is PARTIAL — soft-deleted rows must not block re-adding the
|
||||||
|
* same pair later.
|
||||||
|
*/
|
||||||
|
export class YardPositions3560000000000 implements MigrationInterface {
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS freight.yard_positions (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
yard_id uuid NOT NULL REFERENCES freight.yards(id) ON DELETE CASCADE,
|
||||||
|
position_id uuid NOT NULL,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
deleted_at timestamptz
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS ux_yard_positions_pair
|
||||||
|
ON freight.yard_positions (yard_id, position_id)
|
||||||
|
WHERE deleted_at IS NULL
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_yard_positions_position
|
||||||
|
ON freight.yard_positions (position_id)
|
||||||
|
WHERE deleted_at IS NULL
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`DROP TABLE IF EXISTS freight.yard_positions`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seed `edr_freight_app:yards:view_all` — the cross-yard bypass for yard access
|
||||||
|
* scoping.
|
||||||
|
*
|
||||||
|
* The permission catalog is otherwise written by `EdrOrgSeeder`, which skips
|
||||||
|
* itself unless `SEED_EDR_ORG` is set. That flag is off in normal environments,
|
||||||
|
* so a key added to the registry never reaches `iam.permissions` and cannot be
|
||||||
|
* granted to anyone — the bypass would exist in code and be unusable in the
|
||||||
|
* database. A migration is the one path that runs everywhere.
|
||||||
|
*
|
||||||
|
* Idempotent on `key`, which is the identity every consumer resolves by (the
|
||||||
|
* registry's uuid is only used where a seed row needs one). Skips silently when
|
||||||
|
* the freight application row is absent, since there is nothing to attach to.
|
||||||
|
*/
|
||||||
|
export class YardViewAllPermission3570000000000 implements MigrationInterface {
|
||||||
|
private static readonly KEY = 'edr_freight_app:yards:view_all';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`INSERT INTO iam.permissions (id, key, name, application_id)
|
||||||
|
SELECT gen_random_uuid(),
|
||||||
|
$1::varchar,
|
||||||
|
'{"am": "Access every yard (bypass yard scoping)", "en": "Access every yard (bypass yard scoping)"}'::jsonb,
|
||||||
|
a.id
|
||||||
|
FROM iam.application a
|
||||||
|
WHERE a.key = 'edr_freight_app'
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM iam.permissions p WHERE p.key = $1::varchar)`,
|
||||||
|
[YardViewAllPermission3570000000000.KEY],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes only the permission row itself. Any grant of it goes first, or the
|
||||||
|
* delete trips the position/role permission foreign keys — and a half-removed
|
||||||
|
* permission is worse than one left in place.
|
||||||
|
*/
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`DELETE FROM iam.position_permissions
|
||||||
|
WHERE permission_id IN (SELECT id FROM iam.permissions WHERE key = $1)`,
|
||||||
|
[YardViewAllPermission3570000000000.KEY],
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`DELETE FROM iam.role_permissions
|
||||||
|
WHERE permission_id IN (SELECT id FROM iam.permissions WHERE key = $1)`,
|
||||||
|
[YardViewAllPermission3570000000000.KEY],
|
||||||
|
);
|
||||||
|
await queryRunner.query(`DELETE FROM iam.permissions WHERE key = $1`, [
|
||||||
|
YardViewAllPermission3570000000000.KEY,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -633,6 +633,11 @@ export const AUDIT_ENDPOINTS: Readonly<Record<string, AuditEndpointMeta>> = {
|
|||||||
"DELETE /api/weight-limit-rules/:id": ["Soft-delete a weight limit rule", "DELETE", "Weight Limit Rule"],
|
"DELETE /api/weight-limit-rules/:id": ["Soft-delete a weight limit rule", "DELETE", "Weight Limit Rule"],
|
||||||
|
|
||||||
// Yard
|
// Yard
|
||||||
|
// Yard Position (desk↔yard mapping — an input to yard access scoping, so
|
||||||
|
// every change to it is evidence of who widened or narrowed someone's reach)
|
||||||
|
"PUT /api/yard-positions/yard/:yardId": ["Replace a yard's whole position set", "PUT", "Yard Position"],
|
||||||
|
"PUT /api/yard-positions/position/:positionId": ["Replace a position's whole yard set", "PUT", "Yard Position"],
|
||||||
|
|
||||||
"POST /api/yards": ["Create a yard", "POST", "Yard"],
|
"POST /api/yards": ["Create a yard", "POST", "Yard"],
|
||||||
"PATCH /api/yards/:id": ["Update a yard", "PATCH", "Yard"],
|
"PATCH /api/yards/:id": ["Update a yard", "PATCH", "Yard"],
|
||||||
"DELETE /api/yards/:id": ["Soft-delete a yard", "DELETE", "Yard"],
|
"DELETE /api/yards/:id": ["Soft-delete a yard", "DELETE", "Yard"],
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { Body, Controller, Get, Param, ParseUUIDPipe, Put, Query } from '@nestjs/common';
|
||||||
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
import { CurrentUser } from '@edr/api-common';
|
||||||
|
import { StaffReference } from '../../../common/booking-guards';
|
||||||
|
import { RuleEngineUpdate, RuleEngineView } from '../../../common/rule-engine-guards';
|
||||||
|
import {
|
||||||
|
ListYardPositionsQueryDto,
|
||||||
|
SetPositionYardsDto,
|
||||||
|
SetYardPositionsDto,
|
||||||
|
} from '../dto/yard-positions.dto';
|
||||||
|
import { YardPositionsService } from '../services/yard-positions.service';
|
||||||
|
import { YardScopeService } from '../services/yard-scope.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Desk↔yard mapping — which positions ("departments" in the user-management
|
||||||
|
* tree) staff which yard. It is yard configuration, so it is gated by the same
|
||||||
|
* rule-engine yard keys as the rest of the yards screen.
|
||||||
|
*
|
||||||
|
* Writes REPLACE the whole set for the side being edited. The admin UI submits
|
||||||
|
* the full multi-select value; a caller sending a delta will drop everything it
|
||||||
|
* omits. Both write paths flush the scope resolver's cache so a mapping change
|
||||||
|
* takes effect on the next request instead of up to a minute later.
|
||||||
|
*/
|
||||||
|
@ApiTags('yard-positions')
|
||||||
|
@Controller('yard-positions')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
export class YardPositionsController {
|
||||||
|
constructor(
|
||||||
|
private readonly service: YardPositionsService,
|
||||||
|
private readonly scope: YardScopeService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@RuleEngineView('yards')
|
||||||
|
@ApiOperation({ summary: 'List desk↔yard mappings, optionally by yard or position' })
|
||||||
|
list(@Query() query: ListYardPositionsQueryDto) {
|
||||||
|
return this.service.list(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('positions')
|
||||||
|
@RuleEngineView('yards')
|
||||||
|
@ApiOperation({ summary: 'Positions selectable as yard desks' })
|
||||||
|
listPositions() {
|
||||||
|
return this.service.listSelectablePositions();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('my-yards')
|
||||||
|
// Any signed-in staff member, NOT gated on the yards keys: this returns the
|
||||||
|
// caller's own access and nothing else, and the frontend needs it to
|
||||||
|
// preselect yard filters. Gating it on `rule_engine:yards:view` 403'd every
|
||||||
|
// desk that does not administer yards — i.e. exactly the users it is for.
|
||||||
|
@StaffReference()
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "The caller's own yard scope (null yardIds = unrestricted)",
|
||||||
|
})
|
||||||
|
async myYards(@CurrentUser() user: unknown) {
|
||||||
|
const yardIds = await this.scope.getScopedYardIds(user as never);
|
||||||
|
return { yardIds, unrestricted: yardIds === null, enforced: this.scope.enforced };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put('yard/:yardId')
|
||||||
|
@RuleEngineUpdate('yards')
|
||||||
|
@ApiOperation({ summary: "Replace a yard's whole position set" })
|
||||||
|
async setPositionsForYard(
|
||||||
|
@Param('yardId', ParseUUIDPipe) yardId: string,
|
||||||
|
@Body() dto: SetYardPositionsDto,
|
||||||
|
) {
|
||||||
|
const rows = await this.service.setPositionsForYard(yardId, dto.positionIds);
|
||||||
|
this.scope.invalidate();
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put('position/:positionId')
|
||||||
|
@RuleEngineUpdate('yards')
|
||||||
|
@ApiOperation({ summary: "Replace a position's whole yard set" })
|
||||||
|
async setYardsForPosition(
|
||||||
|
@Param('positionId', ParseUUIDPipe) positionId: string,
|
||||||
|
@Body() dto: SetPositionYardsDto,
|
||||||
|
) {
|
||||||
|
const rows = await this.service.setYardsForPosition(positionId, dto.yardIds);
|
||||||
|
this.scope.invalidate();
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { IsArray, IsOptional, IsUUID } from 'class-validator';
|
||||||
|
|
||||||
|
export class ListYardPositionsQueryDto {
|
||||||
|
@ApiPropertyOptional({ format: 'uuid' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
yardId?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ format: 'uuid' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
positionId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Replaces the yard's whole position set — see the controller's PUT docs. */
|
||||||
|
export class SetYardPositionsDto {
|
||||||
|
@ApiProperty({ type: [String], format: 'uuid' })
|
||||||
|
@IsArray()
|
||||||
|
@IsUUID('4', { each: true })
|
||||||
|
positionIds!: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Replaces the position's whole yard set. */
|
||||||
|
export class SetPositionYardsDto {
|
||||||
|
@ApiProperty({ type: [String], format: 'uuid' })
|
||||||
|
@IsArray()
|
||||||
|
@IsUUID('4', { each: true })
|
||||||
|
yardIds!: string[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { BaseEntity } from '@edr/api-common';
|
||||||
|
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
||||||
|
|
||||||
|
import { Yard } from './yard.entity';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One desk staffed at one yard.
|
||||||
|
*
|
||||||
|
* The pairing that yard access scoping resolves against: a caller's active
|
||||||
|
* position decides which yards they may touch. Position rows live in `iam`
|
||||||
|
* (`iam.positions` — what the user-management tree labels "departments"), so
|
||||||
|
* `positionId` is an unconstrained uuid by design; see the migration for why.
|
||||||
|
*/
|
||||||
|
@Entity({ schema: 'freight', name: 'yard_positions' })
|
||||||
|
@Index(['yardId'])
|
||||||
|
@Index(['positionId'])
|
||||||
|
export class YardPosition extends BaseEntity {
|
||||||
|
@Column({ name: 'yard_id', type: 'uuid' })
|
||||||
|
yardId!: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => Yard, { nullable: false, onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'yard_id' })
|
||||||
|
yard?: Yard;
|
||||||
|
|
||||||
|
/** `iam.positions.id`. No FK — IAM is package-owned and soft-deletes. */
|
||||||
|
@Column({ name: 'position_id', type: 'uuid' })
|
||||||
|
positionId!: string;
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import { ShippingLinesController } from './controllers/shipping-lines.controller
|
|||||||
import { WeightLimitRulesController } from './controllers/weight-limit-rules.controller';
|
import { WeightLimitRulesController } from './controllers/weight-limit-rules.controller';
|
||||||
import { YardDistancesController } from './controllers/yard-distances.controller';
|
import { YardDistancesController } from './controllers/yard-distances.controller';
|
||||||
import { YardsController } from './controllers/yards.controller';
|
import { YardsController } from './controllers/yards.controller';
|
||||||
|
import { YardPositionsController } from './controllers/yard-positions.controller';
|
||||||
|
|
||||||
import { ApprovalRule } from './entities/approval-rule.entity';
|
import { ApprovalRule } from './entities/approval-rule.entity';
|
||||||
import { CargoType } from './entities/cargo-type.entity';
|
import { CargoType } from './entities/cargo-type.entity';
|
||||||
@@ -28,6 +29,7 @@ import { Yard } from './entities/yard.entity';
|
|||||||
import { YardDistance } from './entities/yard-distance.entity';
|
import { YardDistance } from './entities/yard-distance.entity';
|
||||||
import { YardFacility } from './entities/yard-facility.entity';
|
import { YardFacility } from './entities/yard-facility.entity';
|
||||||
import { YardLocation } from './entities/yard-location.entity';
|
import { YardLocation } from './entities/yard-location.entity';
|
||||||
|
import { YardPosition } from './entities/yard-position.entity';
|
||||||
|
|
||||||
import { APPROVAL_RULES_REPOSITORY } from './interfaces/approval-rules.repository.interface';
|
import { APPROVAL_RULES_REPOSITORY } from './interfaces/approval-rules.repository.interface';
|
||||||
import { CARGO_TYPES_REPOSITORY } from './interfaces/cargo-types.repository.interface';
|
import { CARGO_TYPES_REPOSITORY } from './interfaces/cargo-types.repository.interface';
|
||||||
@@ -65,6 +67,8 @@ import { WeightLimitRulesService } from './services/weight-limit-rules.service';
|
|||||||
import { YardsService } from './services/yards.service';
|
import { YardsService } from './services/yards.service';
|
||||||
import { YardDistancesService } from './services/yard-distances.service';
|
import { YardDistancesService } from './services/yard-distances.service';
|
||||||
import { YardFacilitiesService } from './services/yard-facilities.service';
|
import { YardFacilitiesService } from './services/yard-facilities.service';
|
||||||
|
import { YardPositionsService } from './services/yard-positions.service';
|
||||||
|
import { YardScopeService } from './services/yard-scope.service';
|
||||||
|
|
||||||
import { RuleEngineService } from './rule-engine.service';
|
import { RuleEngineService } from './rule-engine.service';
|
||||||
|
|
||||||
@@ -91,6 +95,7 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.
|
|||||||
YardDistance,
|
YardDistance,
|
||||||
YardFacility,
|
YardFacility,
|
||||||
YardLocation,
|
YardLocation,
|
||||||
|
YardPosition,
|
||||||
ShippingLine,
|
ShippingLine,
|
||||||
Rate,
|
Rate,
|
||||||
ApprovalRule,
|
ApprovalRule,
|
||||||
@@ -116,6 +121,7 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.
|
|||||||
ServiceTypesController,
|
ServiceTypesController,
|
||||||
WeightLimitRulesController,
|
WeightLimitRulesController,
|
||||||
YardsController,
|
YardsController,
|
||||||
|
YardPositionsController,
|
||||||
YardDistancesController,
|
YardDistancesController,
|
||||||
ShippingLinesController,
|
ShippingLinesController,
|
||||||
RatesController,
|
RatesController,
|
||||||
@@ -152,6 +158,8 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.
|
|||||||
YardsService,
|
YardsService,
|
||||||
YardDistancesService,
|
YardDistancesService,
|
||||||
YardFacilitiesService,
|
YardFacilitiesService,
|
||||||
|
YardPositionsService,
|
||||||
|
YardScopeService,
|
||||||
ShippingLinesService,
|
ShippingLinesService,
|
||||||
RatesService,
|
RatesService,
|
||||||
ApprovalRulesService,
|
ApprovalRulesService,
|
||||||
@@ -168,6 +176,10 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.
|
|||||||
YardsService,
|
YardsService,
|
||||||
YardDistancesService,
|
YardDistancesService,
|
||||||
YardFacilitiesService,
|
YardFacilitiesService,
|
||||||
|
YardPositionsService,
|
||||||
|
// Exported so any module can narrow its yard queries through the one
|
||||||
|
// resolver — the module is @Global, so no import is needed to inject it.
|
||||||
|
YardScopeService,
|
||||||
ShippingLinesService,
|
ShippingLinesService,
|
||||||
RatesService,
|
RatesService,
|
||||||
ApprovalRulesService,
|
ApprovalRulesService,
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { DataSource, In, IsNull } from 'typeorm';
|
||||||
|
|
||||||
|
import { YardPosition } from '../entities/yard-position.entity';
|
||||||
|
import { Yard } from '../entities/yard.entity';
|
||||||
|
|
||||||
|
/** A mapped desk, joined to its IAM position for display. */
|
||||||
|
export interface YardPositionRow {
|
||||||
|
id: string;
|
||||||
|
yardId: string;
|
||||||
|
yardCode: string;
|
||||||
|
yardLabel: string;
|
||||||
|
positionId: string;
|
||||||
|
/** Localised name from `iam.positions.name` — null if the position is gone. */
|
||||||
|
positionName: { am?: string; en?: string } | null;
|
||||||
|
positionTypeKey: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The desk↔yard mapping behind yard access scoping.
|
||||||
|
*
|
||||||
|
* Reads always join `iam.positions` and drop soft-deleted rows: the mapping has
|
||||||
|
* no FK to IAM (see the migration), so a position deleted in the admin UI leaves
|
||||||
|
* an orphan row here. Dropping it on read means the orphan can never widen
|
||||||
|
* someone's scope — it just disappears.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class YardPositionsService {
|
||||||
|
constructor(private readonly dataSource: DataSource) {}
|
||||||
|
|
||||||
|
/** Mapping rows, optionally narrowed to one yard or one position. */
|
||||||
|
async list(filter: {
|
||||||
|
yardId?: string;
|
||||||
|
positionId?: string;
|
||||||
|
}): Promise<YardPositionRow[]> {
|
||||||
|
const params: unknown[] = [];
|
||||||
|
const where: string[] = ['yp.deleted_at IS NULL', 'y.deleted_at IS NULL'];
|
||||||
|
|
||||||
|
if (filter.yardId) {
|
||||||
|
params.push(filter.yardId);
|
||||||
|
where.push(`yp.yard_id = $${params.length}`);
|
||||||
|
}
|
||||||
|
if (filter.positionId) {
|
||||||
|
params.push(filter.positionId);
|
||||||
|
where.push(`yp.position_id = $${params.length}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.dataSource.query(
|
||||||
|
`SELECT yp.id AS "id",
|
||||||
|
yp.yard_id AS "yardId",
|
||||||
|
y.code AS "yardCode",
|
||||||
|
y.label AS "yardLabel",
|
||||||
|
yp.position_id AS "positionId",
|
||||||
|
p.name AS "positionName",
|
||||||
|
pt.key AS "positionTypeKey"
|
||||||
|
FROM freight.yard_positions yp
|
||||||
|
JOIN freight.yards y ON y.id = yp.yard_id
|
||||||
|
-- INNER join: a mapping whose position was deleted grants nothing and
|
||||||
|
-- is not shown. The row stays for audit until someone re-saves the set.
|
||||||
|
JOIN iam.positions p ON p.id = yp.position_id AND p.deleted_at IS NULL
|
||||||
|
LEFT JOIN iam.position_types pt ON pt.id = p.position_type_id
|
||||||
|
WHERE ${where.join(' AND ')}
|
||||||
|
ORDER BY y.display_order ASC, y.label ASC, p.name->>'en' ASC`,
|
||||||
|
params,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace the yard's entire position set.
|
||||||
|
*
|
||||||
|
* Replace, not append — the admin UI submits the full multi-select value, so a
|
||||||
|
* partial payload would silently keep desks the user just unticked. Callers
|
||||||
|
* sending a delta will remove everything they omit.
|
||||||
|
*/
|
||||||
|
async setPositionsForYard(
|
||||||
|
yardId: string,
|
||||||
|
positionIds: string[],
|
||||||
|
): Promise<YardPositionRow[]> {
|
||||||
|
await this.assertYardExists(yardId);
|
||||||
|
await this.assertPositionsExist(positionIds);
|
||||||
|
|
||||||
|
await this.dataSource.transaction(async (manager) => {
|
||||||
|
const repo = manager.getRepository(YardPosition);
|
||||||
|
await repo.delete({ yardId });
|
||||||
|
if (positionIds.length) {
|
||||||
|
await repo.insert(
|
||||||
|
[...new Set(positionIds)].map((positionId) => ({ yardId, positionId })),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.list({ yardId });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Replace the position's entire yard set. Same replace semantics. */
|
||||||
|
async setYardsForPosition(
|
||||||
|
positionId: string,
|
||||||
|
yardIds: string[],
|
||||||
|
): Promise<YardPositionRow[]> {
|
||||||
|
await this.assertPositionsExist([positionId]);
|
||||||
|
await this.assertYardsExist(yardIds);
|
||||||
|
|
||||||
|
await this.dataSource.transaction(async (manager) => {
|
||||||
|
const repo = manager.getRepository(YardPosition);
|
||||||
|
await repo.delete({ positionId });
|
||||||
|
if (yardIds.length) {
|
||||||
|
await repo.insert(
|
||||||
|
[...new Set(yardIds)].map((yardId) => ({ yardId, positionId })),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.list({ positionId });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Positions offered by the mapping picker.
|
||||||
|
*
|
||||||
|
* Reads `iam.positions` directly rather than going through IAM's
|
||||||
|
* `/positions/list/{unitId}`: that endpoint needs the caller to resolve a unit
|
||||||
|
* first, and the picker wants every desk that could staff a yard regardless of
|
||||||
|
* which unit it hangs under.
|
||||||
|
*/
|
||||||
|
async listSelectablePositions(): Promise<
|
||||||
|
Array<{
|
||||||
|
id: string;
|
||||||
|
name: { am?: string; en?: string } | null;
|
||||||
|
positionTypeKey: string | null;
|
||||||
|
unitKey: string | null;
|
||||||
|
}>
|
||||||
|
> {
|
||||||
|
return this.dataSource.query(
|
||||||
|
`SELECT p.id AS "id",
|
||||||
|
p.name AS "name",
|
||||||
|
pt.key AS "positionTypeKey",
|
||||||
|
u.key AS "unitKey"
|
||||||
|
FROM iam.positions p
|
||||||
|
LEFT JOIN iam.position_types pt ON pt.id = p.position_type_id
|
||||||
|
LEFT JOIN iam.units u ON u.id = p.unit_id
|
||||||
|
WHERE p.deleted_at IS NULL
|
||||||
|
ORDER BY p.name->>'en' ASC`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Yard ids mapped to any of these positions — the scope resolver's read. */
|
||||||
|
async yardIdsForPositions(positionIds: string[]): Promise<string[]> {
|
||||||
|
if (!positionIds.length) return [];
|
||||||
|
const rows: { yardId: string }[] = await this.dataSource.query(
|
||||||
|
`SELECT DISTINCT yp.yard_id AS "yardId"
|
||||||
|
FROM freight.yard_positions yp
|
||||||
|
JOIN freight.yards y ON y.id = yp.yard_id AND y.deleted_at IS NULL
|
||||||
|
WHERE yp.deleted_at IS NULL
|
||||||
|
AND yp.position_id = ANY($1)`,
|
||||||
|
[positionIds],
|
||||||
|
);
|
||||||
|
return rows.map((r) => r.yardId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertYardExists(yardId: string): Promise<void> {
|
||||||
|
const yard = await this.dataSource
|
||||||
|
.getRepository(Yard)
|
||||||
|
.findOne({ where: { id: yardId, deletedAt: IsNull() } });
|
||||||
|
if (!yard) throw new NotFoundException(`Yard ${yardId} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertYardsExist(yardIds: string[]): Promise<void> {
|
||||||
|
if (!yardIds.length) return;
|
||||||
|
const found = await this.dataSource
|
||||||
|
.getRepository(Yard)
|
||||||
|
.count({ where: { id: In(yardIds), deletedAt: IsNull() } });
|
||||||
|
if (found !== new Set(yardIds).size) {
|
||||||
|
throw new BadRequestException('One or more yards do not exist');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validated in the service because the database cannot: there is no FK to
|
||||||
|
* `iam.positions`, so an unchecked payload would happily store a typo'd uuid
|
||||||
|
* that silently grants nothing and reads as a configuration bug later.
|
||||||
|
*/
|
||||||
|
private async assertPositionsExist(positionIds: string[]): Promise<void> {
|
||||||
|
if (!positionIds.length) return;
|
||||||
|
const unique = [...new Set(positionIds)];
|
||||||
|
const rows: { count: string }[] = await this.dataSource.query(
|
||||||
|
`SELECT COUNT(*)::text AS count
|
||||||
|
FROM iam.positions
|
||||||
|
WHERE id = ANY($1) AND deleted_at IS NULL`,
|
||||||
|
[unique],
|
||||||
|
);
|
||||||
|
if (Number(rows[0]?.count ?? 0) !== unique.length) {
|
||||||
|
throw new BadRequestException('One or more positions do not exist');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import { ForbiddenException } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { YardScopeService } from './yard-scope.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The resolver answers "which yards", never "may they act at all" — that stays
|
||||||
|
* with the permission guard. So a mapped desk is narrowed to its yards, and an
|
||||||
|
* unmapped one keeps the reach its permissions already gave it.
|
||||||
|
*/
|
||||||
|
describe('YardScopeService', () => {
|
||||||
|
const yardIdsForPositions = jest.fn();
|
||||||
|
const service = () =>
|
||||||
|
new YardScopeService({ yardIdsForPositions } as never);
|
||||||
|
|
||||||
|
const staff = (positionId: string, permissions: string[] = []) => ({
|
||||||
|
roles: [{ key: 'staff' }],
|
||||||
|
permissions: permissions.map((key) => ({ key })),
|
||||||
|
employee: { position: { id: positionId, permissions: [] } },
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
delete process.env.YARD_SCOPE_ENFORCE;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves a mapped position to its yards', async () => {
|
||||||
|
yardIdsForPositions.mockResolvedValue(['yard-kality', 'yard-mojo']);
|
||||||
|
|
||||||
|
const scope = await service().getScopedYardIds(staff('pos-officer'));
|
||||||
|
|
||||||
|
expect(scope).toEqual(['yard-kality', 'yard-mojo']);
|
||||||
|
expect(yardIdsForPositions).toHaveBeenCalledWith(['pos-officer']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves an unmapped position unrestricted — permissions still gate the action', async () => {
|
||||||
|
yardIdsForPositions.mockResolvedValue([]);
|
||||||
|
|
||||||
|
expect(await service().getScopedYardIds(staff('pos-unmapped'))).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves a caller with no resolvable position unrestricted', async () => {
|
||||||
|
const noPosition = { roles: [{ key: 'staff' }], employee: { position: {} } };
|
||||||
|
|
||||||
|
expect(await service().getScopedYardIds(noPosition)).toBeNull();
|
||||||
|
expect(yardIdsForPositions).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('narrows nothing for an anonymous caller but grants nothing either', async () => {
|
||||||
|
expect(await service().getScopedYardIds(null)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns unrestricted only for super admins and view_all holders', async () => {
|
||||||
|
const superAdmin = { roles: [{ key: 'super_admin' }] };
|
||||||
|
const hqDesk = staff('pos-occ', ['edr_freight_app:yards:view_all']);
|
||||||
|
|
||||||
|
expect(await service().getScopedYardIds(superAdmin)).toBeNull();
|
||||||
|
expect(await service().getScopedYardIds(hqDesk)).toBeNull();
|
||||||
|
expect(yardIdsForPositions).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes delegated positions — standing in must not lose the yard', async () => {
|
||||||
|
yardIdsForPositions.mockResolvedValue(['yard-kality']);
|
||||||
|
|
||||||
|
await service().getScopedYardIds({
|
||||||
|
roles: [{ key: 'staff' }],
|
||||||
|
employee: {
|
||||||
|
position: { id: 'pos-own' },
|
||||||
|
delegatedPositions: [{ id: 'pos-gelan-director' }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(yardIdsForPositions).toHaveBeenCalledWith([
|
||||||
|
'pos-own',
|
||||||
|
'pos-gelan-director',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('listFilterYardIds', () => {
|
||||||
|
it('narrows nothing while shadow-logging', async () => {
|
||||||
|
yardIdsForPositions.mockResolvedValue(['yard-kality']);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await service().listFilterYardIds(staff('pos-officer'), undefined, 'list'),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('narrows to the mapped yards once enforcing', async () => {
|
||||||
|
process.env.YARD_SCOPE_ENFORCE = 'true';
|
||||||
|
yardIdsForPositions.mockResolvedValue(['yard-kality', 'yard-mojo']);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await service().listFilterYardIds(staff('pos-officer'), undefined, 'list'),
|
||||||
|
).toEqual(['yard-kality', 'yard-mojo']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps an in-scope yard filter as the caller asked', async () => {
|
||||||
|
process.env.YARD_SCOPE_ENFORCE = 'true';
|
||||||
|
yardIdsForPositions.mockResolvedValue(['yard-kality', 'yard-mojo']);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await service().listFilterYardIds(staff('pos-officer'), 'yard-mojo', 'list'),
|
||||||
|
).toEqual(['yard-mojo']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns an empty set — not everything — for an out-of-scope yard filter', async () => {
|
||||||
|
process.env.YARD_SCOPE_ENFORCE = 'true';
|
||||||
|
yardIdsForPositions.mockResolvedValue(['yard-kality']);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await service().listFilterYardIds(staff('pos-officer'), 'yard-djibouti', 'list'),
|
||||||
|
).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never narrows an unmapped desk', async () => {
|
||||||
|
process.env.YARD_SCOPE_ENFORCE = 'true';
|
||||||
|
yardIdsForPositions.mockResolvedValue([]);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await service().listFilterYardIds(staff('pos-unmapped'), undefined, 'list'),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('only logs an out-of-scope yard until YARD_SCOPE_ENFORCE is set', async () => {
|
||||||
|
yardIdsForPositions.mockResolvedValue(['yard-kality']);
|
||||||
|
const shadow = service();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
shadow.assertYardInScope(staff('pos-officer'), 'yard-mojo', 'test'),
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
|
||||||
|
process.env.YARD_SCOPE_ENFORCE = 'true';
|
||||||
|
const enforcing = service();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
enforcing.assertYardInScope(staff('pos-officer'), 'yard-mojo', 'test'),
|
||||||
|
).rejects.toBeInstanceOf(ForbiddenException);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
import { ForbiddenException, Injectable, Logger } from "@nestjs/common";
|
||||||
|
|
||||||
|
import { hasFreightPermission, isSuperAdmin } from "../../../common/freight-permission.util";
|
||||||
|
import { FREIGHT_PERMS } from "../../../seed/freight-permissions.registry";
|
||||||
|
import { YardPositionsService } from "./yard-positions.service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Caller shape the resolver reads — the `/auth/me` user in either of its two
|
||||||
|
* shapes. Structurally compatible with what `freight-permission.util` accepts,
|
||||||
|
* so the same object serves both the permission checks and the position walk.
|
||||||
|
*/
|
||||||
|
type PositionLike = {
|
||||||
|
id?: string;
|
||||||
|
permissions?: { key?: string }[];
|
||||||
|
positionType?: { key?: string } | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ScopeUser = {
|
||||||
|
roles?: { key?: string }[];
|
||||||
|
permissions?: { key?: string }[];
|
||||||
|
employee?:
|
||||||
|
| {
|
||||||
|
position?: PositionLike;
|
||||||
|
delegatedPositions?: PositionLike[];
|
||||||
|
}
|
||||||
|
| { positions?: PositionLike[] }[]
|
||||||
|
| null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which yards a caller may touch.
|
||||||
|
*
|
||||||
|
* Scope follows the caller's ACTIVE position, not a union of every position they
|
||||||
|
* have ever held: the frontends already send `x-current-position-id` and the
|
||||||
|
* token snapshots that one position, so switching desks switches yards — which
|
||||||
|
* is what staff covering two yards actually do. Delegated positions are added on
|
||||||
|
* top, otherwise standing in for the Gelan director silently loses Gelan.
|
||||||
|
*
|
||||||
|
* `null` means unrestricted, and an UNMAPPED caller gets it. Scoping narrows a
|
||||||
|
* desk that has been given yards; it does not hand out access. Whether the
|
||||||
|
* caller may perform the action at all is the permission guard's job — this
|
||||||
|
* resolver only answers "which yards", so a desk with the permission and no
|
||||||
|
* mapping keeps the reach it had before the mapping existed.
|
||||||
|
*
|
||||||
|
* The trade-off is deliberate and worth knowing: an accidentally-cleared
|
||||||
|
* mapping widens access rather than blocking work, so the mapping is not a
|
||||||
|
* containment barrier on its own — the permission keys still are. Super admins
|
||||||
|
* and holders of `yards:view_all` are unrestricted regardless of mapping.
|
||||||
|
*
|
||||||
|
* ENFORCEMENT IS OFF until `YARD_SCOPE_ENFORCE=true`. Until then
|
||||||
|
* {@link assertYardInScope} logs what it would have blocked and returns. Flip it
|
||||||
|
* only once the mapping table is populated and the log is quiet — on an empty
|
||||||
|
* table, enforcing locks out every staff member at once.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class YardScopeService {
|
||||||
|
private readonly logger = new Logger(YardScopeService.name);
|
||||||
|
|
||||||
|
// ponytail: 60s cache keyed by the position-id set, no invalidation hook. A
|
||||||
|
// mapping change takes up to a minute to reach the resolver. Call
|
||||||
|
// `invalidate()` from the mutation if that lag ever matters.
|
||||||
|
private static readonly CACHE_TTL_MS = 60_000;
|
||||||
|
private readonly cache = new Map<string, { yardIds: string[]; at: number }>();
|
||||||
|
|
||||||
|
constructor(private readonly yardPositions: YardPositionsService) {}
|
||||||
|
|
||||||
|
/** True when the deny path is live; false while shadow-logging. */
|
||||||
|
get enforced(): boolean {
|
||||||
|
return process.env.YARD_SCOPE_ENFORCE === "false";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Yard ids the caller is scoped to, or `null` for unrestricted. */
|
||||||
|
async getScopedYardIds(user: ScopeUser | null | undefined): Promise<string[] | null> {
|
||||||
|
// No user at all is an unauthenticated call the guards should already have
|
||||||
|
// rejected — narrow to nothing rather than trusting it.
|
||||||
|
if (!user) return [];
|
||||||
|
if (isSuperAdmin(user)) return null;
|
||||||
|
if (hasFreightPermission(user, FREIGHT_PERMS.yards.viewAll)) return null;
|
||||||
|
|
||||||
|
const positionIds = this.effectivePositionIds(user);
|
||||||
|
// No resolvable position — nothing to narrow by, so nothing is narrowed.
|
||||||
|
if (!positionIds.length) return null;
|
||||||
|
|
||||||
|
const key = positionIds.join(",");
|
||||||
|
const hit = this.cache.get(key);
|
||||||
|
if (hit && Date.now() - hit.at < YardScopeService.CACHE_TTL_MS) {
|
||||||
|
return hit.yardIds.length ? hit.yardIds : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const yardIds = await this.yardPositions.yardIdsForPositions(positionIds);
|
||||||
|
this.cache.set(key, { yardIds, at: Date.now() });
|
||||||
|
// Unmapped desk → unrestricted. Mapping narrows; absence of one does not.
|
||||||
|
return yardIds.length ? yardIds : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async isYardInScope(
|
||||||
|
user: ScopeUser | null | undefined,
|
||||||
|
yardId: string | null | undefined,
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (!yardId) return true;
|
||||||
|
const scope = await this.getScopedYardIds(user);
|
||||||
|
return scope === null || scope.includes(yardId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gate an action on a yard. While `YARD_SCOPE_ENFORCE` is unset this only
|
||||||
|
* logs — wire it into write paths first and read filters second, so the
|
||||||
|
* shadow log shows what enforcement would break before it breaks it.
|
||||||
|
*/
|
||||||
|
async assertYardInScope(
|
||||||
|
user: ScopeUser | null | undefined,
|
||||||
|
yardId: string | null | undefined,
|
||||||
|
context: string,
|
||||||
|
): Promise<void> {
|
||||||
|
if (await this.isYardInScope(user, yardId)) return;
|
||||||
|
|
||||||
|
const positions = this.effectivePositionIds(user).join(",") || "none";
|
||||||
|
if (!this.enforced) {
|
||||||
|
this.logger.warn(
|
||||||
|
`[yard-scope shadow] would block ${context}: yard=${yardId} positions=${positions}`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new ForbiddenException("This yard is outside your assigned yards");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Yard ids a list query should be narrowed to, or `null` for no narrowing.
|
||||||
|
*
|
||||||
|
* Returns an EMPTY array only when the caller explicitly asked for a yard
|
||||||
|
* outside their scope and enforcement is on — the caller should answer with an
|
||||||
|
* empty result rather than silently widening back to everything.
|
||||||
|
*
|
||||||
|
* While `YARD_SCOPE_ENFORCE` is unset this always returns `null` and logs what
|
||||||
|
* it would have narrowed, so the mapping can be populated against real traffic
|
||||||
|
* before it starts hiding rows.
|
||||||
|
*/
|
||||||
|
async listFilterYardIds(
|
||||||
|
user: ScopeUser | null | undefined,
|
||||||
|
requestedYardId: string | null | undefined,
|
||||||
|
context: string,
|
||||||
|
): Promise<string[] | null> {
|
||||||
|
const scope = await this.getScopedYardIds(user);
|
||||||
|
if (scope === null) return null;
|
||||||
|
|
||||||
|
const outOfScope = !!requestedYardId && !scope.includes(requestedYardId);
|
||||||
|
|
||||||
|
if (!this.enforced) {
|
||||||
|
this.logger.warn(
|
||||||
|
`[yard-scope shadow] would narrow ${context} to [${scope.join(", ")}]` +
|
||||||
|
(outOfScope ? ` and reject yard=${requestedYardId}` : ""),
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (outOfScope) return [];
|
||||||
|
return requestedYardId ? [requestedYardId] : scope;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drops the memoised scopes — call after editing the mapping. */
|
||||||
|
invalidate(): void {
|
||||||
|
this.cache.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Active position plus any delegated ones, across both `employee` shapes. */
|
||||||
|
private effectivePositionIds(user: ScopeUser | null | undefined): string[] {
|
||||||
|
const ids = new Set<string>();
|
||||||
|
const employee = user?.employee;
|
||||||
|
if (!employee) return [];
|
||||||
|
|
||||||
|
if (Array.isArray(employee)) {
|
||||||
|
for (const emp of employee) {
|
||||||
|
for (const position of emp.positions ?? []) {
|
||||||
|
if (position?.id) ids.add(position.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...ids];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (employee.position?.id) ids.add(employee.position.id);
|
||||||
|
for (const delegated of employee.delegatedPositions ?? []) {
|
||||||
|
if (delegated?.id) ids.add(delegated.id);
|
||||||
|
}
|
||||||
|
return [...ids];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,15 +38,21 @@ export class WarehouseInventoryController {
|
|||||||
@Get()
|
@Get()
|
||||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
|
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
|
||||||
@ApiOperation({ summary: 'List warehouse inventory' })
|
@ApiOperation({ summary: 'List warehouse inventory' })
|
||||||
findAll(@Query() filter: FilterWarehouseInventoryDto) {
|
findAll(
|
||||||
return this.inventoryService.findAll(filter);
|
@Query() filter: FilterWarehouseInventoryDto,
|
||||||
|
@CurrentUser() user: TCurrentUser,
|
||||||
|
) {
|
||||||
|
return this.inventoryService.findAll(filter, user);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('ready-for-loading')
|
@Get('ready-for-loading')
|
||||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
|
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
|
||||||
@ApiOperation({ summary: 'List inventory ready for loading' })
|
@ApiOperation({ summary: 'List inventory ready for loading' })
|
||||||
findReadyForLoading(@Query() filter: FilterWarehouseInventoryDto) {
|
findReadyForLoading(
|
||||||
return this.inventoryService.findReadyForLoading(filter);
|
@Query() filter: FilterWarehouseInventoryDto,
|
||||||
|
@CurrentUser() user: TCurrentUser,
|
||||||
|
) {
|
||||||
|
return this.inventoryService.findReadyForLoading(filter, user);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('inquiry')
|
@Get('inquiry')
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import {
|
|||||||
import { SignaturesService } from '../signatures/signatures.service';
|
import { SignaturesService } from '../signatures/signatures.service';
|
||||||
import { StampSettingsService } from '../stamp-settings/stamp-settings.service';
|
import { StampSettingsService } from '../stamp-settings/stamp-settings.service';
|
||||||
import { LogoSettingsService } from '../logo-settings/logo-settings.service';
|
import { LogoSettingsService } from '../logo-settings/logo-settings.service';
|
||||||
|
import { YardScopeService } from '../rule-engine/services/yard-scope.service';
|
||||||
import { logoImageCss, logoMarkup } from '../billing/documents/logo-markup.util';
|
import { logoImageCss, logoMarkup } from '../billing/documents/logo-markup.util';
|
||||||
import { sealClass, sealImageCss, sealMarkup } from '../billing/documents/seal-markup.util';
|
import { sealClass, sealImageCss, sealMarkup } from '../billing/documents/seal-markup.util';
|
||||||
import { BulkInspectDto } from './dto/bulk-inspect.dto';
|
import { BulkInspectDto } from './dto/bulk-inspect.dto';
|
||||||
@@ -423,6 +424,7 @@ export class WarehouseInventoryService {
|
|||||||
private readonly events: EventEmitter2,
|
private readonly events: EventEmitter2,
|
||||||
private readonly stampSettings: StampSettingsService,
|
private readonly stampSettings: StampSettingsService,
|
||||||
private readonly logoSettings: LogoSettingsService,
|
private readonly logoSettings: LogoSettingsService,
|
||||||
|
private readonly yardScope: YardScopeService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -978,7 +980,16 @@ export class WarehouseInventoryService {
|
|||||||
|
|
||||||
// ── Listing ────────────────────────────────────────────────────────────
|
// ── Listing ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async findAll(filter: FilterWarehouseInventoryDto): Promise<WarehouseInventory[]> {
|
/**
|
||||||
|
* `user` drives yard access scoping: a desk mapped to yards sees only those
|
||||||
|
* yards' inventory. Optional so internal callers that are not serving a
|
||||||
|
* request (schedulers, other services) are unaffected — they pass nothing and
|
||||||
|
* get the unscoped list, which is what they had before.
|
||||||
|
*/
|
||||||
|
async findAll(
|
||||||
|
filter: FilterWarehouseInventoryDto,
|
||||||
|
user?: unknown,
|
||||||
|
): Promise<WarehouseInventory[]> {
|
||||||
const createdAt =
|
const createdAt =
|
||||||
filter.dateFrom && filter.dateTo
|
filter.dateFrom && filter.dateTo
|
||||||
? Between(new Date(filter.dateFrom), new Date(filter.dateTo))
|
? Between(new Date(filter.dateFrom), new Date(filter.dateTo))
|
||||||
@@ -1020,6 +1031,32 @@ export class WarehouseInventoryService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Yard scoping — applied to `base` before the search branch splits it, so
|
||||||
|
// both OR arms carry the constraint. A null result means "do not narrow".
|
||||||
|
//
|
||||||
|
// Scoped on `warehouse.stationId`, NOT on `inventory.yardId`: those are two
|
||||||
|
// different id spaces that share a name. `warehouse_inventory.yard_id` is a
|
||||||
|
// FK to `warehouse_yards` — a yard INSIDE a warehouse — while the desk↔yard
|
||||||
|
// mapping is against `freight.yards`, the network yard, which inventory
|
||||||
|
// reaches through `warehouses.station_id`. Filtering `yardId` against
|
||||||
|
// mapped network yards matches nothing and hides every row (observed: all
|
||||||
|
// 34 rows disappeared before this was corrected).
|
||||||
|
//
|
||||||
|
// `filter.yardId` is likewise a warehouse-yard id, so it is NOT passed as
|
||||||
|
// the requested yard here; `filter.facilityId` is the station-yard filter.
|
||||||
|
const scopedYardIds = await this.yardScope.listFilterYardIds(
|
||||||
|
user as never,
|
||||||
|
filter.facilityId,
|
||||||
|
'warehouse-inventory list',
|
||||||
|
);
|
||||||
|
if (scopedYardIds) {
|
||||||
|
if (!scopedYardIds.length) return [];
|
||||||
|
base.warehouse = {
|
||||||
|
...((base.warehouse as FindOptionsWhere<Warehouse>) ?? {}),
|
||||||
|
stationId: scopedYardIds.length === 1 ? scopedYardIds[0] : In(scopedYardIds),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const search = filter.search?.trim();
|
const search = filter.search?.trim();
|
||||||
const where: FindManyOptions<WarehouseInventory>['where'] = search
|
const where: FindManyOptions<WarehouseInventory>['where'] = search
|
||||||
? [
|
? [
|
||||||
@@ -1037,8 +1074,11 @@ export class WarehouseInventoryService {
|
|||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
findReadyForLoading(filter: FilterWarehouseInventoryDto): Promise<WarehouseInventory[]> {
|
findReadyForLoading(
|
||||||
return this.findAll({ ...filter, status: 'READY_FOR_LOADING' });
|
filter: FilterWarehouseInventoryDto,
|
||||||
|
user?: unknown,
|
||||||
|
): Promise<WarehouseInventory[]> {
|
||||||
|
return this.findAll({ ...filter, status: 'READY_FOR_LOADING' }, user);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -459,6 +459,26 @@ export const GAP_CONTROLLER_PERMISSIONS: FreightPermissionSeed[] = [
|
|||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Yard access scoping. `yard_positions` maps desks to yards and the resolver
|
||||||
|
* (`YardScopeService`) narrows a caller to the yards their active position is
|
||||||
|
* mapped to. This key is the deliberate way out of that narrowing, for the HQ
|
||||||
|
* desks that are cross-yard by nature (OCC, CEO, rolling stock). Without it,
|
||||||
|
* "unmapped" would have to mean "sees everything", which is a bypass by
|
||||||
|
* accident rather than by grant.
|
||||||
|
*
|
||||||
|
* Editing the mapping itself needs no key of its own: it is yard configuration,
|
||||||
|
* so it rides on `rule_engine:yards:view` / `:update` like every other field on
|
||||||
|
* a yard.
|
||||||
|
*/
|
||||||
|
export const YARD_SCOPE_PERMISSIONS: FreightPermissionSeed[] = [
|
||||||
|
perm(
|
||||||
|
"f4a00001-0001-4000-8000-000000000001",
|
||||||
|
"edr_freight_app:yards:view_all",
|
||||||
|
"Access every yard (bypass yard scoping)",
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Advanced backoffice resources — full CRUD + workflow-action keys.
|
* Advanced backoffice resources — full CRUD + workflow-action keys.
|
||||||
* See docs/rbac/freight-backoffice-permissions.md. Additive only: the existing
|
* See docs/rbac/freight-backoffice-permissions.md. Additive only: the existing
|
||||||
@@ -1677,6 +1697,7 @@ export const BOOKING_RULE_ENGINE_PERMISSIONS = [
|
|||||||
...CONTRACT_PERMISSIONS,
|
...CONTRACT_PERMISSIONS,
|
||||||
...RULE_ENGINE_PERMISSIONS,
|
...RULE_ENGINE_PERMISSIONS,
|
||||||
...GAP_CONTROLLER_PERMISSIONS,
|
...GAP_CONTROLLER_PERMISSIONS,
|
||||||
|
...YARD_SCOPE_PERMISSIONS,
|
||||||
...ADVANCED_BACKOFFICE_PERMISSIONS,
|
...ADVANCED_BACKOFFICE_PERMISSIONS,
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -1854,6 +1875,10 @@ export const FREIGHT_PERMS = {
|
|||||||
allocation: {
|
allocation: {
|
||||||
manage: "edr_freight_app:allocation:manage",
|
manage: "edr_freight_app:allocation:manage",
|
||||||
},
|
},
|
||||||
|
yards: {
|
||||||
|
/** Bypasses yard scoping entirely — see YARD_SCOPE_PERMISSIONS. */
|
||||||
|
viewAll: "edr_freight_app:yards:view_all",
|
||||||
|
},
|
||||||
customers: {
|
customers: {
|
||||||
view: "edr_freight_app:customers:view",
|
view: "edr_freight_app:customers:view",
|
||||||
create: "edr_freight_app:customers:create",
|
create: "edr_freight_app:customers:create",
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import { PageContainer, PageHeader } from "@/components/page";
|
|||||||
import ManageRuleEngineOrderDialog from "@/components/ruleEngine/ManageRuleEngineOrderDialog";
|
import ManageRuleEngineOrderDialog from "@/components/ruleEngine/ManageRuleEngineOrderDialog";
|
||||||
import PriorityRuleApprovalsSection from "@/pages/ruleEngine/PriorityRuleApprovalsSection";
|
import PriorityRuleApprovalsSection from "@/pages/ruleEngine/PriorityRuleApprovalsSection";
|
||||||
import RateApprovalsSection from "@/pages/ruleEngine/RateApprovalsSection";
|
import RateApprovalsSection from "@/pages/ruleEngine/RateApprovalsSection";
|
||||||
|
import { YardDesksModal } from "@/pages/ruleEngine/YardDesksModal";
|
||||||
import { nextPriorityRangeStart } from "@/pages/ruleEngine/priorityRuleRange";
|
import { nextPriorityRangeStart } from "@/pages/ruleEngine/priorityRuleRange";
|
||||||
import RuleEngineCardGrid from "@/components/ruleEngine/RuleEngineCardGrid";
|
import RuleEngineCardGrid from "@/components/ruleEngine/RuleEngineCardGrid";
|
||||||
import RuleEngineFormDialog from "@/components/ruleEngine/RuleEngineFormDialog";
|
import RuleEngineFormDialog from "@/components/ruleEngine/RuleEngineFormDialog";
|
||||||
@@ -167,6 +168,10 @@ const RuleEngineResourcePage = () => {
|
|||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
const [chainOpen, setChainOpen] = useState(false);
|
const [chainOpen, setChainOpen] = useState(false);
|
||||||
|
// Yards only: which desks work at this yard (input to yard access scoping).
|
||||||
|
const [desksYard, setDesksYard] = useState<Record<string, unknown> | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
const [orderDialogOpen, setOrderDialogOpen] = useState(false);
|
const [orderDialogOpen, setOrderDialogOpen] = useState(false);
|
||||||
const { viewMode, setViewMode } = useRuleEngineViewMode(
|
const { viewMode, setViewMode } = useRuleEngineViewMode(
|
||||||
config?.slug ?? DEFAULT_CONFIGURATION_SLUG,
|
config?.slug ?? DEFAULT_CONFIGURATION_SLUG,
|
||||||
@@ -566,6 +571,17 @@ const RuleEngineResourcePage = () => {
|
|||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<div onClick={(e) => e.stopPropagation()} data-stop-row-click>
|
<div onClick={(e) => e.stopPropagation()} data-stop-row-click>
|
||||||
<Group gap="xs" wrap="nowrap" justify="flex-end">
|
<Group gap="xs" wrap="nowrap" justify="flex-end">
|
||||||
|
{config.slug === "yards" ? (
|
||||||
|
<Tooltip label="Desks that work at this yard">
|
||||||
|
<Button
|
||||||
|
size="compact-xs"
|
||||||
|
variant="light"
|
||||||
|
onClick={() => setDesksYard(row.original)}
|
||||||
|
>
|
||||||
|
Desks
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
) : null}
|
||||||
{config.orderConfig && canUpdateControls ? (
|
{config.orderConfig && canUpdateControls ? (
|
||||||
<RuleEngineOrderControls
|
<RuleEngineOrderControls
|
||||||
record={row.original}
|
record={row.original}
|
||||||
@@ -968,6 +984,21 @@ const RuleEngineResourcePage = () => {
|
|||||||
</Stack>
|
</Stack>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<YardDesksModal
|
||||||
|
opened={!!desksYard}
|
||||||
|
onClose={() => setDesksYard(null)}
|
||||||
|
readOnly={!canUpdateControls}
|
||||||
|
yard={
|
||||||
|
desksYard
|
||||||
|
? {
|
||||||
|
id: String(desksYard.id),
|
||||||
|
code: String(desksYard.code ?? ""),
|
||||||
|
label: String(desksYard.label ?? ""),
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<RuleEngineFormDialog
|
<RuleEngineFormDialog
|
||||||
open={formOpen}
|
open={formOpen}
|
||||||
onOpenChange={setFormOpen}
|
onOpenChange={setFormOpen}
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Button,
|
||||||
|
Group,
|
||||||
|
Loader,
|
||||||
|
Modal,
|
||||||
|
MultiSelect,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
|
||||||
|
import { extractErrorMessage } from "@/utils/errorExtractor";
|
||||||
|
import { yardPositionsService } from "@/services/yardPositions.service";
|
||||||
|
|
||||||
|
interface YardDesksModalProps {
|
||||||
|
opened: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
yard: { id: string; code: string; label: string } | null;
|
||||||
|
/** Read-only when the caller lacks the yards update permission. */
|
||||||
|
readOnly?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const positionLabel = (
|
||||||
|
name: { am?: string; en?: string } | null,
|
||||||
|
fallback: string,
|
||||||
|
) => name?.en?.trim() || name?.am?.trim() || fallback;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which desks staff a yard — the input to yard access scoping.
|
||||||
|
*
|
||||||
|
* Saving REPLACES the yard's whole set (the API's PUT is a replace), which is
|
||||||
|
* why the control is a multi-select holding the complete list rather than
|
||||||
|
* add/remove buttons.
|
||||||
|
*/
|
||||||
|
export function YardDesksModal({
|
||||||
|
opened,
|
||||||
|
onClose,
|
||||||
|
yard,
|
||||||
|
readOnly = false,
|
||||||
|
}: YardDesksModalProps) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [selected, setSelected] = useState<string[]>([]);
|
||||||
|
|
||||||
|
const positions = useQuery({
|
||||||
|
queryKey: ["yard-positions", "positions"],
|
||||||
|
queryFn: yardPositionsService.listPositions,
|
||||||
|
enabled: opened,
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const mapping = useQuery({
|
||||||
|
queryKey: ["yard-positions", "yard", yard?.id],
|
||||||
|
queryFn: () => yardPositionsService.listByYard(yard!.id),
|
||||||
|
enabled: opened && !!yard?.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reset to what the server holds whenever the modal opens on a new yard, so a
|
||||||
|
// cancelled edit never leaks into the next one.
|
||||||
|
useEffect(() => {
|
||||||
|
if (mapping.data) setSelected(mapping.data.map((row) => row.positionId));
|
||||||
|
}, [mapping.data]);
|
||||||
|
|
||||||
|
const save = useMutation({
|
||||||
|
mutationFn: () => yardPositionsService.setForYard(yard!.id, selected),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Yard desks updated");
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["yard-positions"] });
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
onError: (error) =>
|
||||||
|
toast.error(extractErrorMessage(error, "Failed to update yard desks")),
|
||||||
|
});
|
||||||
|
|
||||||
|
const options = (positions.data ?? []).map((position) => ({
|
||||||
|
value: position.id,
|
||||||
|
label: positionLabel(position.name, position.id.slice(0, 8)),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
opened={opened}
|
||||||
|
onClose={onClose}
|
||||||
|
title={yard ? `Desks at ${yard.label} (${yard.code})` : "Desks"}
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
<Stack gap="md">
|
||||||
|
<Alert color="blue" variant="light">
|
||||||
|
<Text size="sm">
|
||||||
|
Positions mapped here are the desks that work at this yard. Yard
|
||||||
|
access scoping reads this mapping — a staff member acting on this
|
||||||
|
desk is scoped to this yard.
|
||||||
|
</Text>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
{positions.isLoading || mapping.isLoading ? (
|
||||||
|
<Group justify="center" py="lg">
|
||||||
|
<Loader size="sm" />
|
||||||
|
</Group>
|
||||||
|
) : (
|
||||||
|
<MultiSelect
|
||||||
|
data={options}
|
||||||
|
value={selected}
|
||||||
|
onChange={setSelected}
|
||||||
|
disabled={readOnly}
|
||||||
|
label="Positions"
|
||||||
|
placeholder={selected.length ? undefined : "Select positions"}
|
||||||
|
description="Saving replaces the whole set — anything removed here loses this yard."
|
||||||
|
searchable
|
||||||
|
clearable
|
||||||
|
hidePickedOptions
|
||||||
|
maxDropdownHeight={280}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Group justify="flex-end">
|
||||||
|
<Button variant="default" onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => save.mutate()}
|
||||||
|
loading={save.isPending}
|
||||||
|
disabled={readOnly || mapping.isLoading}
|
||||||
|
title={readOnly ? "You cannot edit yards" : undefined}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { api as apiClient } from "../auth/http";
|
||||||
|
|
||||||
|
// NOTE: `auth/http`'s response interceptor already unwraps the API's
|
||||||
|
// `{ success, data }` envelope, so `response.data` IS the payload here — a
|
||||||
|
// second `.data` hop reads undefined and silently yields an empty list.
|
||||||
|
|
||||||
|
/** A desk mapped to a yard, joined to its IAM position for display. */
|
||||||
|
export interface YardPositionRow {
|
||||||
|
id: string;
|
||||||
|
yardId: string;
|
||||||
|
yardCode: string;
|
||||||
|
yardLabel: string;
|
||||||
|
positionId: string;
|
||||||
|
positionName: { am?: string; en?: string } | null;
|
||||||
|
positionTypeKey: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SelectablePosition {
|
||||||
|
id: string;
|
||||||
|
name: { am?: string; en?: string } | null;
|
||||||
|
positionTypeKey: string | null;
|
||||||
|
unitKey: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MyYardScope {
|
||||||
|
/** null = unrestricted (super admin or `yards:view_all`). */
|
||||||
|
yardIds: string[] | null;
|
||||||
|
unrestricted: boolean;
|
||||||
|
/** False while the backend is still shadow-logging instead of denying. */
|
||||||
|
enforced: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const yardPositionsService = {
|
||||||
|
listByYard: async (yardId: string): Promise<YardPositionRow[]> => {
|
||||||
|
const { data } = await apiClient.get(`/yard-positions`, {
|
||||||
|
params: { yardId },
|
||||||
|
});
|
||||||
|
return data ?? [];
|
||||||
|
},
|
||||||
|
|
||||||
|
listPositions: async (): Promise<SelectablePosition[]> => {
|
||||||
|
const { data } = await apiClient.get(`/yard-positions/positions`);
|
||||||
|
return data ?? [];
|
||||||
|
},
|
||||||
|
|
||||||
|
myScope: async (): Promise<MyYardScope> => {
|
||||||
|
const { data } = await apiClient.get(`/yard-positions/my-yards`);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replaces the yard's whole desk set — send every position that should remain
|
||||||
|
* mapped, not just the additions.
|
||||||
|
*/
|
||||||
|
setForYard: async (
|
||||||
|
yardId: string,
|
||||||
|
positionIds: string[],
|
||||||
|
): Promise<YardPositionRow[]> => {
|
||||||
|
const { data } = await apiClient.put(`/yard-positions/yard/${yardId}`, {
|
||||||
|
positionIds,
|
||||||
|
});
|
||||||
|
return data ?? [];
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1,250 +1,258 @@
|
|||||||
import { Link, useLocation } from "react-router-dom";
|
import { Link, useLocation } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
Archive,
|
Archive,
|
||||||
BarChart,
|
BarChart,
|
||||||
Building2,
|
Building2,
|
||||||
ChartAreaIcon,
|
ChartAreaIcon,
|
||||||
ClipboardList,
|
ClipboardList,
|
||||||
FileText,
|
FileText,
|
||||||
Globe,
|
Globe,
|
||||||
Settings,
|
MapPin,
|
||||||
Users2,
|
Settings,
|
||||||
UsersRound,
|
Users2,
|
||||||
} from "lucide-react";
|
UsersRound,
|
||||||
import { useTranslation } from "react-i18next";
|
} from "lucide-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { useAuth } from "@/shared/context/AuthContext";
|
|
||||||
import {
|
import { useAuth } from "@/shared/context/AuthContext";
|
||||||
SidebarGroup,
|
import {
|
||||||
SidebarGroupContent,
|
SidebarGroup,
|
||||||
SidebarGroupLabel,
|
SidebarGroupContent,
|
||||||
SidebarMenu,
|
SidebarGroupLabel,
|
||||||
SidebarMenuButton,
|
SidebarMenu,
|
||||||
SidebarMenuItem,
|
SidebarMenuButton,
|
||||||
useSidebar,
|
SidebarMenuItem,
|
||||||
} from "@/shared/common/ui/sidebar";
|
useSidebar,
|
||||||
|
} from "@/shared/common/ui/sidebar";
|
||||||
export interface MenuItem {
|
|
||||||
label: string;
|
export interface MenuItem {
|
||||||
href: string;
|
label: string;
|
||||||
icon: React.ReactNode;
|
href: string;
|
||||||
roles?: string[];
|
icon: React.ReactNode;
|
||||||
/** Sidebar section this item is bucketed under. */
|
roles?: string[];
|
||||||
group: string;
|
/** Sidebar section this item is bucketed under. */
|
||||||
}
|
group: string;
|
||||||
|
}
|
||||||
// Section render order; groups with no role-visible items are skipped.
|
|
||||||
const GROUP_ORDER = [
|
// Section render order; groups with no role-visible items are skipped.
|
||||||
"Overview",
|
const GROUP_ORDER = [
|
||||||
"Organizations",
|
"Overview",
|
||||||
"Content",
|
"Organizations",
|
||||||
"Records",
|
"Content",
|
||||||
"Configuration",
|
"Records",
|
||||||
"Archive",
|
"Configuration",
|
||||||
"System",
|
"Archive",
|
||||||
];
|
"System",
|
||||||
|
];
|
||||||
export const AppMenuTabs = () => {
|
|
||||||
const { user } = useAuth();
|
export const AppMenuTabs = () => {
|
||||||
const { pathname } = useLocation();
|
const { user } = useAuth();
|
||||||
const { setOpenMobile } = useSidebar();
|
const { pathname } = useLocation();
|
||||||
const { t } = useTranslation();
|
const { setOpenMobile } = useSidebar();
|
||||||
|
const { t } = useTranslation();
|
||||||
const userRoles = user?.roles.map((role) => role.key) || [];
|
|
||||||
|
const userRoles = user?.roles.map((role) => role.key) || [];
|
||||||
const menuItems: MenuItem[] = [
|
|
||||||
{
|
const menuItems: MenuItem[] = [
|
||||||
label: "dashboard",
|
{
|
||||||
href: "/user-management/dashboard",
|
label: "dashboard",
|
||||||
icon: <BarChart className="h-4 w-4" />,
|
href: "/user-management/dashboard",
|
||||||
roles: ["super_admin"],
|
icon: <BarChart className="h-4 w-4" />,
|
||||||
group: "Overview",
|
roles: ["super_admin"],
|
||||||
},
|
group: "Overview",
|
||||||
{
|
},
|
||||||
label: "organizations",
|
{
|
||||||
href: "/user-management/organizations",
|
label: "organizations",
|
||||||
icon: <Building2 className="h-4 w-4" />,
|
href: "/user-management/organizations",
|
||||||
roles: ["super_admin"],
|
icon: <Building2 className="h-4 w-4" />,
|
||||||
group: "Organizations",
|
roles: ["super_admin"],
|
||||||
},
|
group: "Organizations",
|
||||||
{
|
},
|
||||||
label: "organizationAdmins",
|
{
|
||||||
href: "/user-management/organization_admins",
|
label: "organizationAdmins",
|
||||||
icon: <Users2 className="h-4 w-4" />,
|
href: "/user-management/organization_admins",
|
||||||
roles: ["super_admin"],
|
icon: <Users2 className="h-4 w-4" />,
|
||||||
group: "Organizations",
|
roles: ["super_admin"],
|
||||||
},
|
group: "Organizations",
|
||||||
{
|
},
|
||||||
label: "externalUsers",
|
{
|
||||||
href: "/user-management/external_users",
|
label: "externalUsers",
|
||||||
icon: <Users2 className="h-4 w-4" />,
|
href: "/user-management/external_users",
|
||||||
roles: ["super_admin"],
|
icon: <Users2 className="h-4 w-4" />,
|
||||||
group: "Organizations",
|
roles: ["super_admin"],
|
||||||
},
|
group: "Organizations",
|
||||||
{
|
},
|
||||||
label: "dashboard",
|
{
|
||||||
href: "/user-management/user_management-dashboard",
|
label: "dashboard",
|
||||||
icon: <BarChart className="h-4 w-4" />,
|
href: "/user-management/user_management-dashboard",
|
||||||
roles: ["admin", "organization_admin", "unit_admin"],
|
icon: <BarChart className="h-4 w-4" />,
|
||||||
group: "Overview",
|
roles: ["admin", "organization_admin", "unit_admin"],
|
||||||
},
|
group: "Overview",
|
||||||
{
|
},
|
||||||
label: "userManagement",
|
{
|
||||||
href: "/user-management/user_management",
|
label: "userManagement",
|
||||||
icon: <UsersRound className="h-4 w-4" />,
|
href: "/user-management/user_management",
|
||||||
roles: ["admin", "organization_admin", "unit_admin"],
|
icon: <UsersRound className="h-4 w-4" />,
|
||||||
group: "Overview",
|
roles: ["admin", "organization_admin", "unit_admin"],
|
||||||
},
|
group: "Overview",
|
||||||
{
|
},
|
||||||
label: "contentManagement",
|
{
|
||||||
href: "/user-management/content-management",
|
label: "contentManagement",
|
||||||
icon: <FileText className="h-4 w-4" />,
|
href: "/user-management/content-management",
|
||||||
roles: ["admin", "organization_admin", "unit_admin"],
|
icon: <FileText className="h-4 w-4" />,
|
||||||
group: "Content",
|
roles: ["admin", "organization_admin", "unit_admin"],
|
||||||
},
|
group: "Content",
|
||||||
{
|
},
|
||||||
label: "webManagement",
|
{
|
||||||
href: "/user-management/web-management",
|
label: "webManagement",
|
||||||
icon: <Globe className="h-4 w-4" />,
|
href: "/user-management/web-management",
|
||||||
roles: ["admin", "organization_admin", "unit_admin"],
|
icon: <Globe className="h-4 w-4" />,
|
||||||
group: "Content",
|
roles: ["admin", "organization_admin", "unit_admin"],
|
||||||
},
|
group: "Content",
|
||||||
{
|
},
|
||||||
label: "Bulk",
|
{
|
||||||
href: "/user-management/bulk-upload",
|
label: "Bulk",
|
||||||
icon: <FileText className="h-4 w-4" />,
|
href: "/user-management/bulk-upload",
|
||||||
roles: ["admin", "organization_admin", "unit_admin"],
|
icon: <FileText className="h-4 w-4" />,
|
||||||
group: "Content",
|
roles: ["admin", "organization_admin", "unit_admin"],
|
||||||
},
|
group: "Content",
|
||||||
{
|
},
|
||||||
label: "Position",
|
{
|
||||||
href: "/user-management/position-management",
|
label: "Position",
|
||||||
icon: <FileText className="h-4 w-4" />,
|
href: "/user-management/position-management",
|
||||||
roles: ["admin", "organization_admin", "unit_admin", "super_admin"],
|
icon: <FileText className="h-4 w-4" />,
|
||||||
group: "Configuration",
|
roles: ["admin", "organization_admin", "unit_admin", "super_admin"],
|
||||||
},
|
group: "Configuration",
|
||||||
{
|
},
|
||||||
label: "settings",
|
{
|
||||||
href: "/user-management/organization-settings",
|
label: "Locations",
|
||||||
icon: <Settings className="h-4 w-4" />,
|
href: "/user-management/locations",
|
||||||
roles: ["admin", "organization_admin", "unit_admin"],
|
icon: <MapPin className="h-4 w-4" />,
|
||||||
group: "Configuration",
|
roles: ["admin", "organization_admin", "unit_admin", "super_admin"],
|
||||||
},
|
group: "Configuration",
|
||||||
{
|
},
|
||||||
label: "Add Site",
|
{
|
||||||
href: "/user-management/add-site",
|
label: "settings",
|
||||||
icon: <Globe className="h-4 w-4" />,
|
href: "/user-management/organization-settings",
|
||||||
roles: ["super_admin"],
|
icon: <Settings className="h-4 w-4" />,
|
||||||
group: "Configuration",
|
roles: ["admin", "organization_admin", "unit_admin"],
|
||||||
},
|
group: "Configuration",
|
||||||
{
|
},
|
||||||
label: "migratedRecords",
|
{
|
||||||
href: "/user-management/migrated-records-management",
|
label: "Add Site",
|
||||||
icon: <FileText className="h-4 w-4" />,
|
href: "/user-management/add-site",
|
||||||
roles: ["super_admin"],
|
icon: <Globe className="h-4 w-4" />,
|
||||||
group: "Records",
|
roles: ["super_admin"],
|
||||||
},
|
group: "Configuration",
|
||||||
{
|
},
|
||||||
label: "Sector Reports",
|
{
|
||||||
href: "/user-management/sector-reports",
|
label: "migratedRecords",
|
||||||
icon: <ChartAreaIcon className="h-4 w-4" />,
|
href: "/user-management/migrated-records-management",
|
||||||
roles: ["unit_admin", "admin", "organization_admin"],
|
icon: <FileText className="h-4 w-4" />,
|
||||||
group: "Records",
|
roles: ["super_admin"],
|
||||||
},
|
group: "Records",
|
||||||
{
|
},
|
||||||
label: "Archive Users",
|
{
|
||||||
href: "/user-management/archive-users",
|
label: "Sector Reports",
|
||||||
icon: <Archive className="h-4 w-4" />,
|
href: "/user-management/sector-reports",
|
||||||
roles: ["super_admin"],
|
icon: <ChartAreaIcon className="h-4 w-4" />,
|
||||||
group: "Archive",
|
roles: ["unit_admin", "admin", "organization_admin"],
|
||||||
},
|
group: "Records",
|
||||||
{
|
},
|
||||||
label: "Archived Organizations",
|
{
|
||||||
href: "/user-management/archived-organizations",
|
label: "Archive Users",
|
||||||
icon: <Archive className="h-4 w-4" />,
|
href: "/user-management/archive-users",
|
||||||
roles: ["super_admin"],
|
icon: <Archive className="h-4 w-4" />,
|
||||||
group: "Archive",
|
roles: ["super_admin"],
|
||||||
},
|
group: "Archive",
|
||||||
{
|
},
|
||||||
label: "Archive Users",
|
{
|
||||||
href: "/user-management/archives",
|
label: "Archived Organizations",
|
||||||
icon: <Archive className="h-4 w-4" />,
|
href: "/user-management/archived-organizations",
|
||||||
roles: ["admin", "organization_admin", "unit_admin"],
|
icon: <Archive className="h-4 w-4" />,
|
||||||
group: "Archive",
|
roles: ["super_admin"],
|
||||||
},
|
group: "Archive",
|
||||||
{
|
},
|
||||||
label: "Archived Units & Positions",
|
{
|
||||||
href: "/user-management/archived",
|
label: "Archive Users",
|
||||||
icon: <Archive className="h-4 w-4" />,
|
href: "/user-management/archives",
|
||||||
roles: ["admin", "organization_admin", "unit_admin"],
|
icon: <Archive className="h-4 w-4" />,
|
||||||
group: "Archive",
|
roles: ["admin", "organization_admin", "unit_admin"],
|
||||||
},
|
group: "Archive",
|
||||||
{
|
},
|
||||||
label: "activityLog",
|
{
|
||||||
href: "/user-management/activity_log",
|
label: "Archived Units & Positions",
|
||||||
icon: <ClipboardList className="h-4 w-4" />,
|
href: "/user-management/archived",
|
||||||
roles: ["super_admin"],
|
icon: <Archive className="h-4 w-4" />,
|
||||||
group: "System",
|
roles: ["admin", "organization_admin", "unit_admin"],
|
||||||
},
|
group: "Archive",
|
||||||
{
|
},
|
||||||
label: "setting",
|
{
|
||||||
href: "/user-management/settings",
|
label: "activityLog",
|
||||||
icon: <Settings className="h-4 w-4" />,
|
href: "/user-management/activity_log",
|
||||||
roles: ["super_admin"],
|
icon: <ClipboardList className="h-4 w-4" />,
|
||||||
group: "System",
|
roles: ["super_admin"],
|
||||||
},
|
group: "System",
|
||||||
{
|
},
|
||||||
label: "Letter Template",
|
{
|
||||||
href: "/user-management/templates",
|
label: "setting",
|
||||||
icon: <FileText className="h-4 w-4" />,
|
href: "/user-management/settings",
|
||||||
roles: ["super_admin"],
|
icon: <Settings className="h-4 w-4" />,
|
||||||
group: "System",
|
roles: ["super_admin"],
|
||||||
},
|
group: "System",
|
||||||
];
|
},
|
||||||
|
{
|
||||||
const filteredMenu = menuItems.filter((item) =>
|
label: "Letter Template",
|
||||||
item.roles?.some((r) => userRoles.includes(r)),
|
href: "/user-management/templates",
|
||||||
);
|
icon: <FileText className="h-4 w-4" />,
|
||||||
|
roles: ["super_admin"],
|
||||||
const isActive = (href: string) =>
|
group: "System",
|
||||||
pathname === href || pathname.startsWith(`${href}/`);
|
},
|
||||||
|
];
|
||||||
return (
|
|
||||||
<>
|
const filteredMenu = menuItems.filter((item) =>
|
||||||
{GROUP_ORDER.map((group) => {
|
item.roles?.some((r) => userRoles.includes(r)),
|
||||||
const items = filteredMenu.filter((item) => item.group === group);
|
);
|
||||||
if (items.length === 0) return null;
|
|
||||||
|
const isActive = (href: string) =>
|
||||||
return (
|
pathname === href || pathname.startsWith(`${href}/`);
|
||||||
<SidebarGroup key={group} className="pb-0">
|
|
||||||
<SidebarGroupLabel>{group}</SidebarGroupLabel>
|
return (
|
||||||
<SidebarGroupContent>
|
<>
|
||||||
<SidebarMenu>
|
{GROUP_ORDER.map((group) => {
|
||||||
{items.map((item) => {
|
const items = filteredMenu.filter((item) => item.group === group);
|
||||||
const label = t(`organization.${item.label}`, item.label);
|
if (items.length === 0) return null;
|
||||||
return (
|
|
||||||
<SidebarMenuItem key={item.href}>
|
return (
|
||||||
<SidebarMenuButton
|
<SidebarGroup key={group} className="pb-0">
|
||||||
asChild
|
<SidebarGroupLabel>{group}</SidebarGroupLabel>
|
||||||
isActive={isActive(item.href)}
|
<SidebarGroupContent>
|
||||||
tooltip={label}
|
<SidebarMenu>
|
||||||
>
|
{items.map((item) => {
|
||||||
<Link
|
const label = t(`organization.${item.label}`, item.label);
|
||||||
to={item.href}
|
return (
|
||||||
onClick={() => setOpenMobile(false)}
|
<SidebarMenuItem key={item.href}>
|
||||||
>
|
<SidebarMenuButton
|
||||||
{item.icon}
|
asChild
|
||||||
<span>{label}</span>
|
isActive={isActive(item.href)}
|
||||||
</Link>
|
tooltip={label}
|
||||||
</SidebarMenuButton>
|
>
|
||||||
</SidebarMenuItem>
|
<Link
|
||||||
);
|
to={item.href}
|
||||||
})}
|
onClick={() => setOpenMobile(false)}
|
||||||
</SidebarMenu>
|
>
|
||||||
</SidebarGroupContent>
|
{item.icon}
|
||||||
</SidebarGroup>
|
<span>{label}</span>
|
||||||
);
|
</Link>
|
||||||
})}
|
</SidebarMenuButton>
|
||||||
</>
|
</SidebarMenuItem>
|
||||||
);
|
);
|
||||||
};
|
})}
|
||||||
|
</SidebarMenu>
|
||||||
|
</SidebarGroupContent>
|
||||||
|
</SidebarGroup>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -0,0 +1,365 @@
|
|||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import {
|
||||||
|
APIProvider,
|
||||||
|
Map as GoogleMap,
|
||||||
|
Marker,
|
||||||
|
type MapMouseEvent,
|
||||||
|
} from "@vis.gl/react-google-maps";
|
||||||
|
|
||||||
|
import { Button } from "@/shared/common/ui/button";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/shared/common/ui/form";
|
||||||
|
import { Input } from "@/shared/common/ui/input";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/shared/common/ui/select";
|
||||||
|
import { Textarea } from "@/shared/common/ui/textarea";
|
||||||
|
import { useLocalizedName } from "@/shared/common/localizedName";
|
||||||
|
import type {
|
||||||
|
Location,
|
||||||
|
LocationPayload,
|
||||||
|
LocationType,
|
||||||
|
} from "@/user-management/dto/locations/location.type";
|
||||||
|
import { useLocations } from "@/user-management/hooks/useLocations";
|
||||||
|
|
||||||
|
const GOOGLE_MAPS_API_KEY = import.meta.env.VITE_GOOGLE_MAPS_API_KEY?.trim();
|
||||||
|
/** Addis Ababa — where every EDR location is within a map pan. */
|
||||||
|
const DEFAULT_CENTER = { lat: 9.032, lng: 38.7469 };
|
||||||
|
|
||||||
|
const NO_PARENT = "__none__";
|
||||||
|
|
||||||
|
const numeric = (label: string) =>
|
||||||
|
z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.optional()
|
||||||
|
.refine((v) => !v || !Number.isNaN(Number(v)), `${label} must be a number`);
|
||||||
|
|
||||||
|
const locationSchema = z.object({
|
||||||
|
nameAm: z.string().trim().min(1, "Amharic name is required"),
|
||||||
|
nameEn: z.string().trim().optional(),
|
||||||
|
code: z.string().trim().min(1, "Code is required"),
|
||||||
|
locationTypeId: z.string().uuid("Location type is required"),
|
||||||
|
parentId: z.string().optional(),
|
||||||
|
latitude: numeric("Latitude"),
|
||||||
|
longitude: numeric("Longitude"),
|
||||||
|
area: numeric("Area"),
|
||||||
|
boundaryJson: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.optional()
|
||||||
|
.refine((v) => {
|
||||||
|
if (!v) return true;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(v);
|
||||||
|
return typeof parsed === "object" && parsed !== null;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}, "Boundary must be a JSON object"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type LocationFormValues = z.infer<typeof locationSchema>;
|
||||||
|
|
||||||
|
interface LocationFormProps {
|
||||||
|
mode: "create" | "edit";
|
||||||
|
location?: Location;
|
||||||
|
locationTypes: LocationType[];
|
||||||
|
/** Every location, for the parent picker — the API has no filter endpoint. */
|
||||||
|
allLocations: Location[];
|
||||||
|
onSuccess?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LocationForm({
|
||||||
|
mode,
|
||||||
|
location,
|
||||||
|
locationTypes,
|
||||||
|
allLocations,
|
||||||
|
onSuccess,
|
||||||
|
}: LocationFormProps) {
|
||||||
|
const localizedName = useLocalizedName();
|
||||||
|
const { createLocation, updateLocation, isCreatingLocation, isUpdatingLocation } =
|
||||||
|
useLocations();
|
||||||
|
|
||||||
|
const form = useForm<LocationFormValues>({
|
||||||
|
resolver: zodResolver(locationSchema),
|
||||||
|
defaultValues: {
|
||||||
|
nameAm: location?.names?.am ?? "",
|
||||||
|
nameEn: location?.names?.en ?? "",
|
||||||
|
code: location?.code ?? "",
|
||||||
|
locationTypeId: location?.locationTypeId ?? "",
|
||||||
|
parentId: location?.parentId ?? NO_PARENT,
|
||||||
|
latitude: location?.latitude ?? "",
|
||||||
|
longitude: location?.longitude ?? "",
|
||||||
|
area: location?.area ?? "",
|
||||||
|
boundaryJson: location?.boundaryJson
|
||||||
|
? JSON.stringify(location.boundaryJson, null, 2)
|
||||||
|
: "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const [lat, lng] = [form.watch("latitude"), form.watch("longitude")];
|
||||||
|
const pin =
|
||||||
|
lat && lng && !Number.isNaN(Number(lat)) && !Number.isNaN(Number(lng))
|
||||||
|
? { lat: Number(lat), lng: Number(lng) }
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const dropPin = (event: MapMouseEvent) => {
|
||||||
|
const point = event.detail.latLng;
|
||||||
|
if (!point) return;
|
||||||
|
form.setValue("latitude", point.lat.toFixed(6), { shouldDirty: true });
|
||||||
|
form.setValue("longitude", point.lng.toFixed(6), { shouldDirty: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
// ponytail: self only, not descendants — the API accepts any parentId, so a
|
||||||
|
// deep cycle (A → B → A) is still possible. Walk the chain here if it bites.
|
||||||
|
const parentOptions = allLocations.filter((item) => item.id !== location?.id);
|
||||||
|
|
||||||
|
const submit = (values: LocationFormValues) => {
|
||||||
|
const payload: LocationPayload = {
|
||||||
|
names: {
|
||||||
|
am: values.nameAm,
|
||||||
|
...(values.nameEn ? { en: values.nameEn } : {}),
|
||||||
|
},
|
||||||
|
code: values.code,
|
||||||
|
locationTypeId: values.locationTypeId,
|
||||||
|
parentId:
|
||||||
|
values.parentId && values.parentId !== NO_PARENT
|
||||||
|
? values.parentId
|
||||||
|
: undefined,
|
||||||
|
latitude: values.latitude || undefined,
|
||||||
|
longitude: values.longitude || undefined,
|
||||||
|
area: values.area || undefined,
|
||||||
|
boundaryJson: values.boundaryJson
|
||||||
|
? (JSON.parse(values.boundaryJson) as Record<string, unknown>)
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (mode === "create") {
|
||||||
|
createLocation(payload, {
|
||||||
|
onSuccess: () => {
|
||||||
|
form.reset();
|
||||||
|
onSuccess?.();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (location) {
|
||||||
|
updateLocation(
|
||||||
|
{ id: location.id, payload },
|
||||||
|
{ onSuccess: () => onSuccess?.() },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(submit)} className="space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="nameAm"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Amharic Name *</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="አዲስ አበባ" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="nameEn"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>English Name</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="Addis Ababa" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="code"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Code *</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="LOC-001" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="locationTypeId"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Location Type *</FormLabel>
|
||||||
|
<Select onValueChange={field.onChange} value={field.value}>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select a type" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
{locationTypes.map((type) => (
|
||||||
|
<SelectItem key={type.id} value={type.id}>
|
||||||
|
{localizedName(type.names)} · L{type.level}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="parentId"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="col-span-2">
|
||||||
|
<FormLabel>Parent Location</FormLabel>
|
||||||
|
<Select onValueChange={field.onChange} value={field.value}>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="No parent (top level)" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value={NO_PARENT}>
|
||||||
|
No parent (top level)
|
||||||
|
</SelectItem>
|
||||||
|
{parentOptions.map((item) => (
|
||||||
|
<SelectItem key={item.id} value={item.id}>
|
||||||
|
{localizedName(item.names)} ({item.code})
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<FormLabel>Coordinates</FormLabel>
|
||||||
|
{GOOGLE_MAPS_API_KEY ? (
|
||||||
|
<div className="h-64 w-full overflow-hidden rounded-md border">
|
||||||
|
<APIProvider apiKey={GOOGLE_MAPS_API_KEY}>
|
||||||
|
<GoogleMap
|
||||||
|
defaultCenter={pin ?? DEFAULT_CENTER}
|
||||||
|
defaultZoom={pin ? 12 : 6}
|
||||||
|
gestureHandling="greedy"
|
||||||
|
disableDefaultUI={false}
|
||||||
|
onClick={dropPin}
|
||||||
|
style={{ width: "100%", height: "100%" }}
|
||||||
|
>
|
||||||
|
{pin ? <Marker position={pin} /> : null}
|
||||||
|
</GoogleMap>
|
||||||
|
</APIProvider>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
// Name the missing variable rather than rendering a dead grey box.
|
||||||
|
<p className="rounded-md border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800">
|
||||||
|
Map picker unavailable — <code>VITE_GOOGLE_MAPS_API_KEY</code> is
|
||||||
|
not set. Type the coordinates below instead.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{GOOGLE_MAPS_API_KEY ? (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Click the map to drop a pin, or type the values.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="latitude"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Latitude</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="9.032000" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="longitude"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Longitude</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="38.746900" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="area"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Area</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="1000.25" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="boundaryJson"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Boundary (GeoJSON)</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Textarea
|
||||||
|
rows={4}
|
||||||
|
placeholder='{"type":"Polygon","coordinates":[]}'
|
||||||
|
className="font-mono text-xs"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="w-full"
|
||||||
|
disabled={isCreatingLocation || isUpdatingLocation}
|
||||||
|
>
|
||||||
|
{mode === "create" ? "Create Location" : "Save Changes"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
|
||||||
|
import { Button } from "@/shared/common/ui/button";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/shared/common/ui/form";
|
||||||
|
import { Input } from "@/shared/common/ui/input";
|
||||||
|
import { Textarea } from "@/shared/common/ui/textarea";
|
||||||
|
import type {
|
||||||
|
LocationType,
|
||||||
|
LocationTypePayload,
|
||||||
|
} from "@/user-management/dto/locations/location.type";
|
||||||
|
import { useLocationTypes } from "@/user-management/hooks/useLocationTypes";
|
||||||
|
|
||||||
|
const locationTypeSchema = z.object({
|
||||||
|
nameAm: z.string().trim().min(1, "Amharic name is required"),
|
||||||
|
nameEn: z.string().trim().optional(),
|
||||||
|
code: z.string().trim().min(1, "Code is required"),
|
||||||
|
description: z.string().trim().optional(),
|
||||||
|
// Level is the hierarchy depth (1 = country, 2 = region, …). Server takes a
|
||||||
|
// number, so an empty string would post NaN.
|
||||||
|
level: z.coerce.number().int().min(1, "Level must be 1 or greater"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type LocationTypeFormValues = z.input<typeof locationTypeSchema>;
|
||||||
|
|
||||||
|
interface LocationTypeFormProps {
|
||||||
|
mode: "create" | "edit";
|
||||||
|
locationType?: LocationType;
|
||||||
|
onSuccess?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LocationTypeForm({
|
||||||
|
mode,
|
||||||
|
locationType,
|
||||||
|
onSuccess,
|
||||||
|
}: LocationTypeFormProps) {
|
||||||
|
const {
|
||||||
|
createLocationType,
|
||||||
|
updateLocationType,
|
||||||
|
isCreatingLocationType,
|
||||||
|
isUpdatingLocationType,
|
||||||
|
} = useLocationTypes();
|
||||||
|
|
||||||
|
const form = useForm<LocationTypeFormValues, unknown, z.output<typeof locationTypeSchema>>({
|
||||||
|
resolver: zodResolver(locationTypeSchema),
|
||||||
|
defaultValues: {
|
||||||
|
nameAm: locationType?.names?.am ?? "",
|
||||||
|
nameEn: locationType?.names?.en ?? "",
|
||||||
|
code: locationType?.code ?? "",
|
||||||
|
description: locationType?.description ?? "",
|
||||||
|
level: locationType?.level ?? 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const submit = (values: z.output<typeof locationTypeSchema>) => {
|
||||||
|
const payload: LocationTypePayload = {
|
||||||
|
names: {
|
||||||
|
am: values.nameAm,
|
||||||
|
...(values.nameEn ? { en: values.nameEn } : {}),
|
||||||
|
},
|
||||||
|
code: values.code,
|
||||||
|
description: values.description || undefined,
|
||||||
|
level: values.level,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (mode === "create") {
|
||||||
|
createLocationType(payload, {
|
||||||
|
onSuccess: () => {
|
||||||
|
form.reset();
|
||||||
|
onSuccess?.();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (locationType) {
|
||||||
|
updateLocationType(
|
||||||
|
{ id: locationType.id, payload },
|
||||||
|
{ onSuccess: () => onSuccess?.() },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(submit)} className="space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="nameAm"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Amharic Name *</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="ከተማ" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="nameEn"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>English Name</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="City" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="code"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Code *</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="CITY" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="level"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Level *</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="number" min={1} {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="description"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Description</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Textarea rows={3} placeholder="City level location" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="w-full"
|
||||||
|
disabled={isCreatingLocationType || isUpdatingLocationType}
|
||||||
|
>
|
||||||
|
{mode === "create" ? "Create Location Type" : "Save Changes"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { ColumnDef } from "@tanstack/react-table";
|
||||||
|
import { Pencil, Trash2 } from "lucide-react";
|
||||||
|
|
||||||
|
import { useAuth } from "@/auth/useAuth";
|
||||||
|
import { isSuperAdmin } from "@/lib/permissions";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/shared/common/ui/alert-dialog";
|
||||||
|
import { Button } from "@/shared/common/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/shared/common/ui/dialog";
|
||||||
|
import { AdvancedTable } from "@/shared/common/ui/table/AdvancedTable";
|
||||||
|
import { useLocalizedName } from "@/shared/common/localizedName";
|
||||||
|
import { usePermissions } from "@/shared/context/PermissionContext";
|
||||||
|
import type { LocationType } from "@/user-management/dto/locations/location.type";
|
||||||
|
import { useLocationTypes } from "@/user-management/hooks/useLocationTypes";
|
||||||
|
import { LocationTypeForm } from "./LocationTypeForm";
|
||||||
|
|
||||||
|
const PAGE_SIZE = 10;
|
||||||
|
|
||||||
|
export function LocationTypesTab() {
|
||||||
|
const [pageIndex, setPageIndex] = useState(0);
|
||||||
|
const [editing, setEditing] = useState<LocationType | null>(null);
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
const [deleting, setDeleting] = useState<LocationType | null>(null);
|
||||||
|
|
||||||
|
const localizedName = useLocalizedName();
|
||||||
|
const { permissions } = usePermissions();
|
||||||
|
const { user } = useAuth();
|
||||||
|
const superAdmin = isSuperAdmin(user);
|
||||||
|
const can = (key: string) => superAdmin || permissions.includes(key);
|
||||||
|
|
||||||
|
const {
|
||||||
|
locationTypes,
|
||||||
|
isLoadingLocationTypes,
|
||||||
|
refetchLocationTypes,
|
||||||
|
deleteLocationType,
|
||||||
|
isDeletingLocationType,
|
||||||
|
} = useLocationTypes({
|
||||||
|
skip: pageIndex * PAGE_SIZE,
|
||||||
|
take: PAGE_SIZE,
|
||||||
|
orderBy: "level:ASC",
|
||||||
|
});
|
||||||
|
|
||||||
|
const columns: ColumnDef<LocationType>[] = [
|
||||||
|
{
|
||||||
|
accessorKey: "names",
|
||||||
|
header: () => "Name",
|
||||||
|
cell: ({ row }) => <span>{localizedName(row.original.names)}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "code",
|
||||||
|
header: () => "Code",
|
||||||
|
cell: ({ row }) => <span>{row.original.code}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "level",
|
||||||
|
header: () => "Level",
|
||||||
|
cell: ({ row }) => <span>{row.original.level}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "description",
|
||||||
|
header: () => "Description",
|
||||||
|
cell: ({ row }) => <span>{row.original.description || "--"}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "actions",
|
||||||
|
header: () => "Actions",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
disabled={!can("can:update:location_type")}
|
||||||
|
title={
|
||||||
|
can("can:update:location_type")
|
||||||
|
? "Edit"
|
||||||
|
: "You cannot edit location types"
|
||||||
|
}
|
||||||
|
onClick={() => setEditing(row.original)}
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
disabled={!can("can:delete:location_type")}
|
||||||
|
title={
|
||||||
|
can("can:delete:location_type")
|
||||||
|
? "Delete"
|
||||||
|
: "You cannot delete location types"
|
||||||
|
}
|
||||||
|
onClick={() => setDeleting(row.original)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4 text-red-600" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
disabled={!can("can:create:location_type")}
|
||||||
|
title={
|
||||||
|
can("can:create:location_type")
|
||||||
|
? undefined
|
||||||
|
: "You cannot create location types"
|
||||||
|
}
|
||||||
|
onClick={() => setCreating(true)}
|
||||||
|
>
|
||||||
|
New Location Type
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AdvancedTable
|
||||||
|
columns={columns}
|
||||||
|
data={locationTypes?.items ?? []}
|
||||||
|
tableName="Location Types"
|
||||||
|
isLoading={isLoadingLocationTypes}
|
||||||
|
itemCount={locationTypes?.count ?? 0}
|
||||||
|
pageIndex={pageIndex}
|
||||||
|
pageSize={PAGE_SIZE}
|
||||||
|
onPageChange={setPageIndex}
|
||||||
|
nextFunction={() => setPageIndex((page) => page + 1)}
|
||||||
|
prevFunction={() => setPageIndex((page) => Math.max(page - 1, 0))}
|
||||||
|
refresh={refetchLocationTypes}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
open={creating || !!editing}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
setCreating(false);
|
||||||
|
setEditing(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent className="max-w-xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{editing ? "Edit Location Type" : "New Location Type"}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<LocationTypeForm
|
||||||
|
key={editing?.id ?? "create"}
|
||||||
|
mode={editing ? "edit" : "create"}
|
||||||
|
locationType={editing ?? undefined}
|
||||||
|
onSuccess={() => {
|
||||||
|
setCreating(false);
|
||||||
|
setEditing(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<AlertDialog
|
||||||
|
open={!!deleting}
|
||||||
|
onOpenChange={(open) => !open && setDeleting(null)}
|
||||||
|
>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>
|
||||||
|
Delete {deleting ? localizedName(deleting.names) : ""}?
|
||||||
|
</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
This is a permanent delete. Locations already using this type will
|
||||||
|
block it at the foreign key.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
disabled={isDeletingLocationType}
|
||||||
|
onClick={() => {
|
||||||
|
if (deleting) {
|
||||||
|
deleteLocationType(deleting.id, {
|
||||||
|
onSuccess: () => setDeleting(null),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { ColumnDef } from "@tanstack/react-table";
|
||||||
|
import { Pencil, Trash2 } from "lucide-react";
|
||||||
|
|
||||||
|
import { useAuth } from "@/auth/useAuth";
|
||||||
|
import { isSuperAdmin } from "@/lib/permissions";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/shared/common/ui/alert-dialog";
|
||||||
|
import { Button } from "@/shared/common/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/shared/common/ui/dialog";
|
||||||
|
import { AdvancedTable } from "@/shared/common/ui/table/AdvancedTable";
|
||||||
|
import { useLocalizedName } from "@/shared/common/localizedName";
|
||||||
|
import { usePermissions } from "@/shared/context/PermissionContext";
|
||||||
|
import type { Location } from "@/user-management/dto/locations/location.type";
|
||||||
|
import { useLocations } from "@/user-management/hooks/useLocations";
|
||||||
|
import { useLocationTypes } from "@/user-management/hooks/useLocationTypes";
|
||||||
|
import { LocationForm } from "./LocationForm";
|
||||||
|
|
||||||
|
const PAGE_SIZE = 10;
|
||||||
|
/** The API has no filter endpoint, so the parent picker and the type/parent
|
||||||
|
* name columns are resolved from one big list. */
|
||||||
|
const LOOKUP_TAKE = 1000;
|
||||||
|
|
||||||
|
export function LocationsTab() {
|
||||||
|
const [pageIndex, setPageIndex] = useState(0);
|
||||||
|
const [editing, setEditing] = useState<Location | null>(null);
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
const [deleting, setDeleting] = useState<Location | null>(null);
|
||||||
|
|
||||||
|
const localizedName = useLocalizedName();
|
||||||
|
const { permissions } = usePermissions();
|
||||||
|
const { user } = useAuth();
|
||||||
|
const superAdmin = isSuperAdmin(user);
|
||||||
|
const can = (key: string) => superAdmin || permissions.includes(key);
|
||||||
|
|
||||||
|
const { locations, isLoadingLocations, refetchLocations, deleteLocation, isDeletingLocation } =
|
||||||
|
useLocations({ skip: pageIndex * PAGE_SIZE, take: PAGE_SIZE });
|
||||||
|
const { locations: allLocations } = useLocations({ take: LOOKUP_TAKE });
|
||||||
|
const { locationTypes } = useLocationTypes({ take: LOOKUP_TAKE });
|
||||||
|
|
||||||
|
const typeName = useMemo(() => {
|
||||||
|
const byId = new Map(
|
||||||
|
(locationTypes?.items ?? []).map((type) => [type.id, type]),
|
||||||
|
);
|
||||||
|
return (id: string) => {
|
||||||
|
const type = byId.get(id);
|
||||||
|
return type ? `${localizedName(type.names)} (L${type.level})` : "--";
|
||||||
|
};
|
||||||
|
}, [locationTypes, localizedName]);
|
||||||
|
|
||||||
|
const parentName = useMemo(() => {
|
||||||
|
const byId = new Map(
|
||||||
|
(allLocations?.items ?? []).map((item) => [item.id, item]),
|
||||||
|
);
|
||||||
|
return (id?: string | null) => {
|
||||||
|
if (!id) return "--";
|
||||||
|
const parent = byId.get(id);
|
||||||
|
return parent ? localizedName(parent.names) : id.slice(0, 8);
|
||||||
|
};
|
||||||
|
}, [allLocations, localizedName]);
|
||||||
|
|
||||||
|
const columns: ColumnDef<Location>[] = [
|
||||||
|
{
|
||||||
|
accessorKey: "names",
|
||||||
|
header: () => "Name",
|
||||||
|
cell: ({ row }) => <span>{localizedName(row.original.names)}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "code",
|
||||||
|
header: () => "Code",
|
||||||
|
cell: ({ row }) => <span>{row.original.code}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "locationTypeId",
|
||||||
|
header: () => "Type",
|
||||||
|
cell: ({ row }) => <span>{typeName(row.original.locationTypeId)}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "parentId",
|
||||||
|
header: () => "Parent",
|
||||||
|
cell: ({ row }) => <span>{parentName(row.original.parentId)}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "coordinates",
|
||||||
|
header: () => "Coordinates",
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const { latitude, longitude } = row.original;
|
||||||
|
return (
|
||||||
|
<span>{latitude && longitude ? `${latitude}, ${longitude}` : "--"}</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "actions",
|
||||||
|
header: () => "Actions",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
disabled={!can("can:update:location")}
|
||||||
|
title={
|
||||||
|
can("can:update:location") ? "Edit" : "You cannot edit locations"
|
||||||
|
}
|
||||||
|
onClick={() => setEditing(row.original)}
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
disabled={!can("can:delete:location")}
|
||||||
|
title={
|
||||||
|
can("can:delete:location")
|
||||||
|
? "Delete"
|
||||||
|
: "You cannot delete locations"
|
||||||
|
}
|
||||||
|
onClick={() => setDeleting(row.original)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4 text-red-600" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
disabled={!can("can:create:location")}
|
||||||
|
title={
|
||||||
|
can("can:create:location")
|
||||||
|
? undefined
|
||||||
|
: "You cannot create locations"
|
||||||
|
}
|
||||||
|
onClick={() => setCreating(true)}
|
||||||
|
>
|
||||||
|
New Location
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AdvancedTable
|
||||||
|
columns={columns}
|
||||||
|
data={locations?.items ?? []}
|
||||||
|
tableName="Locations"
|
||||||
|
isLoading={isLoadingLocations}
|
||||||
|
itemCount={locations?.count ?? 0}
|
||||||
|
pageIndex={pageIndex}
|
||||||
|
pageSize={PAGE_SIZE}
|
||||||
|
onPageChange={setPageIndex}
|
||||||
|
nextFunction={() => setPageIndex((page) => page + 1)}
|
||||||
|
prevFunction={() => setPageIndex((page) => Math.max(page - 1, 0))}
|
||||||
|
refresh={refetchLocations}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
open={creating || !!editing}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
setCreating(false);
|
||||||
|
setEditing(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{editing ? "Edit Location" : "New Location"}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<LocationForm
|
||||||
|
key={editing?.id ?? "create"}
|
||||||
|
mode={editing ? "edit" : "create"}
|
||||||
|
location={editing ?? undefined}
|
||||||
|
locationTypes={locationTypes?.items ?? []}
|
||||||
|
allLocations={allLocations?.items ?? []}
|
||||||
|
onSuccess={() => {
|
||||||
|
setCreating(false);
|
||||||
|
setEditing(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<AlertDialog
|
||||||
|
open={!!deleting}
|
||||||
|
onOpenChange={(open) => !open && setDeleting(null)}
|
||||||
|
>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>
|
||||||
|
Delete {deleting ? localizedName(deleting.names) : ""}?
|
||||||
|
</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
This is a permanent delete, not an archive. A location that still
|
||||||
|
has child locations or unit clusters attached will be refused by
|
||||||
|
the database.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
disabled={isDeletingLocation}
|
||||||
|
onClick={() => {
|
||||||
|
if (deleting) {
|
||||||
|
deleteLocation(deleting.id, {
|
||||||
|
onSuccess: () => setDeleting(null),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
/**
|
||||||
|
* IAM organisation-structure locations. The API is `@tria-plc/iamapi-common`'s
|
||||||
|
* generic CRUD controller: list returns `{ count, items }` flattened into the
|
||||||
|
* response envelope (`/api/locations` is in `flatResponseModules`), and it
|
||||||
|
* joins nothing — `locationType` and `parent` are NOT expanded, so the UI
|
||||||
|
* resolves both from the type/location lists it already loaded.
|
||||||
|
*/
|
||||||
|
export interface LocaleName {
|
||||||
|
am: string;
|
||||||
|
en?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LocationType {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
names: LocaleName;
|
||||||
|
description?: string | null;
|
||||||
|
level: number;
|
||||||
|
createdAt?: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Location {
|
||||||
|
id: string;
|
||||||
|
parentId?: string | null;
|
||||||
|
locationTypeId: string;
|
||||||
|
names: LocaleName;
|
||||||
|
code: string;
|
||||||
|
/** Decimal strings server-side, not numbers. */
|
||||||
|
latitude?: string | null;
|
||||||
|
longitude?: string | null;
|
||||||
|
area?: string | null;
|
||||||
|
boundaryJson?: Record<string, unknown> | null;
|
||||||
|
createdAt?: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListResponse<T> {
|
||||||
|
count: number;
|
||||||
|
items: T[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListQuery {
|
||||||
|
skip?: number;
|
||||||
|
take?: number;
|
||||||
|
/** `field:ASC` / `field:DESC`, comma separated. No search or filter exists. */
|
||||||
|
orderBy?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LocationPayload = Omit<Location, "id" | "createdAt" | "updatedAt">;
|
||||||
|
export type LocationTypePayload = Omit<
|
||||||
|
LocationType,
|
||||||
|
"id" | "createdAt" | "updatedAt"
|
||||||
|
>;
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
import { useErrorHandler } from "@/shared/hooks/useErrorHandler";
|
||||||
|
import type {
|
||||||
|
ListQuery,
|
||||||
|
ListResponse,
|
||||||
|
LocationType,
|
||||||
|
LocationTypePayload,
|
||||||
|
} from "@/user-management/dto/locations/location.type";
|
||||||
|
import { locationTypeService } from "../services/api/locationService";
|
||||||
|
|
||||||
|
export const useLocationTypes = (params: ListQuery = {}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { handleError } = useErrorHandler(t);
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const invalidate = () =>
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["location-types"] });
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: locationTypes,
|
||||||
|
isLoading: isLoadingLocationTypes,
|
||||||
|
isError: isErrorLocationTypes,
|
||||||
|
refetch: refetchLocationTypes,
|
||||||
|
} = useQuery<ListResponse<LocationType>>({
|
||||||
|
queryKey: ["location-types", params],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await locationTypeService.list(params);
|
||||||
|
return { count: data?.count ?? 0, items: data?.items ?? [] };
|
||||||
|
},
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { mutate: createLocationType, isPending: isCreatingLocationType } =
|
||||||
|
useMutation({
|
||||||
|
mutationFn: async (payload: LocationTypePayload) => {
|
||||||
|
const { data } = await locationTypeService.create(payload);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success(t("locationType.created", "Location type created"));
|
||||||
|
invalidate();
|
||||||
|
},
|
||||||
|
onError: handleError,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { mutate: updateLocationType, isPending: isUpdatingLocationType } =
|
||||||
|
useMutation({
|
||||||
|
mutationFn: async ({
|
||||||
|
id,
|
||||||
|
payload,
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
payload: LocationTypePayload;
|
||||||
|
}) => {
|
||||||
|
const { data } = await locationTypeService.update(id, payload);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success(t("locationType.updated", "Location type updated"));
|
||||||
|
invalidate();
|
||||||
|
},
|
||||||
|
onError: handleError,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { mutate: deleteLocationType, isPending: isDeletingLocationType } =
|
||||||
|
useMutation({
|
||||||
|
mutationFn: async (id: string) => {
|
||||||
|
const { data } = await locationTypeService.remove(id);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success(t("locationType.deleted", "Location type deleted"));
|
||||||
|
invalidate();
|
||||||
|
},
|
||||||
|
onError: handleError,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
locationTypes,
|
||||||
|
isLoadingLocationTypes,
|
||||||
|
isErrorLocationTypes,
|
||||||
|
refetchLocationTypes,
|
||||||
|
createLocationType,
|
||||||
|
isCreatingLocationType,
|
||||||
|
updateLocationType,
|
||||||
|
isUpdatingLocationType,
|
||||||
|
deleteLocationType,
|
||||||
|
isDeletingLocationType,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
import { useErrorHandler } from "@/shared/hooks/useErrorHandler";
|
||||||
|
import type {
|
||||||
|
ListQuery,
|
||||||
|
ListResponse,
|
||||||
|
Location,
|
||||||
|
LocationPayload,
|
||||||
|
} from "@/user-management/dto/locations/location.type";
|
||||||
|
import { locationService } from "../services/api/locationService";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `params` is the whole server-side query surface: skip/take/orderBy. There is
|
||||||
|
* no search or filter endpoint, so a caller that needs every location (parent
|
||||||
|
* picker, name lookups) asks for a large `take`.
|
||||||
|
*/
|
||||||
|
export const useLocations = (params: ListQuery = {}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { handleError } = useErrorHandler(t);
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const invalidate = () =>
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["locations"] });
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: locations,
|
||||||
|
isLoading: isLoadingLocations,
|
||||||
|
isError: isErrorLocations,
|
||||||
|
refetch: refetchLocations,
|
||||||
|
} = useQuery<ListResponse<Location>>({
|
||||||
|
queryKey: ["locations", params],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await locationService.list(params);
|
||||||
|
return { count: data?.count ?? 0, items: data?.items ?? [] };
|
||||||
|
},
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { mutate: createLocation, isPending: isCreatingLocation } = useMutation({
|
||||||
|
mutationFn: async (payload: LocationPayload) => {
|
||||||
|
const { data } = await locationService.create(payload);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success(t("location.created", "Location created"));
|
||||||
|
invalidate();
|
||||||
|
},
|
||||||
|
onError: handleError,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { mutate: updateLocation, isPending: isUpdatingLocation } = useMutation({
|
||||||
|
mutationFn: async ({
|
||||||
|
id,
|
||||||
|
payload,
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
payload: LocationPayload;
|
||||||
|
}) => {
|
||||||
|
const { data } = await locationService.update(id, payload);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success(t("location.updated", "Location updated"));
|
||||||
|
invalidate();
|
||||||
|
},
|
||||||
|
onError: handleError,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { mutate: deleteLocation, isPending: isDeletingLocation } = useMutation({
|
||||||
|
mutationFn: async (id: string) => {
|
||||||
|
const { data } = await locationService.remove(id);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success(t("location.deleted", "Location deleted"));
|
||||||
|
invalidate();
|
||||||
|
},
|
||||||
|
onError: handleError,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
locations,
|
||||||
|
isLoadingLocations,
|
||||||
|
isErrorLocations,
|
||||||
|
refetchLocations,
|
||||||
|
createLocation,
|
||||||
|
isCreatingLocation,
|
||||||
|
updateLocation,
|
||||||
|
isUpdatingLocation,
|
||||||
|
deleteLocation,
|
||||||
|
isDeletingLocation,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import {
|
||||||
|
Tabs,
|
||||||
|
TabsContent,
|
||||||
|
TabsList,
|
||||||
|
TabsTrigger,
|
||||||
|
} from "@/shared/common/ui/tabs";
|
||||||
|
import { LocationsTab } from "@/user-management/components/location-management/LocationsTab";
|
||||||
|
import { LocationTypesTab } from "@/user-management/components/location-management/LocationTypesTab";
|
||||||
|
|
||||||
|
export default function LocationManagementPage() {
|
||||||
|
return (
|
||||||
|
<div className="w-full space-y-6 p-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold">Location Management</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Locations and their hierarchy levels, shared across the IAM
|
||||||
|
organisation structure.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Tabs defaultValue="locations">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="locations">Locations</TabsTrigger>
|
||||||
|
<TabsTrigger value="types">Location Types</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
<TabsContent value="locations" className="pt-4">
|
||||||
|
<LocationsTab />
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="types" className="pt-4">
|
||||||
|
<LocationTypesTab />
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import TemplatePage from "@/super-admin/components/templates/components/template
|
|||||||
import CreatePositionPage from "./pages/position-management/create";
|
import CreatePositionPage from "./pages/position-management/create";
|
||||||
import EditPositionPage from "./pages/position-management/edit";
|
import EditPositionPage from "./pages/position-management/edit";
|
||||||
import PositionManagementPage from "./pages/position-management";
|
import PositionManagementPage from "./pages/position-management";
|
||||||
|
import LocationManagementPage from "./pages/location-management";
|
||||||
import MigratedDataManagementPage from "./pages/position-management/MigratedDataManagementPage";
|
import MigratedDataManagementPage from "./pages/position-management/MigratedDataManagementPage";
|
||||||
import UserPositionApprovalPage from "./pages/UserPositionApprovalPage";
|
import UserPositionApprovalPage from "./pages/UserPositionApprovalPage";
|
||||||
import ViewMigratedDataPage from "./components/MigratedRecords/ViewMigratedDataPage";
|
import ViewMigratedDataPage from "./components/MigratedRecords/ViewMigratedDataPage";
|
||||||
@@ -140,6 +141,10 @@ export function UserManagementRoutes(): ReactElement {
|
|||||||
path="user-management/position-management"
|
path="user-management/position-management"
|
||||||
element={<PositionManagementPage />}
|
element={<PositionManagementPage />}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="user-management/locations"
|
||||||
|
element={<LocationManagementPage />}
|
||||||
|
/>
|
||||||
{/*
|
{/*
|
||||||
The per-officer teeter (ማህተም) + signature upload. This
|
The per-officer teeter (ማህተም) + signature upload. This
|
||||||
is NOT the company stamp: it is the individual approval
|
is NOT the company stamp: it is the individual approval
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { withHeaders } from "@/record-management/services/api/withHeaders";
|
||||||
|
import axiosInstance from "@/shared/services/axiosInstance";
|
||||||
|
import type {
|
||||||
|
ListQuery,
|
||||||
|
ListResponse,
|
||||||
|
Location,
|
||||||
|
LocationPayload,
|
||||||
|
LocationType,
|
||||||
|
LocationTypePayload,
|
||||||
|
} from "@/user-management/dto/locations/location.type";
|
||||||
|
import { AxiosResponse } from "axios";
|
||||||
|
|
||||||
|
export const locationService = {
|
||||||
|
list: (
|
||||||
|
params: ListQuery = {},
|
||||||
|
): Promise<AxiosResponse<ListResponse<Location>>> =>
|
||||||
|
axiosInstance.get(`/locations`, { params, headers: withHeaders() }),
|
||||||
|
|
||||||
|
create: (payload: LocationPayload): Promise<AxiosResponse<Location>> =>
|
||||||
|
axiosInstance.post(`/locations`, payload, { headers: withHeaders() }),
|
||||||
|
|
||||||
|
update: (
|
||||||
|
id: string,
|
||||||
|
payload: LocationPayload,
|
||||||
|
): Promise<AxiosResponse<Location>> =>
|
||||||
|
axiosInstance.put(`/locations/${id}`, payload, { headers: withHeaders() }),
|
||||||
|
|
||||||
|
// Hard delete server-side — a location with children or unit clusters fails
|
||||||
|
// on the foreign key rather than returning a tidy 409.
|
||||||
|
remove: (id: string): Promise<AxiosResponse<void>> =>
|
||||||
|
axiosInstance.delete(`/locations/${id}`, { headers: withHeaders() }),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const locationTypeService = {
|
||||||
|
list: (
|
||||||
|
params: ListQuery = {},
|
||||||
|
): Promise<AxiosResponse<ListResponse<LocationType>>> =>
|
||||||
|
axiosInstance.get(`/location-types`, { params, headers: withHeaders() }),
|
||||||
|
|
||||||
|
create: (
|
||||||
|
payload: LocationTypePayload,
|
||||||
|
): Promise<AxiosResponse<LocationType>> =>
|
||||||
|
axiosInstance.post(`/location-types`, payload, { headers: withHeaders() }),
|
||||||
|
|
||||||
|
update: (
|
||||||
|
id: string,
|
||||||
|
payload: LocationTypePayload,
|
||||||
|
): Promise<AxiosResponse<LocationType>> =>
|
||||||
|
axiosInstance.put(`/location-types/${id}`, payload, {
|
||||||
|
headers: withHeaders(),
|
||||||
|
}),
|
||||||
|
|
||||||
|
remove: (id: string): Promise<AxiosResponse<void>> =>
|
||||||
|
axiosInstance.delete(`/location-types/${id}`, { headers: withHeaders() }),
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user