diff --git a/libs/api/domains/vehicles/src/lib/dto/updateResponseError.dto.ts b/libs/api/domains/vehicles/src/lib/dto/updateResponseError.dto.ts new file mode 100644 index 000000000000..f6382a99fe71 --- /dev/null +++ b/libs/api/domains/vehicles/src/lib/dto/updateResponseError.dto.ts @@ -0,0 +1,12 @@ +export interface UpdateResponseError { + type: string + title: string + status: number + Errors: Array<{ + lookupNo: number + warnSever: string + errorMess: string + permno: string + warningSerialNumber: string + }> +} diff --git a/libs/api/domains/vehicles/src/lib/dto/vehiclesListInputV3.ts b/libs/api/domains/vehicles/src/lib/dto/vehiclesListInputV3.ts index 15c75e1ccd8e..f61910fa95b5 100644 --- a/libs/api/domains/vehicles/src/lib/dto/vehiclesListInputV3.ts +++ b/libs/api/domains/vehicles/src/lib/dto/vehiclesListInputV3.ts @@ -7,4 +7,7 @@ export class VehiclesListInputV3 { @Field() page!: number + + @Field({ nullable: true }) + query?: string } diff --git a/libs/api/domains/vehicles/src/lib/models/v3/bulkMileage/bulkMileageReadingResponse.model.ts b/libs/api/domains/vehicles/src/lib/models/v3/bulkMileage/bulkMileageReadingResponse.model.ts index ef8738dc461f..b84ee37d6e80 100644 --- a/libs/api/domains/vehicles/src/lib/models/v3/bulkMileage/bulkMileageReadingResponse.model.ts +++ b/libs/api/domains/vehicles/src/lib/models/v3/bulkMileage/bulkMileageReadingResponse.model.ts @@ -1,12 +1,16 @@ -import { Field, ObjectType, ID } from '@nestjs/graphql' +import { Field, ObjectType, ID, Int } from '@nestjs/graphql' @ObjectType() export class VehiclesBulkMileageReadingResponse { @Field(() => ID, { + nullable: true, description: 'The GUID of the mileage registration post request. Used to fetch job status', }) - requestId!: string + requestId?: string + + @Field(() => Int, { nullable: true }) + errorCode?: number @Field({ nullable: true }) errorMessage?: string diff --git a/libs/api/domains/vehicles/src/lib/models/v3/postVehicleMileageResponse.model.ts b/libs/api/domains/vehicles/src/lib/models/v3/postVehicleMileageResponse.model.ts new file mode 100644 index 000000000000..3e166c9d6259 --- /dev/null +++ b/libs/api/domains/vehicles/src/lib/models/v3/postVehicleMileageResponse.model.ts @@ -0,0 +1,15 @@ +import { createUnionType } from '@nestjs/graphql' +import { VehicleMileageDetail } from '../getVehicleMileage.model' +import { VehiclesMileageUpdateError } from './vehicleMileageResponseError.model' + +export const VehicleMileagePostResponse = createUnionType({ + name: 'VehicleMileagePostResponse', + types: () => [VehicleMileageDetail, VehiclesMileageUpdateError] as const, + resolveType(value) { + if ('permno' in value && value.permno !== undefined) { + return VehicleMileageDetail + } + + return VehiclesMileageUpdateError + }, +}) diff --git a/libs/api/domains/vehicles/src/lib/models/v3/putVehicleMileageResponse.model.ts b/libs/api/domains/vehicles/src/lib/models/v3/putVehicleMileageResponse.model.ts new file mode 100644 index 000000000000..3bc4e6e09148 --- /dev/null +++ b/libs/api/domains/vehicles/src/lib/models/v3/putVehicleMileageResponse.model.ts @@ -0,0 +1,15 @@ +import { createUnionType } from '@nestjs/graphql' +import { VehicleMileagePutModel } from '../getVehicleMileage.model' +import { VehiclesMileageUpdateError } from './vehicleMileageResponseError.model' + +export const VehicleMileagePutResponse = createUnionType({ + name: 'VehicleMileagePutResponse', + types: () => [VehicleMileagePutModel, VehiclesMileageUpdateError] as const, + resolveType(value) { + if ('permno' in value && value.permno !== undefined) { + return VehicleMileagePutModel + } + + return VehiclesMileageUpdateError + }, +}) diff --git a/libs/api/domains/vehicles/src/lib/models/v3/vehicleMileageResponseError.model.ts b/libs/api/domains/vehicles/src/lib/models/v3/vehicleMileageResponseError.model.ts new file mode 100644 index 000000000000..7df4ffd6359a --- /dev/null +++ b/libs/api/domains/vehicles/src/lib/models/v3/vehicleMileageResponseError.model.ts @@ -0,0 +1,14 @@ +import { Field, Int, ObjectType } from '@nestjs/graphql' +import GraphQLJSON from 'graphql-type-json' + +@ObjectType() +export class VehiclesMileageUpdateError { + @Field() + message!: string + + @Field(() => Int, { nullable: true }) + code?: number + + @Field(() => GraphQLJSON, { nullable: true }) + error?: string +} diff --git a/libs/api/domains/vehicles/src/lib/resolvers/mileage.resolver.ts b/libs/api/domains/vehicles/src/lib/resolvers/mileage.resolver.ts index e2b3f7bf91ed..e97fb5de614a 100644 --- a/libs/api/domains/vehicles/src/lib/resolvers/mileage.resolver.ts +++ b/libs/api/domains/vehicles/src/lib/resolvers/mileage.resolver.ts @@ -35,6 +35,9 @@ import { } from '@island.is/nest/feature-flags' import { mileageDetailConstructor } from '../utils/helpers' import { LOGGER_PROVIDER, type Logger } from '@island.is/logging' +import { VehicleMileagePostResponse } from '../models/v3/postVehicleMileageResponse.model' +import { VehiclesMileageUpdateError } from '../models/v3/vehicleMileageResponseError.model' +import { VehicleMileagePutResponse } from '../models/v3/putVehicleMileageResponse.model' @UseGuards(IdsUserGuard, ScopesGuard, FeatureFlagGuard) @FeatureFlag(Features.servicePortalVehicleMileagePageEnabled) @@ -99,6 +102,48 @@ export class VehiclesMileageResolver { return mileageDetailConstructor(res) } + @Mutation(() => VehicleMileagePostResponse, { + name: 'vehicleMileagePostV2', + nullable: true, + }) + @Audit() + async postVehicleMileageReadingV2( + @Args('input') input: PostVehicleMileageInput, + @CurrentUser() user: User, + ) { + const res = await this.vehiclesService.postMileageReadingV2(user, { + ...input, + mileage: Number(input.mileage ?? input.mileageNumber), + }) + + if (!res || res instanceof VehiclesMileageUpdateError) { + return res + } + + return mileageDetailConstructor(res) + } + + @Mutation(() => VehicleMileagePutResponse, { + name: 'vehicleMileagePutV2', + nullable: true, + }) + @Audit() + async putVehicleMileageReadingV2( + @Args('input') input: PutVehicleMileageInput, + @CurrentUser() user: User, + ) { + const res = await this.vehiclesService.putMileageReadingV2(user, { + ...input, + mileage: Number(input.mileage ?? input.mileageNumber), + }) + + if (!res || res instanceof VehiclesMileageUpdateError) { + return res + } + + return mileageDetailConstructor(res) + } + @ResolveField('canRegisterMileage', () => Boolean, { nullable: true, }) diff --git a/libs/api/domains/vehicles/src/lib/services/bulkMileage.service.ts b/libs/api/domains/vehicles/src/lib/services/bulkMileage.service.ts index 72743c79f0ad..d5aec6f44140 100644 --- a/libs/api/domains/vehicles/src/lib/services/bulkMileage.service.ts +++ b/libs/api/domains/vehicles/src/lib/services/bulkMileage.service.ts @@ -1,6 +1,5 @@ import { Inject, Injectable } from '@nestjs/common' import { - BulkMileageReadingRequestResultDto, GetbulkmileagereadingrequeststatusGuidGetRequest, MileageReadingApi, } from '@island.is/clients/vehicles-mileage' @@ -14,6 +13,7 @@ import { VehiclesBulkMileageReadingResponse } from '../models/v3/bulkMileage/bul import { VehiclesBulkMileageRegistrationJobHistory } from '../models/v3/bulkMileage/bulkMileageRegistrationJobHistory.model' import { VehiclesBulkMileageRegistrationRequestStatus } from '../models/v3/bulkMileage/bulkMileageRegistrationRequestStatus.model' import { VehiclesBulkMileageRegistrationRequestOverview } from '../models/v3/bulkMileage/bulkMileageRegistrationRequestOverview.model' +import { FetchError } from '@island.is/clients/middlewares' @Injectable() export class BulkMileageService { @@ -34,8 +34,10 @@ export class BulkMileageService { return null } - const res: BulkMileageReadingRequestResultDto = - await this.getMileageWithAuth(auth).requestbulkmileagereadingPost({ + try { + const res = await this.getMileageWithAuth( + auth, + ).requestbulkmileagereadingPost({ postBulkMileageReadingModel: { originCode: input.originCode, mileageData: input.mileageData.map((m) => ({ @@ -45,19 +47,30 @@ export class BulkMileageService { }, }) - if (!res.guid) { - this.logger.warn( - 'Missing guid from bulk mileage reading registration response', - { - category: LOG_CATEGORY, - }, - ) - return null - } + if (!res.guid) { + this.logger.warn( + 'Missing guid from bulk mileage reading registration response', + { + category: LOG_CATEGORY, + }, + ) + return null + } - return { - requestId: res.guid, - errorMessage: res.errorMessage ?? undefined, + return { + requestId: res.guid, + errorMessage: res.errorMessage ?? undefined, + } + } catch (e) { + const error: Error = e + if (error instanceof FetchError && error.status === 429) { + return { + requestId: undefined, + errorCode: 429, + errorMessage: error.statusText, + } + } + throw e } } diff --git a/libs/api/domains/vehicles/src/lib/services/vehicles.service.ts b/libs/api/domains/vehicles/src/lib/services/vehicles.service.ts index 19b304517287..b573cdeec805 100644 --- a/libs/api/domains/vehicles/src/lib/services/vehicles.service.ts +++ b/libs/api/domains/vehicles/src/lib/services/vehicles.service.ts @@ -11,6 +11,7 @@ import { VehicleDtoListPagedResponse, PersidnoLookupResultDto, CurrentVehiclesWithMilageAndNextInspDtoListPagedResponse, + ApiResponse, } from '@island.is/clients/vehicles' import { CanregistermileagePermnoGetRequest, @@ -18,6 +19,7 @@ import { MileageReadingApi, MileageReadingDto, PostMileageReadingModel, + PutMileageReadingModel, RequiresmileageregistrationPermnoGetRequest, RootPostRequest, RootPutRequest, @@ -33,10 +35,13 @@ import { GetVehiclesForUserInput, GetVehiclesListV2Input, } from '../dto/getVehiclesForUserInput' -import { VehicleMileageOverview } from '../models/getVehicleMileage.model' +import { + VehicleMileageDetail, + VehicleMileageOverview, +} from '../models/getVehicleMileage.model' import isSameDay from 'date-fns/isSameDay' import { mileageDetailConstructor } from '../utils/helpers' -import { handle404 } from '@island.is/clients/middlewares' +import { FetchError, handle404 } from '@island.is/clients/middlewares' import { VehicleSearchCustomDto } from '../vehicles.type' import { operatorStatusMapper } from '../utils/operatorStatusMapper' import { VehiclesListInputV3 } from '../dto/vehiclesListInputV3' @@ -44,6 +49,8 @@ import { VehiclesCurrentListResponse } from '../models/v3/currentVehicleListResp import { isDefined } from '@island.is/shared/utils' import { GetVehicleMileageInput } from '../dto/getVehicleMileageInput' import { MileageRegistrationHistory } from '../models/v3/mileageRegistrationHistory.model' +import { VehiclesMileageUpdateError } from '../models/v3/vehicleMileageResponseError.model' +import { UpdateResponseError } from '../dto/updateResponseError.dto' const ORIGIN_CODE = 'ISLAND.IS' const LOG_CATEGORY = 'vehicle-service' @@ -111,6 +118,11 @@ export class VehiclesService { showOwned: true, page: input.page, pageSize: input.pageSize, + permno: input.query + ? input.query.length < 5 + ? `${input.query}*` + : `${input.query}` + : undefined, }) if ( @@ -463,6 +475,82 @@ export class VehiclesService { }) } + async postMileageReadingV2( + auth: User, + input: RootPostRequest['postMileageReadingModel'], + ): Promise { + if (!input) return null + + const isAllowed = await this.isAllowedMileageRegistration( + auth, + input.permno, + ) + if (!isAllowed) { + this.logger.error(UNAUTHORIZED_OWNERSHIP_LOG, { + category: LOG_CATEGORY, + error: 'postMileageReading failed', + }) + throw new ForbiddenException(UNAUTHORIZED_OWNERSHIP_LOG) + } + + try { + const res = await this.getMileageWithAuth(auth).rootPostRaw({ + postMileageReadingModel: input, + }) + + if (res.raw.status === 200) { + this.logger.info( + 'Tried to post already existing mileage reading. Should use PUT', + ) + return null + } + + const value = await res.value() + return value + } catch (e) { + if (e instanceof FetchError && (e.status === 400 || e.status === 429)) { + const errorBody = e.body as UpdateResponseError + return { + code: e.status, + message: errorBody.Errors?.[0]?.errorMess || 'Unknown error', + } + } else throw e + } + } + + async putMileageReadingV2( + auth: User, + input: RootPutRequest['putMileageReadingModel'], + ): Promise { + if (!input) return null + + const isAllowed = await this.isAllowedMileageRegistration( + auth, + input.permno, + ) + if (!isAllowed) { + this.logger.error(UNAUTHORIZED_OWNERSHIP_LOG, { + category: LOG_CATEGORY, + error: 'putMileageReading failed', + }) + throw new ForbiddenException(UNAUTHORIZED_OWNERSHIP_LOG) + } + + try { + return this.getMileageWithAuth(auth).rootPut({ + putMileageReadingModel: input, + }) + } catch (e) { + if (e instanceof FetchError && (e.status === 400 || e.status === 429)) { + const errorBody = e.body as UpdateResponseError + return { + code: e.status, + message: errorBody.Errors?.[0]?.errorMess || 'Unknown error', + } + } else throw e + } + } + async canRegisterMileage( auth: User, input: CanregistermileagePermnoGetRequest, diff --git a/libs/service-portal/assets/src/lib/messages.ts b/libs/service-portal/assets/src/lib/messages.ts index eb688c5e5955..caef9991b01f 100644 --- a/libs/service-portal/assets/src/lib/messages.ts +++ b/libs/service-portal/assets/src/lib/messages.ts @@ -874,14 +874,23 @@ export const vehicleMessage = defineMessages({ id: 'sp.vehicles:mileage-errors-input-too-low', defaultMessage: 'Verður að vera hærri en síðasta staðfesta skráning', }, + mileageInputPositive: { + id: 'sp.vehicles:mileage-errors-min-value', + defaultMessage: 'Skráning þarf að vera að minnsta kosti 1 km', + }, mileageInputMinLength: { id: 'sp.vehicles:mileage-errors-min-length', - defaultMessage: 'Skrá verður inn kílómetrastöðu til að vista', + defaultMessage: 'Skrá þarf einhverja kílómetrastöðu', }, mileageSuccessFormTitle: { id: 'sp.vehicles:mileage-success-form-title', defaultMessage: 'Kílómetrastaða skráð', }, + mileageUploadTooManyRequests: { + id: 'sp.vehicles:mileage-error-too-many-request', + defaultMessage: + 'Of margar upphleðslur á stuttum tíma. Vinsamlegast hinkraðu um stund.', + }, mileageSuccessFormText: { id: 'sp.vehicles:mileage-success-form-text', defaultMessage: @@ -972,6 +981,10 @@ export const vehicleMessage = defineMessages({ id: 'sp.vehicles:upload-failed', defaultMessage: 'Upphleðsla mistókst', }, + wrongFileType: { + id: 'sp.vehicles:wrong-file-type', + defaultMessage: 'Vitlaus skráartýpa. Skrá verður að vera .csv eða .xslx', + }, errorWhileProcessing: { id: 'sp.vehicles:error-while-processing', defaultMessage: 'Villa við að meðhöndla skjal. Villur: ', @@ -1026,7 +1039,7 @@ export const vehicleMessage = defineMessages({ }, fileUploadAcceptedTypes: { id: 'sp.vehicles:file-upload-accepted-types', - defaultMessage: 'Tekið er við skjölum með endingu; .csv', + defaultMessage: 'Tekið er við skjölum með endingu; .csv, .xlsx', }, dataAboutJob: { id: 'sp.vehicles:data-about-job', diff --git a/libs/service-portal/assets/src/screens/VehicleBulkMileage/VehicleBulkMileage.graphql b/libs/service-portal/assets/src/screens/VehicleBulkMileage/VehicleBulkMileage.graphql index ba25fd7b2859..75414f355492 100644 --- a/libs/service-portal/assets/src/screens/VehicleBulkMileage/VehicleBulkMileage.graphql +++ b/libs/service-portal/assets/src/screens/VehicleBulkMileage/VehicleBulkMileage.graphql @@ -19,20 +19,32 @@ query vehiclesList($input: VehiclesListInputV3!) { } mutation postSingleVehicleMileage($input: PostVehicleMileageInput!) { - vehicleMileagePost(input: $input) { - permno - readDate - originCode - mileageNumber - internalId + vehicleMileagePostV2(input: $input) { + ... on VehicleMileageDetail { + permno + readDate + originCode + mileageNumber + internalId + } + ... on VehiclesMileageUpdateError { + code + message + } } } mutation putSingleVehicleMileage($input: PutVehicleMileageInput!) { - vehicleMileagePut(input: $input) { - permno - internalId - mileageNumber + vehicleMileagePutV2(input: $input) { + ... on VehicleMileagePutModel { + permno + internalId + mileageNumber + } + ... on VehiclesMileageUpdateError { + code + message + } } } diff --git a/libs/service-portal/assets/src/screens/VehicleBulkMileage/VehicleBulkMileageFileDownloader.tsx b/libs/service-portal/assets/src/screens/VehicleBulkMileage/VehicleBulkMileageFileDownloader.tsx index 83a28eb23d32..4e36ea3792f6 100644 --- a/libs/service-portal/assets/src/screens/VehicleBulkMileage/VehicleBulkMileageFileDownloader.tsx +++ b/libs/service-portal/assets/src/screens/VehicleBulkMileage/VehicleBulkMileageFileDownloader.tsx @@ -1,8 +1,7 @@ -import { Button } from '@island.is/island-ui/core' +import { DropdownMenu } from '@island.is/island-ui/core' import { useLocale } from '@island.is/localization' import { vehicleMessage } from '../../lib/messages' import { downloadFile } from '@island.is/service-portal/core' -import { useState } from 'react' interface Props { onError: (error: string) => void @@ -10,39 +9,33 @@ interface Props { const VehicleBulkMileageFileDownloader = ({ onError }: Props) => { const { formatMessage } = useLocale() - const [isLoading, setIsLoading] = useState(false) - const downloadExampleFile = async () => { - setIsLoading(true) + const downloadExampleFile = async (type: 'csv' | 'xlsx') => { try { downloadFile( `magnskraning_kilometrastodu_example`, - ['permno', 'mileage'], + ['bilnumer', 'kilometrastada'], [ ['ABC001', 10000], ['DEF002', 99999], ], - 'csv', + type, ) } catch (error) { onError(error) - } finally { - setIsLoading(false) } } return ( - + ({ + title: `.${type}`, + onClick: () => downloadExampleFile(type), + }))} + title={formatMessage(vehicleMessage.downloadTemplate)} + /> ) } diff --git a/libs/service-portal/assets/src/screens/VehicleBulkMileage/VehicleBulkMileageRow.tsx b/libs/service-portal/assets/src/screens/VehicleBulkMileage/VehicleBulkMileageRow.tsx index 9203a66f7d67..6536795c32aa 100644 --- a/libs/service-portal/assets/src/screens/VehicleBulkMileage/VehicleBulkMileageRow.tsx +++ b/libs/service-portal/assets/src/screens/VehicleBulkMileage/VehicleBulkMileageRow.tsx @@ -8,7 +8,7 @@ import { useGetUsersMileageLazyQuery, } from './VehicleBulkMileage.generated' -import { useEffect, useState } from 'react' +import { useEffect, useState, useCallback } from 'react' import { ExpandRow, NestedFullTable, @@ -22,20 +22,24 @@ import { InputController } from '@island.is/shared/form-fields' import * as styles from './VehicleBulkMileage.css' import { displayWithUnit } from '../../utils/displayWithUnit' import { isReadDateToday } from '../../utils/readDate' -import { useDebounce } from 'react-use' const ORIGIN_CODE = 'ISLAND.IS' +type MutationStatus = + | 'initial' + | 'posting' + | 'waiting' + | 'success' + | 'error' + | 'validation-error' + interface Props { vehicle: VehicleType } - export const VehicleBulkMileageRow = ({ vehicle }: Props) => { const { formatMessage } = useLocale() const [postError, setPostError] = useState(null) - const [postStatus, setPostStatus] = useState< - 'initial' | 'posting' | 'post-success' | 'put-success' | 'error' - >('initial') + const [postStatus, setPostStatus] = useState('initial') const [ executeRegistrationsQuery, @@ -49,25 +53,25 @@ export const VehicleBulkMileageRow = ({ vehicle }: Props) => { }) const [putAction] = usePutSingleVehicleMileageMutation({ - onError: () => { - setPostError(formatMessage(m.errorTitle)) - setPostStatus('error') - }, - onCompleted: () => { - setPostError(null) - setPostStatus('put-success') - }, + onError: () => handleMutationResponse(true), + onCompleted: ({ vehicleMileagePutV2: data }) => + handleMutationResponse( + data?.__typename === 'VehiclesMileageUpdateError', + data?.__typename === 'VehiclesMileageUpdateError' + ? data?.message ?? formatMessage(m.errorTitle) + : undefined, + ), }) const [postAction] = usePostSingleVehicleMileageMutation({ - onError: () => { - setPostError(formatMessage(m.errorTitle)) - setPostStatus('error') - }, - onCompleted: () => { - setPostError(null) - setPostStatus('post-success') - }, + onError: () => handleMutationResponse(true), + onCompleted: ({ vehicleMileagePostV2: data }) => + handleMutationResponse( + data?.__typename === 'VehiclesMileageUpdateError', + data?.__typename === 'VehiclesMileageUpdateError' + ? data?.message ?? formatMessage(m.errorTitle) + : undefined, + ), }) const [executeMileageQuery, { data: mileageData, refetch: mileageRefetch }] = @@ -82,26 +86,63 @@ export const VehicleBulkMileageRow = ({ vehicle }: Props) => { trigger, } = useFormContext() - const postMileage = () => { - const formerPostStatus = postStatus - setPostError(null) - setPostStatus('posting') - if (formerPostStatus !== 'initial') { - mileageRefetch() - } else { - executeMileageQuery() + const handleMutationResponse = (isError: boolean, message?: string) => + updateStatusAndMessage(isError ? 'error' : 'success', message) + + const updateStatusAndMessage = ( + status: MutationStatus, + errorMessage?: string, + ) => { + if ( + postError && + !errorMessage && + status !== 'error' && + status !== 'validation-error' + ) { + setPostError(null) + } + setPostStatus(status) + + if (errorMessage) { + setPostError(errorMessage) } } - useDebounce( - () => { - if (postStatus === 'put-success' || postStatus === 'post-success') { + const handleValidationErrors = useCallback(() => { + const vehicleErrors = errors?.[vehicle.vehicleId] + if (vehicleErrors) { + updateStatusAndMessage('error', vehicleErrors.message as string) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [errors, vehicle.vehicleId]) + + useEffect(() => { + setTimeout(() => { + if (postStatus === 'success') { registrationsRefetch() } - }, - 500, - [postStatus, registrationsRefetch], - ) + }, 500) + }, [postStatus, registrationsRefetch]) + + useEffect(() => { + switch (postStatus) { + case 'posting': { + postToServer() + return + } + case 'waiting': + if (mileageData?.vehicleMileageDetails) { + setPostStatus('posting') + } + return + case 'validation-error': + handleValidationErrors() + return + default: + return + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [postStatus, mileageData?.vehicleMileageDetails]) const getValueFromForm = async ( formFieldId: string, @@ -111,54 +152,63 @@ export const VehicleBulkMileageRow = ({ vehicle }: Props) => { if (!value && skipEmpty) { return } - if (await trigger(formFieldId)) { + const isValid = await trigger(formFieldId) + if (isValid) { return Number(value) } - return + + //invalid validation, set errors + setPostStatus('validation-error') + } + + const onInputChange = () => { + if (postStatus === 'error' || postStatus === 'validation-error') { + updateStatusAndMessage('initial') + } } const onSaveButtonClick = async () => { - postMileage() + if (postStatus !== 'initial') { + mileageRefetch() + } else { + executeMileageQuery() + } + + updateStatusAndMessage('waiting') } - useEffect(() => { - const post = async () => { - const formValue = await getValueFromForm(vehicle.vehicleId) - if (formValue) { - if ( - mileageData?.vehicleMileageDetails?.editing && - mileageData.vehicleMileageDetails?.data?.[0]?.internalId - ) { - putAction({ - variables: { - input: { - internalId: parseInt( - mileageData.vehicleMileageDetails?.data?.[0]?.internalId, - 10, - ), - permno: vehicle.vehicleId, - mileageNumber: formValue, - }, + const postToServer = useCallback(async () => { + const formValue = await getValueFromForm(vehicle.vehicleId) + if (formValue) { + if ( + mileageData?.vehicleMileageDetails?.editing && + mileageData.vehicleMileageDetails?.data?.[0]?.internalId + ) { + putAction({ + variables: { + input: { + internalId: parseInt( + mileageData.vehicleMileageDetails?.data?.[0]?.internalId, + 10, + ), + permno: vehicle.vehicleId, + mileageNumber: formValue, }, - }) - } else { - postAction({ - variables: { - input: { - permno: vehicle.vehicleId, - originCode: 'ISLAND.IS', - mileageNumber: formValue, - }, + }, + }) + } else { + postAction({ + variables: { + input: { + permno: vehicle.vehicleId, + originCode: 'ISLAND.IS', + mileageNumber: formValue, }, - }) - } + }, + }) } } - - if (mileageData) { - post() - } - }, [mileageData]) + }, [mileageData?.vehicleMileageDetails, vehicle.vehicleId]) return ( { name={vehicle.vehicleId} type="number" suffix=" km" - min={0} thousandSeparator size="xs" maxLength={12} defaultValue={''} - error={ - postError ?? (errors?.[vehicle.vehicleId]?.message as string) + onChange={onInputChange} + error={postError ?? undefined} + aria-invalid={!!postError} + aria-describedby={ + postError ? `${vehicle.vehicleId}-error` : undefined } rules={{ validate: { @@ -254,11 +306,9 @@ export const VehicleBulkMileageRow = ({ vehicle }: Props) => { vehicleMessage.mileageInputMinLength, ), }, - minLength: { + min: { value: 1, - message: formatMessage( - vehicleMessage.mileageInputMinLength, - ), + message: formatMessage(vehicleMessage.mileageInputPositive), }, }} /> @@ -271,24 +321,25 @@ export const VehicleBulkMileageRow = ({ vehicle }: Props) => { submissionStatus={ postStatus === 'error' ? 'error' - : postStatus === 'posting' + : postStatus === 'posting' || postStatus === 'waiting' ? 'loading' - : postStatus === 'post-success' || - postStatus === 'put-success' + : postStatus === 'success' ? 'success' : 'idle' } onClick={onSaveButtonClick} + disabled={postStatus === 'error'} /> ), }, ]} > - {(postStatus === 'post-success' || postStatus === 'put-success') && ( + {postStatus === 'success' && ( { - {displayRegistrationData && - registrations?.requests.map((j) => ( - - - - - {j.vehicleId} - - - {displayWithUnit(j.mileage, 'km', true)} - - {(j.errors ?? []).map((j) => j.message).join(', ')} - - - ))} + {displayRegistrationData + ? registrations?.requests.map((j) => ( + + + + + {j.vehicleId} + + + + {displayWithUnit(j.mileage, 'km', true)} + + + {(j.errors ?? []).map((j) => j.message).join(', ')} + + + )) + : null} {!displayRegistrationData && ( diff --git a/libs/service-portal/assets/src/screens/VehicleBulkMileageUpload/VehicleBulkMileageUpload.graphql b/libs/service-portal/assets/src/screens/VehicleBulkMileageUpload/VehicleBulkMileageUpload.graphql index 5a1a216ada0a..4077d495334e 100644 --- a/libs/service-portal/assets/src/screens/VehicleBulkMileageUpload/VehicleBulkMileageUpload.graphql +++ b/libs/service-portal/assets/src/screens/VehicleBulkMileageUpload/VehicleBulkMileageUpload.graphql @@ -2,5 +2,6 @@ mutation vehicleBulkMileagePost($input: PostVehicleBulkMileageInput!) { vehicleBulkMileagePost(input: $input) { requestId errorMessage + errorCode } } diff --git a/libs/service-portal/assets/src/screens/VehicleBulkMileageUpload/VehicleBulkMileageUpload.tsx b/libs/service-portal/assets/src/screens/VehicleBulkMileageUpload/VehicleBulkMileageUpload.tsx index 7705c4dcb9ba..54aa3ad49589 100644 --- a/libs/service-portal/assets/src/screens/VehicleBulkMileageUpload/VehicleBulkMileageUpload.tsx +++ b/libs/service-portal/assets/src/screens/VehicleBulkMileageUpload/VehicleBulkMileageUpload.tsx @@ -6,6 +6,7 @@ import { AlertMessage, Stack, } from '@island.is/island-ui/core' +import { fileExtensionWhitelist } from '@island.is/island-ui/core/types' import { useLocale, useNamespaces } from '@island.is/localization' import { useEffect, useState } from 'react' import { FileRejection } from 'react-dropzone' @@ -15,15 +16,17 @@ import { SAMGONGUSTOFA_SLUG, m, } from '@island.is/service-portal/core' -import { - MileageRecord, - parseCsvToMileageRecord, -} from '../../utils/parseCsvToMileage' import { Problem } from '@island.is/react-spa/shared' import { AssetsPaths } from '../../lib/paths' import { useVehicleBulkMileagePostMutation } from './VehicleBulkMileageUpload.generated' import VehicleBulkMileageFileDownloader from '../VehicleBulkMileage/VehicleBulkMileageFileDownloader' import { vehicleMessage } from '../../lib/messages' +import { parseFileToMileageRecord } from '../../utils/parseFileToMileage' + +const extensionToType = { + [fileExtensionWhitelist['.csv']]: 'csv', + [fileExtensionWhitelist['.xlsx']]: 'xlsx', +} as const const VehicleBulkMileageUpload = () => { useNamespaces('sp.vehicles') @@ -50,9 +53,9 @@ const VehicleBulkMileageUpload = () => { } }, [data?.vehicleBulkMileagePost?.requestId]) - const postMileage = async (file: File) => { + const postMileage = async (file: File, type: 'xlsx' | 'csv') => { try { - const records = await parseCsvToMileageRecord(file) + const records = await parseFileToMileageRecord(file, type) if (!records.length) { setUploadErrorMessage(formatMessage(vehicleMessage.uploadFailed)) return @@ -90,8 +93,18 @@ const VehicleBulkMileageUpload = () => { setRequestGuid(null) } const file = fileToObject(files[0]) + if (file.status === 'done' && file.originalFileObj instanceof File) { - postMileage(file.originalFileObj) + //use value of file extension as key + + const type = file.type ? extensionToType[file.type] : undefined + + if (!type) { + setUploadErrorMessage(formatMessage(vehicleMessage.wrongFileType)) + return + } + + postMileage(file.originalFileObj, type) } } @@ -118,7 +131,11 @@ const VehicleBulkMileageUpload = () => { )} {downloadError && ( @@ -128,13 +145,6 @@ const VehicleBulkMileageUpload = () => { message={downloadError} /> )} - {data?.vehicleBulkMileagePost?.errorMessage && !loading && !error && ( - - )} {requestGuid && !data?.vehicleBulkMileagePost?.errorMessage && !loading && @@ -169,7 +179,7 @@ const VehicleBulkMileageUpload = () => { description={formatMessage(vehicleMessage.fileUploadAcceptedTypes)} disabled={!!data?.vehicleBulkMileagePost?.errorMessage} buttonLabel={formatMessage(vehicleMessage.selectFileToUpload)} - accept={['.csv', '.xls']} + accept={['.csv', '.xlsx']} multiple={false} onRemove={handleOnInputFileUploadRemove} onChange={handleOnInputFileUploadChange} diff --git a/libs/service-portal/assets/src/utils/parseCsvToMileage.ts b/libs/service-portal/assets/src/utils/parseCsvToMileage.ts deleted file mode 100644 index 7ede1b374717..000000000000 --- a/libs/service-portal/assets/src/utils/parseCsvToMileage.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { isDefined } from '@island.is/shared/utils' - -export interface MileageRecord { - vehicleId: string - mileage: number -} - -const letters = - 'aábcdðeéfghiíjklmnoópqrstuúvwxyýzþæöAÁBCDÐEÉFGHIÍJKLMNOÓPQRSTUÚVWXYÝZÞÆÖ' -const newlines = '\\Q\\r\\n\\E|\\r|\\n' -const wordbreaks = '[;,]' - -const vehicleIndexTitle = ['permno', 'vehicleid', 'ökutæki', 'fastanúmer'] -const mileageIndexTitle = ['kílómetrastaða', 'mileage', 'odometer'] - -export const parseCsvToMileageRecord = async ( - file: File, -): Promise | string> => { - const reader = file.stream().getReader() - - const parsedLines: Array> = [[]] - const parseChunk = async (res: ReadableStreamReadResult) => { - if (res.done) { - return - } - const chunk = Buffer.from(res.value).toString('utf8') - - let rowIndex = 0 - for (const cell of chunk.matchAll( - new RegExp(`([${letters}\\d]+)(${newlines}|${wordbreaks})?`, 'gi'), - )) { - const [_, trimmedValue, delimiter] = cell - const lineBreak = ['\r\n', '\n', '\r'].includes(delimiter) - - parsedLines[rowIndex].push(trimmedValue.trim()) - if (lineBreak) { - parsedLines.push([]) - rowIndex++ - } - } - } - - await reader.read().then(parseChunk) - const [header, ...values] = parsedLines - const vehicleIndex = header.findIndex((l) => - vehicleIndexTitle.includes(l.toLowerCase()), - ) - if (vehicleIndex < 0) { - return `Invalid vehicle column header. Must be one of the following: ${vehicleIndexTitle.join( - ', ', - )}` - } - const mileageIndex = header.findIndex((l) => - mileageIndexTitle.includes(l.toLowerCase()), - ) - - if (mileageIndex < 0) { - return `Invalid mileage column header. Must be one of the following: ${mileageIndexTitle.join( - ', ', - )}` - } - - const uploadedOdometerStatuses: Array = values - .map((row) => { - const mileage = parseInt(row[mileageIndex]) - if (Number.isNaN(mileage)) { - return undefined - } - return { - vehicleId: row[vehicleIndex], - mileage, - } - }) - .filter(isDefined) - return uploadedOdometerStatuses -} diff --git a/libs/service-portal/assets/src/utils/parseFileToMileage.ts b/libs/service-portal/assets/src/utils/parseFileToMileage.ts new file mode 100644 index 000000000000..7382ab57e315 --- /dev/null +++ b/libs/service-portal/assets/src/utils/parseFileToMileage.ts @@ -0,0 +1,131 @@ +import { isDefined } from '@island.is/shared/utils' +import XLSX from 'xlsx' +import parse from 'csv-parse' + +export interface MileageRecord { + vehicleId: string + mileage: number +} + +const vehicleIndexTitle = [ + 'permno', + 'vehicleid', + 'bilnumer', + 'okutaeki', + 'fastanumer', +] +const mileageIndexTitle = ['kilometrastada', 'mileage', 'odometer'] + +export const parseFileToMileageRecord = async ( + file: File, + type: 'csv' | 'xlsx', +): Promise> => { + const parsedLines: Array> = await (type === 'csv' + ? parseCsv(file) + : parseXlsx(file)) + + const [header, ...values] = parsedLines + const vehicleIndex = header.findIndex((l) => + vehicleIndexTitle.includes(l.toLowerCase()), + ) + if (vehicleIndex < 0) { + throw new Error( + `Invalid vehicle column header. Must be one of the following: ${vehicleIndexTitle.join( + ', ', + )}`, + ) + } + const mileageIndex = header.findIndex((l) => + mileageIndexTitle.includes(l.toLowerCase()), + ) + + if (mileageIndex < 0) { + throw new Error( + `Invalid mileage column header. Must be one of the following: ${mileageIndexTitle.join( + ', ', + )}`, + ) + } + + const uploadedOdometerStatuses: Array = values + .map((row) => { + const mileage = Number(row[mileageIndex]) + if (Number.isNaN(mileage)) { + return undefined + } + return { + vehicleId: row[vehicleIndex], + mileage, + } + }) + .filter(isDefined) + return uploadedOdometerStatuses +} + +const parseCsv = async (file: File) => { + const reader = file.stream().getReader() + const decoder = new TextDecoder('utf-8') + + let accumulatedChunk = '' + let done = false + + while (!done) { + const res = await reader.read() + done = res.done + if (!done) { + accumulatedChunk += decoder.decode(res.value) + } + } + + return parseCsvString(accumulatedChunk) +} + +const parseXlsx = async (file: File) => { + try { + //FIRST SHEET ONLY + const buffer = await file.arrayBuffer() + const parsedFile = XLSX.read(buffer, { type: 'buffer' }) + + const jsonData = XLSX.utils.sheet_to_csv( + parsedFile.Sheets[parsedFile.SheetNames[0]], + { + strip: true, + blankrows: false, + }, + ) + + return parseCsvString(jsonData) + } catch (e) { + throw new Error('Failed to parse XLSX file: ' + e.message) + } +} + +const parseCsvString = (chunk: string): Promise => { + return new Promise((resolve, reject) => { + const records: string[][] = [] + + const parser = parse({ + cast: true, + skipEmptyLines: true, + delimiter: [';', ','], + }) + + parser.on('readable', () => { + let record + while ((record = parser.read()) !== null) { + records.push(record) + } + }) + + parser.on('error', (err) => { + reject(err) + }) + + parser.on('end', () => { + resolve(records) + }) + + parser.write(chunk) + parser.end() + }) +}