Skip to content

Commit 0424e41

Browse files
authored
feat: ORCA-1117 bulk add repairs (#1779)
* chore: Add initial add repairs window * chore: further changes to repairs modal * chore: Add validation and other UI changes * chore: Update bulk repairs API * chore: Update frontend DTOs to support bulk add repairs * chore: Hook up backend API calls for adding repairs * chore: Fix validation and tests * chore: Further test fixes * chore: Add more test coverage
1 parent 06c4b69 commit 0424e41

29 files changed

Lines changed: 2784 additions & 108 deletions

File tree

admin/backend/src/resource-asset/dto/resource-asset-repair.dto.ts

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -175,15 +175,46 @@ export class RecreationAssetRepairDto extends BaseRecreationAssetRepairDto {
175175

176176
export class RepairChange {
177177
@ApiProperty({
178-
description: 'Cost of the repair',
178+
description: 'Estimated cost of the repair',
179179
example: 1000.0,
180180
})
181181
@IsNumber()
182182
@IsNotEmpty()
183-
repair_cost: number;
183+
estimated_repair_cost: number;
184+
185+
@ApiPropertyOptional({
186+
description: 'Actual cost of the repair, once known',
187+
example: 950.0,
188+
type: Number,
189+
nullable: true,
190+
})
191+
@IsNumber()
192+
@IsOptional()
193+
actual_repair_cost?: number;
194+
195+
@ApiPropertyOptional({
196+
description: 'Trail station where the repaired segment starts',
197+
example: '49.232423, -128.334343',
198+
type: String,
199+
nullable: true,
200+
})
201+
@IsString()
202+
@IsOptional()
203+
station_start?: string;
204+
205+
@ApiPropertyOptional({
206+
description: 'Trail station where the repaired segment ends',
207+
example: '49.234561, -128.331872',
208+
type: String,
209+
nullable: true,
210+
})
211+
@IsString()
212+
@IsOptional()
213+
station_end?: string;
184214

185215
@ApiProperty({
186216
description: 'Array of asset IDs to which this repair change applies',
217+
type: [Number],
187218
example: [1, 2, 3],
188219
})
189220
@IsArray()
@@ -201,19 +232,23 @@ export class RecreationAssetBulkRepairDto {
201232
@IsNotEmpty()
202233
recreation_remed_repair_code: string;
203234

204-
@ApiProperty({
235+
@ApiPropertyOptional({
205236
description: 'Date when the repairs were completed',
206237
example: '2023-10-01',
238+
type: String,
239+
nullable: true,
207240
})
208241
@IsDateString()
209242
@IsOptional()
210243
completed_date?: string;
211244

212245
@ApiProperty({
213246
description: 'Array of repair changes to be applied across multiple assets',
247+
type: [RepairChange],
214248
example: [
215249
{
216-
repair_cost: 1000.0,
250+
estimated_repair_cost: 1000.0,
251+
actual_repair_cost: 950.0,
217252
asset_ids: [1, 2, 3],
218253
},
219254
],

admin/backend/src/resource-asset/service/resource-asset.service.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -485,8 +485,11 @@ export class RecreationAssetService {
485485
change.asset_ids.map((assetId) => ({
486486
asset_id: BigInt(assetId),
487487
recreation_remed_repair_code: dto.recreation_remed_repair_code,
488-
actual_repair_cost: change.repair_cost,
488+
estimated_repair_cost: change.estimated_repair_cost,
489+
actual_repair_cost: change.actual_repair_cost ?? null,
489490
repair_completed_date: completedDate,
491+
trail_segment_start: change.station_start ?? null,
492+
trail_segment_end: change.station_end ?? null,
490493
})),
491494
);
492495

admin/backend/test/resource-asset/dto/resource-asset-repair.dto.spec.ts

Lines changed: 54 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -232,41 +232,82 @@ describe('Recreation Asset Repair DTOs', () => {
232232
describe('RepairChange', () => {
233233
it('should validate a valid RepairChange object', async () => {
234234
const validChange = {
235-
repair_cost: 1000.0,
235+
estimated_repair_cost: 1000.0,
236+
actual_repair_cost: 950.0,
237+
station_start: '49.232423, -128.334343',
238+
station_end: '49.234561, -128.331872',
236239
asset_ids: [1, 2, 3],
237240
};
238241

239242
const { errors } = await validateDto(RepairChange, validChange);
240243
expect(errors.length).toBe(0);
241244
});
242245

243-
it('should fail when repair_cost is missing or not a number', async () => {
246+
it('should validate successfully without the optional actual cost and stations', async () => {
247+
const minimalChange = {
248+
estimated_repair_cost: 1000.0,
249+
asset_ids: [1, 2, 3],
250+
};
251+
252+
const { errors } = await validateDto(RepairChange, minimalChange);
253+
expect(errors.length).toBe(0);
254+
});
255+
256+
it('should fail when estimated_repair_cost is missing or not a number', async () => {
244257
const invalidCost = {
245-
repair_cost: 'invalid',
258+
estimated_repair_cost: 'invalid',
246259
asset_ids: [1, 2],
247260
};
248261

249262
const { errors } = await validateDto(RepairChange, invalidCost);
250263

251264
const properties = errors.map((e) => e.property);
252-
expect(properties).toContain('repair_cost');
265+
expect(properties).toContain('estimated_repair_cost');
253266
});
254267

255-
it('should fail when repair_cost is null or empty', async () => {
268+
it('should fail when estimated_repair_cost is null or empty', async () => {
256269
const emptyCost = {
257-
repair_cost: null,
270+
estimated_repair_cost: null,
258271
asset_ids: [1, 2],
259272
};
260273

261274
const { errors } = await validateDto(RepairChange, emptyCost);
262275

263276
const properties = errors.map((e) => e.property);
264-
expect(properties).toContain('repair_cost');
277+
expect(properties).toContain('estimated_repair_cost');
278+
});
279+
280+
it('should fail when actual_repair_cost is provided but not a number', async () => {
281+
const invalidActualCost = {
282+
estimated_repair_cost: 500,
283+
actual_repair_cost: 'invalid',
284+
asset_ids: [1, 2],
285+
};
286+
287+
const { errors } = await validateDto(RepairChange, invalidActualCost);
288+
289+
const properties = errors.map((e) => e.property);
290+
expect(properties).toContain('actual_repair_cost');
291+
});
292+
293+
it('should fail when station_start or station_end are provided but not strings', async () => {
294+
const invalidStations = {
295+
estimated_repair_cost: 500,
296+
station_start: 123,
297+
station_end: 456,
298+
asset_ids: [1, 2],
299+
};
300+
301+
const { errors } = await validateDto(RepairChange, invalidStations);
302+
303+
const properties = errors.map((e) => e.property);
304+
expect(properties).toContain('station_start');
305+
expect(properties).toContain('station_end');
265306
});
266307

267308
it('should fail when asset_ids is not an array or contains non-integer values', async () => {
268309
const invalidAssetIds = {
269-
repair_cost: 500,
310+
estimated_repair_cost: 500,
270311
asset_ids: ['abc', 2],
271312
};
272313

@@ -278,7 +319,7 @@ describe('Recreation Asset Repair DTOs', () => {
278319

279320
it('should fail when asset_ids is not an array type', async () => {
280321
const nonArrayAssetIds = {
281-
repair_cost: 500,
322+
estimated_repair_cost: 500,
282323
asset_ids: '1, 2, 3',
283324
};
284325

@@ -296,7 +337,8 @@ describe('Recreation Asset Repair DTOs', () => {
296337
completed_date: '2023-10-01',
297338
changes: [
298339
{
299-
repair_cost: 1000.0,
340+
estimated_repair_cost: 1000.0,
341+
actual_repair_cost: 950.0,
300342
asset_ids: [1, 2, 3],
301343
},
302344
],
@@ -314,7 +356,7 @@ describe('Recreation Asset Repair DTOs', () => {
314356
recreation_remed_repair_code: 'CL',
315357
changes: [
316358
{
317-
repair_cost: 500.0,
359+
estimated_repair_cost: 500.0,
318360
asset_ids: [10],
319361
},
320362
],
@@ -378,7 +420,7 @@ describe('Recreation Asset Repair DTOs', () => {
378420
recreation_remed_repair_code: 'CL',
379421
changes: [
380422
{
381-
repair_cost: 'invalid-cost',
423+
estimated_repair_cost: 'invalid-cost',
382424
asset_ids: ['invalid-id'],
383425
},
384426
],

admin/backend/test/resource-asset/resource-asset.controller.spec.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -309,7 +309,8 @@ describe('RecreationAssetController', () => {
309309
changes: [
310310
{
311311
asset_ids: [1, 2],
312-
repair_cost: 300,
312+
estimated_repair_cost: 350,
313+
actual_repair_cost: 300,
313314
},
314315
],
315316
};

admin/backend/test/resource-asset/service/resource-asset.service.spec.ts

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1330,7 +1330,10 @@ describe('RecreationAssetService', () => {
13301330
changes: [
13311331
{
13321332
asset_ids: [1, 2],
1333-
repair_cost: 250,
1333+
estimated_repair_cost: 300,
1334+
actual_repair_cost: 250,
1335+
station_start: '49.232423, -128.334343',
1336+
station_end: '49.234561, -128.331872',
13341337
},
13351338
],
13361339
};
@@ -1345,20 +1348,26 @@ describe('RecreationAssetService', () => {
13451348
{
13461349
asset_id: 1n,
13471350
recreation_remed_repair_code: 'BULK_REPAIR',
1351+
estimated_repair_cost: 300,
13481352
actual_repair_cost: 250,
13491353
repair_completed_date: new Date('2023-11-15'),
1354+
trail_segment_start: '49.232423, -128.334343',
1355+
trail_segment_end: '49.234561, -128.331872',
13501356
},
13511357
{
13521358
asset_id: 2n,
13531359
recreation_remed_repair_code: 'BULK_REPAIR',
1360+
estimated_repair_cost: 300,
13541361
actual_repair_cost: 250,
13551362
repair_completed_date: new Date('2023-11-15'),
1363+
trail_segment_start: '49.232423, -128.334343',
1364+
trail_segment_end: '49.234561, -128.331872',
13561365
},
13571366
],
13581367
});
13591368
});
13601369

1361-
it('should handle null completed_date during bulk upsert', async () => {
1370+
it('should handle null completed_date, actual cost, and stations during bulk insert', async () => {
13621371
prismaMock.recreation_asset.count.mockResolvedValue(1);
13631372

13641373
const dto = {
@@ -1367,7 +1376,7 @@ describe('RecreationAssetService', () => {
13671376
changes: [
13681377
{
13691378
asset_ids: [1],
1370-
repair_cost: 100,
1379+
estimated_repair_cost: 100,
13711380
},
13721381
],
13731382
};
@@ -1381,8 +1390,11 @@ describe('RecreationAssetService', () => {
13811390
{
13821391
asset_id: 1n,
13831392
recreation_remed_repair_code: 'BULK_REPAIR',
1384-
actual_repair_cost: 100,
1393+
estimated_repair_cost: 100,
1394+
actual_repair_cost: null,
13851395
repair_completed_date: null,
1396+
trail_segment_start: null,
1397+
trail_segment_end: null,
13861398
},
13871399
],
13881400
});

admin/frontend/src/components/form/CurrencyInputField.tsx

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,13 @@ export interface CurrencyInputFieldProps<TFieldValues extends FieldValues> {
1818
disabled?: boolean;
1919
}
2020

21-
interface CurrencyInputProps {
21+
export interface CurrencyInputProps {
2222
value: number | undefined;
2323
onChange: (value: number | undefined) => void;
24-
onBlur: () => void;
25-
placeholder: string;
26-
isInvalid: boolean;
27-
disabled: boolean;
24+
onBlur?: () => void;
25+
placeholder?: string;
26+
isInvalid?: boolean;
27+
disabled?: boolean;
2828
}
2929

3030
const isValidCurrencyInput = (input: string): boolean => {
@@ -35,13 +35,13 @@ const formatCurrency = (value: number): string => {
3535
return value.toFixed(2);
3636
};
3737

38-
const CurrencyInput = ({
38+
export const CurrencyInput = ({
3939
value,
4040
onChange,
4141
onBlur,
42-
placeholder,
43-
isInvalid,
44-
disabled,
42+
placeholder = '0.00',
43+
isInvalid = false,
44+
disabled = false,
4545
}: CurrencyInputProps) => {
4646
const [displayValue, setDisplayValue] = useState<string>(() => {
4747
if (value === undefined || value === null) return '';
@@ -70,7 +70,7 @@ const CurrencyInput = ({
7070
};
7171

7272
const handleBlur = () => {
73-
onBlur();
73+
onBlur?.();
7474

7575
if (!displayValue || displayValue === '.') {
7676
setDisplayValue('');

admin/frontend/src/components/form/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@ export { TextField } from './TextField';
22
export { SelectField } from './SelectField';
33
export { CheckboxDropdownField } from './CheckboxDropdownField';
44
export { DateInputField } from './DateInputField';
5-
export { CurrencyInputField } from './CurrencyInputField';
5+
export { CurrencyInputField, CurrencyInput } from './CurrencyInputField';
6+
export type { CurrencyInputProps } from './CurrencyInputField';
67
export * from './GroupedMultiSelectField';
78
export { RichTextEditor } from './rich-text-editor';
89
export { MultiSelectField } from './MultiSelectField';

0 commit comments

Comments
 (0)