diff --git a/apps/api/infra/api.ts b/apps/api/infra/api.ts index 7591d0b17acb..57e7f8ae7c01 100644 --- a/apps/api/infra/api.ts +++ b/apps/api/infra/api.ts @@ -47,6 +47,8 @@ import { OfficialJournalOfIceland, OfficialJournalOfIcelandApplication, Frigg, + HealthDirectorateOrganDonation, + HealthDirectorateVaccination, } from '../../../infra/src/dsl/xroad' export const serviceSetup = (services: { @@ -427,6 +429,8 @@ export const serviceSetup = (services: { OfficialJournalOfIceland, OfficialJournalOfIcelandApplication, Frigg, + HealthDirectorateOrganDonation, + HealthDirectorateVaccination, ) .files({ filename: 'islyklar.p12', env: 'ISLYKILL_CERT' }) .ingress({ diff --git a/apps/judicial-system/api/src/app/modules/case-list/interceptors/caseList.interceptor.ts b/apps/judicial-system/api/src/app/modules/case-list/interceptors/caseList.interceptor.ts index 5aff6ef70c09..3a61a3ae891a 100644 --- a/apps/judicial-system/api/src/app/modules/case-list/interceptors/caseList.interceptor.ts +++ b/apps/judicial-system/api/src/app/modules/case-list/interceptors/caseList.interceptor.ts @@ -8,6 +8,8 @@ import { NestInterceptor, } from '@nestjs/common' +import { isRequestCase } from '@island.is/judicial-system/types' + import { getIndictmentInfo } from '../../case/interceptors/case.transformer' import { CaseListEntry } from '../models/caseList.model' @@ -27,21 +29,28 @@ export class CaseListInterceptor implements NestInterceptor { return next.handle().pipe( map((cases: CaseListEntry[]) => { return cases.map((theCase) => { + if (isRequestCase(theCase.type)) { + return { + ...theCase, + isValidToDateInThePast: theCase.validToDate + ? Date.now() > new Date(theCase.validToDate).getTime() + : theCase.isValidToDateInThePast, + appealedDate: getAppealedDate( + theCase.prosecutorPostponedAppealDate, + theCase.accusedPostponedAppealDate, + ), + } + } + const indictmentInfo = getIndictmentInfo( + theCase.indictmentRulingDecision, theCase.rulingDate, - theCase.type, theCase.defendants, theCase.eventLogs, ) + return { ...theCase, - isValidToDateInThePast: theCase.validToDate - ? Date.now() > new Date(theCase.validToDate).getTime() - : theCase.isValidToDateInThePast, - appealedDate: getAppealedDate( - theCase.prosecutorPostponedAppealDate, - theCase.accusedPostponedAppealDate, - ), ...indictmentInfo, } }) diff --git a/apps/judicial-system/api/src/app/modules/case-list/models/caseList.model.ts b/apps/judicial-system/api/src/app/modules/case-list/models/caseList.model.ts index 43c9c10acff7..3b2982563026 100644 --- a/apps/judicial-system/api/src/app/modules/case-list/models/caseList.model.ts +++ b/apps/judicial-system/api/src/app/modules/case-list/models/caseList.model.ts @@ -122,8 +122,8 @@ export class CaseListEntry { @Field(() => Boolean, { nullable: true }) readonly indictmentVerdictViewedByAll?: boolean - @Field(() => String, { nullable: true }) - readonly indictmentVerdictAppealDeadline?: string + @Field(() => Boolean, { nullable: true }) + readonly indictmentVerdictAppealDeadlineExpired?: boolean @Field(() => IndictmentDecision, { nullable: true }) readonly indictmentDecision?: IndictmentDecision diff --git a/apps/judicial-system/api/src/app/modules/case/interceptors/case.transformer.spec.ts b/apps/judicial-system/api/src/app/modules/case/interceptors/case.transformer.spec.ts index f8c30b4e3698..54cd32b08d75 100644 --- a/apps/judicial-system/api/src/app/modules/case/interceptors/case.transformer.spec.ts +++ b/apps/judicial-system/api/src/app/modules/case/interceptors/case.transformer.spec.ts @@ -3,13 +3,18 @@ import each from 'jest-each' import { CaseAppealDecision, CaseAppealState, + CaseIndictmentRulingDecision, CaseState, + CaseType, + ServiceRequirement, UserRole, } from '@island.is/judicial-system/types' +import { Defendant } from '../../defendant' import { Case } from '../models/case.model' import { getAppealInfo, + getDefendantsInfo, getIndictmentInfo, transformCase, } from './case.transformer' @@ -25,7 +30,10 @@ describe('transformCase', () => { ({ originalValue, transformedValue }) => { it(`should transform ${originalValue} requestProsecutorOnlySession to ${transformedValue}`, () => { // Arrange - const theCase = { requestProsecutorOnlySession: originalValue } as Case + const theCase = { + type: CaseType.CUSTODY, + requestProsecutorOnlySession: originalValue, + } as Case // Act const res = transformCase(theCase) @@ -36,7 +44,10 @@ describe('transformCase', () => { it(`should transform ${originalValue} isClosedCourtHidden to ${transformedValue}`, () => { // Arrange - const theCase = { isClosedCourtHidden: originalValue } as Case + const theCase = { + type: CaseType.CUSTODY, + isClosedCourtHidden: originalValue, + } as Case // Act const res = transformCase(theCase) @@ -47,7 +58,10 @@ describe('transformCase', () => { it(`should transform ${originalValue} isHightenedSecurityLevel to ${transformedValue}`, () => { // Arrange - const theCase = { isHeightenedSecurityLevel: originalValue } as Case + const theCase = { + type: CaseType.CUSTODY, + isHeightenedSecurityLevel: originalValue, + } as Case // Act const res = transformCase(theCase) @@ -74,7 +88,10 @@ describe('transformCase', () => { // Arrange const validToDate = new Date() validToDate.setSeconds(validToDate.getSeconds() + 1) - const theCase = { validToDate: validToDate.toISOString() } as Case + const theCase = { + type: CaseType.CUSTODY, + validToDate: validToDate.toISOString(), + } as Case // Act const res = transformCase(theCase) @@ -87,7 +104,10 @@ describe('transformCase', () => { // Arrange const validToDate = new Date() validToDate.setSeconds(validToDate.getSeconds() - 1) - const theCase = { validToDate: validToDate.toISOString() } as Case + const theCase = { + type: CaseType.CUSTODY, + validToDate: validToDate.toISOString(), + } as Case // Act const res = transformCase(theCase) @@ -100,7 +120,7 @@ describe('transformCase', () => { describe('isAppealDeadlineExpired', () => { it('should be false when no court date is set', () => { // Arrange - const theCase = {} as Case + const theCase = { type: CaseType.CUSTODY } as Case // Act const res = transformCase(theCase) @@ -114,7 +134,10 @@ describe('transformCase', () => { const rulingDate = new Date() rulingDate.setDate(rulingDate.getDate() - 3) rulingDate.setSeconds(rulingDate.getSeconds() + 1) - const theCase = { rulingDate: rulingDate.toISOString() } as Case + const theCase = { + type: CaseType.CUSTODY, + rulingDate: rulingDate.toISOString(), + } as Case // Act const res = transformCase(theCase) @@ -127,7 +150,10 @@ describe('transformCase', () => { // Arrange const rulingDate = new Date() rulingDate.setDate(rulingDate.getDate() - 3) - const theCase = { rulingDate: rulingDate.toISOString() } as Case + const theCase = { + type: CaseType.CUSTODY, + rulingDate: rulingDate.toISOString(), + } as Case // Act const res = transformCase(theCase) @@ -140,7 +166,7 @@ describe('transformCase', () => { describe('isAppealGracePeriodExpired', () => { it('should be false when no court end time is set', () => { // Arrange - const theCase = {} as Case + const theCase = { type: CaseType.CUSTODY } as Case // Act const res = transformCase(theCase) @@ -154,7 +180,10 @@ describe('transformCase', () => { const rulingDate = new Date() rulingDate.setDate(rulingDate.getDate() - 31) rulingDate.setSeconds(rulingDate.getSeconds() + 1) - const theCase = { rulingDate: rulingDate.toISOString() } as Case + const theCase = { + type: CaseType.CUSTODY, + rulingDate: rulingDate.toISOString(), + } as Case // Act const res = transformCase(theCase) @@ -167,7 +196,10 @@ describe('transformCase', () => { // Arrange const rulingDate = new Date() rulingDate.setDate(rulingDate.getDate() - 31) - const theCase = { rulingDate: rulingDate.toISOString() } as Case + const theCase = { + type: CaseType.CUSTODY, + rulingDate: rulingDate.toISOString(), + } as Case // Act const res = transformCase(theCase) @@ -180,7 +212,7 @@ describe('transformCase', () => { describe('isStatementDeadlineExpired', () => { it('should be false if the case has not been appealed', () => { // Arrange - const theCase = { appealState: undefined } as Case + const theCase = { type: CaseType.CUSTODY, appealState: undefined } as Case // Act const res = transformCase(theCase) @@ -194,6 +226,7 @@ describe('transformCase', () => { const appealReceivedByCourtDate = new Date() appealReceivedByCourtDate.setDate(appealReceivedByCourtDate.getDate() - 2) const theCase = { + type: CaseType.CUSTODY, appealReceivedByCourtDate: appealReceivedByCourtDate.toISOString(), } as Case @@ -212,6 +245,7 @@ describe('transformCase', () => { appealReceivedByCourtDate.getSeconds() - 100, ) const theCase = { + type: CaseType.CUSTODY, appealReceivedByCourtDate: appealReceivedByCourtDate.toISOString(), } as Case @@ -226,7 +260,7 @@ describe('transformCase', () => { describe('appealInfo', () => { it('should be undefined when no court end time is set', () => { // Arrange - const theCase = {} as Case + const theCase = { type: CaseType.CUSTODY } as Case // Act const res = transformCase(theCase) @@ -245,7 +279,10 @@ describe('transformCase', () => { const rulingDate = new Date() rulingDate.setDate(rulingDate.getDate()) rulingDate.setSeconds(rulingDate.getSeconds()) - const theCase = { rulingDate: rulingDate.toISOString() } as Case + const theCase = { + type: CaseType.CUSTODY, + rulingDate: rulingDate.toISOString(), + } as Case // Act const res = transformCase(theCase) @@ -260,6 +297,7 @@ describe('transformCase', () => { const rulingDate = new Date() rulingDate.setDate(rulingDate.getDate() - 1) const theCase = { + type: CaseType.CUSTODY, rulingDate: rulingDate.toISOString(), accusedPostponedAppealDate: '2022-06-15T19:50:08.033Z', appealState: CaseAppealState.APPEALED, @@ -278,6 +316,7 @@ describe('transformCase', () => { const rulingDate = new Date() rulingDate.setDate(rulingDate.getDate() - 1) const theCase = { + type: CaseType.CUSTODY, rulingDate: rulingDate.toISOString(), appealState: CaseAppealState.RECEIVED, appealReceivedByCourtDate: '2021-06-15T19:50:08.033Z', @@ -294,6 +333,7 @@ describe('transformCase', () => { 'should return that case can be appealed when prosecutorAppealDecision is %s', (prosecutorAppealDecision) => { const theCase = { + type: CaseType.CUSTODY, rulingDate: '2022-06-15T19:50:08.033Z', prosecutorAppealDecision, } as Case @@ -314,6 +354,7 @@ describe('transformCase', () => { 'should return that case cannot be appealed when prosecutorAppealDecision is %s', (prosecutorAppealDecision) => { const theCase = { + type: CaseType.CUSTODY, rulingDate: '2022-06-15T19:50:08.033Z', prosecutorAppealDecision, } as Case @@ -332,6 +373,7 @@ describe('transformCase', () => { 'should return that case can be appealed when accusedAppealDecision is %s', (accusedAppealDecision) => { const theCase = { + type: CaseType.CUSTODY, rulingDate: '2022-06-15T19:50:08.033Z', accusedAppealDecision, } as Case @@ -352,6 +394,7 @@ describe('transformCase', () => { 'should return that case cannot be appealed when accusedAppealDecision is %s', (accusedAppealDecision) => { const theCase = { + type: CaseType.CUSTODY, rulingDate: '2022-06-15T19:50:08.033Z', accusedAppealDecision, } as Case @@ -368,6 +411,7 @@ describe('transformCase', () => { it('should return that case has been appealed by the prosecutor, and return the correct appealed date', () => { const theCase = { + type: CaseType.CUSTODY, rulingDate: '2022-06-15T19:50:08.033Z', appealState: CaseAppealState.APPEALED, prosecutorPostponedAppealDate: '2022-06-15T19:50:08.033Z', @@ -387,6 +431,7 @@ describe('transformCase', () => { it('should return that case has been appealed by the defender, and return the correct appealed date', () => { const theCase = { + type: CaseType.CUSTODY, rulingDate: '2022-06-15T19:50:08.033Z', appealState: CaseAppealState.APPEALED, accusedPostponedAppealDate: '2022-06-15T19:50:08.033Z', @@ -406,6 +451,7 @@ describe('transformCase', () => { it('should return that case has not yet been appealed if case appeal decision was postponed and the case has not been appealed yet', () => { const theCase = { + type: CaseType.CUSTODY, rulingDate: '2022-06-15T19:50:08.033Z', prosecutorAppealDecision: CaseAppealDecision.POSTPONE, } as Case @@ -423,6 +469,7 @@ describe('transformCase', () => { it('should return that the case cannot be appealed if case appeal decision does not allow appeal', () => { const theCase = { + type: CaseType.CUSTODY, rulingDate: '2022-06-15T19:50:08.033Z', prosecutorAppealDecision: CaseAppealDecision.ACCEPT, accusedAppealDecision: CaseAppealDecision.APPEAL, @@ -441,6 +488,7 @@ describe('transformCase', () => { it('should return a statement deadline if the case has been marked as received by the court', () => { const theCase = { + type: CaseType.CUSTODY, rulingDate: '2022-06-15T19:50:08.033Z', appealReceivedByCourtDate: '2022-06-15T19:50:08.033Z', state: CaseState.RECEIVED, @@ -462,6 +510,7 @@ describe('transformCase', () => { '$appealState should return that case has been appealed', ({ appealState }) => { const theCase = { + type: CaseType.CUSTODY, rulingDate: '2022-06-15T19:50:08.033Z', appealState, } as Case @@ -478,10 +527,11 @@ describe('transformCase', () => { }) }) }) + describe('getAppealInfo', () => { it('should return empty appeal info when ruling date is not provided', () => { // Arrange - const theCase = {} as Case + const theCase = { type: CaseType.CUSTODY } as Case // Act const appealInfo = getAppealInfo(theCase) @@ -493,6 +543,7 @@ describe('getAppealInfo', () => { it('should return correct appeal info when ruling date is provided', () => { const rulingDate = new Date().toISOString() const theCase = { + type: CaseType.CUSTODY, rulingDate, appealState: CaseAppealState.APPEALED, accusedAppealDecision: CaseAppealDecision.APPEAL, @@ -526,6 +577,7 @@ describe('getAppealInfo', () => { test(`canProsecutorAppeal for appeal decision ${decision} should return ${expected}`, () => { const theCase = { + type: CaseType.CUSTODY, rulingDate, prosecutorAppealDecision: decision, } as Case @@ -543,6 +595,7 @@ describe('getAppealInfo', () => { test(`canDefenderAppeal for appeal decision ${decision} should return ${expected}`, () => { const theCase = { + type: CaseType.CUSTODY, rulingDate, accusedAppealDecision: decision, } as Case @@ -557,10 +610,11 @@ describe('getAppealInfo', () => { describe('getIndictmentInfo', () => { it('should return empty indictment info when ruling date is not provided', () => { // Arrange - const theCase = {} as Case // Act - const indictmentInfo = getIndictmentInfo(theCase.rulingDate) + const indictmentInfo = getIndictmentInfo( + CaseIndictmentRulingDecision.RULING, + ) // Assert expect(indictmentInfo).toEqual({}) @@ -568,16 +622,87 @@ describe('getIndictmentInfo', () => { it('should return correct indictment info when ruling date is provided', () => { const rulingDate = new Date().toISOString() - const theCase = { + + const indictmentInfo = getIndictmentInfo( + CaseIndictmentRulingDecision.RULING, rulingDate, - } as Case + ) + + expect(indictmentInfo).toEqual({ + indictmentAppealDeadline: new Date( + new Date(rulingDate).setDate(new Date(rulingDate).getDate() + 28), + ).toISOString(), + indictmentVerdictViewedByAll: true, + indictmentVerdictAppealDeadlineExpired: true, + }) + }) + + it('should return correct indictment info when some defendants have yet to view the verdict', () => { + const rulingDate = new Date().toISOString() + const defendants = [ + { verdictViewDate: new Date().toISOString() } as Defendant, + { verdictViewDate: undefined } as Defendant, + ] - const indictmentInfo = getIndictmentInfo(theCase.rulingDate) + const indictmentInfo = getIndictmentInfo( + CaseIndictmentRulingDecision.RULING, + rulingDate, + defendants, + ) expect(indictmentInfo).toEqual({ indictmentAppealDeadline: new Date( new Date(rulingDate).setDate(new Date(rulingDate).getDate() + 28), ).toISOString(), + indictmentVerdictViewedByAll: false, + indictmentVerdictAppealDeadlineExpired: false, }) }) + + it('should return correct indictment info when no defendants have yet to view the verdict', () => { + const rulingDate = new Date().toISOString() + const defendants = [ + { verdictViewDate: new Date().toISOString() } as Defendant, + { + serviceRequirement: ServiceRequirement.NOT_REQUIRED, + verdictViewDate: undefined, + } as Defendant, + ] + + const indictmentInfo = getIndictmentInfo( + CaseIndictmentRulingDecision.RULING, + rulingDate, + defendants, + ) + + expect(indictmentInfo).toEqual({ + indictmentAppealDeadline: new Date( + new Date(rulingDate).setDate(new Date(rulingDate).getDate() + 28), + ).toISOString(), + indictmentVerdictViewedByAll: true, + indictmentVerdictAppealDeadlineExpired: false, + }) + }) +}) + +describe('getDefentandInfo', () => { + it('should add verdict appeal deadline for defendants with verdict view date', () => { + const defendants = [ + { verdictViewDate: '2022-06-15T19:50:08.033Z' } as Defendant, + { verdictViewDate: undefined } as Defendant, + ] + + const defendantsInfo = getDefendantsInfo(defendants) + + expect(defendantsInfo).toEqual([ + { + verdictViewDate: '2022-06-15T19:50:08.033Z', + verdictAppealDeadline: '2022-07-13T19:50:08.033Z', + }, + { + verdictViewDate: undefined, + verdictAppealDeadline: undefined, + }, + ]) + }) }) diff --git a/apps/judicial-system/api/src/app/modules/case/interceptors/case.transformer.ts b/apps/judicial-system/api/src/app/modules/case/interceptors/case.transformer.ts index 40e3ff319e56..e4e3ef41187b 100644 --- a/apps/judicial-system/api/src/app/modules/case/interceptors/case.transformer.ts +++ b/apps/judicial-system/api/src/app/modules/case/interceptors/case.transformer.ts @@ -1,10 +1,11 @@ import { CaseAppealDecision, - CaseType, + CaseIndictmentRulingDecision, EventType, - getIndictmentVerdictAppealDeadline, + getIndictmentVerdictAppealDeadlineStatus, getStatementDeadline, - isIndictmentCase, + isRequestCase, + ServiceRequirement, UserRole, } from '@island.is/judicial-system/types' @@ -26,10 +27,10 @@ interface AppealInfo { } interface IndictmentInfo { + indictmentCompletedDate?: string indictmentAppealDeadline?: string indictmentVerdictViewedByAll?: boolean - indictmentVerdictAppealDeadline?: string - indictmentCompletedDate?: string + indictmentVerdictAppealDeadlineExpired?: boolean } const isAppealableDecision = (decision?: CaseAppealDecision | null) => { @@ -88,7 +89,7 @@ export const getAppealInfo = (theCase: Case): AppealInfo => { const theRulingDate = new Date(rulingDate) appealInfo.appealDeadline = new Date( - theRulingDate.setDate(theRulingDate.getDate() + 3), + theRulingDate.getTime() + getDays(3), ).toISOString() if (appealReceivedByCourtDate) { @@ -100,102 +101,106 @@ export const getAppealInfo = (theCase: Case): AppealInfo => { return appealInfo } +const transformRequestCase = (theCase: Case): Case => { + const appealInfo = getAppealInfo(theCase) + + return { + ...theCase, + requestProsecutorOnlySession: theCase.requestProsecutorOnlySession ?? false, + isClosedCourtHidden: theCase.isClosedCourtHidden ?? false, + isHeightenedSecurityLevel: theCase.isHeightenedSecurityLevel ?? false, + isValidToDateInThePast: theCase.validToDate + ? Date.now() > new Date(theCase.validToDate).getTime() + : theCase.isValidToDateInThePast, + + // TODO: Move remaining appeal fields to appealInfo + isAppealDeadlineExpired: appealInfo.appealDeadline + ? Date.now() >= new Date(appealInfo.appealDeadline).getTime() + : false, + isAppealGracePeriodExpired: theCase.rulingDate + ? Date.now() >= new Date(theCase.rulingDate).getTime() + getDays(31) + : false, + isStatementDeadlineExpired: theCase.appealReceivedByCourtDate + ? Date.now() >= + new Date(theCase.appealReceivedByCourtDate).getTime() + getDays(1) + : false, + ...appealInfo, + } +} + export const getIndictmentInfo = ( + rulingDecision?: CaseIndictmentRulingDecision, rulingDate?: string, - caseType?: CaseType, defendants?: Defendant[], eventLog?: EventLog[], ): IndictmentInfo => { const indictmentInfo: IndictmentInfo = {} - if ((caseType && !isIndictmentCase(caseType)) || !rulingDate) { + if (!rulingDate) { return indictmentInfo } const theRulingDate = new Date(rulingDate) indictmentInfo.indictmentAppealDeadline = new Date( - theRulingDate.setDate(theRulingDate.getDate() + 28), + theRulingDate.getTime() + getDays(28), ).toISOString() - if (defendants) { - const verdictViewDates = defendants?.map((defendant) => + const verdictInfo = defendants?.map<[boolean, Date | undefined]>( + (defendant) => [ + rulingDecision === CaseIndictmentRulingDecision.RULING && + defendant.serviceRequirement !== ServiceRequirement.NOT_REQUIRED, defendant.verdictViewDate ? new Date(defendant.verdictViewDate) : undefined, - ) - - const verdictAppealDeadline = - getIndictmentVerdictAppealDeadline(verdictViewDates) - indictmentInfo.indictmentVerdictAppealDeadline = verdictAppealDeadline - ? verdictAppealDeadline.toISOString() - : undefined + ], + ) - indictmentInfo.indictmentVerdictViewedByAll = - indictmentInfo.indictmentVerdictAppealDeadline ? true : false + const [indictmentVerdictViewedByAll, indictmentVerdictAppealDeadlineExpired] = + getIndictmentVerdictAppealDeadlineStatus(verdictInfo) + indictmentInfo.indictmentVerdictViewedByAll = indictmentVerdictViewedByAll + indictmentInfo.indictmentVerdictAppealDeadlineExpired = + indictmentVerdictAppealDeadlineExpired - indictmentInfo.indictmentCompletedDate = eventLog - ?.find((log) => log.eventType === EventType.INDICTMENT_COMPLETED) - ?.created?.toString() - } + indictmentInfo.indictmentCompletedDate = eventLog + ?.find((log) => log.eventType === EventType.INDICTMENT_COMPLETED) + ?.created?.toString() return indictmentInfo } -const getDefendantsInfo = ( - defendants: Defendant[] | undefined, - caseType?: CaseType, -) => { - if (!defendants || !isIndictmentCase(caseType)) { - return defendants - } - - const expiryDays = getDays(28) - - return defendants.map((defendant) => { +export const getDefendantsInfo = (defendants: Defendant[] | undefined) => { + return defendants?.map((defendant) => { const { verdictViewDate } = defendant + const verdictAppealDeadline = verdictViewDate + ? new Date( + new Date(verdictViewDate).getTime() + getDays(28), + ).toISOString() + : undefined + return { ...defendant, - verdictAppealDeadline: verdictViewDate - ? new Date( - new Date(verdictViewDate).getTime() + expiryDays, - ).toISOString() - : undefined, + verdictAppealDeadline, } }) } -export const transformCase = (theCase: Case): Case => { - const appealInfo = getAppealInfo(theCase) - const indictmentInfo = getIndictmentInfo( - theCase.rulingDate, - theCase.type, - theCase.defendants, - theCase.eventLogs, - ) - const defendants = getDefendantsInfo(theCase.defendants, theCase.type) - +const transformIndictmentCase = (theCase: Case): Case => { return { ...theCase, - requestProsecutorOnlySession: theCase.requestProsecutorOnlySession ?? false, - isClosedCourtHidden: theCase.isClosedCourtHidden ?? false, - isHeightenedSecurityLevel: theCase.isHeightenedSecurityLevel ?? false, - isValidToDateInThePast: theCase.validToDate - ? Date.now() > new Date(theCase.validToDate).getTime() - : theCase.isValidToDateInThePast, + ...getIndictmentInfo( + theCase.indictmentRulingDecision, + theCase.rulingDate, + theCase.defendants, + theCase.eventLogs, + ), + defendants: getDefendantsInfo(theCase.defendants), + } +} - // TODO: Move remaining appeal fields to appealInfo - isAppealDeadlineExpired: appealInfo.appealDeadline - ? Date.now() >= new Date(appealInfo.appealDeadline).getTime() - : false, - isAppealGracePeriodExpired: theCase.rulingDate - ? Date.now() >= new Date(theCase.rulingDate).getTime() + getDays(31) - : false, - isStatementDeadlineExpired: theCase.appealReceivedByCourtDate - ? Date.now() >= - new Date(theCase.appealReceivedByCourtDate).getTime() + getDays(1) - : false, - defendants: defendants, - ...appealInfo, - ...indictmentInfo, +export const transformCase = (theCase: Case): Case => { + if (isRequestCase(theCase.type)) { + return transformRequestCase(theCase) } + + return transformIndictmentCase(theCase) } diff --git a/apps/judicial-system/api/src/app/modules/case/models/case.model.ts b/apps/judicial-system/api/src/app/modules/case/models/case.model.ts index 51ec5ec640dd..247a8cc98056 100644 --- a/apps/judicial-system/api/src/app/modules/case/models/case.model.ts +++ b/apps/judicial-system/api/src/app/modules/case/models/case.model.ts @@ -436,8 +436,8 @@ export class Case { @Field(() => Boolean, { nullable: true }) readonly indictmentVerdictViewedByAll?: boolean - @Field(() => String, { nullable: true }) - readonly indictmentVerdictAppealDeadline?: string + @Field(() => Boolean, { nullable: true }) + readonly indictmentVerdictAppealDeadlineExpired?: boolean @Field(() => IndictmentDecision, { nullable: true }) readonly indictmentDecision?: IndictmentDecision diff --git a/apps/judicial-system/backend/migrations/20240904100012-update-institution.js b/apps/judicial-system/backend/migrations/20240904100012-update-institution.js new file mode 100644 index 000000000000..d107dfba7de4 --- /dev/null +++ b/apps/judicial-system/backend/migrations/20240904100012-update-institution.js @@ -0,0 +1,48 @@ +'use strict' + +module.exports = { + up: async (queryInterface, Sequelize) => { + await queryInterface.addColumn('institution', 'address', { + type: Sequelize.STRING, + allowNull: true, + }) + + const institutionsToUpdate = [ + { + name: 'Héraðsdómur Reykjavíkur', + address: 'Dómhúsið við Lækjartorg, Reykjavík', + }, + { name: 'Héraðsdómur Reykjaness', address: 'Fjarðargata 9, Hafnarfirði' }, + { + name: 'Héraðsdómur Vesturlands', + address: 'Bjarnarbraut 8, Borgarnesi', + }, + { name: 'Héraðsdómur Vestfjarða', address: 'Hafnarstræti 9, Ísafirði' }, + { + name: 'Héraðsdómur Norðurlands vestra', + address: 'Skagfirðingabraut 21, Sauðárkróki', + }, + { + name: 'Héraðsdómur Norðurlands eystra', + address: 'Hafnarstræti 107, 4. hæð, Akureyri', + }, + { name: 'Héraðsdómur Austurlands', address: 'Lyngás 15, Egilsstöðum' }, + { name: 'Héraðsdómur Suðurlands', address: 'Austurvegur 4, Selfossi' }, + ] + + await queryInterface.sequelize.transaction(async (transaction) => { + for (const institution of institutionsToUpdate) { + await queryInterface.bulkUpdate( + 'institution', + { address: institution.address }, + { name: institution.name }, + { transaction }, + ) + } + }) + }, + + down: async (queryInterface) => { + await queryInterface.removeColumn('institution', 'address') + }, +} diff --git a/apps/judicial-system/backend/src/app/formatters/confirmedIndictmentPdf.ts b/apps/judicial-system/backend/src/app/formatters/confirmedIndictmentPdf.ts index 1c663df0edfd..f5798cf30849 100644 --- a/apps/judicial-system/backend/src/app/formatters/confirmedIndictmentPdf.ts +++ b/apps/judicial-system/backend/src/app/formatters/confirmedIndictmentPdf.ts @@ -4,14 +4,14 @@ import { formatDate, lowercase } from '@island.is/judicial-system/formatters' import { calculatePt, + Confirmation, drawTextWithEllipsisPDFKit, - IndictmentConfirmation, smallFontSize, } from './pdfHelpers' import { PDFKitCoatOfArms } from './PDFKitCoatOfArms' export const createConfirmedIndictment = async ( - confirmation: IndictmentConfirmation, + confirmation: Confirmation, indictmentPDF: Buffer, ): Promise => { const pdfDoc = await PDFDocument.load(indictmentPDF) diff --git a/apps/judicial-system/backend/src/app/formatters/index.ts b/apps/judicial-system/backend/src/app/formatters/index.ts index 084da81ca6b5..120d7279c56e 100644 --- a/apps/judicial-system/backend/src/app/formatters/index.ts +++ b/apps/judicial-system/backend/src/app/formatters/index.ts @@ -29,7 +29,7 @@ export { formatPostponedCourtDateEmailNotification, stripHtmlTags, } from './formatters' -export { IndictmentConfirmation } from './pdfHelpers' +export { Confirmation } from './pdfHelpers' export { getRequestPdfAsBuffer, getRequestPdfAsString } from './requestPdf' export { getRulingPdfAsBuffer, getRulingPdfAsString } from './rulingPdf' export { createCaseFilesRecord } from './caseFilesRecordPdf' diff --git a/apps/judicial-system/backend/src/app/formatters/indictmentPdf.ts b/apps/judicial-system/backend/src/app/formatters/indictmentPdf.ts index c19da2711a7f..5ab70b81c81a 100644 --- a/apps/judicial-system/backend/src/app/formatters/indictmentPdf.ts +++ b/apps/judicial-system/backend/src/app/formatters/indictmentPdf.ts @@ -16,7 +16,7 @@ import { addNormalPlusJustifiedText, addNormalPlusText, addNormalText, - IndictmentConfirmation, + Confirmation, setTitle, } from './pdfHelpers' @@ -52,7 +52,7 @@ const roman = (num: number) => { export const createIndictment = async ( theCase: Case, formatMessage: FormatMessage, - confirmation?: IndictmentConfirmation, + confirmation?: Confirmation, ): Promise => { const doc = new PDFDocument({ size: 'A4', diff --git a/apps/judicial-system/backend/src/app/formatters/pdfHelpers.ts b/apps/judicial-system/backend/src/app/formatters/pdfHelpers.ts index d348032ebc35..4f44aa249e93 100644 --- a/apps/judicial-system/backend/src/app/formatters/pdfHelpers.ts +++ b/apps/judicial-system/backend/src/app/formatters/pdfHelpers.ts @@ -5,7 +5,7 @@ import { formatDate, lowercase } from '@island.is/judicial-system/formatters' import { coatOfArms } from './coatOfArms' import { policeStar } from './policeStar' -export interface IndictmentConfirmation { +export interface Confirmation { actor: string title?: string institution: string @@ -22,6 +22,10 @@ export const largeFontSize = 18 export const hugeFontSize = 26 export const giganticFontSize = 33 +const lightGray = '#FAFAFA' +const darkGray = '#CBCBCB' +const gold = '#ADA373' + const setFont = (doc: PDFKit.PDFDocument, font?: string) => { if (font) { doc.font(font) @@ -106,13 +110,105 @@ export const addPoliceStar = (doc: PDFKit.PDFDocument) => { doc.scale(25).translate(-270, -70) } +export const addConfirmation = ( + doc: PDFKit.PDFDocument, + confirmation: Confirmation, +) => { + const pageMargin = calculatePt(18) + const shaddowHeight = calculatePt(70) + const coatOfArmsWidth = calculatePt(105) + const coatOfArmsX = pageMargin + calculatePt(8) + const titleHeight = calculatePt(24) + const titleX = coatOfArmsX + coatOfArmsWidth + calculatePt(8) + const institutionWidth = calculatePt(160) + const confirmedByWidth = institutionWidth + calculatePt(48) + const shaddowWidth = institutionWidth + confirmedByWidth + coatOfArmsWidth + const titleWidth = institutionWidth + confirmedByWidth + + // Draw the shadow + doc + .rect(pageMargin, pageMargin + calculatePt(8), shaddowWidth, shaddowHeight) + .fill(lightGray) + .stroke() + + // Draw the coat of arms + doc + .rect(coatOfArmsX, pageMargin, coatOfArmsWidth, shaddowHeight) + .fillAndStroke('white', darkGray) + + addCoatOfArms(doc, calculatePt(49), calculatePt(24)) + + // Draw the title + doc + .rect(coatOfArmsX + coatOfArmsWidth, pageMargin, titleWidth, titleHeight) + .fillAndStroke(lightGray, darkGray) + doc.fill('black') + doc.font('Times-Bold') + doc + .fontSize(calculatePt(smallFontSize)) + .text('Réttarvörslugátt', titleX, pageMargin + calculatePt(9)) + doc.font('Times-Roman') + // The X value here is approx. 8px after the title + doc.text('Rafræn staðfesting', calculatePt(210), pageMargin + calculatePt(9)) + doc.text( + formatDate(confirmation.date) || '', + shaddowWidth - calculatePt(24), + pageMargin + calculatePt(9), + ) + + // Draw the institution + doc + .rect( + coatOfArmsX + coatOfArmsWidth, + pageMargin + titleHeight, + institutionWidth, + shaddowHeight - titleHeight, + ) + .fillAndStroke('white', darkGray) + doc.fill('black') + doc.font('Times-Bold') + doc.text('Dómstóll', titleX, pageMargin + titleHeight + calculatePt(10)) + doc.font('Times-Roman') + drawTextWithEllipsis( + doc, + confirmation.institution, + titleX, + pageMargin + titleHeight + calculatePt(22), + institutionWidth - calculatePt(16), + ) + + // Draw the actor + doc + .rect( + coatOfArmsX + coatOfArmsWidth + institutionWidth, + pageMargin + titleHeight, + confirmedByWidth, + shaddowHeight - titleHeight, + ) + .fillAndStroke('white', darkGray) + doc.fill('black') + doc.font('Times-Bold') + doc.text( + 'Samþykktaraðili', + titleX + institutionWidth, + pageMargin + titleHeight + calculatePt(10), + ) + doc.font('Times-Roman') + doc.text( + `${confirmation.actor}${ + confirmation.title ? `, ${lowercase(confirmation.title)}` : '' + }`, + titleX + institutionWidth, + pageMargin + titleHeight + calculatePt(22), + ) + + doc.fillColor('black') +} + export const addIndictmentConfirmation = ( doc: PDFKit.PDFDocument, - confirmation: IndictmentConfirmation, + confirmation: Confirmation, ) => { - const lightGray = '#FAFAFA' - const darkGray = '#CBCBCB' - const gold = '#ADA373' const pageMargin = calculatePt(18) const shaddowHeight = calculatePt(90) const coatOfArmsWidth = calculatePt(105) diff --git a/apps/judicial-system/backend/src/app/formatters/subpoenaPdf.ts b/apps/judicial-system/backend/src/app/formatters/subpoenaPdf.ts index 40af05fe4ba9..4af8001053e8 100644 --- a/apps/judicial-system/backend/src/app/formatters/subpoenaPdf.ts +++ b/apps/judicial-system/backend/src/app/formatters/subpoenaPdf.ts @@ -13,6 +13,7 @@ import { subpoena as strings } from '../messages' import { Case } from '../modules/case' import { Defendant } from '../modules/defendant' import { + addConfirmation, addEmptyLines, addFooter, addHugeHeading, @@ -22,28 +23,6 @@ import { setTitle, } from './pdfHelpers' -type DistrictCourts = - | 'Héraðsdómur Reykjavíkur' - | 'Héraðsdómur Reykjaness' - | 'Héraðsdómur Vesturlands' - | 'Héraðsdómur Vestfjarða' - | 'Héraðsdómur Norðurlands vestra' - | 'Héraðsdómur Norðurlands eystra' - | 'Héraðsdómur Austurlands' - | 'Héraðsdómur Suðurlands' - -// TODO: Move to databas -const DistrictCourtLocation: Record = { - 'Héraðsdómur Reykjavíkur': 'Dómhúsið við Lækjartorg, Reykjavík', - 'Héraðsdómur Reykjaness': 'Fjarðargata 9, Hafnarfirði', - 'Héraðsdómur Vesturlands': 'Bjarnarbraut 8, Borgarnesi', - 'Héraðsdómur Vestfjarða': 'Hafnarstræti 9, Ísafirði', - 'Héraðsdómur Norðurlands vestra': 'Skagfirðingabraut 21, Sauðárkróki', - 'Héraðsdómur Norðurlands eystra': 'Hafnarstræti 107, 4. hæð, Akureyri', - 'Héraðsdómur Austurlands': 'Lyngás 15, Egilsstöðum', - 'Héraðsdómur Suðurlands': 'Austurvegur 4, Selfossi', -} - export const createSubpoena = ( theCase: Case, defendant: Defendant, @@ -71,6 +50,11 @@ export const createSubpoena = ( doc.on('data', (chunk) => sinc.push(chunk)) setTitle(doc, formatMessage(strings.title)) + + if (dateLog) { + addEmptyLines(doc, 5) + } + addNormalText(doc, `${theCase.court?.name}`, 'Times-Bold', true) addNormalRightAlignedText( @@ -86,7 +70,7 @@ export const createSubpoena = ( if (theCase.court?.name) { addNormalText( doc, - DistrictCourtLocation[theCase.court.name as DistrictCourts], + theCase.court.address || 'Ekki skráð', // the latter shouldn't happen, if it does we have an problem with the court data 'Times-Roman', ) } @@ -170,6 +154,15 @@ export const createSubpoena = ( addFooter(doc) + if (dateLog) { + addConfirmation(doc, { + actor: theCase.judge?.name || '', + title: theCase.judge?.title, + institution: theCase.judge?.institution?.name || '', + date: dateLog.created, + }) + } + doc.end() return new Promise((resolve) => diff --git a/apps/judicial-system/backend/src/app/modules/case/case.controller.ts b/apps/judicial-system/backend/src/app/modules/case/case.controller.ts index cca384ac525a..7b0da96f91d1 100644 --- a/apps/judicial-system/backend/src/app/modules/case/case.controller.ts +++ b/apps/judicial-system/backend/src/app/modules/case/case.controller.ts @@ -68,7 +68,7 @@ import { prosecutorRule, publicProsecutorStaffRule, } from '../../guards' -import { CaseEvent, EventService } from '../event' +import { EventService } from '../event' import { UserService } from '../user' import { CreateCaseDto } from './dto/createCase.dto' import { TransitionCaseDto } from './dto/transitionCase.dto' @@ -99,8 +99,8 @@ import { prosecutorUpdateRule, publicProsecutorStaffUpdateRule, } from './guards/rolesRules' -import { CaseInterceptor } from './interceptors/case.interceptor' import { CaseListInterceptor } from './interceptors/caseList.interceptor' +import { CompletedAppealAccessedInterceptor } from './interceptors/completedAppealAccessed.interceptor' import { Case } from './models/case.model' import { SignatureConfirmationResponse } from './models/signatureConfirmation.response' import { transitionCase } from './state/case.state' @@ -465,7 +465,7 @@ export class CaseController { ) @Get('case/:caseId') @ApiOkResponse({ type: Case, description: 'Gets an existing case' }) - @UseInterceptors(CaseInterceptor) + @UseInterceptors(CompletedAppealAccessedInterceptor) getById(@Param('caseId') caseId: string, @CurrentCase() theCase: Case): Case { this.logger.debug(`Getting case ${caseId} by id`) @@ -545,6 +545,7 @@ export class CaseController { @RolesRules( prosecutorRule, prosecutorRepresentativeRule, + publicProsecutorStaffRule, districtCourtJudgeRule, districtCourtRegistrarRule, districtCourtAssistantRule, @@ -700,6 +701,7 @@ export class CaseController { @RolesRules( prosecutorRule, prosecutorRepresentativeRule, + publicProsecutorStaffRule, districtCourtJudgeRule, districtCourtRegistrarRule, districtCourtAssistantRule, diff --git a/apps/judicial-system/backend/src/app/modules/case/case.service.ts b/apps/judicial-system/backend/src/app/modules/case/case.service.ts index 65a7daca3a84..eabf3a6cee0b 100644 --- a/apps/judicial-system/backend/src/app/modules/case/case.service.ts +++ b/apps/judicial-system/backend/src/app/modules/case/case.service.ts @@ -56,7 +56,7 @@ import { import { AwsS3Service } from '../aws-s3' import { CourtService } from '../court' import { Defendant, DefendantService } from '../defendant' -import { CaseEvent, EventService } from '../event' +import { EventService } from '../event' import { EventLog, EventLogService } from '../event-log' import { CaseFile, FileService } from '../file' import { IndictmentCount } from '../indictment-count' diff --git a/apps/judicial-system/backend/src/app/modules/case/filters/case.filter.ts b/apps/judicial-system/backend/src/app/modules/case/filters/case.filter.ts index d49a4ea4a964..64c00f550136 100644 --- a/apps/judicial-system/backend/src/app/modules/case/filters/case.filter.ts +++ b/apps/judicial-system/backend/src/app/modules/case/filters/case.filter.ts @@ -3,26 +3,26 @@ import type { User } from '@island.is/judicial-system/types' import { CaseAppealState, CaseDecision, + CaseIndictmentRulingDecision, CaseState, CaseType, - DateType, - getIndictmentVerdictAppealDeadline, + getIndictmentVerdictAppealDeadlineStatus, IndictmentCaseReviewDecision, - InstitutionType, isCourtOfAppealsUser, isDefenceUser, isDistrictCourtUser, isIndictmentCase, - isPrisonSystemUser, + isPrisonAdminUser, + isPrisonStaffUser, isProsecutionUser, isPublicProsecutorUser, isRequestCase, isRestrictionCase, RequestSharedWithDefender, + ServiceRequirement, UserRole, } from '@island.is/judicial-system/types' -import { nowFactory } from '../../../factories' import { Case } from '../models/case.model' import { DateLog } from '../models/dateLog.model' @@ -173,47 +173,17 @@ const canAppealsCourtUserAccessCase = (theCase: Case): boolean => { return true } -const canPrisonSystemUserAccessCase = ( +const canPrisonStaffUserAccessCase = ( theCase: Case, - user: User, forUpdate = true, ): boolean => { - // Prison system users cannot update cases + // Prison staff users cannot update cases if (forUpdate) { return false } // Check case type access - if (user.institution?.type === InstitutionType.PRISON_ADMIN) { - if (isIndictmentCase(theCase.type)) { - const verdictViewDates = theCase.defendants?.map( - (defendant) => defendant.verdictViewDate, - ) - - const indictmentVerdictAppealDeadline = - getIndictmentVerdictAppealDeadline(verdictViewDates) - - if (!indictmentVerdictAppealDeadline) { - return false - } - - if ( - theCase.state === CaseState.COMPLETED && - theCase.indictmentReviewDecision === - IndictmentCaseReviewDecision.ACCEPT && - indictmentVerdictAppealDeadline < nowFactory() - ) { - return true - } - } - - if ( - !isRestrictionCase(theCase.type) && - theCase.type !== CaseType.PAROLE_REVOCATION - ) { - return false - } - } else if ( + if ( ![ CaseType.CUSTODY, CaseType.ADMISSION_TO_FACILITY, @@ -228,8 +198,44 @@ const canPrisonSystemUserAccessCase = ( return false } - // Check prison access to alternative travel ban - if (user.institution?.type === InstitutionType.PRISON_ADMIN) { + // Check decision access + if ( + !theCase.decision || + ![CaseDecision.ACCEPTING, CaseDecision.ACCEPTING_PARTIALLY].includes( + theCase.decision, + ) + ) { + return false + } + + return true +} + +const canPrisonAdminUserAccessCase = ( + theCase: Case, + forUpdate = true, +): boolean => { + // Prison admin users cannot update cases + if (forUpdate) { + return false + } + + // Check case type access + if ( + !isRestrictionCase(theCase.type) && + theCase.type !== CaseType.PAROLE_REVOCATION && + !isIndictmentCase(theCase.type) + ) { + return false + } + + if (isRequestCase(theCase.type)) { + // Check case state access + if (theCase.state !== CaseState.ACCEPTED) { + return false + } + + // Check decision access if ( !theCase.decision || ![ @@ -240,15 +246,42 @@ const canPrisonSystemUserAccessCase = ( ) { return false } - } else { + } + + if (isIndictmentCase(theCase.type)) { + // Check case state access + if (theCase.state !== CaseState.COMPLETED) { + return false + } + + // Check case indictment ruling decision access if ( - !theCase.decision || - ![CaseDecision.ACCEPTING, CaseDecision.ACCEPTING_PARTIALLY].includes( - theCase.decision, - ) + theCase.indictmentRulingDecision !== CaseIndictmentRulingDecision.RULING ) { return false } + + // Check indictment case review decision access + if ( + theCase.indictmentReviewDecision !== IndictmentCaseReviewDecision.ACCEPT + ) { + return false + } + + // Check defendant verdict appeal deadline access + const verdictInfo = theCase.defendants?.map<[boolean, Date | undefined]>( + (defendant) => [ + defendant.serviceRequirement !== ServiceRequirement.NOT_REQUIRED, + defendant.verdictViewDate, + ], + ) + + const [_, indictmentVerdictAppealDeadlineExpired] = + getIndictmentVerdictAppealDeadlineStatus(verdictInfo) + + if (!indictmentVerdictAppealDeadlineExpired) { + return false + } } return true @@ -338,8 +371,12 @@ export const canUserAccessCase = ( return canAppealsCourtUserAccessCase(theCase) } - if (isPrisonSystemUser(user)) { - return canPrisonSystemUserAccessCase(theCase, user, forUpdate) + if (isPrisonStaffUser(user)) { + return canPrisonStaffUserAccessCase(theCase, forUpdate) + } + + if (isPrisonAdminUser(user)) { + return canPrisonAdminUserAccessCase(theCase, forUpdate) } if (isDefenceUser(user)) { diff --git a/apps/judicial-system/backend/src/app/modules/case/filters/cases.filter.ts b/apps/judicial-system/backend/src/app/modules/case/filters/cases.filter.ts index c4bfe1eb6331..c9fa79cc834b 100644 --- a/apps/judicial-system/backend/src/app/modules/case/filters/cases.filter.ts +++ b/apps/judicial-system/backend/src/app/modules/case/filters/cases.filter.ts @@ -7,17 +7,18 @@ import type { User } from '@island.is/judicial-system/types' import { CaseAppealState, CaseDecision, + CaseIndictmentRulingDecision, CaseState, CaseType, DateType, IndictmentCaseReviewDecision, indictmentCases, - InstitutionType, investigationCases, isCourtOfAppealsUser, isDefenceUser, isDistrictCourtUser, - isPrisonSystemUser, + isPrisonAdminUser, + isPrisonStaffUser, isProsecutionUser, isPublicProsecutorUser, RequestSharedWithDefender, @@ -26,8 +27,14 @@ import { } from '@island.is/judicial-system/types' const getProsecutionUserCasesQueryFilter = (user: User): WhereOptions => { + const type = + user.role === UserRole.PROSECUTOR + ? [...restrictionCases, ...investigationCases, ...indictmentCases] + : indictmentCases + const options: WhereOptions = [ - { isArchived: false }, + { is_archived: false }, + { type }, { state: [ CaseState.NEW, @@ -59,34 +66,24 @@ const getProsecutionUserCasesQueryFilter = (user: User): WhereOptions => { }, ] - if (user.role === UserRole.PROSECUTOR) { - options.push({ - type: [...restrictionCases, ...investigationCases, ...indictmentCases], - }) - } else { - options.push({ type: indictmentCases }) - } - return { [Op.and]: options, } } const getPublicProsecutionUserCasesQueryFilter = (): WhereOptions => { - const options: WhereOptions = [ - { isArchived: false }, - { state: [CaseState.COMPLETED] }, - { type: indictmentCases }, - ] - return { - [Op.and]: options, + [Op.and]: [ + { is_archived: false }, + { state: [CaseState.COMPLETED] }, + { type: indictmentCases }, + ], } } const getDistrictCourtUserCasesQueryFilter = (user: User): WhereOptions => { const options: WhereOptions = [ - { isArchived: false }, + { is_archived: false }, { [Op.or]: [ { court_id: { [Op.is]: null } }, @@ -150,7 +147,7 @@ const getDistrictCourtUserCasesQueryFilter = (user: User): WhereOptions => { const getAppealsCourtUserCasesQueryFilter = (): WhereOptions => { return { [Op.and]: [ - { isArchived: false }, + { is_archived: false }, { type: [...restrictionCases, ...investigationCases] }, { state: [CaseState.ACCEPTED, CaseState.REJECTED, CaseState.DISMISSED] }, { @@ -170,46 +167,10 @@ const getAppealsCourtUserCasesQueryFilter = (): WhereOptions => { } } -const getPrisonSystemStaffUserCasesQueryFilter = (user: User): WhereOptions => { - const options: WhereOptions = [{ isArchived: false }] - - if (user.institution?.type === InstitutionType.PRISON_ADMIN) { - options.push({ - [Op.or]: [ - { - [Op.and]: [ - { state: CaseState.ACCEPTED }, - { - type: [ - CaseType.CUSTODY, - CaseType.ADMISSION_TO_FACILITY, - CaseType.PAROLE_REVOCATION, - CaseType.TRAVEL_BAN, - ], - }, - ], - }, - { - [Op.and]: [ - { - type: CaseType.INDICTMENT, - state: CaseState.COMPLETED, - indictmentReviewDecision: IndictmentCaseReviewDecision.ACCEPT, - id: { - [Op.notIn]: Sequelize.literal(` - (SELECT "case_id" - FROM "defendant" - WHERE "defendant"."verdict_view_date" IS NULL - OR "defendant"."verdict_view_date" > NOW() - INTERVAL '28 days') - `), - }, - }, - ], - }, - ], - }) - } else { - options.push( +const getPrisonStaffUserCasesQueryFilter = (): WhereOptions => { + return { + [Op.and]: [ + { is_archived: false }, { state: CaseState.ACCEPTED }, { type: [ @@ -218,19 +179,46 @@ const getPrisonSystemStaffUserCasesQueryFilter = (user: User): WhereOptions => { CaseType.PAROLE_REVOCATION, ], }, + { decision: [CaseDecision.ACCEPTING, CaseDecision.ACCEPTING_PARTIALLY] }, + ], + } +} + +const getPrisonAdminUserCasesQueryFilter = (): WhereOptions => { + return { + is_archived: false, + [Op.or]: [ + { + state: CaseState.ACCEPTED, + type: [ + CaseType.CUSTODY, + CaseType.ADMISSION_TO_FACILITY, + CaseType.PAROLE_REVOCATION, + CaseType.TRAVEL_BAN, + ], + }, { - decision: [CaseDecision.ACCEPTING, CaseDecision.ACCEPTING_PARTIALLY], + type: CaseType.INDICTMENT, + state: CaseState.COMPLETED, + indictment_ruling_decision: CaseIndictmentRulingDecision.RULING, + indictment_review_decision: IndictmentCaseReviewDecision.ACCEPT, + id: { + [Op.notIn]: Sequelize.literal(` + (SELECT case_id + FROM defendant + WHERE service_requirement <> 'NOT_REQUIRED' + AND (verdict_view_date IS NULL OR verdict_view_date > NOW() - INTERVAL '28 days')) + `), + }, }, - ) + ], } - - return { [Op.and]: options } } const getDefenceUserCasesQueryFilter = (user: User): WhereOptions => { const formattedNationalId = formatNationalId(user.nationalId) const options: WhereOptions = [ - { isArchived: false }, + { is_archived: false }, { [Op.or]: [ { @@ -307,8 +295,12 @@ export const getCasesQueryFilter = (user: User): WhereOptions => { return getAppealsCourtUserCasesQueryFilter() } - if (isPrisonSystemUser(user)) { - return getPrisonSystemStaffUserCasesQueryFilter(user) + if (isPrisonStaffUser(user)) { + return getPrisonStaffUserCasesQueryFilter() + } + + if (isPrisonAdminUser(user)) { + return getPrisonAdminUserCasesQueryFilter() } if (isDefenceUser(user)) { diff --git a/apps/judicial-system/backend/src/app/modules/case/filters/test/cases.filter.spec.ts b/apps/judicial-system/backend/src/app/modules/case/filters/test/cases.filter.spec.ts index 8c93ddecc010..1c412b863bd7 100644 --- a/apps/judicial-system/backend/src/app/modules/case/filters/test/cases.filter.spec.ts +++ b/apps/judicial-system/backend/src/app/modules/case/filters/test/cases.filter.spec.ts @@ -4,6 +4,7 @@ import type { User } from '@island.is/judicial-system/types' import { CaseAppealState, CaseDecision, + CaseIndictmentRulingDecision, CaseState, CaseType, completedIndictmentCaseStates, @@ -41,7 +42,14 @@ describe('getCasesQueryFilter', () => { // Assert expect(res).toStrictEqual({ [Op.and]: [ - { isArchived: false }, + { is_archived: false }, + { + type: [ + ...restrictionCases, + ...investigationCases, + ...indictmentCases, + ], + }, { state: [ CaseState.NEW, @@ -72,13 +80,6 @@ describe('getCasesQueryFilter', () => { { prosecutor_id: 'Prosecutor Id' }, ], }, - { - type: [ - ...restrictionCases, - ...investigationCases, - ...indictmentCases, - ], - }, ], }) }) @@ -100,7 +101,8 @@ describe('getCasesQueryFilter', () => { // Assert expect(res).toStrictEqual({ [Op.and]: [ - { isArchived: false }, + { is_archived: false }, + { type: indictmentCases }, { state: [ CaseState.NEW, @@ -130,7 +132,6 @@ describe('getCasesQueryFilter', () => { { prosecutor_id: user.id }, ], }, - { type: indictmentCases }, ], }) }) @@ -152,7 +153,7 @@ describe('getCasesQueryFilter', () => { // Assert expect(res).toStrictEqual({ [Op.and]: [ - { isArchived: false }, + { is_archived: false }, { [Op.or]: [ { court_id: { [Op.is]: null } }, @@ -218,7 +219,7 @@ describe('getCasesQueryFilter', () => { // Assert expect(res).toStrictEqual({ [Op.and]: [ - { isArchived: false }, + { is_archived: false }, { [Op.or]: [ { court_id: { [Op.is]: null } }, @@ -256,7 +257,7 @@ describe('getCasesQueryFilter', () => { // Assert expect(res).toStrictEqual({ [Op.and]: [ - { isArchived: false }, + { is_archived: false }, { type: [...restrictionCases, ...investigationCases] }, { state: [ @@ -304,7 +305,7 @@ describe('getCasesQueryFilter', () => { // Assert expect(res).toStrictEqual({ [Op.and]: [ - { isArchived: false }, + { is_archived: false }, { state: [CaseState.COMPLETED], }, @@ -333,7 +334,7 @@ describe('getCasesQueryFilter', () => { // Assert expect(res).toStrictEqual({ [Op.and]: [ - { isArchived: false }, + { is_archived: false }, { state: CaseState.ACCEPTED }, { type: [ @@ -365,42 +366,32 @@ describe('getCasesQueryFilter', () => { // Assert expect(res).toStrictEqual({ - [Op.and]: [ - { isArchived: false }, + is_archived: false, + + [Op.or]: [ { - [Op.or]: [ - { - [Op.and]: [ - { state: CaseState.ACCEPTED }, - { - type: [ - CaseType.CUSTODY, - CaseType.ADMISSION_TO_FACILITY, - CaseType.PAROLE_REVOCATION, - CaseType.TRAVEL_BAN, - ], - }, - ], - }, - { - [Op.and]: [ - { - type: CaseType.INDICTMENT, - state: CaseState.COMPLETED, - indictmentReviewDecision: IndictmentCaseReviewDecision.ACCEPT, - id: { - [Op.notIn]: Sequelize.literal(` - (SELECT "case_id" - FROM "defendant" - WHERE "defendant"."verdict_view_date" IS NULL - OR "defendant"."verdict_view_date" > NOW() - INTERVAL '28 days') - `), - }, - }, - ], - }, + state: CaseState.ACCEPTED, + type: [ + CaseType.CUSTODY, + CaseType.ADMISSION_TO_FACILITY, + CaseType.PAROLE_REVOCATION, + CaseType.TRAVEL_BAN, ], }, + { + type: CaseType.INDICTMENT, + state: CaseState.COMPLETED, + indictment_ruling_decision: CaseIndictmentRulingDecision.RULING, + indictment_review_decision: IndictmentCaseReviewDecision.ACCEPT, + id: { + [Op.notIn]: Sequelize.literal(` + (SELECT case_id + FROM defendant + WHERE service_requirement <> 'NOT_REQUIRED' + AND (verdict_view_date IS NULL OR verdict_view_date > NOW() - INTERVAL '28 days')) + `), + }, + }, ], }) }) @@ -419,7 +410,7 @@ describe('getCasesQueryFilter', () => { // Assert expect(res).toStrictEqual({ [Op.and]: [ - { isArchived: false }, + { is_archived: false }, { [Op.or]: [ { diff --git a/apps/judicial-system/backend/src/app/modules/case/filters/test/prisonAdminUserFilter.spec.ts b/apps/judicial-system/backend/src/app/modules/case/filters/test/prisonAdminUserFilter.spec.ts new file mode 100644 index 000000000000..de0e36f943a2 --- /dev/null +++ b/apps/judicial-system/backend/src/app/modules/case/filters/test/prisonAdminUserFilter.spec.ts @@ -0,0 +1,210 @@ +import { + CaseDecision, + CaseIndictmentRulingDecision, + CaseState, + CaseType, + IndictmentCaseReviewDecision, + indictmentCases, + InstitutionType, + prisonSystemRoles, + restrictionCases, + User, +} from '@island.is/judicial-system/types' + +import { Case } from '../../models/case.model' +import { verifyNoAccess, verifyReadAccess } from './verify' + +describe.each(prisonSystemRoles)('prison admin user %s', (role) => { + const user = { + role, + institution: { type: InstitutionType.PRISON_ADMIN }, + } as User + + const accessibleRequestCaseTypes = [ + ...restrictionCases, + CaseType.PAROLE_REVOCATION, + ] + const accessibleIndictmentCaseTypes = indictmentCases + + describe.each( + Object.values(CaseType).filter( + (type) => + !accessibleRequestCaseTypes.includes(type) && + !accessibleIndictmentCaseTypes.includes(type), + ), + )('inaccessible case type %s', (type) => { + describe.each(Object.values(CaseState))('case state %s', (state) => { + describe.each(Object.values(CaseDecision))( + 'case decision %s', + (decision) => { + const theCase = { type, state, decision } as Case + + verifyNoAccess(theCase, user) + }, + ) + }) + }) + + describe.each(accessibleRequestCaseTypes)( + 'accessible request case type %s', + (type) => { + const accessibleCaseStates = [CaseState.ACCEPTED] + + describe.each( + Object.values(CaseState).filter( + (state) => !accessibleCaseStates.includes(state), + ), + )('inaccessible case state %s', (state) => { + describe.each(Object.values(CaseDecision))( + 'case decision %s', + (decision) => { + const theCase = { type, state, decision } as Case + + verifyNoAccess(theCase, user) + }, + ) + }) + + describe.each(accessibleCaseStates)( + 'accessible case state %s', + (state) => { + const accessibleCaseDecisions = [ + CaseDecision.ACCEPTING, + CaseDecision.ACCEPTING_PARTIALLY, + CaseDecision.ACCEPTING_ALTERNATIVE_TRAVEL_BAN, + ] + + describe.each( + Object.values(CaseDecision).filter( + (decision) => !accessibleCaseDecisions.includes(decision), + ), + )('inaccessible case decision %s', (decision) => { + const theCase = { type, state, decision } as Case + + verifyNoAccess(theCase, user) + }) + + describe.each(accessibleCaseDecisions)( + 'accessible case decision %s', + (decision) => { + const theCase = { type, state, decision } as Case + + verifyReadAccess(theCase, user) + }, + ) + }, + ) + }, + ) + + describe.each(accessibleIndictmentCaseTypes)( + 'accessible request case type %s', + (type) => { + const accessibleCaseStates = [CaseState.COMPLETED] + + describe.each( + Object.values(CaseState).filter( + (state) => !accessibleCaseStates.includes(state), + ), + )('inaccessible case state %s', (state) => { + describe.each(Object.values(CaseIndictmentRulingDecision))( + 'case indictment ruling decision %s', + (indictmentRulingDecision) => { + describe.each(Object.values(IndictmentCaseReviewDecision))( + 'indictment case review decision %s', + (indictmentReviewDecision) => { + const theCase = { + type, + state, + indictmentRulingDecision, + indictmentReviewDecision, + } as Case + + verifyNoAccess(theCase, user) + }, + ) + }, + ) + }) + + describe.each(accessibleCaseStates)( + 'accessible case state %s', + (state) => { + const accessibleCaseIndictmentRulingDecisions = [ + CaseIndictmentRulingDecision.RULING, + ] + + describe.each( + Object.values(CaseIndictmentRulingDecision).filter( + (indictmentRulingDecision) => + !accessibleCaseIndictmentRulingDecisions.includes( + indictmentRulingDecision, + ), + ), + )( + 'inaccessible case indictment ruling decision %s', + (indictmentRulingDecision) => { + describe.each(Object.values(IndictmentCaseReviewDecision))( + 'indictment case review decision %s', + (indictmentReviewDecision) => { + const theCase = { + type, + state, + indictmentRulingDecision, + indictmentReviewDecision, + } as Case + + verifyNoAccess(theCase, user) + }, + ) + }, + ) + + describe.each(accessibleCaseIndictmentRulingDecisions)( + 'accessible case indictment ruling decision %s', + (indictmentRulingDecision) => { + const accessibleIndictmentCaseReviewDecisions = [ + IndictmentCaseReviewDecision.ACCEPT, + ] + + describe.each( + Object.values(IndictmentCaseReviewDecision).filter( + (indictmentReviewDecision) => + !accessibleIndictmentCaseReviewDecisions.includes( + indictmentReviewDecision, + ), + ), + )( + 'inaccessible indictment case review decision %s', + (indictmentReviewDecision) => { + const theCase = { + type, + state, + indictmentRulingDecision, + indictmentReviewDecision, + } as Case + + verifyNoAccess(theCase, user) + }, + ) + + describe.each(accessibleIndictmentCaseReviewDecisions)( + 'accessible indictment case review decision %s', + (indictmentReviewDecision) => { + const theCase = { + type, + state, + indictmentRulingDecision, + indictmentReviewDecision, + } as Case + + verifyReadAccess(theCase, user) + }, + ) + }, + ) + }, + ) + }, + ) +}) diff --git a/apps/judicial-system/backend/src/app/modules/case/filters/test/prisonStaffUserFilter.spec.ts b/apps/judicial-system/backend/src/app/modules/case/filters/test/prisonStaffUserFilter.spec.ts new file mode 100644 index 000000000000..cea8c6023787 --- /dev/null +++ b/apps/judicial-system/backend/src/app/modules/case/filters/test/prisonStaffUserFilter.spec.ts @@ -0,0 +1,70 @@ +import { + CaseDecision, + CaseState, + CaseType, + InstitutionType, + prisonSystemRoles, + User, +} from '@island.is/judicial-system/types' + +import { Case } from '../../models/case.model' +import { verifyNoAccess, verifyReadAccess } from './verify' + +describe.each(prisonSystemRoles)('prison staff user %s', (role) => { + const user = { role, institution: { type: InstitutionType.PRISON } } as User + + const accessibleCaseTypes = [ + CaseType.CUSTODY, + CaseType.ADMISSION_TO_FACILITY, + CaseType.PAROLE_REVOCATION, + ] + describe.each( + Object.values(CaseType).filter( + (type) => !accessibleCaseTypes.includes(type), + ), + )('inaccessible case type %s', (type) => { + const theCase = { type } as Case + + verifyNoAccess(theCase, user) + }) + + describe.each(accessibleCaseTypes)('accessible case type %s', (type) => { + const accessibleCaseStates = [CaseState.ACCEPTED] + + describe.each( + Object.values(CaseState).filter( + (state) => !accessibleCaseStates.includes(state), + ), + )('inaccessible case state %s', (state) => { + const theCase = { type, state } as Case + + verifyNoAccess(theCase, user) + }) + + describe.each(accessibleCaseStates)('accessible case state %s', (state) => { + const accessibleCaseDecisions = [ + CaseDecision.ACCEPTING, + CaseDecision.ACCEPTING_PARTIALLY, + ] + + describe.each( + Object.values(CaseDecision).filter( + (decision) => !accessibleCaseDecisions.includes(decision), + ), + )('inaccessible case decision %s', (decision) => { + const theCase = { type, state, decision } as Case + + verifyNoAccess(theCase, user) + }) + + describe.each(accessibleCaseDecisions)( + 'accessible case decision %s', + (decision) => { + const theCase = { type, state, decision } as Case + + verifyReadAccess(theCase, user) + }, + ) + }) + }) +}) diff --git a/apps/judicial-system/backend/src/app/modules/case/filters/test/prisonSystemUserFilter.spec.ts b/apps/judicial-system/backend/src/app/modules/case/filters/test/prisonSystemUserFilter.spec.ts deleted file mode 100644 index 90ace33a217e..000000000000 --- a/apps/judicial-system/backend/src/app/modules/case/filters/test/prisonSystemUserFilter.spec.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { - CaseDecision, - CaseState, - CaseType, - InstitutionType, - prisonSystemRoles, - restrictionCases, - User, -} from '@island.is/judicial-system/types' - -import { Case } from '../../models/case.model' -import { verifyNoAccess, verifyReadAccess } from './verify' - -describe.each(prisonSystemRoles)('prison system user %s', (role) => { - describe('prison', () => { - const user = { role, institution: { type: InstitutionType.PRISON } } as User - - const accessibleCaseTypes = [ - CaseType.CUSTODY, - CaseType.ADMISSION_TO_FACILITY, - CaseType.PAROLE_REVOCATION, - ] - describe.each( - Object.values(CaseType).filter( - (type) => !accessibleCaseTypes.includes(type), - ), - )('inaccessible case type %s', (type) => { - const theCase = { type } as Case - - verifyNoAccess(theCase, user) - }) - - describe.each(accessibleCaseTypes)('accessible case type %s', (type) => { - const accessibleCaseStates = [CaseState.ACCEPTED] - - describe.each( - Object.values(CaseState).filter( - (state) => !accessibleCaseStates.includes(state), - ), - )('inaccessible case state %s', (state) => { - const theCase = { type, state } as Case - - verifyNoAccess(theCase, user) - }) - - describe.each(accessibleCaseStates)( - 'accessible case state %s', - (state) => { - const accessibleCaseDecisions = [ - CaseDecision.ACCEPTING, - CaseDecision.ACCEPTING_PARTIALLY, - ] - - describe.each( - Object.values(CaseDecision).filter( - (decision) => !accessibleCaseDecisions.includes(decision), - ), - )('inaccessible case decision %s', (decision) => { - const theCase = { type, state, decision } as Case - - verifyNoAccess(theCase, user) - }) - - describe.each(accessibleCaseDecisions)( - 'accessible case decision %s', - (decision) => { - const theCase = { type, state, decision } as Case - - verifyReadAccess(theCase, user) - }, - ) - }, - ) - }) - }) - - describe('prison admin', () => { - const user = { - role, - institution: { type: InstitutionType.PRISON_ADMIN }, - } as User - - const accessibleCaseTypes = [ - ...restrictionCases, - CaseType.PAROLE_REVOCATION, - ] - describe.each( - Object.values(CaseType).filter( - (type) => !accessibleCaseTypes.includes(type), - ), - )('inaccessible case type %s', (type) => { - const theCase = { type } as Case - - verifyNoAccess(theCase, user) - }) - - describe.each(accessibleCaseTypes)('accessible case type %s', (type) => { - const accessibleCaseStates = [CaseState.ACCEPTED] - - describe.each( - Object.values(CaseState).filter( - (state) => !accessibleCaseStates.includes(state), - ), - )('inaccessible case state %s', (state) => { - const theCase = { type, state } as Case - - verifyNoAccess(theCase, user) - }) - - describe.each(accessibleCaseStates)( - 'accessible case state %s', - (state) => { - const accessibleCaseDecisions = [ - CaseDecision.ACCEPTING, - CaseDecision.ACCEPTING_PARTIALLY, - CaseDecision.ACCEPTING_ALTERNATIVE_TRAVEL_BAN, - ] - - describe.each( - Object.values(CaseDecision).filter( - (decision) => !accessibleCaseDecisions.includes(decision), - ), - )('inaccessible case decision %s', (decision) => { - const theCase = { type, state, decision } as Case - - verifyNoAccess(theCase, user) - }) - - describe.each(accessibleCaseDecisions)( - 'accessible case decision %s', - (decision) => { - const theCase = { type, state, decision } as Case - - verifyReadAccess(theCase, user) - }, - ) - }, - ) - }) - }) -}) diff --git a/apps/judicial-system/backend/src/app/modules/case/guards/limitedAccessCaseExists.guard.ts b/apps/judicial-system/backend/src/app/modules/case/guards/limitedAccessCaseExists.guard.ts index 460480edf5f8..f92e78361b74 100644 --- a/apps/judicial-system/backend/src/app/modules/case/guards/limitedAccessCaseExists.guard.ts +++ b/apps/judicial-system/backend/src/app/modules/case/guards/limitedAccessCaseExists.guard.ts @@ -14,7 +14,7 @@ export class LimitedAccessCaseExistsGuard implements CanActivate { async canActivate(context: ExecutionContext): Promise { const request = context.switchToHttp().getRequest() - const caseId = request.params.caseId + const caseId: string = request.params.caseId if (!caseId) { throw new BadRequestException('Missing case id') diff --git a/apps/judicial-system/backend/src/app/modules/case/interceptors/caseFile.interceptor.ts b/apps/judicial-system/backend/src/app/modules/case/interceptors/caseFile.interceptor.ts new file mode 100644 index 000000000000..d7d74fb30a5c --- /dev/null +++ b/apps/judicial-system/backend/src/app/modules/case/interceptors/caseFile.interceptor.ts @@ -0,0 +1,53 @@ +import { Observable } from 'rxjs' +import { map } from 'rxjs/operators' + +import { + CallHandler, + ExecutionContext, + Injectable, + NestInterceptor, +} from '@nestjs/common' + +import { + CaseAppealState, + CaseFileCategory, + isDefenceUser, + isPrisonStaffUser, + isPrisonSystemUser, + User, +} from '@island.is/judicial-system/types' + +import { Case } from '../models/case.model' + +@Injectable() +export class CaseFileInterceptor implements NestInterceptor { + intercept(context: ExecutionContext, next: CallHandler): Observable { + const request = context.switchToHttp().getRequest() + const user: User = request.user + + return next.handle().pipe( + map((data: Case) => { + if (isDefenceUser(user)) { + return data + } + + if ( + isPrisonStaffUser(user) || + data.appealState !== CaseAppealState.COMPLETED + ) { + data.caseFiles?.splice(0, data.caseFiles.length) + } else if (isPrisonSystemUser(user)) { + data.caseFiles?.splice( + 0, + data.caseFiles.length, + ...data.caseFiles.filter( + (cf) => cf.category === CaseFileCategory.APPEAL_RULING, + ), + ) + } + + return data + }), + ) + } +} diff --git a/apps/judicial-system/backend/src/app/modules/case/interceptors/case.interceptor.ts b/apps/judicial-system/backend/src/app/modules/case/interceptors/completedAppealAccessed.interceptor.ts similarity index 94% rename from apps/judicial-system/backend/src/app/modules/case/interceptors/case.interceptor.ts rename to apps/judicial-system/backend/src/app/modules/case/interceptors/completedAppealAccessed.interceptor.ts index 6beab2bc3915..5ff8d84bff3f 100644 --- a/apps/judicial-system/backend/src/app/modules/case/interceptors/case.interceptor.ts +++ b/apps/judicial-system/backend/src/app/modules/case/interceptors/completedAppealAccessed.interceptor.ts @@ -20,7 +20,7 @@ import { EventLogService } from '../../event-log' import { Case } from '../models/case.model' @Injectable() -export class CaseInterceptor implements NestInterceptor { +export class CompletedAppealAccessedInterceptor implements NestInterceptor { constructor(private readonly eventLogService: EventLogService) {} intercept(context: ExecutionContext, next: CallHandler): Observable { diff --git a/apps/judicial-system/backend/src/app/modules/case/internalCase.controller.ts b/apps/judicial-system/backend/src/app/modules/case/internalCase.controller.ts index 9149585ce497..0d73789d2686 100644 --- a/apps/judicial-system/backend/src/app/modules/case/internalCase.controller.ts +++ b/apps/judicial-system/backend/src/app/modules/case/internalCase.controller.ts @@ -23,7 +23,7 @@ import { restrictionCases, } from '@island.is/judicial-system/types' -import { CaseEvent, EventService } from '../event' +import { EventService } from '../event' import { DeliverDto } from './dto/deliver.dto' import { DeliverCancellationNoticeDto } from './dto/deliverCancellationNotice.dto' import { InternalCasesDto } from './dto/internalCases.dto' diff --git a/apps/judicial-system/backend/src/app/modules/case/internalCase.service.ts b/apps/judicial-system/backend/src/app/modules/case/internalCase.service.ts index ebee2422a741..235d74f860a0 100644 --- a/apps/judicial-system/backend/src/app/modules/case/internalCase.service.ts +++ b/apps/judicial-system/backend/src/app/modules/case/internalCase.service.ts @@ -53,7 +53,7 @@ import { AwsS3Service } from '../aws-s3' import { CourtDocumentFolder, CourtService } from '../court' import { courtSubtypes } from '../court/court.service' import { Defendant, DefendantService } from '../defendant' -import { CaseEvent, EventService } from '../event' +import { EventService } from '../event' import { CaseFile, FileService } from '../file' import { IndictmentCount, IndictmentCountService } from '../indictment-count' import { Institution } from '../institution' diff --git a/apps/judicial-system/backend/src/app/modules/case/limitedAccessCase.controller.ts b/apps/judicial-system/backend/src/app/modules/case/limitedAccessCase.controller.ts index 7e17c55bab85..42c90d58ec5e 100644 --- a/apps/judicial-system/backend/src/app/modules/case/limitedAccessCase.controller.ts +++ b/apps/judicial-system/backend/src/app/modules/case/limitedAccessCase.controller.ts @@ -40,7 +40,7 @@ import { import { nowFactory } from '../../factories' import { defenderRule, prisonSystemStaffRule } from '../../guards' -import { CaseEvent, EventService } from '../event' +import { EventService } from '../event' import { User } from '../user' import { TransitionCaseDto } from './dto/transitionCase.dto' import { UpdateCaseDto } from './dto/updateCase.dto' @@ -53,7 +53,8 @@ import { CaseWriteGuard } from './guards/caseWrite.guard' import { LimitedAccessCaseExistsGuard } from './guards/limitedAccessCaseExists.guard' import { RequestSharedWithDefenderGuard } from './guards/requestSharedWithDefender.guard' import { defenderTransitionRule, defenderUpdateRule } from './guards/rolesRules' -import { CaseInterceptor } from './interceptors/case.interceptor' +import { CaseFileInterceptor } from './interceptors/caseFile.interceptor' +import { CompletedAppealAccessedInterceptor } from './interceptors/completedAppealAccessed.interceptor' import { Case } from './models/case.model' import { transitionCase } from './state/case.state' import { @@ -85,7 +86,7 @@ export class LimitedAccessCaseController { type: Case, description: 'Gets a limited set of properties of an existing case', }) - @UseInterceptors(CaseInterceptor) + @UseInterceptors(CompletedAppealAccessedInterceptor, CaseFileInterceptor) async getById( @Param('caseId') caseId: string, @CurrentCase() theCase: Case, diff --git a/apps/judicial-system/backend/src/app/modules/case/pdf.service.ts b/apps/judicial-system/backend/src/app/modules/case/pdf.service.ts index 8289b8d636ec..6b956856ebb1 100644 --- a/apps/judicial-system/backend/src/app/modules/case/pdf.service.ts +++ b/apps/judicial-system/backend/src/app/modules/case/pdf.service.ts @@ -22,6 +22,7 @@ import { } from '@island.is/judicial-system/types' import { + Confirmation, createCaseFilesRecord, createIndictment, createSubpoena, @@ -29,7 +30,6 @@ import { getCustodyNoticePdfAsBuffer, getRequestPdfAsBuffer, getRulingPdfAsBuffer, - IndictmentConfirmation, } from '../../formatters' import { AwsS3Service } from '../aws-s3' import { Defendant } from '../defendant' @@ -206,7 +206,7 @@ export class PdfService { ) } - let confirmation: IndictmentConfirmation | undefined = undefined + let confirmation: Confirmation | undefined = undefined if (hasIndictmentCaseBeenSubmittedToCourt(theCase.state)) { if (theCase.indictmentHash) { diff --git a/apps/judicial-system/backend/src/app/modules/case/test/caseController/getCaseFilesRecordPdfRolesRules.spec.ts b/apps/judicial-system/backend/src/app/modules/case/test/caseController/getCaseFilesRecordPdfRolesRules.spec.ts index 98e04d53d4f8..05822ae9a72c 100644 --- a/apps/judicial-system/backend/src/app/modules/case/test/caseController/getCaseFilesRecordPdfRolesRules.spec.ts +++ b/apps/judicial-system/backend/src/app/modules/case/test/caseController/getCaseFilesRecordPdfRolesRules.spec.ts @@ -4,6 +4,7 @@ import { districtCourtRegistrarRule, prosecutorRepresentativeRule, prosecutorRule, + publicProsecutorStaffRule, } from '../../../../guards' import { CaseController } from '../../case.controller' @@ -19,9 +20,10 @@ describe('CaseController - Get case files record pdf rules', () => { }) it('should give permission to roles', () => { - expect(rules).toHaveLength(5) + expect(rules).toHaveLength(6) expect(rules).toContain(prosecutorRule) expect(rules).toContain(prosecutorRepresentativeRule) + expect(rules).toContain(publicProsecutorStaffRule) expect(rules).toContain(districtCourtJudgeRule) expect(rules).toContain(districtCourtRegistrarRule) expect(rules).toContain(districtCourtAssistantRule) diff --git a/apps/judicial-system/backend/src/app/modules/case/test/caseController/getIndictmentPdfRolesRules.spec.ts b/apps/judicial-system/backend/src/app/modules/case/test/caseController/getIndictmentPdfRolesRules.spec.ts index 170857ab4dca..6fee0d26b903 100644 --- a/apps/judicial-system/backend/src/app/modules/case/test/caseController/getIndictmentPdfRolesRules.spec.ts +++ b/apps/judicial-system/backend/src/app/modules/case/test/caseController/getIndictmentPdfRolesRules.spec.ts @@ -4,6 +4,7 @@ import { districtCourtRegistrarRule, prosecutorRepresentativeRule, prosecutorRule, + publicProsecutorStaffRule, } from '../../../../guards' import { CaseController } from '../../case.controller' @@ -19,9 +20,10 @@ describe('CaseController - Get indictment pdf rules', () => { }) it('should give permission to roles', () => { - expect(rules).toHaveLength(5) + expect(rules).toHaveLength(6) expect(rules).toContain(prosecutorRule) expect(rules).toContain(prosecutorRepresentativeRule) + expect(rules).toContain(publicProsecutorStaffRule) expect(rules).toContain(districtCourtJudgeRule) expect(rules).toContain(districtCourtRegistrarRule) expect(rules).toContain(districtCourtAssistantRule) diff --git a/apps/judicial-system/backend/src/app/modules/file/file.controller.ts b/apps/judicial-system/backend/src/app/modules/file/file.controller.ts index f507d84863e2..340d782f767b 100644 --- a/apps/judicial-system/backend/src/app/modules/file/file.controller.ts +++ b/apps/judicial-system/backend/src/app/modules/file/file.controller.ts @@ -37,6 +37,7 @@ import { prisonSystemStaffRule, prosecutorRepresentativeRule, prosecutorRule, + publicProsecutorStaffRule, } from '../../guards' import { Case, @@ -133,6 +134,7 @@ export class FileController { @RolesRules( prosecutorRule, prosecutorRepresentativeRule, + publicProsecutorStaffRule, districtCourtJudgeRule, districtCourtRegistrarRule, districtCourtAssistantRule, diff --git a/apps/judicial-system/backend/src/app/modules/file/guards/caseFileCategory.ts b/apps/judicial-system/backend/src/app/modules/file/guards/caseFileCategory.ts index 5455ad7976b0..2d8d88353f35 100644 --- a/apps/judicial-system/backend/src/app/modules/file/guards/caseFileCategory.ts +++ b/apps/judicial-system/backend/src/app/modules/file/guards/caseFileCategory.ts @@ -22,3 +22,5 @@ export const defenderCaseFileCategoriesForIndictmentCases = [ CaseFileCategory.PROSECUTOR_CASE_FILE, CaseFileCategory.DEFENDANT_CASE_FILE, ] + +export const prisonAdminCaseFileCategories = [CaseFileCategory.APPEAL_RULING] diff --git a/apps/judicial-system/backend/src/app/modules/file/guards/limitedAccessViewCaseFile.guard.ts b/apps/judicial-system/backend/src/app/modules/file/guards/limitedAccessViewCaseFile.guard.ts index 3526675d6902..a8c2f8295ea7 100644 --- a/apps/judicial-system/backend/src/app/modules/file/guards/limitedAccessViewCaseFile.guard.ts +++ b/apps/judicial-system/backend/src/app/modules/file/guards/limitedAccessViewCaseFile.guard.ts @@ -7,11 +7,10 @@ import { } from '@nestjs/common' import { - CaseFileCategory, isCompletedCase, isDefenceUser, isIndictmentCase, - isPrisonSystemUser, + isPrisonAdminUser, isRequestCase, User, } from '@island.is/judicial-system/types' @@ -21,6 +20,7 @@ import { CaseFile } from '../models/file.model' import { defenderCaseFileCategoriesForIndictmentCases, defenderCaseFileCategoriesForRestrictionAndInvestigationCases, + prisonAdminCaseFileCategories, } from './caseFileCategory' @Injectable() @@ -65,14 +65,13 @@ export class LimitedAccessViewCaseFileGuard implements CanActivate { } } - if (isPrisonSystemUser(user)) { - if ( - isCompletedCase(theCase.state) && - caseFile.category && - caseFile.category === CaseFileCategory.APPEAL_RULING - ) { - return true - } + if ( + caseFile.category && + isCompletedCase(theCase.state) && + isPrisonAdminUser(user) && + prisonAdminCaseFileCategories.includes(caseFile.category) + ) { + return true } throw new ForbiddenException(`Forbidden for ${user.role}`) diff --git a/apps/judicial-system/backend/src/app/modules/file/guards/test/limitedAccessViewCaseFileGuard.spec.ts b/apps/judicial-system/backend/src/app/modules/file/guards/test/limitedAccessViewCaseFileGuard.spec.ts index dd31ac1d7816..e4e7672dc2d6 100644 --- a/apps/judicial-system/backend/src/app/modules/file/guards/test/limitedAccessViewCaseFileGuard.spec.ts +++ b/apps/judicial-system/backend/src/app/modules/file/guards/test/limitedAccessViewCaseFileGuard.spec.ts @@ -229,27 +229,19 @@ describe('Limited Access View Case File Guard', () => { describe.each(allowedCaseFileCategories)( 'prison system users can view %s', (category) => { - let thenPrison: Then let thenPrisonAdmin: Then beforeEach(() => { - mockRequest.mockImplementationOnce(() => ({ - user: prisonUser, - case: { type, state }, - caseFile: { category }, - })) mockRequest.mockImplementationOnce(() => ({ user: prisonAdminUser, case: { type, state }, caseFile: { category }, })) - thenPrison = givenWhenThen() thenPrisonAdmin = givenWhenThen() }) it('should activate', () => { - expect(thenPrison.result).toBe(true) expect(thenPrisonAdmin.result).toBe(true) }) }, diff --git a/apps/judicial-system/backend/src/app/modules/file/guards/test/viewCaseFileGuard.spec.ts b/apps/judicial-system/backend/src/app/modules/file/guards/test/viewCaseFileGuard.spec.ts index d27014f8a5d1..e29e254f3c2d 100644 --- a/apps/judicial-system/backend/src/app/modules/file/guards/test/viewCaseFileGuard.spec.ts +++ b/apps/judicial-system/backend/src/app/modules/file/guards/test/viewCaseFileGuard.spec.ts @@ -12,6 +12,7 @@ import { districtCourtRoles, InstitutionType, prosecutionRoles, + publicProsecutorRoles, User, UserRole, } from '@island.is/judicial-system/types' @@ -210,6 +211,59 @@ describe('View Case File Guard', () => { }) }) + describe.each(publicProsecutorRoles)('role %s', (role) => { + describe.each(completedCaseStates)('%s cases', (state) => { + let then: Then + + beforeEach(() => { + mockRequest.mockImplementationOnce(() => ({ + user: { + role, + institution: { + type: InstitutionType.PROSECUTORS_OFFICE, + id: '8f9e2f6d-6a00-4a5e-b39b-95fd110d762e', + }, + }, + case: { state }, + })) + + then = givenWhenThen() + }) + + it('should activate', () => { + expect(then.result).toBe(true) + }) + }) + + describe.each( + Object.values(CaseState).filter( + (state) => !completedCaseStates.includes(state), + ), + )('%s cases', (state) => { + let then: Then + + beforeEach(() => { + mockRequest.mockImplementationOnce(() => ({ + user: { + role, + institution: { + type: InstitutionType.PROSECUTORS_OFFICE, + id: '8f9e2f6d-6a00-4a5e-b39b-95fd110d762e', + }, + }, + case: { state }, + })) + + then = givenWhenThen() + }) + + it('should throw ForbiddenException', () => { + expect(then.error).toBeInstanceOf(ForbiddenException) + expect(then.error.message).toBe(`Forbidden for ${role}`) + }) + }) + }) + describe.each(Object.keys(CaseState))('in state %s', (state) => { describe.each( Object.keys(UserRole).filter( diff --git a/apps/judicial-system/backend/src/app/modules/file/guards/viewCaseFile.guard.ts b/apps/judicial-system/backend/src/app/modules/file/guards/viewCaseFile.guard.ts index 466cfb67357e..1a39c2e4712a 100644 --- a/apps/judicial-system/backend/src/app/modules/file/guards/viewCaseFile.guard.ts +++ b/apps/judicial-system/backend/src/app/modules/file/guards/viewCaseFile.guard.ts @@ -12,8 +12,8 @@ import { isCompletedCase, isCourtOfAppealsUser, isDistrictCourtUser, - isPrisonSystemUser, isProsecutionUser, + isPublicProsecutorUser, User, } from '@island.is/judicial-system/types' @@ -36,14 +36,14 @@ export class ViewCaseFileGuard implements CanActivate { throw new InternalServerErrorException('Missing case') } - // TODO: Limit access based on a combination of - // case type, case state, appeal case state and case file category - // to get accurate case file permissions - if (isProsecutionUser(user)) { return true } + if (isPublicProsecutorUser(user) && isCompletedCase(theCase.state)) { + return true + } + if ( isDistrictCourtUser(user) && ([CaseState.SUBMITTED, CaseState.RECEIVED].includes(theCase.state) || @@ -65,14 +65,6 @@ export class ViewCaseFileGuard implements CanActivate { return true } - if ( - isPrisonSystemUser(user) && - theCase.appealState && - [CaseAppealState.COMPLETED].includes(theCase.appealState) - ) { - return true - } - throw new ForbiddenException(`Forbidden for ${user.role}`) } } diff --git a/apps/judicial-system/backend/src/app/modules/file/test/fileController/getCaseFileSignedUrlRolesRules.spec.ts b/apps/judicial-system/backend/src/app/modules/file/test/fileController/getCaseFileSignedUrlRolesRules.spec.ts index dd24bcd6d265..86d57470734e 100644 --- a/apps/judicial-system/backend/src/app/modules/file/test/fileController/getCaseFileSignedUrlRolesRules.spec.ts +++ b/apps/judicial-system/backend/src/app/modules/file/test/fileController/getCaseFileSignedUrlRolesRules.spec.ts @@ -8,6 +8,7 @@ import { prisonSystemStaffRule, prosecutorRepresentativeRule, prosecutorRule, + publicProsecutorStaffRule, } from '../../../../guards' import { FileController } from '../../file.controller' @@ -23,9 +24,10 @@ describe('FileController - Get case file signed url rules', () => { }) it('should give permission to roles', () => { - expect(rules).toHaveLength(9) + expect(rules).toHaveLength(10) expect(rules).toContain(prosecutorRule) expect(rules).toContain(prosecutorRepresentativeRule) + expect(rules).toContain(publicProsecutorStaffRule) expect(rules).toContain(districtCourtJudgeRule) expect(rules).toContain(districtCourtRegistrarRule) expect(rules).toContain(districtCourtAssistantRule) diff --git a/apps/judicial-system/backend/src/app/modules/file/test/limitedAccessFileController/createPresignedPostGuards.spec.ts b/apps/judicial-system/backend/src/app/modules/file/test/limitedAccessFileController/createPresignedPostGuards.spec.ts index 310e6b9f70c2..fcb2e12d9efd 100644 --- a/apps/judicial-system/backend/src/app/modules/file/test/limitedAccessFileController/createPresignedPostGuards.spec.ts +++ b/apps/judicial-system/backend/src/app/modules/file/test/limitedAccessFileController/createPresignedPostGuards.spec.ts @@ -4,11 +4,7 @@ import { restrictionCases, } from '@island.is/judicial-system/types' -import { - CaseCompletedGuard, - CaseTypeGuard, - CaseWriteGuard, -} from '../../../case' +import { CaseTypeGuard, CaseWriteGuard } from '../../../case' import { LimitedAccessFileController } from '../../limitedAccessFile.controller' describe('LimitedAccessFileController - Create presigned post guards', () => { diff --git a/apps/judicial-system/backend/src/app/modules/institution/institution.model.ts b/apps/judicial-system/backend/src/app/modules/institution/institution.model.ts index be40cbdadab2..202c29f3f76d 100644 --- a/apps/judicial-system/backend/src/app/modules/institution/institution.model.ts +++ b/apps/judicial-system/backend/src/app/modules/institution/institution.model.ts @@ -62,4 +62,8 @@ export class Institution extends Model { @Column({ type: DataType.STRING, allowNull: true }) @ApiPropertyOptional({ type: String }) nationalId?: string + + @Column({ type: DataType.STRING, allowNull: true }) + @ApiPropertyOptional({ type: String }) + address?: string } diff --git a/apps/judicial-system/backend/src/app/modules/notification/internalNotification.service.ts b/apps/judicial-system/backend/src/app/modules/notification/internalNotification.service.ts index 2c6aa5278d6d..ebce85337c80 100644 --- a/apps/judicial-system/backend/src/app/modules/notification/internalNotification.service.ts +++ b/apps/judicial-system/backend/src/app/modules/notification/internalNotification.service.ts @@ -73,10 +73,9 @@ import { } from '../../formatters' import { notifications } from '../../messages' import { type Case, DateLog } from '../case' -import { ExplanatoryComment } from '../case/models/explanatoryComment.model' import { CourtService } from '../court' import { type Defendant, DefendantService } from '../defendant' -import { CaseEvent, EventService } from '../event' +import { EventService } from '../event' import { DeliverResponse } from './models/deliver.response' import { Notification, Recipient } from './models/notification.model' import { BaseNotificationService } from './baseNotification.service' diff --git a/apps/judicial-system/backend/src/app/modules/notification/notification.service.ts b/apps/judicial-system/backend/src/app/modules/notification/notification.service.ts index 8a9e78671c95..8103b1106f14 100644 --- a/apps/judicial-system/backend/src/app/modules/notification/notification.service.ts +++ b/apps/judicial-system/backend/src/app/modules/notification/notification.service.ts @@ -11,7 +11,7 @@ import { type User } from '@island.is/judicial-system/types' import { CaseState, NotificationType } from '@island.is/judicial-system/types' import { type Case } from '../case' -import { CaseEvent, EventService } from '../event' +import { EventService } from '../event' import { SendNotificationResponse } from './models/sendNotification.response' @Injectable() diff --git a/apps/judicial-system/digital-mailbox-api/src/app/modules/cases/models/case.response.ts b/apps/judicial-system/digital-mailbox-api/src/app/modules/cases/models/case.response.ts index b300b0074575..4f8159657381 100644 --- a/apps/judicial-system/digital-mailbox-api/src/app/modules/cases/models/case.response.ts +++ b/apps/judicial-system/digital-mailbox-api/src/app/modules/cases/models/case.response.ts @@ -11,6 +11,9 @@ class IndictmentCaseData { @ApiProperty({ type: String }) caseNumber!: string + @ApiProperty({ type: Boolean }) + acknowledged?: boolean + @ApiProperty({ type: [Groups] }) groups!: Groups[] } @@ -37,6 +40,7 @@ export class CaseResponse { caseId: res.id, data: { caseNumber: `${t.caseNumber} ${res.courtCaseNumber}`, + acknowledged: false, // TODO: Connect to real data groups: [ { label: t.defendant, diff --git a/apps/judicial-system/digital-mailbox-api/src/app/modules/cases/models/subpoena.response.ts b/apps/judicial-system/digital-mailbox-api/src/app/modules/cases/models/subpoena.response.ts index c6c022006edc..a39933df0f9f 100644 --- a/apps/judicial-system/digital-mailbox-api/src/app/modules/cases/models/subpoena.response.ts +++ b/apps/judicial-system/digital-mailbox-api/src/app/modules/cases/models/subpoena.response.ts @@ -25,6 +25,9 @@ class SubpoenaData { @ApiProperty({ type: () => String }) title!: string + @ApiProperty({ type: Boolean }) + acknowledged?: boolean + @ApiProperty({ type: () => [Groups] }) groups!: Groups[] } @@ -60,12 +63,13 @@ export class SubpoenaResponse { (dateLog) => dateLog.dateType === DateType.ARRAIGNMENT_DATE, ) const arraignmentDate = subpoenaDateLog?.date ?? '' - const subpoenaCreatedDate = subpoenaDateLog?.created ?? '' + const subpoenaCreatedDate = subpoenaDateLog?.created ?? '' //TODO: Change to subpoena created in RLS return { caseId: internalCase.id, data: { title: t.subpoena, + acknowledged: false, // TODO: Connect to real data groups: [ { label: `${t.caseNumber} ${internalCase.courtCaseNumber}`, diff --git a/apps/judicial-system/web/src/components/FormProvider/case.graphql b/apps/judicial-system/web/src/components/FormProvider/case.graphql index ccededfe5708..96bd2420103f 100644 --- a/apps/judicial-system/web/src/components/FormProvider/case.graphql +++ b/apps/judicial-system/web/src/components/FormProvider/case.graphql @@ -264,7 +264,7 @@ query Case($input: CaseQueryInput!) { } indictmentAppealDeadline indictmentVerdictViewedByAll - indictmentVerdictAppealDeadline + indictmentVerdictAppealDeadlineExpired indictmentDecision indictmentReviewDecision indictmentCompletedDate diff --git a/apps/judicial-system/web/src/components/IndictmentCaseFilesList/IndictmentCaseFilesList.spec.tsx b/apps/judicial-system/web/src/components/IndictmentCaseFilesList/IndictmentCaseFilesList.spec.tsx index fdb75baa1555..65ec62d224ed 100644 --- a/apps/judicial-system/web/src/components/IndictmentCaseFilesList/IndictmentCaseFilesList.spec.tsx +++ b/apps/judicial-system/web/src/components/IndictmentCaseFilesList/IndictmentCaseFilesList.spec.tsx @@ -1,7 +1,6 @@ import { render, screen } from '@testing-library/react' import { - CaseDecision, CaseFileCategory, CaseType, } from '@island.is/judicial-system-web/src/graphql/schema' diff --git a/apps/judicial-system/web/src/routes/PublicProsecutor/Indictments/Overview/Overview.tsx b/apps/judicial-system/web/src/routes/PublicProsecutor/Indictments/Overview/Overview.tsx index 9908cf73787a..4112b77909d0 100644 --- a/apps/judicial-system/web/src/routes/PublicProsecutor/Indictments/Overview/Overview.tsx +++ b/apps/judicial-system/web/src/routes/PublicProsecutor/Indictments/Overview/Overview.tsx @@ -5,7 +5,6 @@ import { useRouter } from 'next/router' import { Box, Option, Select, Text } from '@island.is/island-ui/core' import * as constants from '@island.is/judicial-system/consts' import { formatDate } from '@island.is/judicial-system/formatters' -import { isCompletedCase } from '@island.is/judicial-system/types' import { core, titles } from '@island.is/judicial-system-web/messages' import { BlueBox, @@ -26,6 +25,7 @@ import { } from '@island.is/judicial-system-web/src/components' import { useProsecutorSelectionUsersQuery } from '@island.is/judicial-system-web/src/components/ProsecutorSelection/prosecutorSelectionUsers.generated' import { + CaseIndictmentRulingDecision, Defendant, ServiceRequirement, } from '@island.is/judicial-system-web/src/graphql/schema' @@ -138,16 +138,24 @@ export const Overview = () => { { - setSelectedDefendant(defendant) - setModalVisible('DEFENDANT_VIEWS_VERDICT') - }, - icon: 'mailOpen', - isDisabled: isDefendantInfoActionButtonDisabled, - }} - displayAppealExpirationInfo={true} + defendantInfoActionButton={ + workingCase.indictmentRulingDecision === + CaseIndictmentRulingDecision.RULING + ? { + text: fm(strings.displayVerdict), + onClick: (defendant) => { + setSelectedDefendant(defendant) + setModalVisible('DEFENDANT_VIEWS_VERDICT') + }, + icon: 'mailOpen', + isDisabled: isDefendantInfoActionButtonDisabled, + } + : undefined + } + displayAppealExpirationInfo={ + workingCase.indictmentRulingDecision === + CaseIndictmentRulingDecision.RULING + } /> {lawsBroken.size > 0 && ( diff --git a/apps/judicial-system/web/src/routes/PublicProsecutor/Tables/CasesReviewed.tsx b/apps/judicial-system/web/src/routes/PublicProsecutor/Tables/CasesReviewed.tsx index 9110cf900731..6c483c7aedf2 100644 --- a/apps/judicial-system/web/src/routes/PublicProsecutor/Tables/CasesReviewed.tsx +++ b/apps/judicial-system/web/src/routes/PublicProsecutor/Tables/CasesReviewed.tsx @@ -41,16 +41,14 @@ const CasesReviewed: FC = ({ loading, cases }) => { } const getVerdictViewTag = (row: CaseListEntry) => { - const today = new Date() // TODO: Let the server determine if the deadline has passed - const deadline = new Date(row.indictmentVerdictAppealDeadline ?? '') const variant = !row.indictmentVerdictViewedByAll ? 'red' - : today > deadline + : row.indictmentVerdictAppealDeadlineExpired ? 'mint' : 'blue' const message = !row.indictmentVerdictViewedByAll ? strings.tagVerdictUnviewed - : today > deadline + : row.indictmentVerdictAppealDeadlineExpired ? strings.tagVerdictViewComplete : strings.tagVerdictViewOnDeadline return ( diff --git a/apps/judicial-system/web/src/routes/Shared/Cases/Cases.tsx b/apps/judicial-system/web/src/routes/Shared/Cases/Cases.tsx index ddc3c79a69f8..9d8571d7bb3b 100644 --- a/apps/judicial-system/web/src/routes/Shared/Cases/Cases.tsx +++ b/apps/judicial-system/web/src/routes/Shared/Cases/Cases.tsx @@ -147,7 +147,7 @@ export const Cases: FC = () => { const casesAwaitingAssignment = filterCases( (c) => isIndictmentCase(c.type) && - c.state !== CaseState.WAITING_FOR_CANCELLATION && + (c.state === CaseState.SUBMITTED || c.state === CaseState.RECEIVED) && !c.judge, ) diff --git a/apps/judicial-system/web/src/routes/Shared/Cases/cases.graphql b/apps/judicial-system/web/src/routes/Shared/Cases/cases.graphql index 6bfc8a4d391e..62c0d11b2d01 100644 --- a/apps/judicial-system/web/src/routes/Shared/Cases/cases.graphql +++ b/apps/judicial-system/web/src/routes/Shared/Cases/cases.graphql @@ -91,7 +91,7 @@ query Cases { indictmentReviewDecision indictmentAppealDeadline indictmentVerdictViewedByAll - indictmentVerdictAppealDeadline + indictmentVerdictAppealDeadlineExpired indictmentRulingDecision indictmentDecision indictmentCompletedDate diff --git a/apps/native/app/android/link-assets-manifest.json b/apps/native/app/android/link-assets-manifest.json index d9434d82b38c..96ae18202a1b 100644 --- a/apps/native/app/android/link-assets-manifest.json +++ b/apps/native/app/android/link-assets-manifest.json @@ -38,7 +38,7 @@ "sha1": "8bf01afac8fc3e072eb36667374393b8566a044d" }, { - "path": "assets/fonts/IBMPlexSans-Regular.ttf", + "path": "assets/fonts/IBMPlexSans.ttf", "sha1": "ad38f2d6870ca73533f36bdb958cd8083a49c1a8" }, { diff --git a/apps/native/app/ios/IslandApp/Info.plist b/apps/native/app/ios/IslandApp/Info.plist index b074b56c5c00..24d36d07c197 100644 --- a/apps/native/app/ios/IslandApp/Info.plist +++ b/apps/native/app/ios/IslandApp/Info.plist @@ -55,7 +55,7 @@ Used for license scanning capabilities UIAppFonts - IBMPlexSans-Regular.ttf + IBMPlexSans.ttf IBMPlexSans-Italic.ttf IBMPlexSans-Bold.ttf IBMPlexSans-BoldItalic.ttf diff --git a/apps/native/app/ios/link-assets-manifest.json b/apps/native/app/ios/link-assets-manifest.json index d9434d82b38c..96ae18202a1b 100644 --- a/apps/native/app/ios/link-assets-manifest.json +++ b/apps/native/app/ios/link-assets-manifest.json @@ -38,7 +38,7 @@ "sha1": "8bf01afac8fc3e072eb36667374393b8566a044d" }, { - "path": "assets/fonts/IBMPlexSans-Regular.ttf", + "path": "assets/fonts/IBMPlexSans.ttf", "sha1": "ad38f2d6870ca73533f36bdb958cd8083a49c1a8" }, { diff --git a/apps/native/app/src/assets/icons/options.png b/apps/native/app/src/assets/icons/options.png new file mode 100644 index 000000000000..9ba705edee80 Binary files /dev/null and b/apps/native/app/src/assets/icons/options.png differ diff --git a/apps/native/app/src/assets/icons/options@2x.png b/apps/native/app/src/assets/icons/options@2x.png new file mode 100644 index 000000000000..fd2ed7f0fbdd Binary files /dev/null and b/apps/native/app/src/assets/icons/options@2x.png differ diff --git a/apps/native/app/src/assets/icons/options@3x.png b/apps/native/app/src/assets/icons/options@3x.png new file mode 100644 index 000000000000..2a37809e7fff Binary files /dev/null and b/apps/native/app/src/assets/icons/options@3x.png differ diff --git a/apps/native/app/src/assets/illustrations/le-company-s2x.png b/apps/native/app/src/assets/illustrations/le-company-s2x.png deleted file mode 100644 index 3e8d220ad4fa..000000000000 Binary files a/apps/native/app/src/assets/illustrations/le-company-s2x.png and /dev/null differ diff --git a/apps/native/app/src/assets/illustrations/le-jobs-s2.png b/apps/native/app/src/assets/illustrations/le-jobs-s2.png new file mode 100644 index 000000000000..cb02a665b693 Binary files /dev/null and b/apps/native/app/src/assets/illustrations/le-jobs-s2.png differ diff --git a/apps/native/app/src/assets/illustrations/le-jobs-s2@2x.png b/apps/native/app/src/assets/illustrations/le-jobs-s2@2x.png new file mode 100644 index 000000000000..6c3bc11bf1c5 Binary files /dev/null and b/apps/native/app/src/assets/illustrations/le-jobs-s2@2x.png differ diff --git a/apps/native/app/src/assets/illustrations/le-jobs-s2@3x.png b/apps/native/app/src/assets/illustrations/le-jobs-s2@3x.png new file mode 100644 index 000000000000..c4c6cd122bc5 Binary files /dev/null and b/apps/native/app/src/assets/illustrations/le-jobs-s2@3x.png differ diff --git a/apps/native/app/src/assets/illustrations/le-moving-s4.png b/apps/native/app/src/assets/illustrations/le-moving-s4.png new file mode 100644 index 000000000000..04cca0599fde Binary files /dev/null and b/apps/native/app/src/assets/illustrations/le-moving-s4.png differ diff --git a/apps/native/app/src/assets/illustrations/le-moving-s4@2x.png b/apps/native/app/src/assets/illustrations/le-moving-s4@2x.png new file mode 100644 index 000000000000..6b8a1b39107c Binary files /dev/null and b/apps/native/app/src/assets/illustrations/le-moving-s4@2x.png differ diff --git a/apps/native/app/src/assets/illustrations/le-moving-s4@3x.png b/apps/native/app/src/assets/illustrations/le-moving-s4@3x.png new file mode 100644 index 000000000000..5333a6baeaea Binary files /dev/null and b/apps/native/app/src/assets/illustrations/le-moving-s4@3x.png differ diff --git a/apps/native/app/src/assets/illustrations/le-retirement-s3-large.png b/apps/native/app/src/assets/illustrations/le-retirement-s3-large.png new file mode 100644 index 000000000000..5ef4bc706329 Binary files /dev/null and b/apps/native/app/src/assets/illustrations/le-retirement-s3-large.png differ diff --git a/apps/native/app/src/assets/illustrations/le-retirement-s3-large@2x.png b/apps/native/app/src/assets/illustrations/le-retirement-s3-large@2x.png new file mode 100644 index 000000000000..cdc127b4e52d Binary files /dev/null and b/apps/native/app/src/assets/illustrations/le-retirement-s3-large@2x.png differ diff --git a/apps/native/app/src/assets/illustrations/le-retirement-s3-large@3x.png b/apps/native/app/src/assets/illustrations/le-retirement-s3-large@3x.png new file mode 100644 index 000000000000..5dd3d1ed214a Binary files /dev/null and b/apps/native/app/src/assets/illustrations/le-retirement-s3-large@3x.png differ diff --git a/apps/native/app/src/assets/illustrations/le-retirement-s3.png b/apps/native/app/src/assets/illustrations/le-retirement-s3.png index 5ef4bc706329..984968350684 100644 Binary files a/apps/native/app/src/assets/illustrations/le-retirement-s3.png and b/apps/native/app/src/assets/illustrations/le-retirement-s3.png differ diff --git a/apps/native/app/src/assets/illustrations/le-retirement-s3@2x.png b/apps/native/app/src/assets/illustrations/le-retirement-s3@2x.png index cdc127b4e52d..c3ee59030fa5 100644 Binary files a/apps/native/app/src/assets/illustrations/le-retirement-s3@2x.png and b/apps/native/app/src/assets/illustrations/le-retirement-s3@2x.png differ diff --git a/apps/native/app/src/assets/illustrations/le-retirement-s3@3x.png b/apps/native/app/src/assets/illustrations/le-retirement-s3@3x.png index 5dd3d1ed214a..09dfca6f41d2 100644 Binary files a/apps/native/app/src/assets/illustrations/le-retirement-s3@3x.png and b/apps/native/app/src/assets/illustrations/le-retirement-s3@3x.png differ diff --git a/apps/native/app/src/assets/illustrations/moving.png b/apps/native/app/src/assets/illustrations/moving.png deleted file mode 100644 index 23edfd42d93b..000000000000 Binary files a/apps/native/app/src/assets/illustrations/moving.png and /dev/null differ diff --git a/apps/native/app/src/assets/illustrations/moving@2x.png b/apps/native/app/src/assets/illustrations/moving@2x.png deleted file mode 100644 index 6b615268efaf..000000000000 Binary files a/apps/native/app/src/assets/illustrations/moving@2x.png and /dev/null differ diff --git a/apps/native/app/src/assets/illustrations/moving@3x.png b/apps/native/app/src/assets/illustrations/moving@3x.png deleted file mode 100644 index 080da89dde27..000000000000 Binary files a/apps/native/app/src/assets/illustrations/moving@3x.png and /dev/null differ diff --git a/apps/native/app/src/graphql/client.ts b/apps/native/app/src/graphql/client.ts index 7ac9267a0ae0..fbac7b3719a1 100644 --- a/apps/native/app/src/graphql/client.ts +++ b/apps/native/app/src/graphql/client.ts @@ -159,6 +159,9 @@ const cache = new InMemoryCache({ userNotifications: { merge: true, }, + getUserProfile: { + merge: true, + }, }, }, DocumentV2: { diff --git a/apps/native/app/src/messages/en.ts b/apps/native/app/src/messages/en.ts index 389df9de9346..5dbbb6010dc4 100644 --- a/apps/native/app/src/messages/en.ts +++ b/apps/native/app/src/messages/en.ts @@ -110,6 +110,7 @@ export const en: TranslatedMessages = { 'settings.security.appLockTimeoutLabel': 'App lock timeout', 'settings.security.appLockTimeoutDescription': 'Time until app lock will appear', + 'settings.security.appLockTimeoutSeconds': 'sec.', 'settings.about.groupTitle': 'About', 'settings.about.versionLabel': 'Version', 'settings.about.logoutLabel': 'Logout', @@ -168,7 +169,6 @@ export const en: TranslatedMessages = { 'home.screenTitle': 'Overview', 'home.applicationsStatus': 'Applications', 'home.allApplications': 'Digital applications', - 'home.inbox': 'Latest in inbox', 'home.welcomeText': 'Hi', 'home.goodDay': 'Good day,', 'home.onboardingModule.card1': @@ -184,6 +184,18 @@ export const en: TranslatedMessages = { 'home.vehicleModule.button': 'My vehicles', 'button.seeAll': 'See all', + // home options + 'homeOptions.screenTitle': 'Home screen', + 'homeOptions.heading.title': 'Configure home screen', + 'homeOptions.heading.subtitle': + 'Here you can configure what is displayed on the home screen.', + 'homeOptions.graphic': 'Display graphic', + 'homeOptions.inbox': 'Latest in inbox', + 'homeOptions.licenses': 'Licenses', + 'homeOptions.applications': 'Applications', + 'homeOptions.vehicles': 'Vehicles', + 'homeOptions.airDiscount': 'Air discount scheme', + // inbox 'inbox.screenTitle': 'Inbox', 'inbox.bottomTabText': 'Inbox', @@ -490,8 +502,8 @@ export const en: TranslatedMessages = { 'edit.phone.inputlabel': 'Phone number', 'edit.phone.button': 'Save', 'edit.phone.button.empty': 'Save empty', - 'edit.phone.button.error': 'Error', - 'edit.phone.button.errorMessage': 'Could not send verification code', + 'edit.phone.error': 'Error', + 'edit.phone.errorMessage': 'Could not send verification code', // edit email 'edit.email.screenTitle': 'Edit Email', @@ -499,8 +511,8 @@ export const en: TranslatedMessages = { 'edit.email.inputlabel': 'Email', 'edit.email.button': 'Save', 'edit.email.button.empty': 'Save empty', - 'edit.email.button.error': 'Error', - 'edit.email.button.errorMessage': 'Could not send verification code', + 'edit.email.error': 'Error', + 'edit.email.errorMessage': 'Could not send verification code', // edit bank info 'edit.bankinfo.screenTitle': 'Edit Bank Info', @@ -510,6 +522,8 @@ export const en: TranslatedMessages = { 'edit.bankinfo.inputlabel.book': 'Hb.', 'edit.bankinfo.inputlabel.number': 'Account number', 'edit.bankinfo.button': 'Save', + 'edit.bankinfo.error': 'Error', + 'edit.bankinfo.errorMessage': 'Could not save bank info', // edit confirm 'edit.confirm.screenTitle': 'Confirm edit', @@ -523,6 +537,8 @@ export const en: TranslatedMessages = { 'edit.confirm.inputlabel': 'Security number', 'edit.cancel.button': 'Cancel', 'edit.confirm.button': 'Confirm', + 'edit.confirm.error': 'Error', + 'edit.confirm.errorMessage': 'Could not update information', // air discount 'airDiscount.screenTitle': 'Air discount scheme', diff --git a/apps/native/app/src/messages/is.ts b/apps/native/app/src/messages/is.ts index 502f6f0ebc57..ac89579c2ed5 100644 --- a/apps/native/app/src/messages/is.ts +++ b/apps/native/app/src/messages/is.ts @@ -109,6 +109,7 @@ export const is = { 'settings.security.appLockTimeoutLabel': 'Biðtími skjálæsingar', 'settings.security.appLockTimeoutDescription': 'Tíminn þar til skjálæsing fer í gang', + 'settings.security.appLockTimeoutSeconds': 'sek.', 'settings.about.groupTitle': 'Um appið', 'settings.about.versionLabel': 'Útgáfa', 'settings.about.logoutLabel': 'Útskrá', @@ -169,7 +170,6 @@ export const is = { 'home.applicationsStatus': 'Staða umsókna', 'home.allApplications': 'Stafrænar umsóknir', 'home.welcomeText': 'Hæ', - 'home.inbox': 'Nýjast í pósthólfinu', 'home.goodDay': 'Góðan dag,', 'home.onboardingModule.card1': 'Nú sérð þú upplýsingar um ökutæki, fasteignir og fjölskyldu þína í appinu til viðbótar við skjöl og skírteini.', @@ -184,6 +184,18 @@ export const is = { 'home.vehicleModule.button': 'Mín ökutæki', 'button.seeAll': 'Sjá allt', + // home options + 'homeOptions.screenTitle': 'Heimaskjár', + 'homeOptions.heading.title': 'Stilla heimaskjá', + 'homeOptions.heading.subtitle': + 'Hér er hægt að stilla hvað birtist á heimaskjá.', + 'homeOptions.graphic': 'Birta myndskreytingu', + 'homeOptions.inbox': 'Nýjast í pósthólfinu', + 'homeOptions.licenses': 'Skírteini', + 'homeOptions.applications': 'Umsóknir', + 'homeOptions.vehicles': 'Ökutæki', + 'homeOptions.airDiscount': 'Loftbrú', + // inbox 'inbox.screenTitle': 'Pósthólf', 'inbox.bottomTabText': 'Pósthólf', @@ -490,8 +502,8 @@ export const is = { 'edit.phone.inputlabel': 'Símanúmer', 'edit.phone.button': 'Vista', 'edit.phone.button.empty': 'Vista tómt', - 'edit.phone.button.error': 'Villa', - 'edit.phone.button.errorMessage': 'Gat ekki sent staðfestingarkóða', + 'edit.phone.error': 'Villa', + 'edit.phone.errorMessage': 'Gat ekki sent staðfestingarkóða', // edit email 'edit.email.screenTitle': 'Breyta Netfangi', @@ -499,8 +511,8 @@ export const is = { 'edit.email.inputlabel': 'Netfang', 'edit.email.button': 'Vista', 'edit.email.button.empty': 'Vista tómt', - 'edit.email.button.error': 'Villa', - 'edit.email.button.errorMessage': 'Gat ekki sent staðfestingarkóða', + 'edit.email.error': 'Villa', + 'edit.email.errorMessage': 'Gat ekki sent staðfestingarkóða', // edit bank info 'edit.bankinfo.screenTitle': 'Breyta banka upplýsingum', @@ -510,6 +522,8 @@ export const is = { 'edit.bankinfo.inputlabel.book': 'Hb.', 'edit.bankinfo.inputlabel.number': 'Reikningsnúmer', 'edit.bankinfo.button': 'Vista', + 'edit.bankinfo.error': 'Villa', + 'edit.bankinfo.errorMessage': 'Gat ekki vistað reikningsupplýsingar', // edit confirm 'edit.confirm.screenTitle': 'Staðfesta aðgerð', @@ -523,6 +537,8 @@ export const is = { 'edit.confirm.inputlabel': 'Öryggisnúmer', 'edit.cancel.button': 'Hætta við', 'edit.confirm.button': 'Staðfesta', + 'edit.confirm.error': 'Villa', + 'edit.confirm.errorMessage': 'Gat ekki uppfært upplýsingar', // air discount 'airDiscount.screenTitle': 'Loftbrú', diff --git a/apps/native/app/src/screens/applications/applications.tsx b/apps/native/app/src/screens/applications/applications.tsx index c4cca3365bcc..fe5117c18e28 100644 --- a/apps/native/app/src/screens/applications/applications.tsx +++ b/apps/native/app/src/screens/applications/applications.tsx @@ -197,11 +197,7 @@ export const ApplicationsScreen: NavigationFunctionComponent = ({ ListHeaderComponent={ theme.spacing[2]}px; +` + +interface AirDiscountModuleProps { + data: GetAirDiscountQuery | undefined + loading: boolean + error?: ApolloError | undefined +} + +const validateAirDiscountInitialData = ({ + data, + loading, +}: { + data: GetAirDiscountQuery | undefined + loading: boolean +}) => { + if (loading) { + return true + } + + const noRights = + data?.airDiscountSchemeDiscounts?.filter( + (item) => item.user.fund?.credit === 0 && item.user.fund.used === 0, + ).length === data?.airDiscountSchemeDiscounts?.length + + // Only show widget initially if the user has air discount rights + if (!noRights) { + return true + } + + return false +} + +const AirDiscountModule = React.memo( + ({ data, loading, error }: AirDiscountModuleProps) => { + const theme = useTheme() + const intl = useIntl() + + if (error && !data) { + return null + } + + const noRights = + data?.airDiscountSchemeDiscounts?.filter( + (item) => item.user.fund?.credit === 0 && item.user.fund.used === 0, + ).length === data?.airDiscountSchemeDiscounts?.length + + const discounts = data?.airDiscountSchemeDiscounts?.filter( + ({ user }) => !(user.fund?.used === 0 && user.fund.credit === 0), + ) + + const count = discounts?.length ?? 0 + + const items = discounts?.slice(0, 3).map(({ discountCode, user }) => ( + 1 + ? { + width: screenWidth - theme.spacing[2] * 4, + marginLeft: theme.spacing[2], + } + : { + width: '100%', + } + } + /> + )) + + return ( + + + navigateTo(`/air-discount`)} + > + navigateTo('/air-discount')} + style={{ + flexDirection: 'row', + alignItems: 'center', + }} + > + + + + + + ) + } + > + + + + {loading && !data ? ( + + ) : ( + <> + {noRights && ( + + } + link={null} + /> + )} + {count === 1 && items} + {count >= 2 && {items}} + + )} + + + ) + }, +) + +export { + AirDiscountModule, + useGetAirDiscountQuery, + validateAirDiscountInitialData, +} diff --git a/apps/native/app/src/screens/home/applications-module.tsx b/apps/native/app/src/screens/home/applications-module.tsx index fac62d47f682..7bc79248ce9b 100644 --- a/apps/native/app/src/screens/home/applications-module.tsx +++ b/apps/native/app/src/screens/home/applications-module.tsx @@ -13,35 +13,65 @@ import { import React from 'react' import { useIntl } from 'react-intl' import { Image, SafeAreaView, TouchableOpacity } from 'react-native' +import { useTheme } from 'styled-components' +import { ApolloError } from '@apollo/client' + import leJobss3 from '../../assets/illustrations/le-jobs-s3.png' -import { Application } from '../../graphql/types/schema' +import { + ListApplicationsQuery, + useListApplicationsQuery, +} from '../../graphql/types/schema' import { navigateTo } from '../../lib/deep-linking' import { useBrowser } from '../../lib/use-browser' import { getApplicationUrl } from '../../utils/applications-utils' -import { useTheme } from 'styled-components' +import { screenWidth } from '../../utils/dimensions' interface ApplicationsModuleProps { - applications: Application[] + data: ListApplicationsQuery | undefined loading: boolean + error?: ApolloError | undefined componentId: string hideAction?: boolean hideSeeAllButton?: boolean } -export const ApplicationsModule = React.memo( +const validateApplicationsInitialData = ({ + data, + loading, +}: { + data: ListApplicationsQuery | undefined + loading: boolean +}) => { + if (loading) { + return true + } + // Only show widget initially if there are applications + if (data?.applicationApplications?.length !== 0) { + return true + } + return false +} + +const ApplicationsModule = React.memo( ({ - applications, + data, loading, + error, componentId, hideAction, hideSeeAllButton = false, }: ApplicationsModuleProps) => { const intl = useIntl() const theme = useTheme() + const applications = data?.applicationApplications ?? [] const count = applications.length const { openBrowser } = useBrowser() - const children = applications.slice(0, 5).map((application) => ( + if (error && !data) { + return null + } + + const items = applications.slice(0, 3).map((application) => ( 1 ? { - width: 283, + width: screenWidth - theme.spacing[2] * 4, marginLeft: 16, } : {} @@ -80,10 +110,12 @@ export const ApplicationsModule = React.memo( - navigateTo(`/applications`)}> + navigateTo(`/applications`)} + > - + {intl.formatMessage({ id: 'button.seeAll' })} @@ -105,7 +137,7 @@ export const ApplicationsModule = React.memo( {intl.formatMessage({ id: 'home.applicationsStatus' })} - {loading ? ( + {loading && !data ? ( ) : ( <> @@ -136,11 +168,17 @@ export const ApplicationsModule = React.memo( } /> )} - {count === 1 && children.slice(0, 1)} - {count >= 2 && {children}} + {count === 1 && items} + {count >= 2 && {items}} )} ) }, ) + +export { + ApplicationsModule, + useListApplicationsQuery, + validateApplicationsInitialData, +} diff --git a/apps/native/app/src/screens/home/hello-module.tsx b/apps/native/app/src/screens/home/hello-module.tsx index 4c2212f5952d..dbdef1720410 100644 --- a/apps/native/app/src/screens/home/hello-module.tsx +++ b/apps/native/app/src/screens/home/hello-module.tsx @@ -20,7 +20,7 @@ const ImageWrapper = styled.View` export const HelloModule = React.memo(() => { const theme = useTheme() - const { dismissed } = usePreferencesStore() + const { dismissed, graphicWidgetEnabled } = usePreferencesStore() const { userInfo } = useAuthStore() const [imageSrc, setImageSrc] = React.useState(undefined) @@ -98,7 +98,7 @@ export const HelloModule = React.memo(() => { {userInfo?.name} - {imageSrc && ( + {graphicWidgetEnabled && imageSrc && ( {loading ? ( ({ + topBar: { + title: { + text: intl.formatMessage({ id: 'homeOptions.screenTitle' }), + }, + }, + bottomTabs: { + visible: false, + drawBehind: true, + }, + })) + +export const HomeOptionsScreen: NavigationFunctionComponent = ({ + componentId, +}) => { + useNavigationOptions(componentId) + + const intl = useIntl() + const theme = useTheme() + const vehiclesWidgetEnabled = usePreferencesStore( + ({ vehiclesWidgetEnabled }) => vehiclesWidgetEnabled, + ) + const inboxWidgetEnabled = usePreferencesStore( + ({ inboxWidgetEnabled }) => inboxWidgetEnabled, + ) + const licensesWidgetEnabled = usePreferencesStore( + ({ licensesWidgetEnabled }) => licensesWidgetEnabled, + ) + const applicationsWidgetEnabled = usePreferencesStore( + ({ applicationsWidgetEnabled }) => applicationsWidgetEnabled, + ) + const airDiscountWidgetEnabled = usePreferencesStore( + ({ airDiscountWidgetEnabled }) => airDiscountWidgetEnabled, + ) + const graphicWidgetEnabled = usePreferencesStore( + ({ graphicWidgetEnabled }) => graphicWidgetEnabled, + ) + return ( + + + + + + + + + + { + preferencesStore.setState({ + graphicWidgetEnabled: value, + }) + }} + value={graphicWidgetEnabled} + thumbColor={Platform.select({ android: theme.color.dark100 })} + trackColor={{ + false: theme.color.dark200, + true: theme.color.blue400, + }} + /> + } + /> + { + preferencesStore.setState({ + inboxWidgetEnabled: value, + }) + }} + value={inboxWidgetEnabled} + thumbColor={Platform.select({ android: theme.color.dark100 })} + trackColor={{ + false: theme.color.dark200, + true: theme.color.blue400, + }} + /> + } + /> + { + preferencesStore.setState({ + licensesWidgetEnabled: value, + }) + }} + value={licensesWidgetEnabled} + thumbColor={Platform.select({ android: theme.color.dark100 })} + trackColor={{ + false: theme.color.dark200, + true: theme.color.blue400, + }} + /> + } + /> + { + preferencesStore.setState({ + applicationsWidgetEnabled: value, + }) + }} + value={applicationsWidgetEnabled} + thumbColor={Platform.select({ android: theme.color.dark100 })} + trackColor={{ + false: theme.color.dark200, + true: theme.color.blue400, + }} + /> + } + /> + { + preferencesStore.setState({ + vehiclesWidgetEnabled: value, + }) + }} + value={vehiclesWidgetEnabled} + thumbColor={Platform.select({ android: theme.color.dark100 })} + trackColor={{ + false: theme.color.dark200, + true: theme.color.blue400, + }} + /> + } + /> + { + preferencesStore.setState({ + airDiscountWidgetEnabled: value, + }) + }} + value={airDiscountWidgetEnabled} + thumbColor={Platform.select({ android: theme.color.dark100 })} + trackColor={{ + false: theme.color.dark200, + true: theme.color.blue400, + }} + /> + } + /> + + ) +} + +HomeOptionsScreen.options = getNavigationOptions diff --git a/apps/native/app/src/screens/home/home.tsx b/apps/native/app/src/screens/home/home.tsx index 40fa0f020189..d9d375c872b3 100644 --- a/apps/native/app/src/screens/home/home.tsx +++ b/apps/native/app/src/screens/home/home.tsx @@ -16,11 +16,7 @@ import { import CodePush from 'react-native-code-push' import { NavigationFunctionComponent } from 'react-native-navigation' import { BottomTabsIndicator } from '../../components/bottom-tabs-indicator/bottom-tabs-indicator' -import { - Application, - useListApplicationsQuery, - useListDocumentsQuery, -} from '../../graphql/types/schema' + import { createNavigationOptionHooks } from '../../hooks/create-navigation-option-hooks' import { useAndroidNotificationPermission } from '../../hooks/use-android-notification-permission' import { useConnectivityIndicator } from '../../hooks/use-connectivity-indicator' @@ -30,10 +26,37 @@ import { isAndroid } from '../../utils/devices' import { getRightButtons } from '../../utils/get-main-root' import { handleInitialNotification } from '../../utils/lifecycle/setup-notifications' import { testIDs } from '../../utils/test-ids' -import { ApplicationsModule } from './applications-module' +import { + ApplicationsModule, + useListApplicationsQuery, + validateApplicationsInitialData, +} from './applications-module' import { HelloModule } from './hello-module' -import { InboxModule } from './inbox-module' +import { + InboxModule, + useListDocumentsQuery, + validateInboxInitialData, +} from './inbox-module' import { OnboardingModule } from './onboarding-module' +import { + VehiclesModule, + useListVehiclesQuery, + validateVehiclesInitialData, +} from './vehicles-module' +import { + preferencesStore, + usePreferencesStore, +} from '../../stores/preferences-store' +import { + AirDiscountModule, + useGetAirDiscountQuery, + validateAirDiscountInitialData, +} from './air-discount-module' +import { + LicensesModule, + validateLicensesInitialData, + useGetLicensesData, +} from './licenses-module' interface ListItem { id: string @@ -50,7 +73,10 @@ const { useNavigationOptions, getNavigationOptions } = (theme, intl, initialized) => ({ topBar: { rightButtons: initialized - ? getRightButtons({ icons: ['notifications'], theme: theme as any }) + ? getRightButtons({ + icons: ['notifications', 'options'], + theme: theme as any, + }) : [], }, bottomTab: { @@ -99,19 +125,123 @@ export const MainHomeScreen: NavigationFunctionComponent = ({ useAndroidNotificationPermission() const syncToken = useNotificationsStore(({ syncToken }) => syncToken) const checkUnseen = useNotificationsStore(({ checkUnseen }) => checkUnseen) + const getAndSetLocale = usePreferencesStore( + ({ getAndSetLocale }) => getAndSetLocale, + ) const [refetching, setRefetching] = useState(false) const flatListRef = useRef(null) const ui = useUiStore() + const vehiclesWidgetEnabled = usePreferencesStore( + ({ vehiclesWidgetEnabled }) => vehiclesWidgetEnabled, + ) + const inboxWidgetEnabled = usePreferencesStore( + ({ inboxWidgetEnabled }) => inboxWidgetEnabled, + ) + const licensesWidgetEnabled = usePreferencesStore( + ({ licensesWidgetEnabled }) => licensesWidgetEnabled, + ) + const applicationsWidgetEnabled = usePreferencesStore( + ({ applicationsWidgetEnabled }) => applicationsWidgetEnabled, + ) + const airDiscountWidgetEnabled = usePreferencesStore( + ({ airDiscountWidgetEnabled }) => airDiscountWidgetEnabled, + ) + const widgetsInitialised = usePreferencesStore( + ({ widgetsInitialised }) => widgetsInitialised, + ) + + const applicationsRes = useListApplicationsQuery({ + skip: !applicationsWidgetEnabled, + }) - const applicationsRes = useListApplicationsQuery() const inboxRes = useListDocumentsQuery({ - variables: { input: { page: 1, pageSize: 3 } }, + variables: { + input: { page: 1, pageSize: 3 }, + }, + skip: !inboxWidgetEnabled, + }) + + const licensesRes = useGetLicensesData({ + skipFetching: !licensesWidgetEnabled, }) + const airDiscountRes = useGetAirDiscountQuery({ + fetchPolicy: 'network-only', + skip: !airDiscountWidgetEnabled, + }) + + const vehiclesRes = useListVehiclesQuery({ + variables: { + input: { + page: 1, + pageSize: 15, + showDeregeristered: false, + showHistory: false, + }, + }, + skip: !vehiclesWidgetEnabled, + }) + + useEffect(() => { + // If widgets have not been initialized, validate data and set state accordingly + if (!widgetsInitialised) { + const shouldShowInboxWidget = validateInboxInitialData({ ...inboxRes }) + + const shouldShowLicensesWidget = validateLicensesInitialData({ + ...licensesRes, + }) + + const shouldShowApplicationsWidget = validateApplicationsInitialData({ + ...applicationsRes, + }) + + const shouldShowVehiclesWidget = validateVehiclesInitialData({ + ...vehiclesRes, + }) + + const shouldShowAirDiscountWidget = validateAirDiscountInitialData({ + ...airDiscountRes, + }) + + preferencesStore.setState({ + inboxWidgetEnabled: shouldShowInboxWidget, + licensesWidgetEnabled: shouldShowLicensesWidget, + applicationsWidgetEnabled: shouldShowApplicationsWidget, + vehiclesWidgetEnabled: shouldShowVehiclesWidget, + airDiscountWidgetEnabled: shouldShowAirDiscountWidget, + }) + + // Don't set initialized state if any of the queries are still loading + if ( + licensesRes.loading || + applicationsRes.loading || + inboxRes.loading || + airDiscountRes.loading || + vehiclesRes.loading + ) { + return + } + + preferencesStore.setState({ widgetsInitialised: true }) + } + }, [ + licensesRes.loading, + applicationsRes.loading, + inboxRes.loading, + airDiscountRes.loading, + vehiclesRes.loading, + ]) + useConnectivityIndicator({ componentId, - rightButtons: getRightButtons({ icons: ['notifications'] }), - queryResult: applicationsRes, + rightButtons: getRightButtons({ icons: ['notifications', 'options'] }), + queryResult: [ + applicationsRes, + inboxRes, + licensesRes, + airDiscountRes, + vehiclesRes, + ], refetching, }) @@ -126,6 +256,8 @@ export const MainHomeScreen: NavigationFunctionComponent = ({ // Sync push tokens and unseen notifications syncToken() checkUnseen() + // Get user locale from server + getAndSetLocale() // Handle initial notification handleInitialNotification() @@ -135,13 +267,33 @@ export const MainHomeScreen: NavigationFunctionComponent = ({ setRefetching(true) try { - await applicationsRes.refetch() + const promises = [ + applicationsWidgetEnabled && applicationsRes.refetch(), + inboxWidgetEnabled && inboxRes.refetch(), + licensesWidgetEnabled && licensesRes.refetch(), + licensesWidgetEnabled && licensesRes.refetchPassport(), + airDiscountWidgetEnabled && airDiscountRes.refetch(), + vehiclesWidgetEnabled && vehiclesRes.refetch(), + ].filter(Boolean) + + await Promise.all(promises) } catch (err) { // noop } setRefetching(false) - }, [applicationsRes]) + }, [ + applicationsRes, + inboxRes, + licensesRes, + airDiscountRes, + vehiclesRes, + vehiclesWidgetEnabled, + airDiscountWidgetEnabled, + applicationsWidgetEnabled, + licensesWidgetEnabled, + inboxWidgetEnabled, + ]) if (!ui.initializedApp) { return null @@ -156,27 +308,34 @@ export const MainHomeScreen: NavigationFunctionComponent = ({ id: 'onboarding', component: , }, + { id: 'inbox', - component: ( - - ), + component: inboxWidgetEnabled ? : null, + }, + { + id: 'licenses', + component: licensesWidgetEnabled ? ( + + ) : null, }, { id: 'applications', - component: ( - - ), + component: applicationsWidgetEnabled ? ( + + ) : null, + }, + { + id: 'vehicles', + component: vehiclesWidgetEnabled ? ( + + ) : null, + }, + { + id: 'air-discount', + component: airDiscountWidgetEnabled ? ( + + ) : null, }, ].filter(Boolean) as Array<{ id: string diff --git a/apps/native/app/src/screens/home/inbox-module.tsx b/apps/native/app/src/screens/home/inbox-module.tsx index 7f235ebed265..2d2c32bae705 100644 --- a/apps/native/app/src/screens/home/inbox-module.tsx +++ b/apps/native/app/src/screens/home/inbox-module.tsx @@ -10,10 +10,14 @@ import React from 'react' import { FormattedMessage, useIntl } from 'react-intl' import { Image, SafeAreaView, TouchableOpacity } from 'react-native' import styled, { useTheme } from 'styled-components/native' +import { ApolloError } from '@apollo/client' import leCompanys3 from '../../assets/illustrations/le-company-s3.png' import { navigateTo } from '../../lib/deep-linking' -import { DocumentV2 } from '../../graphql/types/schema' +import { + ListDocumentsQuery, + useListDocumentsQuery, +} from '../../graphql/types/schema' import { useOrganizationsStore } from '../../stores/organizations-store' import { InboxCard } from '@ui/lib/card/inbox-card' @@ -26,90 +30,116 @@ const EmptyWrapper = styled.View` ` interface InboxModuleProps { - documents: DocumentV2[] + data: ListDocumentsQuery | undefined + loading: boolean + error?: ApolloError | undefined +} + +const validateInboxInitialData = ({ + data, + loading, +}: { + data: ListDocumentsQuery | undefined loading: boolean +}) => { + if (loading) { + return true + } + + // Only show widget initially if there are documents in the inbox + if (data?.documentsV2?.data.length) { + return true + } + + return false } -export const InboxModule = React.memo( - ({ documents, loading }: InboxModuleProps) => { - const theme = useTheme() - const intl = useIntl() - const { getOrganizationLogoUrl } = useOrganizationsStore() +const InboxModule = React.memo(({ data, loading, error }: InboxModuleProps) => { + const theme = useTheme() + const intl = useIntl() + const { getOrganizationLogoUrl } = useOrganizationsStore() - return ( - - - navigateTo(`/inbox`)} - style={{ marginHorizontal: theme.spacing[2] }} + if (error && !data) { + return null + } + + const documents = data?.documentsV2?.data ?? [] + + return ( + + + navigateTo(`/inbox`)} + style={{ marginHorizontal: theme.spacing[2] }} + > + navigateTo('/inbox')} + style={{ + flexDirection: 'row', + alignItems: 'center', + }} + > + + + + + + ) + } > - navigateTo('/inbox')} - style={{ - flexDirection: 'row', - alignItems: 'center', - }} - > - - - - - - ) + + + + {loading && !data ? ( + Array.from({ length: 3 }) + .map((_, id) => ({ + id: String(id), + })) + .map((item) => ) + ) : documents.length === 0 ? ( + + + } + link={null} + /> + + ) : ( + documents.map((item, index) => ( + - - - - {loading ? ( - Array.from({ length: 3 }) - .map((_, id) => ({ - id: String(id), - })) - .map((item) => ) - ) : documents.length === 0 ? ( - - - } - link={null} - /> - - ) : ( - documents.map((item, index) => ( - - navigateTo(`/inbox/${item.id}`, { - title: item.sender.name, - }) - } - /> - )) - )} - - - ) - }, -) + onPress={() => + navigateTo(`/inbox/${item.id}`, { + title: item.sender.name, + }) + } + /> + )) + )} + + + ) +}) + +export { InboxModule, useListDocumentsQuery, validateInboxInitialData } diff --git a/apps/native/app/src/screens/home/licenses-module.tsx b/apps/native/app/src/screens/home/licenses-module.tsx new file mode 100644 index 000000000000..0e282e3221f0 --- /dev/null +++ b/apps/native/app/src/screens/home/licenses-module.tsx @@ -0,0 +1,213 @@ +import { + Typography, + Heading, + ChevronRight, + ViewPager, + EmptyCard, + GeneralCardSkeleton, +} from '@ui' + +import React from 'react' +import { FormattedMessage, useIntl } from 'react-intl' +import { Image, SafeAreaView, TouchableOpacity } from 'react-native' +import styled, { useTheme } from 'styled-components/native' +import { ApolloError } from '@apollo/client' + +import { navigateTo } from '../../lib/deep-linking' +import { + GenericLicenseType, + GetIdentityDocumentQuery, + ListLicensesQuery, + useGetIdentityDocumentQuery, + useListLicensesQuery, +} from '../../graphql/types/schema' +import illustrationSrc from '../../assets/illustrations/le-retirement-s3.png' +import { WalletItem } from '../wallet/components/wallet-item' +import { screenWidth } from '../../utils/dimensions' + +const Host = styled.View` + margin-bottom: ${({ theme }) => theme.spacing[2]}px; +` + +interface LicenseModuleProps { + data: ListLicensesQuery | undefined + dataPassport: GetIdentityDocumentQuery | undefined + loading: boolean + loadingPassport: boolean + error?: ApolloError | undefined +} + +const validateLicensesInitialData = ({ + data, + loading, +}: { + data: ListLicensesQuery | undefined + loading: boolean +}) => { + if (loading) { + return true + } + // We only want to show the widget for the first time if the user has driving license + if ( + data?.genericLicenses?.some( + (license) => license.license.type === GenericLicenseType.DriversLicense, + ) + ) { + return true + } + + return false +} + +const useGetLicensesData = ({ skipFetching }: { skipFetching: boolean }) => { + // Query list of licenses + const { data, loading, error, refetch } = useListLicensesQuery({ + variables: { + input: { + includedTypes: [ + GenericLicenseType.DriversLicense, + GenericLicenseType.AdrLicense, + GenericLicenseType.MachineLicense, + GenericLicenseType.FirearmLicense, + GenericLicenseType.DisabilityLicense, + GenericLicenseType.PCard, + GenericLicenseType.Ehic, + GenericLicenseType.HuntingLicense, + ], + }, + }, + skip: skipFetching, + }) + + // Additional licenses + const { + data: dataPassport, + loading: loadingPassport, + error: errorPassport, + refetch: refetchPassport, + } = useGetIdentityDocumentQuery({ skip: skipFetching }) + + return { + data, + dataPassport, + loading, + loadingPassport, + error, + errorPassport, + refetch, + refetchPassport, + } +} + +const LicensesModule = React.memo( + ({ + data, + dataPassport, + loading, + loadingPassport, + error, + }: LicenseModuleProps) => { + const theme = useTheme() + const intl = useIntl() + + if (error && !data) { + return null + } + + const licenses = data?.genericLicenses + const passport = dataPassport?.getIdentityDocument + + const count = licenses?.length ?? 0 + (passport ? 1 : 0) + + const allLicenses = [...(licenses ?? []), ...(passport ?? [])] + + const items = allLicenses + .filter( + (license) => + license.__typename === 'GenericUserLicense' || + license.__typename === 'IdentityDocumentModel', + ) + ?.slice(0, 3) + .map((item, index) => ( + 1 + ? { + width: screenWidth - theme.spacing[2] * 3, + paddingLeft: theme.spacing[2], + paddingRight: 0, + } + : { + width: '100%', + paddingLeft: 0, + paddingRight: 0, + } + } + noPadding + /> + )) + + return ( + + + navigateTo(`/wallet`)} + > + navigateTo('/wallet')} + style={{ + flexDirection: 'row', + alignItems: 'center', + }} + > + + + + + + ) + } + > + + + + {(loading || loadingPassport) && !data ? ( + + ) : ( + <> + {count === 0 && ( + + } + link={null} + /> + )} + {count === 1 && items} + {count >= 2 && {items}} + + )} + + + ) + }, +) + +export { LicensesModule, validateLicensesInitialData, useGetLicensesData } diff --git a/apps/native/app/src/screens/home/onboarding-module.tsx b/apps/native/app/src/screens/home/onboarding-module.tsx index d6358b8768a6..7e09052f6f30 100644 --- a/apps/native/app/src/screens/home/onboarding-module.tsx +++ b/apps/native/app/src/screens/home/onboarding-module.tsx @@ -4,7 +4,7 @@ import { SafeAreaView, TouchableOpacity } from 'react-native' import { useTheme } from 'styled-components/native' import illustration1 from '../../assets/illustrations/digital-services-m3.png' import illustration3 from '../../assets/illustrations/le-company-s2.png' -import illustration2 from '../../assets/illustrations/le-retirement-s3.png' +import illustration2 from '../../assets/illustrations/le-retirement-s3-large.png' import illustration4 from '../../assets/illustrations/le_jobs_s5.png' import { useIntl } from 'react-intl' diff --git a/apps/native/app/src/screens/home/vehicles-module.tsx b/apps/native/app/src/screens/home/vehicles-module.tsx new file mode 100644 index 000000000000..16d5f1a3b4c2 --- /dev/null +++ b/apps/native/app/src/screens/home/vehicles-module.tsx @@ -0,0 +1,170 @@ +import { + Typography, + Heading, + ChevronRight, + ViewPager, + EmptyCard, + GeneralCardSkeleton, +} from '@ui' + +import React, { useMemo } from 'react' +import { FormattedMessage, useIntl } from 'react-intl' +import { Image, SafeAreaView, TouchableOpacity } from 'react-native' +import styled, { useTheme } from 'styled-components/native' +import { ApolloError } from '@apollo/client' + +import illustrationSrc from '../../assets/illustrations/le-moving-s4.png' +import { navigateTo } from '../../lib/deep-linking' +import { VehicleItem } from '../vehicles/components/vehicle-item' +import { + ListVehiclesQuery, + useListVehiclesQuery, +} from '../../graphql/types/schema' +import { screenWidth } from '../../utils/dimensions' + +const Host = styled.View` + margin-bottom: ${({ theme }) => theme.spacing[2]}px; +` + +const validateVehiclesInitialData = ({ + data, + loading, +}: { + data: ListVehiclesQuery | undefined + loading: boolean +}) => { + if (loading) { + return true + } + // Only show widget initially if there are vehicles that require mileage registration + if ( + data?.vehiclesList?.vehicleList?.some( + (vehicle) => vehicle.requiresMileageRegistration, + ) + ) { + return true + } + + return false +} + +interface VehiclesModuleProps { + data: ListVehiclesQuery | undefined + loading: boolean + error?: ApolloError | undefined +} + +const VehiclesModule = React.memo( + ({ data, loading, error }: VehiclesModuleProps) => { + const theme = useTheme() + const intl = useIntl() + + const vehicles = data?.vehiclesList?.vehicleList + + // Reorder vehicles so vehicles that require mileage registration are shown first + const reorderedVehicles = useMemo( + () => + vehicles + ? [...vehicles]?.sort((a, b) => { + if ( + a.requiresMileageRegistration && + !b.requiresMileageRegistration + ) { + return -1 + } else if ( + !a.requiresMileageRegistration && + b.requiresMileageRegistration + ) { + return 1 + } + return 0 + }) + : vehicles, + [vehicles], + ) + if (error && !data) { + return null + } + + const count = reorderedVehicles?.length ?? 0 + + const items = reorderedVehicles?.slice(0, 3).map((vehicle, index) => ( + 1 + ? { + width: screenWidth - theme.spacing[2] * 3, + paddingHorizontal: 0, + paddingLeft: theme.spacing[2], + minHeight: 176, + } + : { + width: '100%', + paddingHorizontal: 0, + } + } + /> + )) + + return ( + + + navigateTo(`/vehicles`)} + > + navigateTo('/vehicles')} + style={{ + flexDirection: 'row', + alignItems: 'center', + }} + > + + + + + + ) + } + > + + + + {loading && !data ? ( + + ) : ( + <> + {count === 0 && ( + + } + link={null} + /> + )} + {count === 1 && items} + {count >= 2 && {items}} + + )} + + + ) + }, +) + +export { VehiclesModule, validateVehiclesInitialData, useListVehiclesQuery } diff --git a/apps/native/app/src/screens/inbox/inbox.tsx b/apps/native/app/src/screens/inbox/inbox.tsx index 6b97574ba2cc..6356e3f4c626 100644 --- a/apps/native/app/src/screens/inbox/inbox.tsx +++ b/apps/native/app/src/screens/inbox/inbox.tsx @@ -8,7 +8,6 @@ import { TopLine, InboxCard, } from '@ui' -import { setBadgeCountAsync } from 'expo-notifications' import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useIntl } from 'react-intl' import { @@ -371,7 +370,6 @@ export const InboxScreen: NavigationFunctionComponent<{ badgeColor: theme.color.red400, }, }) - setBadgeCountAsync(unreadCount) }, [intl, theme, unreadCount]) const keyExtractor = useCallback((item: ListItem) => { diff --git a/apps/native/app/src/screens/settings/edit-bank-info.tsx b/apps/native/app/src/screens/settings/edit-bank-info.tsx index ea92a495db8e..a6de5f0f03e8 100644 --- a/apps/native/app/src/screens/settings/edit-bank-info.tsx +++ b/apps/native/app/src/screens/settings/edit-bank-info.tsx @@ -119,7 +119,7 @@ export const EditBankInfoScreen: NavigationFunctionComponent = ({ }) if (!res.data) { - throw new Error('Faild to update') + throw new Error('Failed to update') } Navigation.dismissModal(componentId) @@ -128,7 +128,10 @@ export const EditBankInfoScreen: NavigationFunctionComponent = ({ throw new Error('Failed to update') } } catch (e) { - Alert.alert('Villa', 'Gat ekki vistað reikningsupplýsingar') + Alert.alert( + intl.formatMessage({ id: 'edit.bankinfo.error' }), + intl.formatMessage({ id: 'edit.bankinfo.errorMessage' }), + ) } }} /> diff --git a/apps/native/app/src/screens/settings/edit-confirm.tsx b/apps/native/app/src/screens/settings/edit-confirm.tsx index a7e3c22a3527..c624d83e259a 100644 --- a/apps/native/app/src/screens/settings/edit-confirm.tsx +++ b/apps/native/app/src/screens/settings/edit-confirm.tsx @@ -53,10 +53,13 @@ export const EditConfirmScreen: NavigationFunctionComponent = ({ Navigation.dismissModal(parentComponentId) } } else { - throw new Error('Failed') + throw new Error('Failed to update profile') } } catch (e) { - Alert.alert('Villa', 'Gat ekki uppfært upplýsingar') + Alert.alert( + intl.formatMessage({ id: 'edit.confirm.error' }), + intl.formatMessage({ id: 'edit.confirm.errorMessage' }), + ) } setLoading(false) } diff --git a/apps/native/app/src/screens/settings/edit-email.tsx b/apps/native/app/src/screens/settings/edit-email.tsx index b2d5a0df0020..12994c6d11f9 100644 --- a/apps/native/app/src/screens/settings/edit-email.tsx +++ b/apps/native/app/src/screens/settings/edit-email.tsx @@ -100,7 +100,6 @@ export const EditEmailScreen: NavigationFunctionComponent<{ }) if (res.data) { Navigation.dismissModal(componentId) - console.log(res.data, 'Uppfærði tómt netfang') } else { throw new Error('Failed to delete email') } diff --git a/apps/native/app/src/screens/settings/settings.tsx b/apps/native/app/src/screens/settings/settings.tsx index 5b54f1980738..81f1f49e81c6 100644 --- a/apps/native/app/src/screens/settings/settings.tsx +++ b/apps/native/app/src/screens/settings/settings.tsx @@ -14,7 +14,6 @@ import { Image, Linking, Platform, - Pressable, ScrollView, Switch, TouchableOpacity, @@ -151,6 +150,8 @@ export const SettingsScreen: NavigationFunctionComponent = ({ }).then(({ selectedItem }: any) => { if (selectedItem) { setLocale(selectedItem.id) + const locale = selectedItem.id === 'is-IS' ? 'is' : 'en' + updateLocale(locale) } }) } @@ -166,7 +167,7 @@ export const SettingsScreen: NavigationFunctionComponent = ({ }, 330) }, []) - function updateDocumentNotifications(value: boolean) { + const updateDocumentNotifications = (value: boolean) => { client .mutate({ mutation: UpdateProfileDocument, @@ -198,7 +199,7 @@ export const SettingsScreen: NavigationFunctionComponent = ({ }) } - function updateEmailNotifications(value: boolean) { + const updateEmailNotifications = (value: boolean) => { client .mutate({ mutation: UpdateProfileDocument, @@ -230,6 +231,30 @@ export const SettingsScreen: NavigationFunctionComponent = ({ }) } + const updateLocale = (value: string) => { + client + .mutate({ + mutation: UpdateProfileDocument, + update(cache, { data }) { + cache.modify({ + fields: { + getUserProfile: (existing) => { + return { ...existing, ...data?.updateProfile } + }, + }, + }) + }, + variables: { + input: { + locale: value, + }, + }, + }) + .catch(() => { + // noop + }) + } + useEffect(() => { if (userProfile) { setDocumentNotifications( @@ -516,27 +541,33 @@ export const SettingsScreen: NavigationFunctionComponent = ({ items: [ { id: '5000', - label: intl.formatNumber(5, { + label: `${intl.formatNumber(5, { style: 'decimal', unitDisplay: 'long', unit: 'second', - }), + })} ${intl.formatMessage({ + id: 'settings.security.appLockTimeoutSeconds', + })}`, }, { id: '10000', - label: intl.formatNumber(10, { + label: `${intl.formatNumber(10, { style: 'decimal', unitDisplay: 'long', unit: 'second', - }), + })} ${intl.formatMessage({ + id: 'settings.security.appLockTimeoutSeconds', + })}`, }, { id: '15000', - label: intl.formatNumber(15, { + label: `${intl.formatNumber(15, { style: 'decimal', unitDisplay: 'long', unit: 'second', - }), + })} ${intl.formatMessage({ + id: 'settings.security.appLockTimeoutSeconds', + })}`, }, ], cancel: true, @@ -557,11 +588,13 @@ export const SettingsScreen: NavigationFunctionComponent = ({ })} accessory={ - {intl.formatNumber(Math.floor(appLockTimeout / 1000), { + {`${intl.formatNumber(Math.floor(appLockTimeout / 1000), { style: 'decimal', unitDisplay: 'short', unit: 'second', - })} + })} ${intl.formatMessage({ + id: 'settings.security.appLockTimeoutSeconds', + })}`} } /> diff --git a/apps/native/app/src/screens/vehicles/components/vehicle-item.tsx b/apps/native/app/src/screens/vehicles/components/vehicle-item.tsx index 6fc3c5867897..da31cff6935e 100644 --- a/apps/native/app/src/screens/vehicles/components/vehicle-item.tsx +++ b/apps/native/app/src/screens/vehicles/components/vehicle-item.tsx @@ -1,7 +1,7 @@ import { Label, VehicleCard } from '@ui' import React from 'react' import { FormattedDate, FormattedMessage } from 'react-intl' -import { SafeAreaView, TouchableHighlight, View } from 'react-native' +import { SafeAreaView, TouchableHighlight, View, ViewStyle } from 'react-native' import { useTheme } from 'styled-components/native' import { ListVehiclesQuery } from '../../../graphql/types/schema' import { navigateTo } from '../../../lib/deep-linking' @@ -17,11 +17,13 @@ type VehicleListItem = NonNullable< export const VehicleItem = React.memo( ({ item, - mileage, + minHeight, + style, }: { item: VehicleListItem index: number - mileage?: boolean + minHeight?: number + style?: ViewStyle }) => { const theme = useTheme() const nextInspection = item?.nextInspection?.nextInspectionDate @@ -33,15 +35,18 @@ export const VehicleItem = React.memo( ? differenceInMonths(new Date(nextInspection), new Date()) : 0) < 0 - const isMileageRequired = item.requiresMileageRegistration && mileage + const isMileageRequired = item.requiresMileageRegistration return ( - + { navigateTo(`/vehicle/`, { id: item.permno, @@ -54,6 +59,7 @@ export const VehicleItem = React.memo( title={item.type} color={item.color} number={item.regno} + minHeight={minHeight} label={ isInspectionDeadline && nextInspection ? ( ) -const SkeletonItem = () => { - const theme = useTheme() - return ( - - - - ) -} - const input = { page: 1, pageSize: 10, @@ -95,6 +69,7 @@ export const VehiclesScreen: NavigationFunctionComponent = ({ componentId, }) => { useNavigationOptions(componentId) + const theme = useTheme() const flatListRef = useRef(null) const [refetching, setRefetching] = useState(false) @@ -114,12 +89,6 @@ export const VehiclesScreen: NavigationFunctionComponent = ({ refetching, }) - // Get feature flag for mileage - const isMileageEnabled = useFeatureFlag( - 'isServicePortalVehicleMileagePageEnabled', - false, - ) - // What to do when refreshing const onRefresh = useCallback(() => { try { @@ -151,14 +120,16 @@ export const VehiclesScreen: NavigationFunctionComponent = ({ const renderItem = useCallback( ({ item, index }: { item: ListItem; index: number }) => { if (item.__typename === 'Skeleton') { - return + return ( + + + + ) } - return ( - - ) + return }, - [isMileageEnabled], + [], ) // Extract key of data diff --git a/apps/native/app/src/screens/wallet/components/wallet-item.tsx b/apps/native/app/src/screens/wallet/components/wallet-item.tsx index cebbc23366c7..8a6eec412b70 100644 --- a/apps/native/app/src/screens/wallet/components/wallet-item.tsx +++ b/apps/native/app/src/screens/wallet/components/wallet-item.tsx @@ -1,6 +1,6 @@ import { CustomLicenseType, LicenseCard } from '@ui' import React from 'react' -import { SafeAreaView } from 'react-native' +import { SafeAreaView, ViewStyle } from 'react-native' import styled from 'styled-components/native' import { Pressable as PressableRaw } from '../../../components/pressable/pressable' import { @@ -20,7 +20,13 @@ const Pressable = styled(PressableRaw)` ` export const WalletItem = React.memo( - ({ item }: { item: GenericUserLicense | IdentityDocumentModel }) => { + ({ + item, + style, + }: { + item: GenericUserLicense | IdentityDocumentModel + style?: ViewStyle + }) => { let cardHeight = 140 const type = item.__typename @@ -30,11 +36,13 @@ export const WalletItem = React.memo( return ( { cardHeight = Math.round(e.nativeEvent.layout.height) }} > { navigateTo(`/walletpassport/${item?.number}`, { fromId: `license-${CustomLicenseType.Passport}_source`, @@ -61,6 +69,7 @@ export const WalletItem = React.memo( } else if (type === 'GenericUserLicense') { return ( { cardHeight = Math.round(e.nativeEvent.layout.height) }} diff --git a/apps/native/app/src/screens/wallet/wallet.tsx b/apps/native/app/src/screens/wallet/wallet.tsx index 063a4fa482be..6856dd0fceac 100644 --- a/apps/native/app/src/screens/wallet/wallet.tsx +++ b/apps/native/app/src/screens/wallet/wallet.tsx @@ -1,4 +1,4 @@ -import { Alert, EmptyList, Skeleton, TopLine } from '@ui' +import { Alert, EmptyList, GeneralCardSkeleton, TopLine } from '@ui' import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useIntl } from 'react-intl' @@ -15,7 +15,7 @@ import SpotlightSearch from 'react-native-spotlight-search' import { useTheme } from 'styled-components/native' import { useNavigationComponentDidAppear } from 'react-native-navigation-hooks' -import illustrationSrc from '../../assets/illustrations/le-moving-s6.png' +import illustrationSrc from '../../assets/illustrations/le-retirement-s3.png' import { BottomTabsIndicator } from '../../components/bottom-tabs-indicator/bottom-tabs-indicator' import { useFeatureFlag } from '../../contexts/feature-flag-provider' import { @@ -205,24 +205,8 @@ export const WalletScreen: NavigationFunctionComponent = ({ componentId }) => { ({ item }: ListRenderItemInfo) => { if (item.__typename === 'Skeleton') { return ( - - + + ) } diff --git a/apps/native/app/src/stores/auth-store.ts b/apps/native/app/src/stores/auth-store.ts index 3091123afcf8..f9693962a082 100644 --- a/apps/native/app/src/stores/auth-store.ts +++ b/apps/native/app/src/stores/auth-store.ts @@ -214,6 +214,8 @@ export const authStore = create((set, get) => ({ }), true, ) + // Reset home screen widgets + preferencesStore.getState().resetHomeScreenWidgets() return true }, })) diff --git a/apps/native/app/src/stores/notifications-store.ts b/apps/native/app/src/stores/notifications-store.ts index 768a537b1ab6..2a70dd6be495 100644 --- a/apps/native/app/src/stores/notifications-store.ts +++ b/apps/native/app/src/stores/notifications-store.ts @@ -18,6 +18,7 @@ import { } from '../graphql/types/schema' import { ComponentRegistry } from '../utils/component-registry' import { getRightButtons } from '../utils/get-main-root' +import { setBadgeCountAsync } from 'expo-notifications' export interface Notification { id: string @@ -114,12 +115,13 @@ export const notificationsStore = create( }, updateNavigationUnseenCount(unseenCount: number) { set({ unseenCount }) + setBadgeCountAsync(unseenCount) Navigation.mergeOptions(ComponentRegistry.HomeScreen, { topBar: { rightButtons: getRightButtons({ unseenCount, - icons: ['notifications'], + icons: ['notifications', 'options'], }), }, }) diff --git a/apps/native/app/src/stores/preferences-store.ts b/apps/native/app/src/stores/preferences-store.ts index a456d69faec2..b931fca7d184 100644 --- a/apps/native/app/src/stores/preferences-store.ts +++ b/apps/native/app/src/stores/preferences-store.ts @@ -5,6 +5,12 @@ import { persist } from 'zustand/middleware' import create, { State } from 'zustand/vanilla' import { getDefaultOptions } from '../utils/get-default-options' import { getThemeWithPreferences } from '../utils/get-theme-with-preferences' +import { getApolloClientAsync } from '../graphql/client' +import { + GetProfileDocument, + GetProfileQuery, + GetProfileQueryVariables, +} from '../graphql/types/schema' export type Locale = 'en-US' | 'is-IS' | 'en-IS' | 'is-US' export type ThemeMode = 'dark' | 'light' | 'efficient' @@ -19,6 +25,13 @@ export interface PreferencesStore extends State { hasAcceptedBiometrics: boolean hasOnboardedPasskeys: boolean hasCreatedPasskey: boolean + graphicWidgetEnabled: boolean + inboxWidgetEnabled: boolean + applicationsWidgetEnabled: boolean + licensesWidgetEnabled: boolean + vehiclesWidgetEnabled: boolean + airDiscountWidgetEnabled: boolean + widgetsInitialised: boolean lastUsedPasskey: number notificationsNewDocuments: boolean notificationsAppUpdates: boolean @@ -29,9 +42,11 @@ export interface PreferencesStore extends State { appearanceMode: AppearanceMode appLockTimeout: number setLocale(locale: Locale): void + getAndSetLocale(): void setAppearanceMode(appearanceMode: AppearanceMode): void setUseBiometrics(useBiometrics: boolean): void dismiss(key: string, value?: boolean): void + resetHomeScreenWidgets(): void reset(): void } @@ -49,6 +64,13 @@ const defaultPreferences = { hasAcceptedBiometrics: false, hasOnboardedPasskeys: false, hasCreatedPasskey: false, + graphicWidgetEnabled: true, + inboxWidgetEnabled: true, + applicationsWidgetEnabled: true, + licensesWidgetEnabled: true, + vehiclesWidgetEnabled: true, + airDiscountWidgetEnabled: true, + widgetsInitialised: false, lastUsedPasskey: 0, notificationsNewDocuments: true, notificationsAppUpdates: true, @@ -61,6 +83,24 @@ export const preferencesStore = create( persist( (set, get) => ({ ...(defaultPreferences as PreferencesStore), + async getAndSetLocale() { + const client = await getApolloClientAsync() + + try { + const res = await client.query< + GetProfileQuery, + GetProfileQueryVariables + >({ + query: GetProfileDocument, + }) + + const locale = res.data?.getUserProfile?.locale + const appLocale = locale === 'en' ? 'en-US' : 'is-IS' + set({ locale: appLocale }) + } catch (err) { + // noop + } + }, setLocale(locale: Locale) { if (!availableLocales.includes(locale)) { throw new Error('Not supported locale') @@ -81,6 +121,17 @@ export const preferencesStore = create( set({ dismissed: [...now.filter((k) => k !== key)] }) } }, + resetHomeScreenWidgets() { + set({ + graphicWidgetEnabled: true, + inboxWidgetEnabled: true, + applicationsWidgetEnabled: true, + licensesWidgetEnabled: true, + vehiclesWidgetEnabled: true, + airDiscountWidgetEnabled: true, + widgetsInitialised: false, + }) + }, reset() { set(defaultPreferences as PreferencesStore) }, diff --git a/apps/native/app/src/ui/index.ts b/apps/native/app/src/ui/index.ts index a03d29f2c1fd..40700141149e 100644 --- a/apps/native/app/src/ui/index.ts +++ b/apps/native/app/src/ui/index.ts @@ -46,6 +46,7 @@ export * from './lib/search-header/search-header' export * from './lib/skeleton/skeleton' export * from './lib/skeleton/list-item-skeleton' export * from './lib/skeleton/status-card-skeleton' +export * from './lib/skeleton/general-card-skeleton' export * from './lib/tab-bar/tab-bar' export * from './lib/tableview/tableview-cell' export * from './lib/tableview/tableview-group' diff --git a/apps/native/app/src/ui/lib/accordion/accordion-item.tsx b/apps/native/app/src/ui/lib/accordion/accordion-item.tsx index a2792cdaa45f..4305671910e2 100644 --- a/apps/native/app/src/ui/lib/accordion/accordion-item.tsx +++ b/apps/native/app/src/ui/lib/accordion/accordion-item.tsx @@ -98,7 +98,7 @@ export function AccordionItem({ > {icon && {icon}} - {title} + {title} theme.spacing[1]}px; @@ -84,6 +85,7 @@ interface AirDiscountProps { code?: string | null text?: string | null validUntil?: string | Date | null + style?: ViewStyle } export function AirDiscountCard({ @@ -92,12 +94,13 @@ export function AirDiscountCard({ text, validUntil, credit, + style, }: AirDiscountProps) { const intl = useIntl() const theme = useTheme() const discountCode = credit === 0 ? undefined : code ?? '0' return ( - + theme.spacing[2]}px; row-gap: ${LICENSE_CARD_ROW_GAP}px; border-radius: ${({ theme }) => theme.border.radius.extraLarge}; overflow: hidden; + justify-content: center; ` const ContentContainer = styled.View` @@ -130,6 +131,8 @@ const ImgWrap = styled.View` flex-shrink: 0; align-content: center; justify-content: center; + height: 72px; + width: 72px; ` // Todo when we know the status type add to intl @@ -340,7 +343,7 @@ export function LicenseCard({ {isString(logo) ? ( ) : ( - + )} )} diff --git a/apps/native/app/src/ui/lib/card/vehicle-card.tsx b/apps/native/app/src/ui/lib/card/vehicle-card.tsx index 6687b6e2eb20..1c4fa43af59d 100644 --- a/apps/native/app/src/ui/lib/card/vehicle-card.tsx +++ b/apps/native/app/src/ui/lib/card/vehicle-card.tsx @@ -5,7 +5,7 @@ import chevronForward from '../../assets/icons/chevron-forward.png' import { dynamicColor } from '../../utils' import { Typography } from '../typography/typography' -const Host = styled.View` +const Host = styled.View<{ minHeight?: number }>` display: flex; flex-direction: row; padding: ${({ theme }) => theme.spacing[3]}px; @@ -21,6 +21,7 @@ const Host = styled.View` )}; align-items: center; justify-content: space-between; + min-height: ${({ minHeight }) => (minHeight ? minHeight + 'px' : 'auto')}; ` const Content = styled.View` @@ -49,12 +50,19 @@ interface VehicleCardProps { title?: string | null color?: string | null number?: string | null + minHeight?: number label?: React.ReactNode } -export function VehicleCard({ title, color, number, label }: VehicleCardProps) { +export function VehicleCard({ + title, + color, + number, + label, + minHeight, +}: VehicleCardProps) { return ( - + {title} diff --git a/apps/native/app/src/ui/lib/checkbox/checkbox.tsx b/apps/native/app/src/ui/lib/checkbox/checkbox.tsx index 763983b57df2..1d06146165a4 100644 --- a/apps/native/app/src/ui/lib/checkbox/checkbox.tsx +++ b/apps/native/app/src/ui/lib/checkbox/checkbox.tsx @@ -32,7 +32,7 @@ interface CheckboxProps { export const Checkbox = ({ label, checked, onPress }: CheckboxProps) => { return ( - + {label} diff --git a/apps/native/app/src/ui/lib/empty-state/empty-card.tsx b/apps/native/app/src/ui/lib/empty-state/empty-card.tsx index 21ebe4b8269d..b9f083e04f7c 100644 --- a/apps/native/app/src/ui/lib/empty-state/empty-card.tsx +++ b/apps/native/app/src/ui/lib/empty-state/empty-card.tsx @@ -9,7 +9,7 @@ const Host = styled.View` flex-direction: row; padding-horizontal: ${({ theme }) => theme.spacing[3]}px; padding-vertical: ${({ theme }) => theme.spacing[3]}px; - + min-height: 130px; border-radius: ${({ theme }) => theme.border.radius.large}; border-width: 1px; border-color: ${dynamicColor( @@ -23,10 +23,6 @@ const Host = styled.View` align-items: center; ` -const Message = styled(Typography)` - margin-bottom: ${({ theme }) => theme.spacing[2]}px; -` - const TextWrapper = styled.View` flex: 1; ` @@ -41,7 +37,7 @@ export function EmptyCard({ text, image, link }: EmptyCardProps) { return ( - {text} + {text} {link && {link}} {image} diff --git a/apps/native/app/src/ui/lib/list/list-item.tsx b/apps/native/app/src/ui/lib/list/list-item.tsx index 8f77cd93bc13..5454e1172994 100644 --- a/apps/native/app/src/ui/lib/list/list-item.tsx +++ b/apps/native/app/src/ui/lib/list/list-item.tsx @@ -117,7 +117,6 @@ export function ListItem({ variant="body3" numberOfLines={1} ellipsizeMode="tail" - style={{ fontWeight: '300' }} > {title} diff --git a/apps/native/app/src/ui/lib/skeleton/general-card-skeleton.tsx b/apps/native/app/src/ui/lib/skeleton/general-card-skeleton.tsx new file mode 100644 index 000000000000..f64d3c68e0e4 --- /dev/null +++ b/apps/native/app/src/ui/lib/skeleton/general-card-skeleton.tsx @@ -0,0 +1,26 @@ +import React from 'react' +import { useTheme } from 'styled-components/native' +import { Skeleton } from './skeleton' + +export const GeneralCardSkeleton = ({ height }: { height: number }) => { + const theme = useTheme() + return ( + + ) +} diff --git a/apps/native/app/src/ui/lib/tableview/tableview-cell.tsx b/apps/native/app/src/ui/lib/tableview/tableview-cell.tsx index 645979f4b8f1..c42e4a6c2df1 100644 --- a/apps/native/app/src/ui/lib/tableview/tableview-cell.tsx +++ b/apps/native/app/src/ui/lib/tableview/tableview-cell.tsx @@ -3,6 +3,7 @@ import { SafeAreaView, ViewStyle } from 'react-native' import styled from 'styled-components/native' import { dynamicColor } from '../../utils' import { font } from '../../utils/font' +import { Typography } from '../typography/typography' interface TableViewCellProps { /** @@ -40,12 +41,12 @@ interface TableViewCellProps { const Cell = styled.View<{ border: boolean; disabled: boolean }>` flex-direction: row; - min-height: 71px; + min-height: 56px; border-bottom-width: ${(props) => (props.border ? 1 : 0)}px; border-bottom-color: ${dynamicColor( ({ theme }) => ({ dark: theme.shades.dark.shade200, - light: theme.color.blue100, + light: theme.color.blue200, }), true, )}; @@ -64,7 +65,7 @@ const Center = styled.View<{ accessory: boolean }>` flex: 1; justify-content: center; flex-direction: column; - padding-right: ${(props) => (props.accessory ? 15 : 0)}px; + padding-right: ${(props) => (props.accessory ? props.theme.spacing[2] : 0)}px; padding-top: ${({ theme }) => theme.spacing[2]}px; padding-bottom: ${({ theme }) => theme.spacing[2]}px; ` @@ -80,16 +81,12 @@ const Content = styled.View` flex-direction: column; ` -const Title = styled.View`` - -const TitleText = styled.Text` - ${font({ - fontSize: 15, - fontWeight: '400', - })} +const Title = styled.View` + margin-left: ${({ theme }) => theme.spacing[2]}px; ` const Subtitle = styled.View` + margin-left: ${({ theme }) => theme.spacing[2]}px; margin-top: 2px; ` @@ -124,7 +121,7 @@ export const TableViewCell = React.memo((props: TableViewCellProps) => { style = {}, } = props return ( - + { {title && ( {typeof title === 'string' ? ( - <TitleText>{title}</TitleText> + <Typography weight={400}>{title}</Typography> ) : ( title )} diff --git a/apps/native/app/src/utils/component-registry.ts b/apps/native/app/src/utils/component-registry.ts index f48cf6125a04..7e80ba888c8d 100644 --- a/apps/native/app/src/utils/component-registry.ts +++ b/apps/native/app/src/utils/component-registry.ts @@ -15,6 +15,7 @@ export const ComponentRegistry = { OnboardingBiometricsScreen: `${prefix}.screens.OnboardingBiometrics`, OnboardingNotificationsScreen: `${prefix}.screens.OnboardingNotifications`, HomeScreen: `${prefix}.screens.Home`, + HomeOptionsScreen: `${prefix}.screens.HomeOptions`, InboxScreen: `${prefix}.screens.Inbox`, ApplicationsScreen: `${prefix}.screens.ApplicationsScreen`, WalletScreen: `${prefix}.screens.Wallet`, @@ -65,6 +66,7 @@ export const ButtonRegistry = { DocumentStarButton: `${prefix}.button.DocumentStarButton`, DocumentArchiveButton: `${prefix}.button.DocumentArchiveButton`, InboxFilterClearButton: `${prefix}.button.InboxFilterClearButton`, + HomeScreenOptionsButton: `${prefix}.button.HomeScreenOptionsButton`, } export const StackRegistry = { diff --git a/apps/native/app/src/utils/get-main-root.ts b/apps/native/app/src/utils/get-main-root.ts index 0d9f21e955da..d23a134e2dfc 100644 --- a/apps/native/app/src/utils/get-main-root.ts +++ b/apps/native/app/src/utils/get-main-root.ts @@ -10,7 +10,7 @@ import { import { getThemeWithPreferences } from './get-theme-with-preferences' import { testIDs } from './test-ids' -type Icons = 'notifications' | 'settings' | 'licenseScan' +type Icons = 'notifications' | 'settings' | 'licenseScan' | 'options' type RightButtonProps = { unseenCount?: number @@ -65,6 +65,14 @@ export const getRightButtons = ({ color: theme.color.blue400, iconBackground, }) + } else if (icon === 'options') { + rightButtons.push({ + id: ButtonRegistry.HomeScreenOptionsButton, + testID: testIDs.TOPBAR_HOME_OPTIONS_BUTTON, + icon: require('../assets/icons/options.png'), + color: theme.color.blue400, + iconBackground, + }) } }) return rightButtons diff --git a/apps/native/app/src/utils/lifecycle/setup-components.tsx b/apps/native/app/src/utils/lifecycle/setup-components.tsx index a3650e4eb66a..8a90b927b4df 100644 --- a/apps/native/app/src/utils/lifecycle/setup-components.tsx +++ b/apps/native/app/src/utils/lifecycle/setup-components.tsx @@ -13,6 +13,7 @@ import { FamilyOverviewScreen } from '../../screens/family/family-overview' import { FinanceScreen } from '../../screens/finance/finance' import { FinanceStatusDetailScreen } from '../../screens/finance/finance-status-detail' import { HomeScreen } from '../../screens/home/home' +import { HomeOptionsScreen } from '../../screens/home/home-options' import { InboxScreen } from '../../screens/inbox/inbox' import { InboxFilterScreen } from '../../screens/inbox/inbox-filter' import { LicenseScanDetailScreen } from '../../screens/license-scanner/license-scan-detail' @@ -99,6 +100,7 @@ export function registerAllComponents() { registerComponent(CR.InboxFilterScreen, InboxFilterScreen) registerComponent(CR.AirDiscountScreen, AirDiscountScreen) registerComponent(CR.PasskeyScreen, PasskeyScreen) + registerComponent(CR.HomeOptionsScreen, HomeOptionsScreen) // Overlay registerComponent(CR.OfflineBanner, OfflineBanner) diff --git a/apps/native/app/src/utils/lifecycle/setup-event-handlers.ts b/apps/native/app/src/utils/lifecycle/setup-event-handlers.ts index f7698378ef44..4512b2028297 100644 --- a/apps/native/app/src/utils/lifecycle/setup-event-handlers.ts +++ b/apps/native/app/src/utils/lifecycle/setup-event-handlers.ts @@ -164,6 +164,8 @@ export function setupEventHandlers() { return navigateTo('/license-scanner') case ButtonRegistry.OfflineButton: return handleOfflineButtonClick() + case ButtonRegistry.HomeScreenOptionsButton: + return navigateTo('/home-options') } }, ) diff --git a/apps/native/app/src/utils/lifecycle/setup-routes.ts b/apps/native/app/src/utils/lifecycle/setup-routes.ts index a487e6dbdb70..a56a0bd2fb4e 100644 --- a/apps/native/app/src/utils/lifecycle/setup-routes.ts +++ b/apps/native/app/src/utils/lifecycle/setup-routes.ts @@ -68,6 +68,18 @@ export function setupRoutes() { }) }) + addRoute('/home-options', async (passProps) => { + Navigation.dismissAllModals() + selectTab(2) + await Navigation.popToRoot(StackRegistry.HomeStack) + await Navigation.push(StackRegistry.HomeStack, { + component: { + name: ComponentRegistry.HomeOptionsScreen, + passProps, + }, + }) + }) + addRoute('/assets', async (passProps) => { await Navigation.dismissAllModals() await Navigation.popToRoot(StackRegistry.MoreStack) @@ -92,6 +104,7 @@ export function setupRoutes() { addRoute('/air-discount', async (passProps) => { await Navigation.dismissAllModals() + selectTab(4) await Navigation.popToRoot(StackRegistry.MoreStack) await Navigation.push(ComponentRegistry.MoreScreen, { component: { @@ -206,7 +219,10 @@ export function setupRoutes() { }) }) - addRoute('/vehicle/:id', (passProps: any) => { + addRoute('/vehicle/:id', async (passProps: any) => { + await Navigation.dismissAllModals() + selectTab(4) + await Navigation.popToRoot(StackRegistry.MoreStack) Navigation.push(ComponentRegistry.MoreScreen, { component: { name: ComponentRegistry.VehicleDetailScreen, diff --git a/apps/native/app/src/utils/test-ids.ts b/apps/native/app/src/utils/test-ids.ts index f010eb50a58e..ddfe84ba83ac 100644 --- a/apps/native/app/src/utils/test-ids.ts +++ b/apps/native/app/src/utils/test-ids.ts @@ -12,6 +12,7 @@ export const testIDs = { TOPBAR_OFFLINE_BUTTON: 'TOPBAR_OFFLINE_BUTTON', TOPBAR_NOTIFICATIONS_BUTTON: 'TOPBAR_NOTIFICATIONS_BUTTON', TOPBAR_SCAN_LICENSE_BUTTON: 'TOPBAR_SCAN_LICENSE_BUTTON', + TOPBAR_HOME_OPTIONS_BUTTON: 'TOPBAR_HOME_OPTIONS_BUTTON', // shared components diff --git a/apps/services/auth/delegation-api/infra/delegation-api.ts b/apps/services/auth/delegation-api/infra/delegation-api.ts index e68fc0951175..1ceff205f6d3 100644 --- a/apps/services/auth/delegation-api/infra/delegation-api.ts +++ b/apps/services/auth/delegation-api/infra/delegation-api.ts @@ -90,5 +90,10 @@ export const serviceSetup = (services: { public: false, }, }) - .grantNamespaces('nginx-ingress-internal', 'islandis', 'service-portal') + .grantNamespaces( + 'nginx-ingress-internal', + 'islandis', + 'service-portal', + 'user-notification-worker', + ) } diff --git a/apps/services/regulations-admin-backend/src/main.ts b/apps/services/regulations-admin-backend/src/main.ts index 8d3c254eaaff..b0f1f43fea55 100644 --- a/apps/services/regulations-admin-backend/src/main.ts +++ b/apps/services/regulations-admin-backend/src/main.ts @@ -7,4 +7,5 @@ bootstrap({ appModule: AppModule, name: 'regulations-admin-backend', openApi, + jsonBodyLimit: '300kb', }) diff --git a/apps/web/components/Organization/MarkdownText/MarkdownText.tsx b/apps/web/components/Organization/MarkdownText/MarkdownText.tsx index d2266de2df6c..a28544a9f807 100644 --- a/apps/web/components/Organization/MarkdownText/MarkdownText.tsx +++ b/apps/web/components/Organization/MarkdownText/MarkdownText.tsx @@ -1,17 +1,29 @@ +import React from 'react' import Markdown from 'markdown-to-jsx' + import { Bullet, BulletList, Text, TextProps } from '@island.is/island-ui/core' -import React from 'react' + import * as styles from './MarkdownText.css' interface MarkdownTextProps { children: string color?: TextProps['color'] variant?: TextProps['variant'] + replaceNewLinesWithBreaks?: boolean } export const MarkdownText: React.FC< React.PropsWithChildren<MarkdownTextProps> -> = ({ children, color = null, variant = 'default' }) => { +> = ({ + children, + color = null, + variant = 'default', + replaceNewLinesWithBreaks = true, +}) => { + const processedChildren = replaceNewLinesWithBreaks + ? (children as string).replace(/\n/gi, '<br>') + : children + return ( <div className={styles.markdownText}> <Markdown @@ -59,7 +71,7 @@ export const MarkdownText: React.FC< }, }} > - {(children as string).replace(/\n/gi, '<br>')} + {processedChildren} </Markdown> </div> ) diff --git a/apps/web/components/connected/ParentalLeaveCalculator/ParentalLeaveCalculator.css.ts b/apps/web/components/connected/ParentalLeaveCalculator/ParentalLeaveCalculator.css.ts new file mode 100644 index 000000000000..e7006fe6fb85 --- /dev/null +++ b/apps/web/components/connected/ParentalLeaveCalculator/ParentalLeaveCalculator.css.ts @@ -0,0 +1,7 @@ +import { style } from '@vanilla-extract/css' + +import { theme } from '@island.is/island-ui/theme' + +export const resultBorder = style({ + border: `1px dashed ${theme.color.blue300}`, +}) diff --git a/apps/web/components/connected/ParentalLeaveCalculator/ParentalLeaveCalculator.tsx b/apps/web/components/connected/ParentalLeaveCalculator/ParentalLeaveCalculator.tsx new file mode 100644 index 000000000000..9132ba9f85f6 --- /dev/null +++ b/apps/web/components/connected/ParentalLeaveCalculator/ParentalLeaveCalculator.tsx @@ -0,0 +1,1089 @@ +import { type PropsWithChildren, useMemo, useRef, useState } from 'react' +import { useIntl } from 'react-intl' +import NumberFormat from 'react-number-format' +import { + parseAsInteger, + parseAsString, + parseAsStringEnum, + useQueryState, +} from 'next-usequerystate' +import { z } from 'zod' + +import { + AlertMessage, + Box, + Button, + GridColumn, + GridRow, + Inline, + Input, + type Option, + RadioButton, + Select, + Stack, + Table, + Text, + Tooltip, +} from '@island.is/island-ui/core' +import { sortAlpha } from '@island.is/shared/utils' +import type { ConnectedComponent } from '@island.is/web/graphql/schema' +import { formatCurrency as formatCurrencyUtil } from '@island.is/web/utils/currency' + +import { MarkdownText } from '../../Organization' +import { translations as t } from './translations.strings' +import * as styles from './ParentalLeaveCalculator.css' + +interface FieldProps { + heading: string + headingTooltip?: string + description?: string + tooltip?: string +} + +const Field = ({ + heading, + headingTooltip, + description, + tooltip, + children, +}: PropsWithChildren<FieldProps>) => { + return ( + <Stack space={2}> + <Inline flexWrap="nowrap" space={1} alignY="center"> + <Text variant="h3">{heading}</Text> + {headingTooltip && <Tooltip text={headingTooltip} />} + </Inline> + {description && ( + <Text variant="medium"> + {description} + {tooltip && <Tooltip text={tooltip} />} + </Text> + )} + {children} + </Stack> + ) +} + +enum Status { + PARENTAL_LEAVE = 'parentalLeave', + STUDENT = 'student', + OUTSIDE_WORKFORCE = 'outsideWorkForce', +} + +enum WorkPercentage { + OPTION_1 = 'option1', + OPTION_2 = 'option2', +} + +enum ParentalLeavePeriod { + MONTH = 'month', + THREE_WEEKS = 'threeWeeks', + TWO_WEEKS = 'twoWeeks', +} + +enum Screen { + FORM = 'form', + RESULTS = 'results', +} + +enum LegalDomicileInIceland { + YES = 'y', + NO = 'n', +} + +interface ParentalLeaveCalculatorProps { + slice: ConnectedComponent +} + +interface ScreenProps extends ParentalLeaveCalculatorProps { + changeScreen: () => void +} + +const FormScreen = ({ slice, changeScreen }: ScreenProps) => { + const { formatMessage } = useIntl() + + const statusOptions = useMemo<Option<Status>[]>(() => { + return [ + { + label: formatMessage(t.status.parentalLeaveOption), + value: Status.PARENTAL_LEAVE, + }, + { + label: formatMessage(t.status.studentOption), + value: Status.STUDENT, + }, + { + label: formatMessage(t.status.outsideWorkforceOption), + value: Status.OUTSIDE_WORKFORCE, + }, + ] + }, [formatMessage]) + + const yearOptions = useMemo<Option<number>[]>(() => { + const keys = Object.keys(slice.configJson?.yearConfig || {}).map(Number) + keys.sort() + return keys.map((key) => ({ + label: String(key), + value: key, + })) + }, [slice.configJson?.yearConfig]) + + const additionalPensionFundingOptions = useMemo< + Option<number | null>[] + >(() => { + const options: number[] = slice.configJson + ?.additionalPensionFundingOptions ?? [1, 2, 3, 4] + + return [ + { value: null, label: formatMessage(t.additionalPensionFunding.none) }, + ...options.map((option) => ({ + label: `${option} ${formatMessage( + t.additionalPensionFunding.optionSuffix, + )}`, + value: option, + })), + ] + }, [formatMessage, slice.configJson?.additionalPensionFundingOptions]) + + const unionOptions = useMemo<Option<string | null>[]>(() => { + const options: { label: string; percentage: number }[] = slice.configJson + ?.unionOptions + ? [...slice.configJson.unionOptions] + : [] + + options.sort(sortAlpha('label')) + + return [ + { + value: null, + label: formatMessage(t.union.none), + }, + ...options.map((option) => ({ + label: option.label, + value: option.label, + })), + ] + }, [formatMessage, slice.configJson?.unionOptions]) + + const parentalLeavePeriodOptions = useMemo<Option<string>[]>(() => { + return [ + { + label: formatMessage(t.parentalLeavePeriod.monthOption), + value: ParentalLeavePeriod.MONTH, + }, + { + label: formatMessage(t.parentalLeavePeriod.threeWeeksOption), + value: ParentalLeavePeriod.THREE_WEEKS, + }, + { + label: formatMessage(t.parentalLeavePeriod.twoWeeksOption), + value: ParentalLeavePeriod.TWO_WEEKS, + }, + ] + }, [formatMessage]) + + const [status, setStatus] = useQueryState<Status>( + 'status', + parseAsStringEnum(Object.values(Status)).withDefault(Status.PARENTAL_LEAVE), + ) + const [birthyear, setBirthyear] = useQueryState('birthyear', parseAsInteger) + const [workPercentage, setWorkPercentage] = useQueryState( + 'workPercentage', + parseAsStringEnum(Object.values(WorkPercentage)), + ) + const [income, setIncome] = useQueryState('income', parseAsInteger) + const [ + additionalPensionFundingPercentage, + setAdditionalPensionFundingPercentage, + ] = useQueryState('additionalPensionFunding', parseAsInteger) + const [union, setUnion] = useQueryState('union', parseAsString) + const [personalDiscount, setPersonalDiscount] = useQueryState( + 'personalDiscount', + parseAsInteger.withDefault(100), + ) + const [parentalLeavePeriod, setParentalLeavePeriod] = useQueryState( + 'parentalLeavePeriod', + parseAsStringEnum(Object.values(ParentalLeavePeriod)), + ) + const [parentalLeaveRatio, setParentalLeaveRatio] = useQueryState( + 'parentalLeaveRatio', + parseAsInteger.withDefault(100), + ) + const [legalDomicileInIceland, setLegalDomicileInIceland] = useQueryState( + 'legalDomicileInIceland', + parseAsStringEnum(Object.values(LegalDomicileInIceland)), + ) + + const canCalculate = () => { + let value = + Object.values(Status).includes(status) && + yearOptions.some((year) => year.value === birthyear) + + if (status === Status.OUTSIDE_WORKFORCE) { + value = value && legalDomicileInIceland === LegalDomicileInIceland.YES + } + + if (status === Status.PARENTAL_LEAVE) { + value = + value && + typeof income === 'number' && + income > 0 && + !!workPercentage && + Object.values(WorkPercentage).includes(workPercentage) && + parentalLeavePeriodOptions.some( + (option) => option.value === parentalLeavePeriod, + ) + } + + return value + } + + return ( + <Box background="blue100" paddingY={[3, 3, 5]} paddingX={[3, 3, 3, 3, 12]}> + <Stack space={5}> + <Field heading={formatMessage(t.status.heading)}> + <Select + onChange={(option) => { + setStatus((option?.value as Status) ?? null) + }} + value={statusOptions.find((option) => option.value === status)} + label={formatMessage(t.status.label)} + options={statusOptions} + /> + </Field> + + {status === Status.OUTSIDE_WORKFORCE && ( + <Field + heading={formatMessage(t.legalDomicile.heading)} + headingTooltip={formatMessage(t.legalDomicile.tooltip)} + > + <GridRow rowGap={1}> + <GridColumn span={['1/1', '1/2']}> + <RadioButton + hasError={ + legalDomicileInIceland === LegalDomicileInIceland.NO + } + id={LegalDomicileInIceland.YES} + onChange={() => { + setLegalDomicileInIceland(LegalDomicileInIceland.YES) + }} + checked={ + legalDomicileInIceland === LegalDomicileInIceland.YES + } + value={LegalDomicileInIceland.YES} + backgroundColor="white" + large={true} + label={formatMessage(t.legalDomicile.yes)} + /> + </GridColumn> + <GridColumn span={['1/1', '1/2']}> + <RadioButton + hasError={ + legalDomicileInIceland === LegalDomicileInIceland.NO + } + id={LegalDomicileInIceland.NO} + onChange={() => { + setLegalDomicileInIceland(LegalDomicileInIceland.NO) + }} + checked={legalDomicileInIceland === LegalDomicileInIceland.NO} + value={LegalDomicileInIceland.NO} + backgroundColor="white" + large={true} + label={formatMessage(t.legalDomicile.no)} + /> + </GridColumn> + </GridRow> + {legalDomicileInIceland === LegalDomicileInIceland.NO && ( + <Text fontWeight="semiBold" color="red400"> + {formatMessage(t.legalDomicile.dontHaveRight)} + </Text> + )} + </Field> + )} + + <Field + heading={formatMessage(t.childBirthYear.heading)} + description={formatMessage(t.childBirthYear.description)} + > + <Select + onChange={(option) => { + setBirthyear(option?.value ?? null) + }} + value={yearOptions.find((option) => option.value === birthyear)} + label={formatMessage(t.childBirthYear.label)} + options={yearOptions} + /> + </Field> + + {status === Status.PARENTAL_LEAVE && ( + <Field + heading={formatMessage(t.workPercentage.heading)} + description={formatMessage(t.workPercentage.description)} + tooltip={formatMessage(t.workPercentage.tooltip)} + > + <GridRow rowGap={1}> + <GridColumn span={['1/1', '1/2']}> + <RadioButton + id={WorkPercentage.OPTION_1} + onChange={() => { + setWorkPercentage(WorkPercentage.OPTION_1) + }} + checked={workPercentage === WorkPercentage.OPTION_1} + value={WorkPercentage.OPTION_1} + backgroundColor="white" + large={true} + label={formatMessage(t.workPercentage.option1)} + /> + </GridColumn> + <GridColumn span={['1/1', '1/2']}> + <RadioButton + id={WorkPercentage.OPTION_2} + onChange={() => { + setWorkPercentage(WorkPercentage.OPTION_2) + }} + checked={workPercentage === WorkPercentage.OPTION_2} + value={WorkPercentage.OPTION_2} + backgroundColor="white" + large={true} + label={formatMessage(t.workPercentage.option2)} + /> + </GridColumn> + </GridRow> + </Field> + )} + + {status === Status.PARENTAL_LEAVE && ( + <Field + heading={formatMessage(t.income.heading)} + description={formatMessage(t.income.description)} + > + <NumberFormat + onValueChange={({ value }) => { + setIncome(Number(value)) + }} + label={formatMessage(t.income.label)} + tooltip={formatMessage(t.income.tooltip)} + value={String(income || '')} + customInput={Input} + name="income" + id="income" + type="text" + inputMode="numeric" + thousandSeparator="." + decimalSeparator="," + suffix={formatMessage(t.income.inputSuffix)} + placeholder={formatMessage(t.income.inputPlaceholder)} + maxLength={ + formatMessage(t.income.inputSuffix).length + + (slice.configJson?.incomeInputMaxLength ?? 12) + } + /> + </Field> + )} + + {status === Status.PARENTAL_LEAVE && ( + <Field + heading={formatMessage(t.additionalPensionFunding.heading)} + description={formatMessage(t.additionalPensionFunding.description)} + > + <Select + onChange={(option) => { + setAdditionalPensionFundingPercentage(option?.value ?? null) + }} + value={additionalPensionFundingOptions.find( + (option) => option.value === additionalPensionFundingPercentage, + )} + label={formatMessage(t.additionalPensionFunding.label)} + options={additionalPensionFundingOptions} + /> + </Field> + )} + + {status === Status.PARENTAL_LEAVE && ( + <Field + heading={formatMessage(t.union.heading)} + description={formatMessage(t.union.description)} + > + <Select + onChange={(option) => { + setUnion(option?.value ?? null) + }} + value={unionOptions.find((option) => option.value === union)} + label={formatMessage(t.union.label)} + options={unionOptions} + /> + </Field> + )} + + <Field + heading={formatMessage(t.personalDiscount.heading)} + description={formatMessage(t.personalDiscount.description)} + > + <NumberFormat + onValueChange={({ value }) => { + setPersonalDiscount(Number(value)) + }} + label={formatMessage(t.personalDiscount.label)} + value={String(personalDiscount || '')} + customInput={Input} + name="personalDiscount" + id="personalDiscount" + type="text" + inputMode="numeric" + suffix={formatMessage(t.personalDiscount.suffix)} + placeholder={formatMessage(t.personalDiscount.placeholder)} + format={(value) => { + const maxPersonalDiscount = + slice.configJson?.maxPersonalDiscount ?? 100 + if (Number(value) > maxPersonalDiscount) { + value = String(maxPersonalDiscount) + } + return `${value}${formatMessage(t.personalDiscount.suffix)}` + }} + /> + </Field> + + {status === Status.PARENTAL_LEAVE && ( + <Field + heading={formatMessage(t.parentalLeavePeriod.heading)} + description={formatMessage(t.parentalLeavePeriod.description)} + > + <Select + onChange={(option) => { + setParentalLeavePeriod( + (option?.value as ParentalLeavePeriod) ?? null, + ) + }} + value={parentalLeavePeriodOptions.find( + (option) => option.value === parentalLeavePeriod, + )} + label={formatMessage(t.parentalLeavePeriod.label)} + options={parentalLeavePeriodOptions} + /> + </Field> + )} + + {status === Status.PARENTAL_LEAVE && ( + <Field + heading={formatMessage(t.parentalLeaveRatio.heading)} + description={formatMessage(t.parentalLeaveRatio.description)} + > + <NumberFormat + onValueChange={({ value }) => { + setParentalLeaveRatio(Number(value)) + }} + label={formatMessage(t.parentalLeaveRatio.label)} + value={String(parentalLeaveRatio || '')} + customInput={Input} + name="parentalLeaveRatio" + id="parentalLeaveRatio" + type="text" + inputMode="numeric" + suffix={formatMessage(t.parentalLeaveRatio.suffix)} + placeholder={formatMessage(t.parentalLeaveRatio.placeholder)} + format={(value) => { + const maxParentalLeaveRatio = + slice.configJson?.maxParentalLeaveRatio ?? 100 + if (Number(value) > maxParentalLeaveRatio) { + value = String(maxParentalLeaveRatio) + } + return `${value}${formatMessage(t.parentalLeaveRatio.suffix)}` + }} + /> + </Field> + )} + + <Button disabled={!canCalculate()} onClick={changeScreen}> + {formatMessage(t.calculate.buttonText)} + </Button> + </Stack> + </Box> + ) +} + +const yearConfigSchema = z.object({ + Persónuafsláttur: z.number(), + 'Skattmörk þrep 1': z.number(), + 'Skattmörk þrep 2': z.number(), + 'Skattprósenta þrep 1': z.number(), + 'Skattprósenta þrep 2': z.number(), + 'Skattprósenta þrep 3': z.number(), + 'Fæðingarstyrkur hærri': z.number(), + 'Fæðingarstyrkur lægri': z.number(), + 'Hlutfall fæðingarorlofs': z.number(), + 'Fæðingarstyrkur almennur': z.number(), + 'Fæðingarstyrkur námsmanna': z.number(), + 'Hámarks laun fyrir fæðingarorlof': z.number(), + 'Skyldu lífeyrir': z.number(), +}) + +const calculateResults = ( + input: { + status: Status | null + income: number | null + personalDiscount: number | null + additionalPensionFundingPercentage: number | null + union: string | null + parentalLeavePeriod: ParentalLeavePeriod | null + parentalLeaveRatio: number | null + birthyear: number | null + workPercentage: WorkPercentage | null + legalDomicileInIceland: LegalDomicileInIceland | null + }, + slice: ParentalLeaveCalculatorProps['slice'], +) => { + const yearConfig = slice.configJson?.yearConfig?.[String(input.birthyear)] + const parseResult = yearConfigSchema.safeParse(yearConfig) + if (!parseResult.success) { + return null + } + + const constants = { + personalDiscount: parseResult.data['Persónuafsláttur'], + taxBracket1: parseResult.data['Skattmörk þrep 1'], + taxBracket2: parseResult.data['Skattmörk þrep 2'], + taxRate1: parseResult.data['Skattprósenta þrep 1'], + taxRate2: parseResult.data['Skattprósenta þrep 2'], + taxRate3: parseResult.data['Skattprósenta þrep 3'], + parentalLeaveHigh: parseResult.data['Fæðingarstyrkur hærri'], + parentalLeaveLow: parseResult.data['Fæðingarstyrkur lægri'], + parentalLeaveRatio: parseResult.data['Hlutfall fæðingarorlofs'], + parentalLeaveGeneral: parseResult.data['Fæðingarstyrkur almennur'], + parentalLeaveStudent: parseResult.data['Fæðingarstyrkur námsmanna'], + maxIncome: parseResult.data['Hámarks laun fyrir fæðingarorlof'], + pensionFundingRequiredPercentage: parseResult.data['Skyldu lífeyrir'], + } + + let mainResultBeforeDeduction = 0 + let mainResultAfterDeduction = 0 + let unionFee = 0 + let totalTax = 0 + let usedPersonalDiscount = 0 + let additionalPensionFunding = 0 + let pensionFunding = 0 + + if (input.status === Status.STUDENT) { + mainResultBeforeDeduction = constants.parentalLeaveStudent + + let taxStep = 1 + + if (mainResultBeforeDeduction > constants.taxBracket1) { + taxStep = 2 + } else if (mainResultBeforeDeduction > constants.taxBracket2) { + taxStep = 3 + } + + if (taxStep === 1) { + totalTax = mainResultBeforeDeduction * (constants.taxRate1 / 100) + } else if (taxStep === 2) { + totalTax = mainResultBeforeDeduction * (constants.taxRate2 / 100) + } else if (taxStep === 3) { + totalTax = mainResultBeforeDeduction * (constants.taxRate3 / 100) + } + + usedPersonalDiscount = + constants.personalDiscount * ((input.personalDiscount ?? 100) / 100) + + if (usedPersonalDiscount > totalTax) { + usedPersonalDiscount = totalTax + } + + mainResultAfterDeduction = mainResultBeforeDeduction + + mainResultAfterDeduction = + mainResultAfterDeduction - (totalTax - usedPersonalDiscount) + } + + if (input.status === Status.OUTSIDE_WORKFORCE) { + mainResultBeforeDeduction = constants.parentalLeaveGeneral + + let taxStep = 1 + + if (mainResultBeforeDeduction > constants.taxBracket1) { + taxStep = 2 + } else if (mainResultBeforeDeduction > constants.taxBracket2) { + taxStep = 3 + } + + if (taxStep === 1) { + totalTax = mainResultBeforeDeduction * (constants.taxRate1 / 100) + } else if (taxStep === 2) { + totalTax = mainResultBeforeDeduction * (constants.taxRate2 / 100) + } else if (taxStep === 3) { + totalTax = mainResultBeforeDeduction * (constants.taxRate3 / 100) + } + + usedPersonalDiscount = + constants.personalDiscount * ((input.personalDiscount ?? 100) / 100) + + if (usedPersonalDiscount > totalTax) { + usedPersonalDiscount = totalTax + } + + mainResultAfterDeduction = mainResultBeforeDeduction + + mainResultAfterDeduction = + mainResultAfterDeduction - (totalTax - usedPersonalDiscount) + } + + if (input.status === Status.PARENTAL_LEAVE) { + if ( + typeof input.income !== 'number' || + typeof input.parentalLeaveRatio !== 'number' + ) { + return null + } + + mainResultBeforeDeduction = + input.income * (constants.parentalLeaveRatio / 100) + + if ( + input.workPercentage === WorkPercentage.OPTION_1 && + mainResultBeforeDeduction < constants.parentalLeaveLow + ) { + mainResultBeforeDeduction = constants.parentalLeaveLow + } else if ( + input.workPercentage === WorkPercentage.OPTION_2 && + mainResultBeforeDeduction < constants.parentalLeaveHigh + ) { + mainResultBeforeDeduction = constants.parentalLeaveHigh + } + + if (mainResultBeforeDeduction > constants.maxIncome) { + mainResultBeforeDeduction = constants.maxIncome + } + + mainResultBeforeDeduction *= input.parentalLeaveRatio / 100 + + let paternityLeavePeriodMultiplier = 1 + + if (input.parentalLeavePeriod === ParentalLeavePeriod.THREE_WEEKS) { + paternityLeavePeriodMultiplier = 3 / 4 + } else if (input.parentalLeavePeriod === ParentalLeavePeriod.TWO_WEEKS) { + paternityLeavePeriodMultiplier = 1 / 2 + } + + mainResultBeforeDeduction *= paternityLeavePeriodMultiplier + + let taxStep = 1 + + if (mainResultBeforeDeduction > constants.taxBracket1) { + taxStep = 2 + } else if (mainResultBeforeDeduction > constants.taxBracket2) { + taxStep = 3 + } + + if (taxStep === 1) { + totalTax = mainResultBeforeDeduction * (constants.taxRate1 / 100) + } else if (taxStep === 2) { + totalTax = mainResultBeforeDeduction * (constants.taxRate2 / 100) + } else if (taxStep === 3) { + totalTax = mainResultBeforeDeduction * (constants.taxRate3 / 100) + } + + usedPersonalDiscount = + constants.personalDiscount * + ((input.personalDiscount ?? 0) / 100) * + paternityLeavePeriodMultiplier + + if (usedPersonalDiscount > totalTax) { + usedPersonalDiscount = totalTax + } + + additionalPensionFunding = + mainResultBeforeDeduction * + ((input.additionalPensionFundingPercentage ?? 0) / 100) + + pensionFunding = + mainResultBeforeDeduction * + (constants.pensionFundingRequiredPercentage / 100) + + const unionOptions: { label: string; percentage: number }[] = + slice.configJson?.unionOptions ?? [] + + unionFee = + mainResultBeforeDeduction * + ((unionOptions.find((option) => option.label === input.union) + ?.percentage ?? 0) / + 100) + + /* --- After deduction --- */ + mainResultAfterDeduction = mainResultBeforeDeduction + mainResultAfterDeduction -= unionFee + mainResultAfterDeduction = + mainResultAfterDeduction - (totalTax - usedPersonalDiscount) + mainResultAfterDeduction -= additionalPensionFunding + mainResultAfterDeduction -= pensionFunding + } + + return { + results: { + mainResultBeforeDeduction, + mainResultAfterDeduction, + unionFee, + pensionFunding, + totalTax, + usedPersonalDiscount, + additionalPensionFunding, + }, + constants, + } +} + +const ResultsScreen = ({ slice, changeScreen }: ScreenProps) => { + const { formatMessage } = useIntl() + + const [status] = useQueryState<Status>( + 'status', + parseAsStringEnum(Object.values(Status)).withDefault(Status.PARENTAL_LEAVE), + ) + const [birthyear] = useQueryState('birthyear', parseAsInteger) + const [workPercentage] = useQueryState( + 'workPercentage', + parseAsStringEnum(Object.values(WorkPercentage)), + ) + const [income] = useQueryState('income', parseAsInteger) + const [additionalPensionFundingPercentage] = useQueryState( + 'additionalPensionFunding', + parseAsInteger, + ) + const [union] = useQueryState('union', parseAsString) + const [personalDiscount] = useQueryState( + 'personalDiscount', + parseAsInteger.withDefault(100), + ) + const [parentalLeavePeriod] = useQueryState( + 'parentalLeavePeriod', + parseAsStringEnum(Object.values(ParentalLeavePeriod)), + ) + const [parentalLeaveRatio] = useQueryState( + 'parentalLeaveRatio', + parseAsInteger.withDefault(100), + ) + const [legalDomicileInIceland] = useQueryState( + 'legalDomicileInIceland', + parseAsStringEnum(Object.values(LegalDomicileInIceland)), + ) + + const calculation = calculateResults( + { + status, + birthyear, + workPercentage, + income, + additionalPensionFundingPercentage, + union, + personalDiscount, + parentalLeavePeriod, + parentalLeaveRatio, + legalDomicileInIceland, + }, + slice, + ) + + if (!calculation) { + return ( + <Stack space={3}> + <AlertMessage + type="error" + title={formatMessage(t.error.title)} + message={formatMessage(t.error.message)} + /> + <Button onClick={changeScreen} variant="text" preTextIcon="arrowBack"> + {formatMessage(t.results.changeAssumptions)} + </Button> + </Stack> + ) + } + + const { results, constants } = calculation + + const mainSectionKeys = { + [Status.PARENTAL_LEAVE]: { + [ParentalLeavePeriod.TWO_WEEKS]: { + heading: t.results.mainParentalLeaveHeadingTwoWeeks, + description: t.results.mainParentalLeaveDescriptionTwoWeeks, + }, + [ParentalLeavePeriod.THREE_WEEKS]: { + heading: t.results.mainParentalLeaveHeadingThreeWeeks, + description: t.results.mainParentalLeaveDescriptionThreeWeeks, + }, + [ParentalLeavePeriod.MONTH]: { + heading: t.results.mainParentalLeaveHeadingMonth, + description: t.results.mainParentalLeaveDescriptionMonth, + }, + }, + [Status.STUDENT]: { + heading: t.results.mainStudentHeading, + description: t.results.mainStudentDescription, + }, + [Status.OUTSIDE_WORKFORCE]: { + heading: t.results.mainOutsideWorkforceHeading, + description: t.results.mainOutsideWorkforceDescription, + }, + } + + const ratio = + status === Status.PARENTAL_LEAVE && parentalLeaveRatio < 100 + ? parentalLeaveRatio + : 100 + + const formatCurrency = (value: number | null | undefined) => + formatCurrencyUtil( + value, + formatMessage(t.results.currencySuffix), + Math.ceil, + ) + + let mainResultBeforeDeductionPrefixKey = + t.results.mainResultBeforeDeductionDescriptionMonth + + if (status === Status.PARENTAL_LEAVE) { + if (parentalLeavePeriod === ParentalLeavePeriod.THREE_WEEKS) { + mainResultBeforeDeductionPrefixKey = + t.results.mainResultBeforeDeductionDescriptionThreeWeeks + } else if (parentalLeavePeriod === ParentalLeavePeriod.TWO_WEEKS) { + mainResultBeforeDeductionPrefixKey = + t.results.mainResultBeforeDeductionDescriptionTwoWeeks + } + } + + if (status === Status.STUDENT) { + mainResultBeforeDeductionPrefixKey = + t.results.mainResultBeforeDeductionDescriptionStudent + } + + if (status === Status.OUTSIDE_WORKFORCE) { + mainResultBeforeDeductionPrefixKey = + t.results.mainResultBeforeDeductionDescriptionOutsideWorkforce + } + + return ( + <Stack space={5}> + <Stack space={3}> + <Box background="blue100" paddingY={3} paddingX={4}> + <Box className={styles.resultBorder} paddingY={2} paddingX={3}> + <Stack space={2}> + <Text variant="h3"> + {status === Status.PARENTAL_LEAVE + ? formatMessage( + mainSectionKeys[status][ + parentalLeavePeriod ?? ParentalLeavePeriod.MONTH + ].heading, + ) + : formatMessage(mainSectionKeys[status].heading)} + </Text> + <Text> + {status === Status.PARENTAL_LEAVE + ? formatMessage( + mainSectionKeys[status][ + parentalLeavePeriod ?? ParentalLeavePeriod.MONTH + ].description, + { + ratio, + }, + ) + : formatMessage(mainSectionKeys[status].description, { + ratio, + })} + </Text> + <Text fontWeight="semiBold" variant="h3"> + {formatCurrency(results.mainResultAfterDeduction)} + </Text> + <Text>{formatMessage(t.results.mainDisclaimer)}</Text> + </Stack> + </Box> + </Box> + + <Button onClick={changeScreen} variant="text" preTextIcon="arrowBack"> + {formatMessage(t.results.changeAssumptions)} + </Button> + </Stack> + + <Stack space={6}> + {status === Status.PARENTAL_LEAVE && ( + <Stack space={3}> + <MarkdownText replaceNewLinesWithBreaks={false}> + {formatMessage(t.results.incomePrerequisitesDescription, { + maxIncome: formatCurrencyUtil( + constants.maxIncome, + '', + Math.ceil, + ), + parentalLeaveRatio: constants.parentalLeaveRatio, + parentalLeaveLow: formatCurrencyUtil( + constants.parentalLeaveLow, + '', + Math.ceil, + ), + parentalLeaveHigh: formatCurrencyUtil( + constants.parentalLeaveHigh, + '', + Math.ceil, + ), + })} + </MarkdownText> + <Table.Table> + <Table.Head> + <Table.HeadData> + {formatMessage(t.results.incomePrerequisitesHeading)} + </Table.HeadData> + <Table.HeadData align="right"> + {formatMessage(t.results.perMonth)} + </Table.HeadData> + </Table.Head> + <Table.Body> + <Table.Row> + <Table.Data> + <Text fontWeight="semiBold"> + {formatMessage(t.results.incomePrerequisitesSubHeading)} + </Text> + </Table.Data> + <Table.Data> + <Text whiteSpace="nowrap">{formatCurrency(income)}</Text> + </Table.Data> + </Table.Row> + </Table.Body> + </Table.Table> + </Stack> + )} + + <Table.Table> + <Table.Head> + <Table.HeadData> + {formatMessage(t.results.mainResultBeforeDeductionHeading)} + </Table.HeadData> + <Table.HeadData align="right"> + {formatMessage(t.results.perMonth)} + </Table.HeadData> + </Table.Head> + <Table.Body> + <Table.Row> + <Table.Data> + <Text fontWeight="semiBold"> + {formatMessage(mainResultBeforeDeductionPrefixKey, { + ratio, + })} + </Text> + </Table.Data> + <Table.Data align="right"> + <Text whiteSpace="nowrap"> + {formatCurrency(results.mainResultBeforeDeduction)} + </Text> + </Table.Data> + </Table.Row> + </Table.Body> + </Table.Table> + + <Table.Table> + <Table.Head> + <Table.HeadData> + {formatMessage(t.results.deductionHeading)} + </Table.HeadData> + <Table.HeadData align="right"> + {formatMessage(t.results.amount)} + </Table.HeadData> + </Table.Head> + <Table.Body> + <Table.Row> + <Table.Data> + <Text fontWeight="semiBold"> + {formatMessage(t.results.pensionFunding)} + </Text> + </Table.Data> + <Table.Data align="right"> + <Text whiteSpace="nowrap"> + {formatCurrency(results.pensionFunding)} + </Text> + </Table.Data> + </Table.Row> + <Table.Row> + <Table.Data> + <Text fontWeight="semiBold"> + {formatMessage(t.results.additionalPensionFunding)} + </Text> + </Table.Data> + <Table.Data align="right"> + <Text whiteSpace="nowrap"> + {formatCurrency(results.additionalPensionFunding)} + </Text> + </Table.Data> + </Table.Row> + <Table.Row> + <Table.Data> + <Stack space={2}> + <Text fontWeight="semiBold"> + {formatMessage(t.results.tax)} + </Text> + <Text> + {formatMessage(t.results.totalTax)} -{' '} + {formatCurrency(results.totalTax)} + </Text> + <Text> + {formatMessage(t.results.usedPersonalDiscount)} -{' '} + {formatCurrency(results.usedPersonalDiscount)} + </Text> + </Stack> + </Table.Data> + <Table.Data align="right" style={{ verticalAlign: 'top' }}> + <Text whiteSpace="nowrap"> + {formatCurrency( + results.totalTax - results.usedPersonalDiscount, + )} + </Text> + </Table.Data> + </Table.Row> + <Table.Row> + <Table.Data> + <Text fontWeight="semiBold"> + {formatMessage(t.results.unionFee)} + </Text> + </Table.Data> + <Table.Data align="right"> + <Text whiteSpace="nowrap"> + {formatCurrency(results.unionFee)} + </Text> + </Table.Data> + </Table.Row> + </Table.Body> + </Table.Table> + </Stack> + </Stack> + ) +} + +export const ParentalLeaveCalculator = ({ + slice, +}: ParentalLeaveCalculatorProps) => { + const containerRef = useRef<HTMLDivElement>(null) + const [activeScreen, setActiveScreen] = useState(Screen.FORM) + + return ( + <div ref={containerRef}> + {activeScreen !== Screen.RESULTS && ( + <FormScreen + slice={slice} + changeScreen={() => { + setActiveScreen(Screen.RESULTS) + window.scrollTo({ + behavior: 'smooth', + top: containerRef.current?.offsetTop ?? 0, + }) + }} + /> + )} + {activeScreen === Screen.RESULTS && ( + <ResultsScreen + slice={slice} + changeScreen={() => { + setActiveScreen(Screen.FORM) + window.scrollTo({ + behavior: 'smooth', + top: containerRef.current?.offsetTop ?? 0, + }) + }} + /> + )} + </div> + ) +} diff --git a/apps/web/components/connected/ParentalLeaveCalculator/translations.strings.ts b/apps/web/components/connected/ParentalLeaveCalculator/translations.strings.ts new file mode 100644 index 000000000000..2a5b163bc687 --- /dev/null +++ b/apps/web/components/connected/ParentalLeaveCalculator/translations.strings.ts @@ -0,0 +1,496 @@ +import { defineMessages } from 'react-intl' + +export const translations = { + status: defineMessages({ + heading: { + id: 'web.parentalLeaveCalculator:status.heading', + defaultMessage: 'Veldu það sem á við þig', + description: 'Heading fyrir ofan "Val um stöðu" dropdown', + }, + label: { + id: 'web.parentalLeaveCalculator:status.label', + defaultMessage: 'Val um stöðu', + description: 'Label fyrir "Val um stöðu" dropdown', + }, + parentalLeaveOption: { + id: 'web.parentalLeaveCalculator:status.parentalLeaveOption', + defaultMessage: 'Fæðingarorlof', + description: 'Fæðingarorlofs - valmöguleiki í "Val um stöðu" dropdown', + }, + studentOption: { + id: 'web.parentalLeaveCalculator:status.studentOption', + defaultMessage: 'Fæðingarstyrkur námsmanna', + description: + 'Fæðingarstyrkur námsmanna - valmöguleiki í "Val um stöðu" dropdown', + }, + outsideWorkforceOption: { + id: 'web.parentalLeaveCalculator:status.outsideWorkforceOption', + defaultMessage: 'Fæðingarstyrkur utan vinnumarkaðs', + description: + 'Fæðingarstyrkur utan vinnumarkaða - valmöguleiki í "Val um stöðu" dropdown', + }, + }), + legalDomicile: defineMessages({ + heading: { + id: 'web.parentalLeaveCalculator:legalDomicile.heading', + defaultMessage: 'Átt þú lögheimili á Íslandi', + description: + 'Heading fyrir ofan "Átt þú lögheimili á Íslandi" radio takka', + }, + tooltip: { + id: 'web.parentalLeaveCalculator:legalDomicile.tooltip', + defaultMessage: + 'Nánari upplýsingar um það hvað telst vera samfellt starf hjá starfsmönnum eða sjálfstætt starfandi einstaklingum og upplýsingar um önnur tilvik sem teljast jafnframt til þátttöku á innlendum vinnumarkaði má finna undir flipanum réttindi foreldra á innlendum vinnumarkaði á heimasíðu Fæðingarorlofssjóðs.', + description: 'Tooltip fyrir ofan "Lögheimli á íslandi" dropdown', + }, + yes: { + id: 'web.parentalLeaveCalculator:legalDomicile.yes', + defaultMessage: 'Já', + description: 'Já', + }, + no: { + id: 'web.parentalLeaveCalculator:legalDomicile.no', + defaultMessage: 'Nei', + description: 'Nei', + }, + dontHaveRight: { + id: 'web.parentalLeaveCalculator:legalDomicile.dontHaveRight', + defaultMessage: 'Réttur ekki til staðar miðað við uppgefnar forsendur.', + description: 'Réttur ekki til staðar miðað við uppgefnar forsendur.', + }, + }), + childBirthYear: defineMessages({ + heading: { + id: 'web.parentalLeaveCalculator:childBirthYear.heading', + defaultMessage: 'Fæðingarár barns', + description: 'Heading fyrir ofan "Fæðingarár barns" dropdown', + }, + description: { + id: 'web.parentalLeaveCalculator:childBirthYear.description', + defaultMessage: + 'Miðað er við ár sem barn fæðist, kemur inn á heimili ef það er frumætleitt eða tekið í varanlegt fóstur.', + description: 'Lýsing fyrir ofan "Fæðingarár barns" dropdown', + }, + label: { + id: 'web.parentalLeaveCalculator:childBirthYear.label', + defaultMessage: 'Veldu ár', + description: 'Label fyrir "Fæðingarár barns" dropdown', + }, + }), + workPercentage: defineMessages({ + heading: { + id: 'web.parentalLeaveCalculator:workPercentage.heading', + defaultMessage: 'Starfshlutfall', + description: 'Heading fyrir ofan "Starfshlutfall" reit', + }, + description: { + id: 'web.parentalLeaveCalculator:workPercentage.description', + defaultMessage: + 'Hlutfall vinnu á íslenskum vinnumarkaði síðustu 6 mánuði fyrir áætlaðan fæðingardag. Ef barn er frumættleitt eða tekið í varanlegt fóstur er miðað við daginn sem barnið kemur inn á heimilið.', + description: 'Lýsing fyrir ofan "Fæðingarár barns" dropdown', + }, + tooltip: { + id: 'web.parentalLeaveCalculator:workPercentage.tooltip', + defaultMessage: + 'Nánari upplýsingar um það hvernig starfshlutfall er fundið út má finna undir flipanum réttindi foreldra á innlendum vinnumarkaði á heimasíðu Fæðingarorlofssjóðs.', + description: 'Texti fyrir tooltip við "Starfshlutfall" reit', + }, + option1: { + id: 'web.parentalLeaveCalculator:workPercentage.option1', + defaultMessage: '25% til 49%', + description: 'Valmöguleiki 1 fyrir starfshlutfall', + }, + option2: { + id: 'web.parentalLeaveCalculator:workPercentage.option2', + defaultMessage: '50% til 100%', + description: 'Valmöguleiki 2 fyrir starfshlutfall', + }, + }), + income: defineMessages({ + heading: { + id: 'web.parentalLeaveCalculator:income.heading', + defaultMessage: 'Meðaltekjur á mánuði fyrir skatt', + description: 'Heading fyrir ofan "Meðaltekjur" reit', + }, + description: { + id: 'web.parentalLeaveCalculator:income.description', + defaultMessage: + 'Fyrir launafólk er miðað við 12 mánaða tímabil sem lýkur 6 mánuðum fyrir fæðingardag barns. Fyrir sjálfstætt starfandi er miðað við tekjuárið á undan fæðingarári barnsins.', + description: 'Lýsing fyrir ofan "Meðaltekjur" reit', + }, + inputSuffix: { + id: 'web.parentalLeaveCalculator:income.inputSuffix', + defaultMessage: ' krónur', + description: 'Viðskeyti eftir tekjutöluna sem notandi hefur slegið inn', + }, + inputPlaceholder: { + id: 'web.parentalLeaveCalculator:income.inputPlaceholder', + defaultMessage: 'krónur', + description: 'Placeholder texti fyrir meðaltekju innsláttarreit', + }, + label: { + id: 'web.parentalLeaveCalculator:income.label', + defaultMessage: 'Meðaltekjur', + description: 'Label á meðaltekju innsláttarreit', + }, + tooltip: { + id: 'web.parentalLeaveCalculator:income.tooltip', + defaultMessage: + 'Miðað er við allar þær tekjur sem greitt er tryggingagjald af og greiðslur úr Fæðingarorlofssjóði, Atvinnuleysistryggingasjóði, Ábyrgðasjóði launa, sjúkra- og slysadagpeninga, greiðslur úr sjúkrasjóðum stéttarfélaga, bætur frá tryggingafélagi vegna tímabundins atvinnutjóns og tekjutengdar greiðslur samkvæmt III. kafla laga um greiðslur til foreldra langveikra eða alvarlegra fatlaðra barna.', + description: 'Tooltip á meðaltekju innsláttarreit', + }, + }), + additionalPensionFunding: defineMessages({ + heading: { + id: 'web.parentalLeaveCalculator:additionalPensionFunding.heading', + defaultMessage: 'Viðbótalífeyrissparnaður', + description: 'Heading fyrir ofan "Viðbótalífeyrissparnaður" dropdown', + }, + label: { + id: 'web.parentalLeaveCalculator:additionalPensionFunding.label', + defaultMessage: 'Viðbótalífeyrissparnaður', + description: 'Label fyrir "Viðbótalífeyrissparnaður" dropdown', + }, + description: { + id: 'web.parentalLeaveCalculator:additionalPensionFunding.description', + defaultMessage: + 'Það er valkvætt að greiða viðbótarlífeyrissparnað. Fæðingarorlofssjóður greiðir ekki mótframlag.', + description: 'Lýsing fyrir ofan "Viðbótalífeyrissparnaður" dropdown', + }, + optionSuffix: { + id: 'web.parentalLeaveCalculator:additionalPensionFunding.optionSuffix', + defaultMessage: ' prósent', + description: + 'Viðskeyti eftir valmöguleika í Viðbótalífeyrissparnaðs dropdown, dæmi "1< prósent>"', + }, + none: { + id: 'web.parentalLeaveCalculator:additionalPensionFunding.none', + defaultMessage: 'Enginn', + description: + 'Valmöguleiki ef það er enginn viðbótalífeyrissparnaður til staðar hjá viðkomandi', + }, + }), + union: defineMessages({ + heading: { + id: 'web.parentalLeaveCalculator:union.heading', + defaultMessage: 'Stéttarfélagsgjöld', + description: 'Heading fyrir ofan "Stéttarfélagsgjöld" dropdown', + }, + description: { + id: 'web.parentalLeaveCalculator:union.description', + defaultMessage: + 'Það er valkvætt að greiða í stéttarfélag. Athugið að réttindi geta tapast hjá stéttarfélagi ef greiðslum þar er ekki viðhaldið meðan á fæðingarorlofi stendur.', + description: 'Lýsing fyrir ofan "Stéttarfélagsgjöld" dropdown', + }, + label: { + id: 'web.parentalLeaveCalculator:union.label', + defaultMessage: 'Stéttarfélagsgjöld', + description: 'Label á "Stéttarfélagsgjöld" dropdown', + }, + none: { + id: 'web.parentalLeaveCalculator:union.none', + defaultMessage: 'Engin', + description: + 'Valmöguleiki ef það er engin stéttarfélagsgjöld til staðar hjá viðkomandi', + }, + }), + error: defineMessages({ + title: { + id: 'web.parentalLeaveCalculator:error.title', + defaultMessage: 'Villa', + description: 'Titill á villuskilaboðum ef ekki tekst að reikna', + }, + message: { + id: 'web.parentalLeaveCalculator:error.message', + defaultMessage: 'Ekki tókst að reikna', + description: + 'Villutexti ef ekki tekst að reikna (ef það t.d. vantar fasta í vefumsjónarkerfi)', + }, + }), + personalDiscount: defineMessages({ + heading: { + id: 'web.parentalLeaveCalculator:personalDiscount.heading', + defaultMessage: 'Hlutfall persónuafsláttar', + description: 'Heading fyrir ofan "Hlutfall persónuafsláttar" reit', + }, + description: { + id: 'web.parentalLeaveCalculator:personalDiscount.description', + defaultMessage: + 'Hægt er að velja hversu hátt hlutfall persónuafsláttar á að nýta hjá Fæðingarorlofssjóði.', + description: 'Lýsing fyrir ofan "Hlutfall persónuafsláttar" reit', + }, + label: { + id: 'web.parentalLeaveCalculator:personalDiscount.label', + defaultMessage: 'Hlutfall persónuafsláttar', + description: 'Label á "Hlutfall persónuafsláttar" reit', + }, + placeholder: { + id: 'web.parentalLeaveCalculator:personalDiscount.placeholder', + defaultMessage: '%', + description: 'Placeholder á "Hlutfall persónuafsláttar" reit', + }, + suffix: { + id: 'web.parentalLeaveCalculator:personalDiscount.suffix', + defaultMessage: '%', + description: + 'Viðskeyti á eftir því sem notandi slær inn í "Hlutfall persónuafsláttar" reit', + }, + }), + parentalLeavePeriod: defineMessages({ + heading: { + id: 'web.parentalLeaveCalculator:parentalLeavePeriod.heading', + defaultMessage: 'Tímabil fæðingarorlofs (Lágmark 2 vikur)', + description: 'Heading fyrir ofan "Tímabil fæðingarorlofs" dropdown', + }, + description: { + id: 'web.parentalLeaveCalculator:parentalLeavePeriod.description', + defaultMessage: + 'Útreikningar miðast við mánuð í fæðingarorlofi eða hluta úr mánuði.', + description: 'Lýsing fyrir ofan "Hlutfall persónuafsláttar" dropdown', + }, + label: { + id: 'web.parentalLeaveCalculator:parentalLeavePeriod.label', + defaultMessage: 'Tímabil fæðingarorlofs', + description: 'Label á "Tímabil fæðingarorlofs" dropdown', + }, + placeholder: { + id: 'web.parentalLeaveCalculator:parentalLeavePeriod.placeholder', + defaultMessage: 'Veldu', + description: 'Placeholder á "Hlutfall persónuafsláttar" dropdown', + }, + twoWeeksOption: { + id: 'web.parentalLeaveCalculator:parentalLeavePeriod.twoWeeksOption', + defaultMessage: '2 vikur', + description: 'Valmöguleiki um 2 vikur í fæðingarorlofstímabili', + }, + threeWeeksOption: { + id: 'web.parentalLeaveCalculator:parentalLeavePeriod.threeWeeksOption', + defaultMessage: '3 vikur', + description: 'Valmöguleiki um 3 vikur í fæðingarorlofstímabili', + }, + monthOption: { + id: 'web.parentalLeaveCalculator:parentalLeavePeriod.monthOption', + defaultMessage: '1 mánuður', + description: 'Valmöguleiki um 1 mánuð í fæðingarorlofstímabili', + }, + }), + parentalLeaveRatio: defineMessages({ + heading: { + id: 'web.parentalLeaveCalculator:parentalLeaveRatio.heading', + defaultMessage: 'Hlutfall fæðingarorlofs', + description: 'Heading fyrir ofan "Hlutfall fæðingarorlofs" reit', + }, + description: { + id: 'web.parentalLeaveCalculator:parentalLeaveRatio.description', + defaultMessage: + 'Hægt er að velja lægra hlutfall fæðingarorlofs ef foreldri vill t.d. dreifa greiðslum yfir lengra tímabil eða vinna samhliða fæðingarorlofi.', + description: 'Lýsing fyrir ofan "Hlutfall fæðingarorlofs" reit', + }, + tooltip: { + id: 'web.parentalLeaveCalculator:parentalLeaveRatio.tooltip', + defaultMessage: + 'Dæmi um lægra hlutfall fæðingarorlofs: 3 mánuðir dreift á 4,5 mánuði = 66% hlutfall eða 6 mánuðir dreift á 9 mánuði = 66% hlutfall. 3 mánuðir dreift á 6 mánuði = 50% hlutfall eða 6 mánuðir dreift á 12 mánuði = 50% hlutfall. 3 mánuðir dreift á 9 mánuði = 33% hlutfall eða 6 mánuðir dreift á 18 mánuði = 33% hlutfall. 3 mánuðir dreift á 12 mánuði = 25% hlutfall eða 6 mánuðir dreift á 24 mánuði = 25% hlutfall.', + description: 'Tooltip fyrir ofan "Hlutfall fæðingarorlofs" reit', + }, + label: { + id: 'web.parentalLeaveCalculator:parentalLeaveRatio.label', + defaultMessage: 'Hlutfall fæðingarorlofs', + description: 'Label á "Hlutfall fæðingarorlofs" reit', + }, + placeholder: { + id: 'web.parentalLeaveCalculator:parentalLeaveRatio.placeholder', + defaultMessage: '%', + description: 'Placeholder á "Hlutfall fæðingarorlofs" reit', + }, + suffix: { + id: 'web.parentalLeaveCalculator:parentalLeaveRatio.suffix', + defaultMessage: '%', + description: + 'Viðskeyti á eftir því sem notandi slær inn í "Hlutfall fæðingarorlofs" reit', + }, + }), + calculate: defineMessages({ + buttonText: { + id: 'web.parentalLeaveCalculator:calculate', + defaultMessage: 'Reikna', + description: 'Texti á "Reikna" hnapp', + }, + }), + results: defineMessages({ + mainParentalLeaveHeadingMonth: { + id: 'web.parentalLeaveCalculator:results.mainParentalLeaveHeadingMonth', + defaultMessage: 'Fæðingarorlof á mánuði', + description: + 'Niðurstöðuskjár - Aðal heading fyrir fæðingarorlof á mánuði', + }, + mainParentalLeaveDescriptionMonth: { + id: 'web.parentalLeaveCalculator:results.mainParentalLeaveDescriptionMonth', + defaultMessage: '{ratio}% fæðingarorlof á mánuði (eftir frádrátt)', + description: + 'Niðurstöðuskjár - Lýsing á prósentutölu fæðingarorlofs á mánuði sem notandi valdi', + }, + mainParentalLeaveHeadingThreeWeeks: { + id: 'web.parentalLeaveCalculator:results.mainParentalLeaveHeadingThreeWeeks', + defaultMessage: 'Fæðingarorlof á 3 vikum', + description: + 'Niðurstöðuskjár - Aðal heading fyrir fæðingarorlof á 3 vikum', + }, + mainParentalLeaveDescriptionThreeWeeks: { + id: 'web.parentalLeaveCalculator:results.mainParentalLeaveDescriptionThreeWeeks', + defaultMessage: '{ratio}% fæðingarorlof á 3 vikum (eftir frádrátt)', + description: + 'Niðurstöðuskjár - Lýsing á prósentutölu fæðingarorlofs á 3 vikum sem notandi valdi', + }, + mainParentalLeaveHeadingTwoWeeks: { + id: 'web.parentalLeaveCalculator:results.mainParentalLeaveHeadingTwoWeeks', + defaultMessage: 'Fæðingarorlof á 2 vikum', + description: + 'Niðurstöðuskjár - Aðal heading fyrir fæðingarorlof á 2 vikum', + }, + mainParentalLeaveDescriptionTwoWeeks: { + id: 'web.parentalLeaveCalculator:results.mainParentalLeaveDescriptionTwoWeeks', + defaultMessage: '{ratio}% fæðingarorlof á 2 vikum (eftir frádrátt)', + description: + 'Niðurstöðuskjár - Lýsing á prósentutölu fæðingarorlofs á 2 vikum sem notandi valdi', + }, + mainStudentHeading: { + id: 'web.parentalLeaveCalculator:results.mainStudentHeading', + defaultMessage: 'Fæðingarstyrkur námsmanna á mánuði', + description: + 'Niðurstöðuskjár - Aðal heading fyrir fæðingarstyrk námsmanna', + }, + mainStudentDescription: { + id: 'web.parentalLeaveCalculator:results.mainStudentDescription', + defaultMessage: + '{ratio}% fæðingarstyrkur námsmanna á mánuði (eftir frádrátt)', + description: + 'Niðurstöðuskjár - Lýsing á prósentutölu fæðingarstyrks á mánuði sem notandi valdi', + }, + mainOutsideWorkforceHeading: { + id: 'web.parentalLeaveCalculator:results.mainOutsideWorkforceHeading', + defaultMessage: 'Fæðingarstyrkur á mánuði', + description: + 'Niðurstöðuskjár - Aðal heading fyrir fæðingarstyrk utan vinnumarkaðs', + }, + mainOutsideWorkforceDescription: { + id: 'web.parentalLeaveCalculator:results.mainOutsideWorkforceDescription', + defaultMessage: '{ratio}% fæðingarstyrkur á mánuði (eftir frádrátt)', + description: + 'Niðurstöðuskjár - Lýsing á prósentutölu fæðingarstyrks á mánuði sem notandi valdi', + }, + currencySuffix: { + id: 'web.parentalLeaveCalculator:results.currencySuffix', + defaultMessage: ' krónur', + description: 'Niðurstöðuskjár - Viðskeyti eftir krónutölu', + }, + mainDisclaimer: { + id: 'web.parentalLeaveCalculator:results.mainDisclaimer', + defaultMessage: + 'Vinsamlega hafðu í huga að reiknivélin reiknar greiðslur miðað við þær forsendur sem þú gefur upp. Líkanið er einungis til leiðbeiningar en veitir ekki bindandi upplýsingar um endanlega afgreiðslu máls eða greiðslufjárhæðir.', + description: 'Niðurstöðuskjár - Texti fyrir neðan aðalkrónutölu', + }, + incomePrerequisitesHeading: { + id: 'web.parentalLeaveCalculator:results.incomePrerequisitesHeading', + defaultMessage: 'Launaforsendur', + description: 'Niðurstöðuskjár - Heading fyrir "Launaforsendur" lið', + }, + incomePrerequisitesSubHeading: { + id: 'web.parentalLeaveCalculator:results.incomePrerequisitesSubHeading', + defaultMessage: + 'Uppgefnar meðaltekjur innanlands á mánuði miðað við árið 2024', + description: 'Niðurstöðuskjár - Subheading fyrir "Launaforsendur" lið', + }, + incomePrerequisitesDescription: { + id: 'web.parentalLeaveCalculator:results.incomePrerequisitesDescription#markdown', + defaultMessage: `Fæðingarorlof nemur aldrei meira en {parentalLeaveRatio}% af uppgefnum launum\nFæðingarorlof er aldrei hærra en {maxIncome} krónur á mánuði\nFæðingarorlof er aldrei lægra en {parentalLeaveLow} krónur fyrir 25-49% starfshlutfall og {parentalLeaveHigh} krónur fyrir 50-100% starfshlutfall`, + description: 'Niðurstöðuskjár - Lýsing fyrir "Launaforsendur" lið', + }, + mainResultBeforeDeductionHeading: { + id: 'web.parentalLeaveCalculator:results.mainResultBeforeDeductionHeading', + defaultMessage: 'Niðurstaða', + description: + 'Niðurstöðuskjár - Heading fyrir niðurstöðulið (fyrir frádrátt)', + }, + mainResultBeforeDeductionDescriptionMonth: { + id: 'web.parentalLeaveCalculator:results.mainResultBeforeDeductionDescriptionMonth', + defaultMessage: '{ratio}% fæðingarorlof á mánuði (fyrir frádrátt)', + description: + 'Niðurstöðuskjár - Lýsing á hve mikið fæðingarorlof á mánuði notandi fær (fyrir frádrátt)', + }, + mainResultBeforeDeductionDescriptionThreeWeeks: { + id: 'web.parentalLeaveCalculator:results.mainResultBeforeDeductionDescriptionThreeWeeks', + defaultMessage: '{ratio}% fæðingarorlof á 3 vikum (fyrir frádrátt)', + description: + 'Niðurstöðuskjár - Lýsing á hve mikið fæðingarorlof notandi fær á 3 vikum (fyrir frádrátt)', + }, + mainResultBeforeDeductionDescriptionTwoWeeks: { + id: 'web.parentalLeaveCalculator:results.mainResultBeforeDeductionDescriptionTwoWeeks', + defaultMessage: '{ratio}% fæðingarorlof á 2 vikum (fyrir frádrátt)', + description: + 'Niðurstöðuskjár - Lýsing á hve mikið fæðingarorlof á 2 vikum notandi fær (fyrir frádrátt)', + }, + mainResultBeforeDeductionDescriptionStudent: { + id: 'web.parentalLeaveCalculator:results.mainResultBeforeDeductionDescriptionStudent', + defaultMessage: + '{ratio}% fæðingarstyrkur námsmanna á mánuði (fyrir frádrátt)', + description: + 'Niðurstöðuskjár - Lýsing á hve mikið fæðingarstyrkur námsmanna er (fyrir frádrátt)', + }, + mainResultBeforeDeductionDescriptionOutsideWorkforce: { + id: 'web.parentalLeaveCalculator:results.mainResultBeforeDeductionDescriptionOutsideWorkforce', + defaultMessage: '{ratio}% fæðingarstyrkur á mánuði (fyrir frádrátt)', + description: + 'Niðurstöðuskjár - Lýsing á hve mikið fæðingarstyrkur utan vinnumarkaðar er (fyrir frádrátt)', + }, + deductionHeading: { + id: 'web.parentalLeaveCalculator:results.deductionHeading', + defaultMessage: 'Frádreginn kostnaður', + description: 'Niðurstöðuskjár - Heading fyrir "Frádreginn kostnaður" lið', + }, + amount: { + id: 'web.parentalLeaveCalculator:results.amount', + defaultMessage: 'Upphæð', + description: 'Niðurstöðuskjár - Upphæð', + }, + perMonth: { + id: 'web.parentalLeaveCalculator:results.perMonth', + defaultMessage: 'Á mánuði', + description: 'Niðurstöðuskjár - Á mánuði', + }, + pensionFunding: { + id: 'web.parentalLeaveCalculator:results.pensionFunding', + defaultMessage: 'Lífeyrissjóðir', + description: 'Niðurstöðuskjár - Lífeyrissjóðir', + }, + additionalPensionFunding: { + id: 'web.parentalLeaveCalculator:results.additionalPensionFunding', + defaultMessage: 'Viðbótalífeyrissparnaður', + description: 'Niðurstöðuskjár - Viðbótalífeyrissparnaður', + }, + tax: { + id: 'web.parentalLeaveCalculator:results.tax', + defaultMessage: 'Frádreginn skattur', + description: 'Niðurstöðuskjár - Frádreginn skattur', + }, + totalTax: { + id: 'web.parentalLeaveCalculator:results.totalTax', + defaultMessage: 'Heildarskattur', + description: 'Niðurstöðuskjár - Heildarskattur', + }, + usedPersonalDiscount: { + id: 'web.parentalLeaveCalculator:results.usedPersonalDiscount', + defaultMessage: 'Nýttur persónuafsláttur', + description: 'Niðurstöðuskjár - Nýttur persónuafsláttur', + }, + unionFee: { + id: 'web.parentalLeaveCalculator:results.unionFee', + defaultMessage: 'Stéttarfélagsgjöld', + description: 'Niðurstöðuskjár - Stéttarfélagsgjöld', + }, + changeAssumptions: { + id: 'web.parentalLeaveCalculator:results.changeAssumptions', + defaultMessage: 'Breyta forsendum', + description: 'Niðurstöðuskjár - Breyta forsendum', + }, + }), +} diff --git a/apps/web/components/real.ts b/apps/web/components/real.ts index 53c38bb58324..e6f84f68ca80 100644 --- a/apps/web/components/real.ts +++ b/apps/web/components/real.ts @@ -94,3 +94,4 @@ export * from './TableOfContents/TableOfContents' export * from './CardWithFeaturedItems/CardWithFeaturedItems' export * from './GenericList/GenericList' export * from './GenericList/LatestGenericListItems/LatestGenericListItems' +export * from './connected/ParentalLeaveCalculator/ParentalLeaveCalculator' diff --git a/apps/web/screens/Search/Search.tsx b/apps/web/screens/Search/Search.tsx index 86018074625d..1ee9de5acab3 100644 --- a/apps/web/screens/Search/Search.tsx +++ b/apps/web/screens/Search/Search.tsx @@ -604,7 +604,13 @@ const Search: Screen<CategoryProps> = ({ active={!query?.type?.length} onClick={() => { dispatch({ - type: ActionType.RESET_SEARCH, + type: ActionType.SET_PARAMS, + payload: { + query: { + type: [], + processentry: false, + }, + }, }) }} > @@ -628,8 +634,6 @@ const Search: Screen<CategoryProps> = ({ query: { processentry: false, ...getSearchParams(key), - category: [], - organization: [], }, searchLocked: false, }, @@ -676,7 +680,6 @@ const Search: Screen<CategoryProps> = ({ type: ActionType.SET_PARAMS, payload: { query: { - ...getSearchParams('webArticle'), ...payload, }, }, diff --git a/apps/web/utils/currency.ts b/apps/web/utils/currency.ts index 2184dba5282e..67871ef693d8 100644 --- a/apps/web/utils/currency.ts +++ b/apps/web/utils/currency.ts @@ -1,9 +1,11 @@ export const formatCurrency = ( answer: number | null | undefined, suffix = ' kr.', + numericFunction = Math.floor, ) => { if (typeof answer !== 'number') return answer return ( - String(Math.floor(answer)).replace(/\B(?=(\d{3})+(?!\d))/g, '.') + suffix + String(numericFunction(answer)).replace(/\B(?=(\d{3})+(?!\d))/g, '.') + + suffix ) } diff --git a/apps/web/utils/richText.tsx b/apps/web/utils/richText.tsx index 62f9e1bee9ca..837fffe1f256 100644 --- a/apps/web/utils/richText.tsx +++ b/apps/web/utils/richText.tsx @@ -35,6 +35,7 @@ import { MultipleStatistics, OneColumnTextSlice, OverviewLinksSlice, + ParentalLeaveCalculator, PlateAvailableSearch, PowerBiSlice, PublicShipSearch, @@ -187,6 +188,9 @@ export const webRenderConnectedComponent = ( case 'Ums/CostOfLivingCalculator': connectedComponent = <UmsCostOfLivingCalculator /> break + case 'VMST/ParentalLeaveCalculator': + connectedComponent = <ParentalLeaveCalculator slice={slice} /> + break default: connectedComponent = renderConnectedComponent(slice) } diff --git a/charts/identity-server/values.dev.yaml b/charts/identity-server/values.dev.yaml index 5f2548139268..1de58ff732d4 100644 --- a/charts/identity-server/values.dev.yaml +++ b/charts/identity-server/values.dev.yaml @@ -308,6 +308,7 @@ services-auth-delegation-api: - 'nginx-ingress-internal' - 'islandis' - 'service-portal' + - 'user-notification-worker' grantNamespacesEnabled: true healthCheck: liveness: diff --git a/charts/identity-server/values.prod.yaml b/charts/identity-server/values.prod.yaml index 6d152fff6b1c..d05db0f9e15c 100644 --- a/charts/identity-server/values.prod.yaml +++ b/charts/identity-server/values.prod.yaml @@ -305,6 +305,7 @@ services-auth-delegation-api: - 'nginx-ingress-internal' - 'islandis' - 'service-portal' + - 'user-notification-worker' grantNamespacesEnabled: true healthCheck: liveness: diff --git a/charts/identity-server/values.staging.yaml b/charts/identity-server/values.staging.yaml index 235d50eef758..e8b29ca0e315 100644 --- a/charts/identity-server/values.staging.yaml +++ b/charts/identity-server/values.staging.yaml @@ -308,6 +308,7 @@ services-auth-delegation-api: - 'nginx-ingress-internal' - 'islandis' - 'service-portal' + - 'user-notification-worker' grantNamespacesEnabled: true healthCheck: liveness: diff --git a/charts/islandis/values.dev.yaml b/charts/islandis/values.dev.yaml index af8f99c4d36f..8501ebbc35a0 100644 --- a/charts/islandis/values.dev.yaml +++ b/charts/islandis/values.dev.yaml @@ -352,7 +352,9 @@ api: XROAD_FINANCES_V2_PATH: 'IS-DEV/GOV/10021/FJS-Public/financeServicesFJS_v2' XROAD_FINANCIAL_AID_BACKEND_PATH: 'IS-DEV/MUN/10023/samband-sveitarfelaga/financial-aid-backend' XROAD_FIREARM_LICENSE_PATH: 'IS-DEV/GOV/10005/Logreglan-Protected/island-api-v1' + XROAD_HEALTH_DIRECTORATE_ORGAN_DONATION_PATH: 'IS-DEV/GOV/10015/EmbaettiLandlaeknis-Protected/organ-donation-v1' XROAD_HEALTH_DIRECTORATE_PATH: 'IS-DEV/GOV/10015/EmbaettiLandlaeknis-Protected/landlaeknir' + XROAD_HEALTH_DIRECTORATE_VACCINATION_PATH: 'IS-DEV/GOV/10015/EmbaettiLandlaeknis-Protected/vaccination-v1' XROAD_HEALTH_INSURANCE_ID: 'IS-DEV/GOV/10007/SJUKRA-Protected' XROAD_HEALTH_INSURANCE_MY_PAGES_PATH: 'IS-DEV/GOV/10007/SJUKRA-Protected/minarsidur' XROAD_HEALTH_INSURANCE_WSDLURL: 'https://test-huld.sjukra.is/islandrg?wsdl' diff --git a/charts/islandis/values.prod.yaml b/charts/islandis/values.prod.yaml index c47adc3b38ed..7bd42ad81698 100644 --- a/charts/islandis/values.prod.yaml +++ b/charts/islandis/values.prod.yaml @@ -342,7 +342,9 @@ api: XROAD_FINANCES_V2_PATH: 'IS/GOV/5402697509/FJS-Public/financeServicesFJS_v2' XROAD_FINANCIAL_AID_BACKEND_PATH: 'IS/MUN/5502694739/samband-sveitarfelaga/financial-aid-backend' XROAD_FIREARM_LICENSE_PATH: 'IS/GOV/5309672079/Logreglan-Protected/island-api-v1' + XROAD_HEALTH_DIRECTORATE_ORGAN_DONATION_PATH: 'IS/GOV/7101695009/EmbaettiLandlaeknis-Protected/organ-donation-v1' XROAD_HEALTH_DIRECTORATE_PATH: 'IS/GOV/7101695009/EmbaettiLandlaeknis-Protected/landlaeknir' + XROAD_HEALTH_DIRECTORATE_VACCINATION_PATH: 'IS/GOV/7101695009/EmbaettiLandlaeknis-Protected/vaccination-v1' XROAD_HEALTH_INSURANCE_ID: 'IS/GOV/4804080550/SJUKRA-Protected' XROAD_HEALTH_INSURANCE_MY_PAGES_PATH: 'IS/GOV/4804080550/SJUKRA-Protected/minarsidur' XROAD_HEALTH_INSURANCE_WSDLURL: 'https://huld.sjukra.is/islandrg?wsdl' diff --git a/charts/islandis/values.staging.yaml b/charts/islandis/values.staging.yaml index b25e6403014a..a0dfb5fe4ab9 100644 --- a/charts/islandis/values.staging.yaml +++ b/charts/islandis/values.staging.yaml @@ -352,7 +352,9 @@ api: XROAD_FINANCES_V2_PATH: 'IS-TEST/GOV/10021/FJS-Public/financeServicesFJS_v2' XROAD_FINANCIAL_AID_BACKEND_PATH: 'IS-TEST/MUN/5502694739/samband-sveitarfelaga/financial-aid-backend' XROAD_FIREARM_LICENSE_PATH: 'IS/GOV/5309672079/Logreglan-Protected/island-api-v1' + XROAD_HEALTH_DIRECTORATE_ORGAN_DONATION_PATH: 'IS-TEST/GOV/10015/EmbaettiLandlaeknis-Protected/organ-donation-v1' XROAD_HEALTH_DIRECTORATE_PATH: 'IS-TEST/GOV/10015/EmbaettiLandlaeknis-Protected/landlaeknir' + XROAD_HEALTH_DIRECTORATE_VACCINATION_PATH: 'IS-TEST/GOV/10015/EmbaettiLandlaeknis-Protected/vaccination-v1' XROAD_HEALTH_INSURANCE_ID: 'IS-TEST/GOV/4804080550/SJUKRA-Protected' XROAD_HEALTH_INSURANCE_MY_PAGES_PATH: 'IS-TEST/GOV/4804080550/SJUKRA-Protected/minarsidur' XROAD_HEALTH_INSURANCE_WSDLURL: 'https://test-huld.sjukra.is/islandrg?wsdl' diff --git a/infra/src/dsl/xroad.ts b/infra/src/dsl/xroad.ts index a61c0e718b27..0dc7df744772 100644 --- a/infra/src/dsl/xroad.ts +++ b/infra/src/dsl/xroad.ts @@ -890,3 +890,24 @@ export const Frigg = new XroadConf({ }, }, }) + +export const HealthDirectorateOrganDonation = new XroadConf({ + env: { + XROAD_HEALTH_DIRECTORATE_ORGAN_DONATION_PATH: { + dev: 'IS-DEV/GOV/10015/EmbaettiLandlaeknis-Protected/organ-donation-v1', + staging: + 'IS-TEST/GOV/10015/EmbaettiLandlaeknis-Protected/organ-donation-v1', + prod: 'IS/GOV/7101695009/EmbaettiLandlaeknis-Protected/organ-donation-v1', + }, + }, +}) + +export const HealthDirectorateVaccination = new XroadConf({ + env: { + XROAD_HEALTH_DIRECTORATE_VACCINATION_PATH: { + dev: 'IS-DEV/GOV/10015/EmbaettiLandlaeknis-Protected/vaccination-v1', + staging: 'IS-TEST/GOV/10015/EmbaettiLandlaeknis-Protected/vaccination-v1', + prod: 'IS/GOV/7101695009/EmbaettiLandlaeknis-Protected/vaccination-v1', + }, + }, +}) diff --git a/libs/api/domains/health-directorate/src/lib/health-directorate.resolver.ts b/libs/api/domains/health-directorate/src/lib/health-directorate.resolver.ts index acd7c72f406c..05f73ec5209d 100644 --- a/libs/api/domains/health-directorate/src/lib/health-directorate.resolver.ts +++ b/libs/api/domains/health-directorate/src/lib/health-directorate.resolver.ts @@ -1,4 +1,11 @@ -import { Args, Mutation, Query, Resolver } from '@nestjs/graphql' +import { + Args, + Mutation, + Parent, + Query, + ResolveField, + Resolver, +} from '@nestjs/graphql' import { UseGuards } from '@nestjs/common' @@ -11,11 +18,7 @@ import { import type { User } from '@island.is/auth-nest-tools' import { ApiScope } from '@island.is/auth/scopes' import { Audit } from '@island.is/nest/audit' -import { - DonationException, - DonorStatus, - DonorStatusInput, -} from './models/organ-donation.model' +import { DonorInput, Organ, OrganDonation } from './models/organ-donation.model' import type { Locale } from '@island.is/shared/types' import { Vaccinations } from './models/vaccinations.model' import { HealthDirectorateService } from './health-directorate.service' @@ -23,53 +26,51 @@ import { HealthDirectorateService } from './health-directorate.service' @UseGuards(IdsUserGuard, ScopesGuard) @Scopes(ApiScope.internal) @Audit({ namespace: '@island.is/api/health-directorate' }) -@Resolver() +@Resolver(() => OrganDonation) export class HealthDirectorateResolver { constructor(private api: HealthDirectorateService) {} /* Organ Donation */ - @Query(() => DonorStatus, { - name: 'HealthDirectorateOrganDonationGetDonorStatus', + @Query(() => OrganDonation, { + name: 'healthDirectorateOrganDonation', }) @Audit() - getDonorStatus(@CurrentUser() user: User): Promise<DonorStatus> { - return this.api.getDonorStatus(user) - } - - @Query(() => DonationException, { - name: 'HealthDirectorateOrganDonationGetDonationExceptions', - }) - @Audit() - getDonationExceptions( + async getDonorStatus( @Args('locale', { type: () => String, nullable: true }) locale: Locale = 'is', @CurrentUser() user: User, - ): Promise<DonationException> { - return this.api.getDonationExceptions(user, locale) + ): Promise<OrganDonation> { + const data = await this.api.getDonorStatus(user, locale) + return { donor: data, locale: locale } + } + @ResolveField('organList', () => [Organ], { + nullable: true, + }) + async resolveOrganList( + @Parent() organDonation: OrganDonation, + @CurrentUser() user: User, + ): Promise<Array<Organ>> { + return this.api.getDonationExceptions(user, organDonation.locale ?? 'is') } @Mutation(() => Boolean, { nullable: true, - name: 'HealthDirectorateOrganDonationUpdateDonorStatus', + name: 'healthDirectorateOrganDonationUpdateDonorStatus', }) @Audit() async updateDonorStatus( - @Args('input') input: DonorStatusInput, + @Args('input') input: DonorInput, @CurrentUser() user: User, ): Promise<void> { return this.api.updateDonorStatus(user, input) } /* Vaccinations */ - @Query(() => [Vaccinations], { - name: 'HealthDirectorateVaccinationsGetVaccinations', + @Query(() => Vaccinations, { + name: 'healthDirectorateVaccinations', }) @Audit() - getVaccinations( - @Args('locale', { type: () => String, nullable: true }) - locale: Locale = 'is', - @CurrentUser() user: User, - ): Promise<Array<Vaccinations>> { - return this.api.getVaccinations(user, locale) + getVaccinations(@CurrentUser() user: User): Promise<Vaccinations | null> { + return this.api.getVaccinations(user) } } diff --git a/libs/api/domains/health-directorate/src/lib/health-directorate.service.ts b/libs/api/domains/health-directorate/src/lib/health-directorate.service.ts index 3390b5b24216..4e40a778de6c 100644 --- a/libs/api/domains/health-directorate/src/lib/health-directorate.service.ts +++ b/libs/api/domains/health-directorate/src/lib/health-directorate.service.ts @@ -8,13 +8,9 @@ import { } from '@island.is/clients/health-directorate' import { Auth } from '@island.is/auth-nest-tools' import type { Locale } from '@island.is/shared/types' -import { - DonationException, - DonorStatus, - DonorStatusInput, -} from './models/organ-donation.model' +import { Donor, DonorInput, Organ } from './models/organ-donation.model' -import { Vaccinations } from './models/vaccinations.model' +import { Info, Vaccination, Vaccinations } from './models/vaccinations.model' @Injectable() export class HealthDirectorateService { @@ -24,15 +20,23 @@ export class HealthDirectorateService { ) {} /* Organ Donation */ - async getDonorStatus(auth: Auth): Promise<DonorStatus> { + async getDonorStatus(auth: Auth, locale: Locale): Promise<Donor | null> { + const lang: organLocale = locale === 'is' ? organLocale.Is : organLocale.En const data: OrganDonorDto | null = - await this.organDonationApi.getOrganDonation(auth) + await this.organDonationApi.getOrganDonation(auth, lang) + // Fetch organ list to get all names in correct language to sort out the names of the organs the user has limitations for - const donorStatus: DonorStatus = { - isDonor: data?.isDonor || false, - registrationDate: data?.registrationDate, - exceptions: data?.exceptions, - exceptionComment: data?.exceptionComment, + if (data === null) { + return null + } + const donorStatus: Donor = { + isDonor: data?.isDonor ?? true, + limitations: { + hasLimitations: + ((data?.exceptions?.length ?? 0) > 0 && data?.isDonor) ?? false, + limitedOrgansList: data?.exceptions, + comment: data?.exceptionComment, + }, } return donorStatus } @@ -40,11 +44,10 @@ export class HealthDirectorateService { async getDonationExceptions( auth: Auth, locale: Locale, - ): Promise<DonationException> { + ): Promise<Array<Organ>> { const lang: organLocale = locale === 'is' ? organLocale.Is : organLocale.En const data = await this.organDonationApi.getDonationExceptions(auth, lang) - const exceptions: DonationException = { values: [] } - exceptions.values = + const limitations: Array<Organ> = data?.map((item) => { return { id: item.id, @@ -52,56 +55,49 @@ export class HealthDirectorateService { } }) ?? [] - return exceptions + return limitations } - async updateDonorStatus(auth: Auth, input: DonorStatusInput): Promise<void> { - await this.organDonationApi - .updateOrganDonation(auth, { - exceptionComment: input.exceptionComment ?? '', - isDonor: input.isDonor, - exceptions: input.exceptions ?? [], - registrationDate: new Date(), - }) - .then(() => { - return true - }) - .catch(() => { - return false - }) + async updateDonorStatus(auth: Auth, input: DonorInput): Promise<void> { + return await this.organDonationApi.updateOrganDonation(auth, { + isDonor: input.isDonor, + exceptions: input.organLimitations ?? [], + }) } /* Vaccinations */ - async getVaccinations( - auth: Auth, - locale: Locale, - ): Promise<Array<Vaccinations>> { - const data = await this.vaccinationApi.getVaccinationDiseaseDetail(auth, { - locale, - }) - const vaccinations: Array<Vaccinations> = + async getVaccinations(auth: Auth): Promise<Vaccinations | null> { + const data = await this.vaccinationApi.getVaccinationDiseaseDetail(auth) + if (data === null) { + return null + } + const vaccinations: Array<Vaccination> = data?.map((item) => { return { - diseaseId: item.diseaseId, - diseaseName: item.diseaseName, - diseaseDescription: item.diseaseDescription, - vaccinationStatus: item.vaccinationStatus, - vaccinationsStatusName: item.vaccinationStatusName, - lastVaccinationDate: item.lastVaccinationDate, - vaccinations: - item.vaccinations?.map((vaccination: VaccinationDto) => { + id: item.diseaseId, + name: item.diseaseName, + description: item.diseaseDescription, + isFeatured: item.isFeatured, + status: item.vaccinationStatus, + statusName: item.vaccinationStatusName, + statusColor: item.vaccinationStatusColor, + lastVaccinationDate: item.lastVaccinationDate ?? null, + comments: item.comments, + vaccinationsInfo: item.vaccinations?.map( + (vaccination: VaccinationDto) => { return { id: vaccination.id, - nationalId: vaccination.nationalId, - code: vaccination.code, - vaccinationDate: vaccination.vaccinationDate, - vaccinationsAge: vaccination.vaccinationAge, - generalComment: vaccination.generalComment.toString(), + name: vaccination.vaccineCodeDescriptionShort, + date: vaccination.vaccinationDate, + age: vaccination.vaccinationAge, + url: vaccination.vaccineUrl, + comment: vaccination.generalComment, rejected: vaccination.rejected, } - }) ?? [], + }, + ), } }) ?? [] - return vaccinations + return { vaccinations } } } diff --git a/libs/api/domains/health-directorate/src/lib/models/organ-donation.model.ts b/libs/api/domains/health-directorate/src/lib/models/organ-donation.model.ts index a26a7ffacac7..8893f2149324 100644 --- a/libs/api/domains/health-directorate/src/lib/models/organ-donation.model.ts +++ b/libs/api/domains/health-directorate/src/lib/models/organ-donation.model.ts @@ -1,61 +1,57 @@ -import { ObjectType, Field, Int, InputType } from '@nestjs/graphql' +import { ObjectType, Field, InputType } from '@nestjs/graphql' -@ObjectType('HealthDirectorateOrganDonorStatus') -export class DonorStatus { - @Field(() => Boolean) - isDonor!: boolean - - @Field(() => [String], { nullable: true }) - exceptions?: string[] +@ObjectType('HealthDirectorateOrganDonationOrgan') +export class Organ { + @Field() + id!: string - @Field({ nullable: true }) - exceptionComment?: string - - @Field(() => Date, { nullable: true }) - registrationDate?: Date + @Field() + name!: string +} +@ObjectType('HealthDirectorateOrganDonationLimitations') +export class Limitations { + @Field(() => Boolean) + hasLimitations!: boolean + + @Field(() => [Organ], { + nullable: true, + description: 'List of organs NOT to donate', + }) + limitedOrgansList?: Organ[] + + @Field({ + nullable: true, + description: 'Text to display if user does not want to donate all organs', + }) + comment?: string } -@InputType('HealthDirectorateOrganDonorStatusInput') -export class DonorStatusInput { +@ObjectType('HealthDirectorateOrganDonor') +export class Donor { @Field(() => Boolean) isDonor!: boolean - @Field(() => [String], { nullable: true }) - exceptions?: string[] - - @Field({ nullable: true }) - exceptionComment?: string + @Field(() => Limitations, { nullable: true }) + limitations?: Limitations } -@ObjectType('HealthDirectorateOrganDonationExceptionObject') -export class DonationExceptionObject { - @Field({ nullable: true }) - id?: string +@ObjectType('HealthDirectorateOrganDonation') +export class OrganDonation { + @Field(() => Donor, { nullable: true }) + donor?: Donor | null - @Field({ nullable: true }) - name?: string -} + @Field(() => [Organ], { nullable: true }) + organList?: Array<Organ> -@ObjectType('HealthDirectorateOrganDonationException') -export class DonationException { - @Field(() => [DonationExceptionObject], { nullable: true }) - values?: DonationExceptionObject[] + @Field(() => String, { nullable: true }) + locale?: 'is' | 'en' } -@ObjectType('HealthDirectorateError') -export class HealthDirectorateError { - @Field({ nullable: true }) - type?: string - - @Field({ nullable: true }) - title?: string - - @Field(() => Int, { nullable: true }) - status?: number - - @Field({ nullable: true }) - detail?: string +@InputType('HealthDirectorateOrganDonorInput') +export class DonorInput { + @Field(() => Boolean) + isDonor!: boolean - @Field({ nullable: true }) - instance?: string + @Field(() => [String], { nullable: true }) + organLimitations?: string[] } diff --git a/libs/api/domains/health-directorate/src/lib/models/vaccinations.model.ts b/libs/api/domains/health-directorate/src/lib/models/vaccinations.model.ts index 95f4c70c8cf3..6b1c9fc8115c 100644 --- a/libs/api/domains/health-directorate/src/lib/models/vaccinations.model.ts +++ b/libs/api/domains/health-directorate/src/lib/models/vaccinations.model.ts @@ -1,69 +1,78 @@ -import { Field, ObjectType } from '@nestjs/graphql' +import { DiseaseVaccinationDtoVaccinationStatusEnum } from '@island.is/clients/health-directorate' +import { Field, Int, ObjectType, registerEnumType } from '@nestjs/graphql' + +registerEnumType(DiseaseVaccinationDtoVaccinationStatusEnum, { + name: 'HealthDirectorateVaccinationsStatus', +}) @ObjectType('HealthDirectorateVaccinationsAge') -export class VaccinationsAge { - @Field(() => Number, { nullable: true }) +export class Age { + @Field(() => Int, { nullable: true }) years?: number - @Field(() => Number, { nullable: true }) + @Field(() => Int, { nullable: true }) months?: number } -@ObjectType('HealthDirectorateVaccinationsDetail') -export class VaccinationsDetail { - @Field(() => Number) +@ObjectType('HealthDirectorateVaccinationsInfo') +export class Info { + @Field(() => Int) id!: number @Field({ nullable: true }) - nationalId?: string - - @Field({ nullable: true }) - code?: string + name?: string @Field(() => Date, { nullable: true }) - vaccinationDate?: Date + date?: Date | null - @Field(() => VaccinationsAge, { nullable: true }) - vaccinationsAge?: VaccinationsAge + @Field(() => Age, { nullable: true }) + age?: Age + + @Field({ nullable: true }) + url?: string @Field({ nullable: true }) - generalComment?: string + comment?: string @Field(() => Boolean, { nullable: true }) rejected?: boolean } -@ObjectType('HealthDirectorateVaccinations') -export class Vaccinations { +@ObjectType('HealthDirectorateVaccination') +export class Vaccination { @Field() - diseaseId!: string + id!: string @Field({ nullable: true }) - diseaseName?: string + name?: string @Field({ nullable: true }) - diseaseDescription?: string + description?: string - @Field() - vaccinationStatus!: - | 'valid' - | 'expired' - | 'complete' - | 'incomplete' - | 'undocumented' - | 'unvaccinated' - | 'rejected' - | 'undetermined' + @Field(() => Boolean, { nullable: true }) + isFeatured?: boolean @Field({ nullable: true }) - vaccinationsStatusName?: string + status?: string + + @Field({ nullable: true }) + statusName?: string + + @Field({ nullable: true }) + statusColor?: string @Field(() => Date, { nullable: true }) - lastVaccinationDate?: Date + lastVaccinationDate?: Date | null - @Field(() => [VaccinationsDetail], { nullable: true }) - vaccinations?: VaccinationsDetail[] + @Field(() => [Info], { nullable: true }) + vaccinationsInfo?: Info[] @Field(() => [String], { nullable: true }) comments?: string[] } + +@ObjectType('HealthDirectorateVaccinations') +export class Vaccinations { + @Field(() => [Vaccination]) + vaccinations!: Vaccination[] +} diff --git a/libs/api/domains/official-journal-of-iceland-application/src/lib/mappers.ts b/libs/api/domains/official-journal-of-iceland-application/src/lib/mappers.ts new file mode 100644 index 000000000000..e88a77424d14 --- /dev/null +++ b/libs/api/domains/official-journal-of-iceland-application/src/lib/mappers.ts @@ -0,0 +1,44 @@ +import { + AddApplicationAttachmentTypeEnum, + GetApplicationAttachmentsTypeEnum, + GetPresignedUrlTypeEnum, +} from '@island.is/clients/official-journal-of-iceland/application' + +export const mapAttachmentType = ( + val: any, +): AddApplicationAttachmentTypeEnum => { + switch (val) { + case AddApplicationAttachmentTypeEnum.Frumrit: + case GetPresignedUrlTypeEnum.Frumrit: + return AddApplicationAttachmentTypeEnum.Frumrit + case AddApplicationAttachmentTypeEnum.Fylgiskjol: + case GetPresignedUrlTypeEnum.Fylgiskjol: + return AddApplicationAttachmentTypeEnum.Fylgiskjol + default: + return AddApplicationAttachmentTypeEnum.Fylgiskjol + } +} + +export const mapPresignedUrlType = (val: any): GetPresignedUrlTypeEnum => { + switch (val) { + case GetPresignedUrlTypeEnum.Frumrit: + return GetPresignedUrlTypeEnum.Frumrit + case GetPresignedUrlTypeEnum.Fylgiskjol: + return GetPresignedUrlTypeEnum.Fylgiskjol + default: + return GetPresignedUrlTypeEnum.Fylgiskjol + } +} + +export const mapGetAttachmentType = ( + val: any, +): GetApplicationAttachmentsTypeEnum => { + switch (val) { + case GetApplicationAttachmentsTypeEnum.Frumrit: + return GetApplicationAttachmentsTypeEnum.Frumrit + case GetApplicationAttachmentsTypeEnum.Fylgiskjol: + return GetApplicationAttachmentsTypeEnum.Fylgiskjol + default: + return GetApplicationAttachmentsTypeEnum.Fylgiskjol + } +} diff --git a/libs/api/domains/official-journal-of-iceland-application/src/lib/ojoiApplication.resolver.ts b/libs/api/domains/official-journal-of-iceland-application/src/lib/ojoiApplication.resolver.ts index 4b681ff24ef4..8d7c5cee47f7 100644 --- a/libs/api/domains/official-journal-of-iceland-application/src/lib/ojoiApplication.resolver.ts +++ b/libs/api/domains/official-journal-of-iceland-application/src/lib/ojoiApplication.resolver.ts @@ -1,4 +1,4 @@ -import { Args, Query, Resolver } from '@nestjs/graphql' +import { Args, Mutation, Query, Resolver } from '@nestjs/graphql' import { IdsUserGuard, Scopes, ScopesGuard } from '@island.is/auth-nest-tools' import { ApiScope } from '@island.is/auth/scopes' import { FeatureFlag, Features } from '@island.is/nest/feature-flags' @@ -11,7 +11,13 @@ import { PostApplicationInput } from '../models/postApplication.input' import { UseGuards } from '@nestjs/common' import { CaseGetPriceResponse } from '../models/getPrice.response' import { GetPdfUrlResponse } from '../models/getPdfUrlResponse' -import { GetPdfResponse } from '../models/getPdfResponse' +import { GetPresignedUrlInput } from '../models/getPresignedUrl.input' +import { GetPresignedUrlResponse } from '../models/getPresignedUrl.response' +import { AddApplicationAttachmentResponse } from '../models/addApplicationAttachment.response' +import { AddApplicationAttachmentInput } from '../models/addApplicationAttachment.input' +import { GetApplicationAttachmentInput } from '../models/getApplicationAttachment.input' +import { GetApplicationAttachmentsResponse } from '../models/getApplicationAttachments.response' +import { DeleteApplicationAttachmentInput } from '../models/deleteApplicationAttachment.input' @Scopes(ApiScope.internal) @UseGuards(IdsUserGuard, ScopesGuard) @@ -25,42 +31,75 @@ export class OfficialJournalOfIcelandApplicationResolver { @Query(() => GetCommentsResponse, { name: 'officialJournalOfIcelandApplicationGetComments', }) - async getComments(@Args('input') input: GetCommentsInput) { - return await this.ojoiApplicationService.getComments(input) + getComments(@Args('input') input: GetCommentsInput) { + return this.ojoiApplicationService.getComments(input) } - @Query(() => PostCommentResponse, { + @Mutation(() => PostCommentResponse, { name: 'officialJournalOfIcelandApplicationPostComment', }) - async postComment(@Args('input') input: PostCommentInput) { - return await this.ojoiApplicationService.postComment(input) + postComment(@Args('input') input: PostCommentInput) { + return this.ojoiApplicationService.postComment(input) } @Query(() => Boolean, { name: 'officialJournalOfIcelandApplicationPostApplication', }) - async postApplication(@Args('input') input: PostApplicationInput) { - return await this.ojoiApplicationService.postApplication(input) + postApplication(@Args('input') input: PostApplicationInput) { + return this.ojoiApplicationService.postApplication(input) } @Query(() => CaseGetPriceResponse, { name: 'officialJournalOfIcelandApplicationGetPrice', }) - async getPrice(@Args('id') id: string) { - return await this.ojoiApplicationService.getPrice(id) + getPrice(@Args('id') id: string) { + return this.ojoiApplicationService.getPrice(id) } @Query(() => GetPdfUrlResponse, { name: 'officialJournalOfIcelandApplicationGetPdfUrl', }) - async getPdfUrl(@Args('id') id: string) { - return await this.ojoiApplicationService.getPdfUrl(id) + getPdfUrl(@Args('id') id: string) { + return this.ojoiApplicationService.getPdfUrl(id) } - @Query(() => GetPdfResponse, { - name: 'officialJournalOfIcelandApplicationGetPdf', + @Mutation(() => GetPresignedUrlResponse, { + name: 'officialJournalOfIcelandApplicationGetPresignedUrl', }) - async getPdf(@Args('id') id: string) { - return (await this.ojoiApplicationService.getPdf(id)).toString('base64') + getPresignedUrl( + @Args('input', { type: () => GetPresignedUrlInput }) + input: GetPresignedUrlInput, + ) { + return this.ojoiApplicationService.getPresignedUrl(input) + } + + @Mutation(() => AddApplicationAttachmentResponse, { + name: 'officialJournalOfIcelandApplicationAddAttachment', + }) + addAttachment( + @Args('input', { type: () => AddApplicationAttachmentInput }) + input: AddApplicationAttachmentInput, + ) { + return this.ojoiApplicationService.addApplicationAttachment(input) + } + + @Query(() => GetApplicationAttachmentsResponse, { + name: 'officialJournalOfIcelandApplicationGetAttachments', + }) + getAttachments( + @Args('input', { type: () => GetApplicationAttachmentInput }) + input: AddApplicationAttachmentInput, + ) { + return this.ojoiApplicationService.getApplicationAttachments(input) + } + + @Mutation(() => AddApplicationAttachmentResponse, { + name: 'officialJournalOfIcelandApplicationDeleteAttachment', + }) + deleteAttachment( + @Args('input', { type: () => DeleteApplicationAttachmentInput }) + input: DeleteApplicationAttachmentInput, + ) { + return this.ojoiApplicationService.deleteApplicationAttachment(input) } } diff --git a/libs/api/domains/official-journal-of-iceland-application/src/lib/ojoiApplication.service.ts b/libs/api/domains/official-journal-of-iceland-application/src/lib/ojoiApplication.service.ts index a810f69c9345..e8b2adc91172 100644 --- a/libs/api/domains/official-journal-of-iceland-application/src/lib/ojoiApplication.service.ts +++ b/libs/api/domains/official-journal-of-iceland-application/src/lib/ojoiApplication.service.ts @@ -1,12 +1,29 @@ import { OfficialJournalOfIcelandApplicationClientService } from '@island.is/clients/official-journal-of-iceland/application' -import { Injectable } from '@nestjs/common' +import { Inject, Injectable } from '@nestjs/common' import { PostCommentInput } from '../models/postComment.input' import { PostApplicationInput } from '../models/postApplication.input' import { GetCommentsInput } from '../models/getComments.input' +import { GetPresignedUrlInput } from '../models/getPresignedUrl.input' +import { GetPresignedUrlResponse } from '../models/getPresignedUrl.response' +import { AddApplicationAttachmentInput } from '../models/addApplicationAttachment.input' +import { + mapAttachmentType, + mapGetAttachmentType, + mapPresignedUrlType, +} from './mappers' +import { AddApplicationAttachmentResponse } from '../models/addApplicationAttachment.response' +import { GetApplicationAttachmentInput } from '../models/getApplicationAttachment.input' +import { DeleteApplicationAttachmentInput } from '../models/deleteApplicationAttachment.input' +import { LOGGER_PROVIDER } from '@island.is/logging' +import type { Logger } from '@island.is/logging' + +const LOG_CATEGORY = 'official-journal-of-iceland-application' @Injectable() export class OfficialJournalOfIcelandApplicationService { constructor( + @Inject(LOGGER_PROVIDER) + private logger: Logger, private readonly ojoiApplicationService: OfficialJournalOfIcelandApplicationClientService, ) {} @@ -15,10 +32,16 @@ export class OfficialJournalOfIcelandApplicationService { } async postComment(input: PostCommentInput) { - return this.ojoiApplicationService.postComment({ + const success = this.ojoiApplicationService.postComment({ id: input.id, - // comment: input.comment, + postApplicationComment: { + comment: input.comment, + }, }) + + return { + success, + } } async getPdfUrl(id: string) { @@ -27,12 +50,6 @@ export class OfficialJournalOfIcelandApplicationService { }) } - async getPdf(id: string) { - return this.ojoiApplicationService.getPdf({ - id, - }) - } - async postApplication(input: PostApplicationInput): Promise<boolean> { return this.ojoiApplicationService.postApplication(input) } @@ -42,4 +59,78 @@ export class OfficialJournalOfIcelandApplicationService { id, }) } + + async getPresignedUrl( + input: GetPresignedUrlInput, + ): Promise<GetPresignedUrlResponse> { + const attachmentType = mapPresignedUrlType(input.attachmentType) + + return this.ojoiApplicationService.getPresignedUrl({ + id: input.applicationId, + type: attachmentType, + getPresignedUrlBody: { + fileName: input.fileName, + fileType: input.fileType, + }, + }) + } + + async addApplicationAttachment( + input: AddApplicationAttachmentInput, + ): Promise<AddApplicationAttachmentResponse> { + try { + const attachmentType = mapAttachmentType(input.attachmentType) + + this.ojoiApplicationService.addApplicationAttachment({ + id: input.applicationId, + type: attachmentType, + postApplicationAttachmentBody: { + fileName: input.fileName, + originalFileName: input.originalFileName, + fileFormat: input.fileFormat, + fileExtension: input.fileExtension, + fileLocation: input.fileLocation, + fileSize: input.fileSize, + }, + }) + + return { + success: true, + } + } catch (error) { + this.logger.error('Failed to add application attachment', { + category: LOG_CATEGORY, + applicationId: input.applicationId, + error: error, + }) + return { + success: false, + } + } + } + + async getApplicationAttachments(input: GetApplicationAttachmentInput) { + return this.ojoiApplicationService.getApplicationAttachments({ + id: input.applicationId, + type: mapGetAttachmentType(input.attachmentType), + }) + } + + async deleteApplicationAttachment(input: DeleteApplicationAttachmentInput) { + try { + await this.ojoiApplicationService.deleteApplicationAttachment({ + id: input.applicationId, + key: input.key, + }) + + return { success: true } + } catch (error) { + this.logger.error('Failed to delete application attachment', { + category: LOG_CATEGORY, + applicationId: input.applicationId, + error: error, + }) + return { success: false } + } + } } diff --git a/libs/api/domains/official-journal-of-iceland-application/src/models/addApplicationAttachment.input.ts b/libs/api/domains/official-journal-of-iceland-application/src/models/addApplicationAttachment.input.ts new file mode 100644 index 000000000000..f15358a3cb60 --- /dev/null +++ b/libs/api/domains/official-journal-of-iceland-application/src/models/addApplicationAttachment.input.ts @@ -0,0 +1,28 @@ +import { InputType, Field, Int } from '@nestjs/graphql' + +@InputType('OfficialJournalOfIcelandApplicationAddApplicationAttachmentInput') +export class AddApplicationAttachmentInput { + @Field() + applicationId!: string + + @Field() + attachmentType!: string + + @Field() + fileName!: string + + @Field() + originalFileName!: string + + @Field() + fileFormat!: string + + @Field() + fileExtension!: string + + @Field() + fileLocation!: string + + @Field(() => Int) + fileSize!: number +} diff --git a/libs/api/domains/official-journal-of-iceland-application/src/models/addApplicationAttachment.response.ts b/libs/api/domains/official-journal-of-iceland-application/src/models/addApplicationAttachment.response.ts new file mode 100644 index 000000000000..83fd6cac76cc --- /dev/null +++ b/libs/api/domains/official-journal-of-iceland-application/src/models/addApplicationAttachment.response.ts @@ -0,0 +1,9 @@ +import { Field, ObjectType } from '@nestjs/graphql' + +@ObjectType( + 'OfficialJournalOfIcelandApplicationAddApplicationAttachmentResponse', +) +export class AddApplicationAttachmentResponse { + @Field() + success!: boolean +} diff --git a/libs/api/domains/official-journal-of-iceland-application/src/models/deleteApplicationAttachment.input.ts b/libs/api/domains/official-journal-of-iceland-application/src/models/deleteApplicationAttachment.input.ts new file mode 100644 index 000000000000..0c3ae8857689 --- /dev/null +++ b/libs/api/domains/official-journal-of-iceland-application/src/models/deleteApplicationAttachment.input.ts @@ -0,0 +1,12 @@ +import { Field, InputType } from '@nestjs/graphql' + +@InputType( + 'OfficialJournalOfIcelandApplicationDeleteApplicationAttachmentInput', +) +export class DeleteApplicationAttachmentInput { + @Field() + applicationId!: string + + @Field() + key!: string +} diff --git a/libs/api/domains/official-journal-of-iceland-application/src/models/getApplicationAttachment.input.ts b/libs/api/domains/official-journal-of-iceland-application/src/models/getApplicationAttachment.input.ts new file mode 100644 index 000000000000..3a19532f5fbd --- /dev/null +++ b/libs/api/domains/official-journal-of-iceland-application/src/models/getApplicationAttachment.input.ts @@ -0,0 +1,10 @@ +import { InputType, Field } from '@nestjs/graphql' + +@InputType('OfficialJournalOfIcelandApplicationGetApplicationAttachmentInput') +export class GetApplicationAttachmentInput { + @Field() + applicationId!: string + + @Field() + attachmentType!: string +} diff --git a/libs/api/domains/official-journal-of-iceland-application/src/models/getApplicationAttachments.response.ts b/libs/api/domains/official-journal-of-iceland-application/src/models/getApplicationAttachments.response.ts new file mode 100644 index 000000000000..57938abbd749 --- /dev/null +++ b/libs/api/domains/official-journal-of-iceland-application/src/models/getApplicationAttachments.response.ts @@ -0,0 +1,33 @@ +import { Field, Int, ObjectType } from '@nestjs/graphql' + +@ObjectType('OfficialJournalOfIcelandApplicationGetApplicationAttachments') +export class GetApplicationAttachmentsResponse { + @Field(() => [GetApplicationAttachmentResponse]) + attachments!: GetApplicationAttachmentResponse[] +} + +@ObjectType( + 'OfficialJournalOfIcelandApplicationGetApplicationAttachmentResponse', +) +export class GetApplicationAttachmentResponse { + @Field() + id!: string + + @Field() + fileName!: string + + @Field() + originalFileName!: string + + @Field() + fileFormat!: string + + @Field() + fileExtension!: string + + @Field() + fileLocation!: string + + @Field(() => Int) + fileSize!: number +} diff --git a/libs/api/domains/official-journal-of-iceland-application/src/models/getComments.input.ts b/libs/api/domains/official-journal-of-iceland-application/src/models/getComments.input.ts index dd550de8404d..fdbe6f6a03ab 100644 --- a/libs/api/domains/official-journal-of-iceland-application/src/models/getComments.input.ts +++ b/libs/api/domains/official-journal-of-iceland-application/src/models/getComments.input.ts @@ -2,6 +2,6 @@ import { Field, InputType } from '@nestjs/graphql' @InputType('OfficialJournalOfIcelandApplicationGetCommentsInput') export class GetCommentsInput { - @Field(() => String) + @Field() id!: string } diff --git a/libs/api/domains/official-journal-of-iceland-application/src/models/getComments.response.ts b/libs/api/domains/official-journal-of-iceland-application/src/models/getComments.response.ts index a5203aa6fcab..cf472295d940 100644 --- a/libs/api/domains/official-journal-of-iceland-application/src/models/getComments.response.ts +++ b/libs/api/domains/official-journal-of-iceland-application/src/models/getComments.response.ts @@ -8,7 +8,7 @@ export class CaseCommentTask { @Field(() => String, { nullable: true }) to!: string | null - @Field(() => String) + @Field() title!: string @Field(() => String, { nullable: true }) comment!: string | null @@ -16,22 +16,22 @@ export class CaseCommentTask { @ObjectType('OfficialJournalOfIcelandApplicationComment') export class CaseComment { - @Field(() => String) + @Field() id!: string - @Field(() => String) + @Field() createdAt!: string - @Field(() => Boolean) + @Field() internal!: boolean - @Field(() => String) + @Field() type!: string - @Field(() => String) + @Field() caseStatus!: string - @Field(() => String) + @Field() state!: string @Field(() => CaseCommentTask) diff --git a/libs/api/domains/official-journal-of-iceland-application/src/models/getPdfResponse.ts b/libs/api/domains/official-journal-of-iceland-application/src/models/getPdfResponse.ts deleted file mode 100644 index 3f9ecbdb49b5..000000000000 --- a/libs/api/domains/official-journal-of-iceland-application/src/models/getPdfResponse.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { Field, ObjectType } from '@nestjs/graphql' - -@ObjectType('OfficialJournalOfIcelandApplicationGetPdfResponse') -export class GetPdfResponse { - @Field(() => String) - buffer!: Buffer -} diff --git a/libs/api/domains/official-journal-of-iceland-application/src/models/getPdfUrlResponse.ts b/libs/api/domains/official-journal-of-iceland-application/src/models/getPdfUrlResponse.ts index 5e3062492d49..4ceb94e91350 100644 --- a/libs/api/domains/official-journal-of-iceland-application/src/models/getPdfUrlResponse.ts +++ b/libs/api/domains/official-journal-of-iceland-application/src/models/getPdfUrlResponse.ts @@ -2,6 +2,6 @@ import { Field, ObjectType } from '@nestjs/graphql' @ObjectType('OfficialJournalOfIcelandApplicationGetPdfUrlResponse') export class GetPdfUrlResponse { - @Field(() => String) + @Field() url!: string } diff --git a/libs/api/domains/official-journal-of-iceland-application/src/models/getPresignedUrl.input.ts b/libs/api/domains/official-journal-of-iceland-application/src/models/getPresignedUrl.input.ts new file mode 100644 index 000000000000..b944f64608cf --- /dev/null +++ b/libs/api/domains/official-journal-of-iceland-application/src/models/getPresignedUrl.input.ts @@ -0,0 +1,16 @@ +import { Field, InputType } from '@nestjs/graphql' + +@InputType('OfficialJournalOfIcelandApplicationGetPresignedUrlInput') +export class GetPresignedUrlInput { + @Field() + applicationId!: string + + @Field() + fileName!: string + + @Field() + fileType!: string + + @Field() + attachmentType!: string +} diff --git a/libs/api/domains/official-journal-of-iceland-application/src/models/getPresignedUrl.response.ts b/libs/api/domains/official-journal-of-iceland-application/src/models/getPresignedUrl.response.ts new file mode 100644 index 000000000000..e2c168dd4132 --- /dev/null +++ b/libs/api/domains/official-journal-of-iceland-application/src/models/getPresignedUrl.response.ts @@ -0,0 +1,7 @@ +import { Field, ObjectType } from '@nestjs/graphql' + +@ObjectType('OfficialJournalOfIcelandApplicationGetPresignedUrlResponse') +export class GetPresignedUrlResponse { + @Field() + url!: string +} diff --git a/libs/api/domains/official-journal-of-iceland-application/src/models/postApplication.input.ts b/libs/api/domains/official-journal-of-iceland-application/src/models/postApplication.input.ts index b89efcef4356..6c673065c049 100644 --- a/libs/api/domains/official-journal-of-iceland-application/src/models/postApplication.input.ts +++ b/libs/api/domains/official-journal-of-iceland-application/src/models/postApplication.input.ts @@ -4,6 +4,6 @@ import { Field, InputType } from '@nestjs/graphql' description: 'Submit application input', }) export class PostApplicationInput { - @Field(() => String) + @Field() id!: string } diff --git a/libs/api/domains/official-journal-of-iceland-application/src/models/postComment.input.ts b/libs/api/domains/official-journal-of-iceland-application/src/models/postComment.input.ts index de02467dcf61..8a9f6929199d 100644 --- a/libs/api/domains/official-journal-of-iceland-application/src/models/postComment.input.ts +++ b/libs/api/domains/official-journal-of-iceland-application/src/models/postComment.input.ts @@ -5,6 +5,6 @@ export class PostCommentInput { @Field(() => String, { description: 'Application ID' }) id!: string - @Field(() => String) + @Field() comment!: string } diff --git a/libs/api/domains/official-journal-of-iceland-application/src/models/postComment.response.ts b/libs/api/domains/official-journal-of-iceland-application/src/models/postComment.response.ts index 4c83110bb5d3..eddcf30b8b83 100644 --- a/libs/api/domains/official-journal-of-iceland-application/src/models/postComment.response.ts +++ b/libs/api/domains/official-journal-of-iceland-application/src/models/postComment.response.ts @@ -1,8 +1,7 @@ import { Field, ObjectType } from '@nestjs/graphql' -import { CaseComment } from './getComments.response' @ObjectType('OfficialJournalOfIcelandApplicationPostCommentResponse') export class PostCommentResponse { - @Field(() => CaseComment) - comment!: CaseComment + @Field() + success!: boolean } diff --git a/libs/api/domains/official-journal-of-iceland/src/lib/models/advert.input.ts b/libs/api/domains/official-journal-of-iceland/src/lib/models/advert.input.ts index ac167ed5ffcb..14d782567e88 100644 --- a/libs/api/domains/official-journal-of-iceland/src/lib/models/advert.input.ts +++ b/libs/api/domains/official-journal-of-iceland/src/lib/models/advert.input.ts @@ -45,6 +45,9 @@ export class TypeQueryParams { @Field(() => Number, { nullable: true }) page?: number + + @Field(() => Number, { nullable: true }) + pageSize?: number } @InputType('OfficialJournalOfIcelandAdvertSingleParams') diff --git a/libs/api/domains/social-insurance/src/lib/models/income/category.model.ts b/libs/api/domains/social-insurance/src/lib/models/income/category.model.ts index a7c82ef08629..a456e3ed6ec8 100644 --- a/libs/api/domains/social-insurance/src/lib/models/income/category.model.ts +++ b/libs/api/domains/social-insurance/src/lib/models/income/category.model.ts @@ -5,6 +5,9 @@ export class IncomeCategory { @Field() name!: string + @Field() + typeName!: string + @Field(() => Int) annualSum!: number diff --git a/libs/api/domains/social-insurance/src/lib/socialInsurance.service.ts b/libs/api/domains/social-insurance/src/lib/socialInsurance.service.ts index 5b53e46c6cc3..98dfe04d5166 100644 --- a/libs/api/domains/social-insurance/src/lib/socialInsurance.service.ts +++ b/libs/api/domains/social-insurance/src/lib/socialInsurance.service.ts @@ -142,14 +142,15 @@ export class SocialInsuranceService { let hasIncompleteLines = false const incomeCategories = data.incomeTypeLines .map((i) => { - if (!i.incomeCategoryName || !i.totalSum || !i.currency) { + if (!i.incomeTypeName || !i.incomeCategoryName || !i.totalSum) { hasIncompleteLines = true return undefined } return { name: i.incomeCategoryName, + typeName: i.incomeTypeName, annualSum: i.totalSum, - currency: i.currency, + currency: i.currency ?? undefined, } }) .filter(isDefined) diff --git a/libs/application/template-api-modules/src/lib/modules/shared/api/national-registry/national-registry.service.ts b/libs/application/template-api-modules/src/lib/modules/shared/api/national-registry/national-registry.service.ts index 1f861243d450..cb2b746c8b0d 100644 --- a/libs/application/template-api-modules/src/lib/modules/shared/api/national-registry/national-registry.service.ts +++ b/libs/application/template-api-modules/src/lib/modules/shared/api/national-registry/national-registry.service.ts @@ -51,6 +51,25 @@ export class NationalRegistryService extends BaseTemplateApiService { await this.validateChildren(params, children) } + //allow parents whose children are icelandic citizenships in, but if no children, then check citizenship + if (params?.allowIfChildHasCitizenship) { + const children = await this.nationalRegistryApi.getCustodyChildren(auth) + if (children.length > 0) { + let foundChildWithIcelandicCitizenship = false + for (const child of children) { + const individual = await this.getIndividual(child) + if (individual?.citizenship?.code === 'IS') { + foundChildWithIcelandicCitizenship = true + break + } + } + //if child validates with icelandic citizenship, then do not check parents citizenship + if (foundChildWithIcelandicCitizenship) { + params = { ...params, icelandicCitizenship: false } + } + } + } + // Validate individual this.validateIndividual(individual, false, params) diff --git a/libs/application/template-api-modules/src/lib/modules/templates/aosh/change-machine-supervisor/change-machine-supervisor.utils.ts b/libs/application/template-api-modules/src/lib/modules/templates/aosh/change-machine-supervisor/change-machine-supervisor.utils.ts index afcc83b8d593..566f17ad80ee 100644 --- a/libs/application/template-api-modules/src/lib/modules/templates/aosh/change-machine-supervisor/change-machine-supervisor.utils.ts +++ b/libs/application/template-api-modules/src/lib/modules/templates/aosh/change-machine-supervisor/change-machine-supervisor.utils.ts @@ -72,9 +72,19 @@ export const sendNotificationsToRecipients = async ( ), application, ) - .catch(() => { + .catch((e) => { errors.push( - `Error sending email about submit application to ${recipientList[i].email}`, + `Error sending email about submit application in application: ID: ${ + application.id + }, + role: ${ + recipientList[i].ssn === application.applicant + ? 'Applicant' + : `Assignee index ${application.assignees.findIndex( + (assignee) => assignee === recipientList[i].ssn, + )}` + }`, + e, ) }) } @@ -90,9 +100,18 @@ export const sendNotificationsToRecipients = async ( ), application, ) - .catch(() => { + .catch((e) => { errors.push( - `Error sending sms about submit application to ${recipientList[i].phone}`, + `Error sending sms about submit application to + a phonenumber in application: ID: ${application.id}, + role: ${ + recipientList[i].ssn === application.applicant + ? 'Applicant' + : `Assignee index ${application.assignees.findIndex( + (assignee) => assignee === recipientList[i].ssn, + )}` + }`, + e, ) }) } diff --git a/libs/application/template-api-modules/src/lib/modules/templates/aosh/transfer-of-machine-ownership/transfer-of-machine-ownership.service.ts b/libs/application/template-api-modules/src/lib/modules/templates/aosh/transfer-of-machine-ownership/transfer-of-machine-ownership.service.ts index 0857f0b31515..14a2bb0b4b8e 100644 --- a/libs/application/template-api-modules/src/lib/modules/templates/aosh/transfer-of-machine-ownership/transfer-of-machine-ownership.service.ts +++ b/libs/application/template-api-modules/src/lib/modules/templates/aosh/transfer-of-machine-ownership/transfer-of-machine-ownership.service.ts @@ -128,7 +128,16 @@ export class TransferOfMachineOwnershipTemplateService extends BaseTemplateApiSe ) .catch((e) => { this.logger.error( - `Error sending email about submit application to ${recipientList[i].email}`, + `Error sending email about submit application in application: ID: ${ + application.id + }, + role: ${ + recipientList[i].ssn === application.applicant + ? 'Applicant' + : `Assignee index ${application.assignees.findIndex( + (assignee) => assignee === recipientList[i].ssn, + )}` + }`, e, ) }) @@ -143,7 +152,15 @@ export class TransferOfMachineOwnershipTemplateService extends BaseTemplateApiSe ) .catch((e) => { this.logger.error( - `Error sending sms about submit application to ${recipientList[i].phone}`, + `Error sending sms about submit application to + a phonenumber in application: ID: ${application.id}, + role: ${ + recipientList[i].ssn === application.applicant + ? 'Applicant' + : `Assignee index ${application.assignees.findIndex( + (assignee) => assignee === recipientList[i].ssn, + )}` + }`, e, ) }) @@ -200,7 +217,16 @@ export class TransferOfMachineOwnershipTemplateService extends BaseTemplateApiSe ) .catch((e) => { this.logger.error( - `Error sending email about initReview to ${recipientList[i].email}`, + `Error sending email about initReview in application: ID: ${ + application.id + }, + role: ${ + recipientList[i].ssn === application.applicant + ? 'Applicant' + : `Assignee index ${application.assignees.findIndex( + (assignee) => assignee === recipientList[i].ssn, + )}` + }`, e, ) }) @@ -215,7 +241,15 @@ export class TransferOfMachineOwnershipTemplateService extends BaseTemplateApiSe ) .catch((e) => { this.logger.error( - `Error sending sms about initReview to ${recipientList[i].phone}`, + `Error sending sms about initReview to + a phonenumber in application: ID: ${application.id}, + role: ${ + recipientList[i].ssn === application.applicant + ? 'Applicant' + : `Assignee index ${application.assignees.findIndex( + (assignee) => assignee === recipientList[i].ssn, + )}` + }`, e, ) }) @@ -297,9 +331,19 @@ export class TransferOfMachineOwnershipTemplateService extends BaseTemplateApiSe ), application, ) - .catch(() => { + .catch((e) => { this.logger.error( - `Error sending email about rejectApplication to ${recipientList[i].email}`, + `Error sending email about rejectApplication in application: ID: ${ + application.id + }, + role: ${ + recipientList[i].ssn === application.applicant + ? 'Applicant' + : `Assignee index ${application.assignees.findIndex( + (assignee) => assignee === recipientList[i].ssn, + )}` + }`, + e, ) }) } @@ -315,9 +359,18 @@ export class TransferOfMachineOwnershipTemplateService extends BaseTemplateApiSe ), application, ) - .catch(() => { + .catch((e) => { this.logger.error( - `Error sending sms about rejectApplication to ${recipientList[i].phone}`, + `Error sending sms about rejectApplication to + a phonenumber in application: ID: ${application.id}, + role: ${ + recipientList[i].ssn === application.applicant + ? 'Applicant' + : `Assignee index ${application.assignees.findIndex( + (assignee) => assignee === recipientList[i].ssn, + )}` + }`, + e, ) }) } diff --git a/libs/application/template-api-modules/src/lib/modules/templates/health-insurance-declaration/health-insurance-declaration.utils.ts b/libs/application/template-api-modules/src/lib/modules/templates/health-insurance-declaration/health-insurance-declaration.utils.ts index 289537cf7f61..6d45b033e222 100644 --- a/libs/application/template-api-modules/src/lib/modules/templates/health-insurance-declaration/health-insurance-declaration.utils.ts +++ b/libs/application/template-api-modules/src/lib/modules/templates/health-insurance-declaration/health-insurance-declaration.utils.ts @@ -56,41 +56,48 @@ const getApplicants = ( const applicants: InsuranceStatementsApplicantDTO[] = [] // Applicant - if (answers.isHealthInsured) { - applicants.push({ - nationalId: answers.applicant.nationalId, - name: answers.applicant.name, - type: 0, - }) - } - - // Spouse - answers.selectedApplicants?.registerPersonsSpouseCheckboxField?.map((s) => { - const externalSpouse = - application.externalData.nationalRegistrySpouse.data.nationalId === s - ? application.externalData.nationalRegistrySpouse.data - : undefined - if (externalSpouse) { + answers.selectedApplicants?.registerPersonsApplicantCheckboxField?.forEach( + (a) => { applicants.push({ - nationalId: externalSpouse.nationalId, - name: externalSpouse.name, - type: 1, + nationalId: answers.applicant.nationalId, + name: answers.applicant.name, + type: 0, }) - } - }) + }, + ) + + // Spouse + answers.selectedApplicants?.registerPersonsSpouseCheckboxField?.forEach( + (s) => { + const externalSpouse = + application.externalData.nationalRegistrySpouse.data.nationalId === s + ? application.externalData.nationalRegistrySpouse.data + : undefined + if (externalSpouse) { + applicants.push({ + nationalId: externalSpouse.nationalId, + name: externalSpouse.name, + type: 1, + }) + } + }, + ) // Children - answers.selectedApplicants?.registerPersonsChildrenCheckboxField?.map((c) => { - const child = application.externalData.childrenCustodyInformation.data.find( - (externalChild) => externalChild.nationalId === c, - ) - if (child) { - applicants.push({ - nationalId: child.nationalId, - name: child.fullName, - type: 2, - }) - } - }) + answers.selectedApplicants?.registerPersonsChildrenCheckboxField?.forEach( + (c) => { + const child = + application.externalData.childrenCustodyInformation.data.find( + (externalChild) => externalChild.nationalId === c, + ) + if (child) { + applicants.push({ + nationalId: child.nationalId, + name: child.fullName, + type: 2, + }) + } + }, + ) return applicants } diff --git a/libs/application/template-api-modules/src/lib/modules/templates/id-card/constants.ts b/libs/application/template-api-modules/src/lib/modules/templates/id-card/constants.ts index a37f589a2429..3c4868e71220 100644 --- a/libs/application/template-api-modules/src/lib/modules/templates/id-card/constants.ts +++ b/libs/application/template-api-modules/src/lib/modules/templates/id-card/constants.ts @@ -12,26 +12,3 @@ export type DistrictCommissionerAgencies = { address: string key: string } - -export interface IdentityDocumentChild { - childNationalId: string - secondParent: string - secondParentName: string - childName: string - passports?: IdentityDocument[] -} - -export type IdentityDocument = { - number: string - type: string - verboseType: string - subType: string - status: string - issuingDate: string - expirationDate: string - displayFirstName: string - displayLastName: string - mrzFirstName: string - mrzLastName: string - sex: string -} diff --git a/libs/application/template-api-modules/src/lib/modules/templates/id-card/id-card.service.ts b/libs/application/template-api-modules/src/lib/modules/templates/id-card/id-card.service.ts index 238827e8b161..daa1e7625091 100644 --- a/libs/application/template-api-modules/src/lib/modules/templates/id-card/id-card.service.ts +++ b/libs/application/template-api-modules/src/lib/modules/templates/id-card/id-card.service.ts @@ -4,16 +4,16 @@ import { LOGGER_PROVIDER } from '@island.is/logging' import { SharedTemplateApiService } from '../../shared' import { TemplateApiModuleActionProps } from '../../../types' import { coreErrorMessages, getValueViaPath } from '@island.is/application/core' -import { - DistrictCommissionerAgencies, - IdentityDocumentChild, -} from './constants' +import { DistrictCommissionerAgencies } from './constants' import { ChargeFjsV2ClientService, getPaymentIdFromExternalData, } from '@island.is/clients/charge-fjs-v2' import { generateAssignParentBApplicationEmail } from './emailGenerators/assignParentBEmail' -import { PassportsService } from '@island.is/clients/passports' +import { + IdentityDocumentChild, + PassportsService, +} from '@island.is/clients/passports' import { BaseTemplateApiService } from '../../base-template-api.service' import { ApplicationTypes, @@ -27,7 +27,7 @@ import { } from '@island.is/application/templates/id-card' import { generateApplicationRejectEmail } from './emailGenerators/rejectApplicationEmail' import { generateApplicationSubmittedEmail } from './emailGenerators/applicationSubmittedEmail' - +import { info } from 'kennitala' @Injectable() export class IdCardService extends BaseTemplateApiService { constructor( @@ -57,33 +57,44 @@ export class IdCardService extends BaseTemplateApiService { ) } - const expDate = identityDocument.userPassport?.expirationDate?.toString() // if applicant has valid id that is not withinExpirationDate, then not available for application, // otherwise available, either with no id or id within expiration limit // applicant can have a valid ID and apply for II - const applicantIdentityWithinLimits = expDate - ? isAvailableForApplication( - expDate, - 'ID', - `${identityDocument.userPassport?.type}${identityDocument.userPassport?.subType}`, - ) - : true + const applicantAge = info(auth.nationalId).age + const applicantInformation = { + age: applicantAge, + nationalId: auth.nationalId, + passport: identityDocument.userPassport, + children: identityDocument.childPassports, + } + const applicantIDWithinLimits = isAvailableForApplication( + 'ID', + applicantInformation, + ) + const applicantIIWithinLimits = isAvailableForApplication( + 'II', + applicantInformation, + ) let childIdentityWithinLimits = false identityDocument.childPassports?.map((child) => { if (child.passports && child.passports.length > 0) { child.passports.map((id) => { - const withinLimits = id.expirationDate - ? isAvailableForApplication( - id.expirationDate.toString(), - 'ID', - `${id.type}${id.subType}`, - ) - : true + if (child.childNationalId) { + const childInformation = { + age: info(child.childNationalId).age, + nationalId: child.childNationalId, + passport: child.passports?.[0], + } + const withinLimits = isAvailableForApplication( + 'ID', + childInformation, + ) - if (withinLimits) { - // if there is any id for any child that is within limits then user should be let through dataProvider - childIdentityWithinLimits = true + if (withinLimits) { + // if there is any id for any child that is within limits then user should be let through dataProvider + childIdentityWithinLimits = true + } } }) } else { @@ -91,7 +102,11 @@ export class IdCardService extends BaseTemplateApiService { } }) - if (!applicantIdentityWithinLimits && !childIdentityWithinLimits) { + if ( + !applicantIDWithinLimits && + !applicantIIWithinLimits && + !childIdentityWithinLimits + ) { throw new TemplateApiError( { title: coreErrorMessages.idCardApplicationRequirementsNotMet, @@ -278,7 +293,6 @@ export class IdCardService extends BaseTemplateApiService { guid: application.id, appliedForPersonId: auth.nationalId, priority: answers.priceList.priceChoice === Services.EXPRESS ? 1 : 0, - deliveryName: answers.priceList.location, contactInfo: { phoneAtHome: applicantInformation.phoneNumber, phoneAtWork: applicantInformation.phoneNumber, @@ -298,7 +312,6 @@ export class IdCardService extends BaseTemplateApiService { guid: application.id, appliedForPersonId: applicantInformation.nationalId, priority: answers.priceList.priceChoice === Services.EXPRESS ? 1 : 0, - deliveryName: answers.priceList.location, approvalA: { personId: firstGuardianInformation?.nationalId?.replace('-', '') || '', diff --git a/libs/application/template-api-modules/src/lib/modules/templates/transport-authority/change-co-owner-of-vehicle/change-co-owner-of-vehicle.service.ts b/libs/application/template-api-modules/src/lib/modules/templates/transport-authority/change-co-owner-of-vehicle/change-co-owner-of-vehicle.service.ts index 8b66b8f0cf34..4354a5020f61 100644 --- a/libs/application/template-api-modules/src/lib/modules/templates/transport-authority/change-co-owner-of-vehicle/change-co-owner-of-vehicle.service.ts +++ b/libs/application/template-api-modules/src/lib/modules/templates/transport-authority/change-co-owner-of-vehicle/change-co-owner-of-vehicle.service.ts @@ -279,9 +279,19 @@ export class ChangeCoOwnerOfVehicleService extends BaseTemplateApiService { (props) => generateRequestReviewEmail(props, recipientList[i]), application, ) - .catch(() => { + .catch((e) => { this.logger.error( - `Error sending email about initReview to ${recipientList[i].email}`, + `Error sending email about initReview in application: ID: ${ + application.id + }, + role: ${ + recipientList[i].ssn === application.applicant + ? 'Applicant' + : `Assignee index ${application.assignees.findIndex( + (assignee) => assignee === recipientList[i].ssn, + )}` + }`, + e, ) }) } @@ -293,9 +303,18 @@ export class ChangeCoOwnerOfVehicleService extends BaseTemplateApiService { generateRequestReviewSms(application, options, recipientList[i]), application, ) - .catch(() => { + .catch((e) => { this.logger.error( - `Error sending sms about initReview to ${recipientList[i].phone}`, + `Error sending sms about initReview to + a phonenumber in application: ID: ${application.id}, + role: ${ + recipientList[i].ssn === application.applicant + ? 'Applicant' + : `Assignee index ${application.assignees.findIndex( + (assignee) => assignee === recipientList[i].ssn, + )}` + }`, + e, ) }) } @@ -334,9 +353,19 @@ export class ChangeCoOwnerOfVehicleService extends BaseTemplateApiService { ), application, ) - .catch(() => { + .catch((e) => { this.logger.error( - `Error sending email about rejectApplication to ${recipientList[i].email}`, + `Error sending email about rejectApplication in application: ID: ${ + application.id + }, + role: ${ + recipientList[i].ssn === application.applicant + ? 'Applicant' + : `Assignee index ${application.assignees.findIndex( + (assignee) => assignee === recipientList[i].ssn, + )}` + }`, + e, ) }) } @@ -352,9 +381,18 @@ export class ChangeCoOwnerOfVehicleService extends BaseTemplateApiService { ), application, ) - .catch(() => { + .catch((e) => { this.logger.error( - `Error sending sms about rejectApplication to ${recipientList[i].phone}`, + `Error sending sms about rejectApplication to + a phonenumber in application: ID: ${application.id}, + role: ${ + recipientList[i].ssn === application.applicant + ? 'Applicant' + : `Assignee index ${application.assignees.findIndex( + (assignee) => assignee === recipientList[i].ssn, + )}` + }`, + e, ) }) } @@ -474,7 +512,16 @@ export class ChangeCoOwnerOfVehicleService extends BaseTemplateApiService { ) .catch(() => { this.logger.error( - `Error sending email about submitApplication to ${recipientList[i].email}`, + `Error sending email about submitApplication in application: ID: ${ + application.id + }, + role: ${ + recipientList[i].ssn === application.applicant + ? 'Applicant' + : `Assignee index ${application.assignees.findIndex( + (assignee) => assignee === recipientList[i].ssn, + )}` + }`, ) }) } @@ -486,9 +533,18 @@ export class ChangeCoOwnerOfVehicleService extends BaseTemplateApiService { generateApplicationSubmittedSms(application, recipientList[i]), application, ) - .catch(() => { + .catch((e) => { this.logger.error( - `Error sending sms about submitApplication to ${recipientList[i].phone}`, + `Error sending sms about submitApplication to + a phonenumber in application: ID: ${application.id}, + role: ${ + recipientList[i].ssn === application.applicant + ? 'Applicant' + : `Assignee index ${application.assignees.findIndex( + (assignee) => assignee === recipientList[i].ssn, + )}` + }`, + e, ) }) } diff --git a/libs/application/template-api-modules/src/lib/modules/templates/transport-authority/change-operator-of-vehicle/change-operator-of-vehicle.service.ts b/libs/application/template-api-modules/src/lib/modules/templates/transport-authority/change-operator-of-vehicle/change-operator-of-vehicle.service.ts index 797595b6c3ea..ea24278392a1 100644 --- a/libs/application/template-api-modules/src/lib/modules/templates/transport-authority/change-operator-of-vehicle/change-operator-of-vehicle.service.ts +++ b/libs/application/template-api-modules/src/lib/modules/templates/transport-authority/change-operator-of-vehicle/change-operator-of-vehicle.service.ts @@ -251,9 +251,19 @@ export class ChangeOperatorOfVehicleService extends BaseTemplateApiService { (props) => generateRequestReviewEmail(props, recipientList[i]), application, ) - .catch(() => { + .catch((e) => { this.logger.error( - `Error sending email about initReview to ${recipientList[i].email}`, + `Error sending email about initReview in application: ID: ${ + application.id + }, + role: ${ + recipientList[i].ssn === application.applicant + ? 'Applicant' + : `Assignee index ${application.assignees.findIndex( + (assignee) => assignee === recipientList[i].ssn, + )}` + }`, + e, ) }) } @@ -265,9 +275,18 @@ export class ChangeOperatorOfVehicleService extends BaseTemplateApiService { generateRequestReviewSms(application, options, recipientList[i]), application, ) - .catch(() => { + .catch((e) => { this.logger.error( - `Error sending sms about initReview to ${recipientList[i].phone}`, + `Error sending sms about initReview to + a phonenumber in application: ID: ${application.id}, + role: ${ + recipientList[i].ssn === application.applicant + ? 'Applicant' + : `Assignee index ${application.assignees.findIndex( + (assignee) => assignee === recipientList[i].ssn, + )}` + }`, + e, ) }) } @@ -306,9 +325,19 @@ export class ChangeOperatorOfVehicleService extends BaseTemplateApiService { ), application, ) - .catch(() => { + .catch((e) => { this.logger.error( - `Error sending email about rejectApplication to ${recipientList[i].email}`, + `Error sending email about rejectApplication in application: ID: ${ + application.id + }, + role: ${ + recipientList[i].ssn === application.applicant + ? 'Applicant' + : `Assignee index ${application.assignees.findIndex( + (assignee) => assignee === recipientList[i].ssn, + )}` + }`, + e, ) }) } @@ -324,9 +353,18 @@ export class ChangeOperatorOfVehicleService extends BaseTemplateApiService { ), application, ) - .catch(() => { + .catch((e) => { this.logger.error( - `Error sending sms about rejectApplication to ${recipientList[i].phone}`, + `Error sending sms about rejectApplication to + a phonenumber in application: ID: ${application.id}, + role: ${ + recipientList[i].ssn === application.applicant + ? 'Applicant' + : `Assignee index ${application.assignees.findIndex( + (assignee) => assignee === recipientList[i].ssn, + )}` + }`, + e, ) }) } @@ -421,9 +459,19 @@ export class ChangeOperatorOfVehicleService extends BaseTemplateApiService { generateApplicationSubmittedEmail(props, recipientList[i]), application, ) - .catch(() => { + .catch((e) => { this.logger.error( - `Error sending email about submitApplication to ${recipientList[i].email}`, + `Error sending email about submitApplication in application: ID: ${ + application.id + }, + role: ${ + recipientList[i].ssn === application.applicant + ? 'Applicant' + : `Assignee index ${application.assignees.findIndex( + (assignee) => assignee === recipientList[i].ssn, + )}` + }`, + e, ) }) } @@ -435,9 +483,18 @@ export class ChangeOperatorOfVehicleService extends BaseTemplateApiService { generateApplicationSubmittedSms(application, recipientList[i]), application, ) - .catch(() => { + .catch((e) => { this.logger.error( - `Error sending sms about submitApplication to ${recipientList[i].phone}`, + `Error sending sms about submitApplication to + a phonenumber in application: ID: ${application.id}, + role: ${ + recipientList[i].ssn === application.applicant + ? 'Applicant' + : `Assignee index ${application.assignees.findIndex( + (assignee) => assignee === recipientList[i].ssn, + )}` + }`, + e, ) }) } diff --git a/libs/application/template-api-modules/src/lib/modules/templates/transport-authority/transfer-of-vehicle-ownership/transfer-of-vehicle-ownership.service.ts b/libs/application/template-api-modules/src/lib/modules/templates/transport-authority/transfer-of-vehicle-ownership/transfer-of-vehicle-ownership.service.ts index 1406b9b2c6f4..eaaae0abe751 100644 --- a/libs/application/template-api-modules/src/lib/modules/templates/transport-authority/transfer-of-vehicle-ownership/transfer-of-vehicle-ownership.service.ts +++ b/libs/application/template-api-modules/src/lib/modules/templates/transport-authority/transfer-of-vehicle-ownership/transfer-of-vehicle-ownership.service.ts @@ -281,9 +281,19 @@ export class TransferOfVehicleOwnershipService extends BaseTemplateApiService { (props) => generateRequestReviewEmail(props, recipientList[i]), application, ) - .catch(() => { + .catch((e) => { this.logger.error( - `Error sending email about initReview to ${recipientList[i].email}`, + `Error sending email about initReview in application: ID: ${ + application.id + }, + role: ${ + recipientList[i].ssn === application.applicant + ? 'Applicant' + : `Assignee index ${application.assignees.findIndex( + (assignee) => assignee === recipientList[i].ssn, + )}` + }`, + e, ) }) } @@ -295,9 +305,18 @@ export class TransferOfVehicleOwnershipService extends BaseTemplateApiService { generateRequestReviewSms(application, options, recipientList[i]), application, ) - .catch(() => { + .catch((e) => { this.logger.error( - `Error sending sms about initReview to ${recipientList[i].phone}`, + `Error sending sms about initReview to + a phonenumber in application: ID: ${application.id}, + role: ${ + recipientList[i].ssn === application.applicant + ? 'Applicant' + : `Assignee index ${application.assignees.findIndex( + (assignee) => assignee === recipientList[i].ssn, + )}` + }`, + e, ) }) } @@ -402,9 +421,19 @@ export class TransferOfVehicleOwnershipService extends BaseTemplateApiService { generateRequestReviewEmail(props, newlyAddedRecipientList[i]), application, ) - .catch(() => { + .catch((e) => { this.logger.error( - `Error sending email about addReview to ${newlyAddedRecipientList[i].email}`, + `Error sending email about addReview in application: ID: ${ + application.id + }, + role: ${ + newlyAddedRecipientList[i].ssn === application.applicant + ? 'Applicant' + : `Assignee index ${application.assignees.findIndex( + (assignee) => assignee === newlyAddedRecipientList[i].ssn, + )}` + }`, + e, ) }) } @@ -419,9 +448,18 @@ export class TransferOfVehicleOwnershipService extends BaseTemplateApiService { ), application, ) - .catch(() => { + .catch((e) => { this.logger.error( - `Error sending sms about addReview to ${newlyAddedRecipientList[i].phone}`, + `Error sending sms about addReview to + a phonenumber in application: ID: ${application.id}, + role: ${ + newlyAddedRecipientList[i].ssn === application.applicant + ? 'Applicant' + : `Assignee index ${application.assignees.findIndex( + (assignee) => assignee === newlyAddedRecipientList[i].ssn, + )}` + }`, + e, ) }) } @@ -458,9 +496,19 @@ export class TransferOfVehicleOwnershipService extends BaseTemplateApiService { ), application, ) - .catch(() => { + .catch((e) => { this.logger.error( - `Error sending email about rejectApplication to ${recipientList[i].email}`, + `Error sending email about rejectApplication in application: ID: ${ + application.id + }, + role: ${ + recipientList[i].ssn === application.applicant + ? 'Applicant' + : `Assignee index ${application.assignees.findIndex( + (assignee) => assignee === recipientList[i].ssn, + )}` + }`, + e, ) }) } @@ -476,9 +524,18 @@ export class TransferOfVehicleOwnershipService extends BaseTemplateApiService { ), application, ) - .catch(() => { + .catch((e) => { this.logger.error( - `Error sending sms about rejectApplication to ${recipientList[i].phone}`, + `Error sending sms about rejectApplication to + a phonenumber in application: ID: ${application.id}, + role: ${ + recipientList[i].ssn === application.applicant + ? 'Applicant' + : `Assignee index ${application.assignees.findIndex( + (assignee) => assignee === recipientList[i].ssn, + )}` + }`, + e, ) }) } @@ -582,9 +639,19 @@ export class TransferOfVehicleOwnershipService extends BaseTemplateApiService { generateApplicationSubmittedEmail(props, recipientList[i]), application, ) - .catch(() => { + .catch((e) => { this.logger.error( - `Error sending email about submitApplication to ${recipientList[i].email}`, + `Error sending email about submitApplication in application: ID: ${ + application.id + }, + role: ${ + recipientList[i].ssn === application.applicant + ? 'Applicant' + : `Assignee index ${application.assignees.findIndex( + (assignee) => assignee === recipientList[i].ssn, + )}` + }`, + e, ) }) } @@ -596,9 +663,18 @@ export class TransferOfVehicleOwnershipService extends BaseTemplateApiService { generateApplicationSubmittedSms(application, recipientList[i]), application, ) - .catch(() => { + .catch((e) => { this.logger.error( - `Error sending sms about submitApplication to ${recipientList[i].phone}`, + `Error sending sms about rejectApplication to + a phonenumber in application: ID: ${application.id}, + role: ${ + recipientList[i].ssn === application.applicant + ? 'Applicant' + : `Assignee index ${application.assignees.findIndex( + (assignee) => assignee === recipientList[i].ssn, + )}` + }`, + e, ) }) } diff --git a/libs/application/template-api-modules/src/lib/modules/templates/university/university.service.ts b/libs/application/template-api-modules/src/lib/modules/templates/university/university.service.ts index 3405f7851e79..c6d9b0006ec3 100644 --- a/libs/application/template-api-modules/src/lib/modules/templates/university/university.service.ts +++ b/libs/application/template-api-modules/src/lib/modules/templates/university/university.service.ts @@ -4,7 +4,6 @@ import { TemplateApiModuleActionProps } from '../../../types' import { BaseTemplateApiService } from '../../base-template-api.service' import { ApplicationTypes, - ApplicationWithAttachments, NationalRegistryIndividual, } from '@island.is/application/types' @@ -23,7 +22,10 @@ import { CreateApplicationDtoEducationOptionEnum, } from '@island.is/clients/university-gateway-api' -import { UniversityAnswers } from '@island.is/application/templates/university' +import { + UniversityAnswers, + UniversityGatewayProgram, +} from '@island.is/application/templates/university' import { Auth, AuthMiddleware } from '@island.is/auth-nest-tools' import { InnaClientService } from '@island.is/clients/inna' @@ -120,6 +122,14 @@ export class UniversityService extends BaseTemplateApiService { email: userFromAnswers.email, phone: userFromAnswers.phone, } + const programs = externalData.programs + ?.data as Array<UniversityGatewayProgram> + const modesOfDeliveryFromChosenProgram = programs.find( + (x) => x.id === answers.programInformation.program, + ) + const defaultModeOfDelivery = modesOfDeliveryFromChosenProgram + ?.modeOfDelivery[0] + .modeOfDelivery as CreateApplicationDtoModeOfDeliveryEnum //all possible types of education data from the application answers const educationOptionChosen = @@ -235,7 +245,8 @@ export class UniversityService extends BaseTemplateApiService { universityId: answers.programInformation.university, programId: answers.programInformation.program, modeOfDelivery: mapStringToEnum( - answers.modeOfDeliveryInformation.chosenMode, + answers.modeOfDeliveryInformation?.chosenMode || + defaultModeOfDelivery, CreateApplicationDtoModeOfDeliveryEnum, 'CreateApplicationDtoModeOfDeliveryEnum', ), diff --git a/libs/application/templates/README.md b/libs/application/templates/README.md index 684234ff491f..55b51ce87c0f 100644 --- a/libs/application/templates/README.md +++ b/libs/application/templates/README.md @@ -1 +1,47 @@ # Templates + +## Mocking XROAD endpoints with Mockoon for templates + +### Prerequisites + +Since the requests from the services we are running locally default to making their calls on port `8081`so the mock will be listening on port `8081`. This means the port forwarding for xroad needs to be listening on port `8082` (or some other port) and then we will set the mock server will forward requests it does not have mock responses for to that port. + +To set the port forwarding to listen on port `8082` you can pass a port argument to the proxies script like so `yarn proxies xroad --p 8082`. Alternatively if you use kubectl and socat just replace `8081:80` with `8082:80`. + +### How to + +The mockoon CLI is a dev dependency so it should be installed along with everything else when you `yarn`. When you want to use the mockoon-cli you simply call `mockoon-cli start --data <path to capture file>`. The capture file can be one you made yourself (see below) or some applications have mock files already created for them, in which case they can be found under `libs/application/<application name>/mockData`. + +Mockoon should now be listening on port `8081` and proxying non-mocked traffic to port `8082`. + +For more in-depth instructions, you can check out the [mockoon site](https://mockoon.com/cli/). + +### Mockoon app + +It is very much recommended to install the [Mockoon app](https://mockoon.com/download/) as that allows you to both capture new mock data, select which endppoints should be mocked or even modify the mocked payloads to name a few things. + +### Current mocks + +If mockdata is available for an application it should be in the mockData directory in the application in question (see above under how to). If you create mock data for an application that doesn't have any, consider adding it under the appropriate directory. + +## Q&A + +### What if I need to call an endpoint that isn't mocked + +No problem, mockoon will transparently proxy whatever requests it does not have mocks for. + +### What if I want to get an actual response from an endpoint being mocked + +Find the endpoint in question in the `Routes` panel, click on the three little dots in the upper right corner of the route entry and select `Toggle`. This will cause any incoming requests to be proxied rather than mocked. + +### What if I want to update the mocked data for an endpoint + +The simplest way is to delete the existing endpoint by finding it in the routes list as above but selecting `Delete` instead of `Toggle`, turning on the recording function by clicking the little dot in the `Logs` tab above the request list and then performing a call to the underlying endpoint. You can also toggle the endpoint mock off as described above, do a call to the endpoint, find the log for that call in the logs tab and simply copy over the returned data. + +### My calls aren't being mocked + +The mocks are currently set up for the Gervimaður Færeyjar fake person. If you need to mock other fake persons, you can download the [mockoon app](https://mockoon.com/download/) and either open the applicable collection or start your own with [automocking](https://mockoon.com/docs/latest/logging-and-recording/auto-mocking-and-recording/). + +### Does the mocking proxy only respond with mocks when the proxied service is down? + +No, one of the benefits of mocking locally is a significantly shorter response time, and to achieve that, it's necessary to use mocks even if the underlying service is operational. If you want to send calls to the proxied endpoint you can toggle the mock off in the `Routes` tab. diff --git a/mocks/national-registry/v2/accident-notification.json b/libs/application/templates/accident-notification/mockData/accident-notification.json similarity index 100% rename from mocks/national-registry/v2/accident-notification.json rename to libs/application/templates/accident-notification/mockData/accident-notification.json diff --git a/libs/application/templates/accident-notification/src/lib/AccidentNotificationTemplate.ts b/libs/application/templates/accident-notification/src/lib/AccidentNotificationTemplate.ts index fa4f73a2235c..6493795fdb1b 100644 --- a/libs/application/templates/accident-notification/src/lib/AccidentNotificationTemplate.ts +++ b/libs/application/templates/accident-notification/src/lib/AccidentNotificationTemplate.ts @@ -274,7 +274,7 @@ const AccidentNotificationTemplate: ApplicationTemplate< // State when assignee has approved or reject the appliction [States.IN_FINAL_REVIEW]: { meta: { - status: 'inprogress', + status: 'completed', name: States.IN_FINAL_REVIEW, progress: 1, lifecycle: DefaultStateLifeCycle, diff --git a/libs/application/templates/driving-license/src/dataProviders/index.ts b/libs/application/templates/driving-license/src/dataProviders/index.ts index c6d5f3243fa1..fe90c3829b53 100644 --- a/libs/application/templates/driving-license/src/dataProviders/index.ts +++ b/libs/application/templates/driving-license/src/dataProviders/index.ts @@ -9,7 +9,6 @@ export { UserProfileApi, CurrentLicenseApi, DrivingAssessmentApi, - JurisdictionApi, QualityPhotoApi, ExistingApplicationApi, } from '@island.is/application/types' diff --git a/libs/application/templates/driving-license/src/forms/draft/draft.ts b/libs/application/templates/driving-license/src/forms/draft/draft.ts index d120dd2231c3..3757e636945a 100644 --- a/libs/application/templates/driving-license/src/forms/draft/draft.ts +++ b/libs/application/templates/driving-license/src/forms/draft/draft.ts @@ -6,7 +6,6 @@ import { subSectionTempInfo } from './subSectionTempInfo' import { subSectionOtherCountry } from './subSectionOtherCountry' import { subSectionOtherCountryDirections } from './subSectionOtherCountryDirections' import { subSectionQualityPhoto } from './subSectionQualityPhoto' -import { subSectionDelivery } from './subSectionDelivery' import { subSectionHealthDeclaration } from './subSectionHealthDeclaration' import { subSectionSummary } from './subSectionSummary' import { subSectionPhone } from './subSectionPhone' @@ -33,7 +32,6 @@ export const draft: Form = buildForm({ subSectionOtherCountry, subSectionOtherCountryDirections, subSectionQualityPhoto, - subSectionDelivery, subSectionHealthDeclaration, subSectionSummary, ], diff --git a/libs/application/templates/driving-license/src/forms/draft/subSectionDelivery.ts b/libs/application/templates/driving-license/src/forms/draft/subSectionDelivery.ts deleted file mode 100644 index a6295a5344c5..000000000000 --- a/libs/application/templates/driving-license/src/forms/draft/subSectionDelivery.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { - buildDescriptionField, - buildMultiField, - buildSelectField, - buildSubSection, -} from '@island.is/application/core' -import { m } from '../../lib/messages' -import { - chooseDistrictCommissionerDescription, - hasNoDrivingLicenseInOtherCountry, -} from '../../lib/utils' - -import { Jurisdiction } from '@island.is/clients/driving-license' - -export const subSectionDelivery = buildSubSection({ - id: 'user', - title: m.informationSectionTitle, - condition: hasNoDrivingLicenseInOtherCountry, - children: [ - buildMultiField({ - id: 'info', - title: m.pickupLocationTitle, - space: 1, - children: [ - buildDescriptionField({ - id: 'afhending', - title: m.districtCommisionerTitle, - titleVariant: 'h4', - description: chooseDistrictCommissionerDescription, - }), - buildSelectField({ - id: 'jurisdiction', - title: m.districtCommisionerPickup, - disabled: false, - required: true, - options: ({ - externalData: { - jurisdictions: { data }, - }, - }) => { - return (data as Jurisdiction[]).map(({ id, name, zip }) => ({ - value: `${id}`, - label: name, - tooltip: `Póstnúmer ${zip}`, - })) - }, - }), - ], - }), - ], -}) diff --git a/libs/application/templates/driving-license/src/forms/prerequisites/getForm.ts b/libs/application/templates/driving-license/src/forms/prerequisites/getForm.ts index a003e2366867..404cabe3d5fe 100644 --- a/libs/application/templates/driving-license/src/forms/prerequisites/getForm.ts +++ b/libs/application/templates/driving-license/src/forms/prerequisites/getForm.ts @@ -7,6 +7,7 @@ import { sectionExternalData } from './sectionExternalData' import { sectionApplicationFor } from './sectionApplicationFor' import { sectionRequirements } from './sectionRequirements' import { sectionExistingApplication } from './sectionExistingApplication' +import { sectionDigitalLicenseInfo } from './sectionDigitalLicenseInfo' export const getForm = ({ allowFakeData = false, @@ -29,6 +30,7 @@ export const getForm = ({ sectionExternalData, sectionExistingApplication, ...(allowPickLicense ? [sectionApplicationFor(allowBELicense)] : []), + sectionDigitalLicenseInfo, sectionRequirements, ], }), @@ -43,7 +45,7 @@ export const getForm = ({ children: [], }), buildSection({ - id: 'confim', + id: 'confirm', title: m.applicationDone, children: [], }), diff --git a/libs/application/templates/driving-license/src/forms/prerequisites/sectionDigitalLicenseInfo.ts b/libs/application/templates/driving-license/src/forms/prerequisites/sectionDigitalLicenseInfo.ts new file mode 100644 index 000000000000..279e17edd222 --- /dev/null +++ b/libs/application/templates/driving-license/src/forms/prerequisites/sectionDigitalLicenseInfo.ts @@ -0,0 +1,30 @@ +import { + buildAlertMessageField, + buildMultiField, + buildSubSection, +} from '@island.is/application/core' +import { m } from '../../lib/messages' +import { B_TEMP } from '../../lib/constants' + +export const sectionDigitalLicenseInfo = buildSubSection({ + id: 'digitalLicenseInfo', + title: m.digitalLicenseInfoTitle, + children: [ + buildMultiField({ + id: 'info', + title: m.digitalLicenseInfoTitle, + description: m.digitalLicenseInfoDescription, + children: [ + buildAlertMessageField({ + id: 'digitalLicenseInfo', + title: m.digitalLicenseInfoAlertTitle, + message: ({ answers }) => + answers.applicationFor === B_TEMP + ? m.digitalLicenseInfoAlertMessageBTemp + : m.digitalLicenseInfoAlertMessageBFull, + alertType: 'info', + }), + ], + }), + ], +}) diff --git a/libs/application/templates/driving-license/src/forms/prerequisites/sectionExternalData.ts b/libs/application/templates/driving-license/src/forms/prerequisites/sectionExternalData.ts index b920cf6deeca..5a8c4a131fdf 100644 --- a/libs/application/templates/driving-license/src/forms/prerequisites/sectionExternalData.ts +++ b/libs/application/templates/driving-license/src/forms/prerequisites/sectionExternalData.ts @@ -10,7 +10,6 @@ import { UserProfileApi, CurrentLicenseApi, DrivingAssessmentApi, - JurisdictionApi, QualityPhotoApi, ExistingApplicationApi, } from '@island.is/application/types' @@ -57,10 +56,6 @@ export const sectionExternalData = buildSubSection({ provider: DrivingAssessmentApi, title: '', }), - buildDataProviderItem({ - provider: JurisdictionApi, - title: '', - }), buildDataProviderItem({ provider: SyslumadurPaymentCatalogApi, title: '', diff --git a/libs/application/templates/driving-license/src/lib/drivingLicenseTemplate.ts b/libs/application/templates/driving-license/src/lib/drivingLicenseTemplate.ts index 210c180849c8..43d724e7f327 100644 --- a/libs/application/templates/driving-license/src/lib/drivingLicenseTemplate.ts +++ b/libs/application/templates/driving-license/src/lib/drivingLicenseTemplate.ts @@ -11,7 +11,6 @@ import { ApplicationStateSchema, DefaultEvents, defineTemplateApi, - JurisdictionApi, CurrentLicenseApi, DrivingAssessmentApi, NationalRegistryUserApi, @@ -133,7 +132,6 @@ const template: ApplicationTemplate< }, }), DrivingAssessmentApi, - JurisdictionApi, QualityPhotoApi, ExistingApplicationApi.configure({ params: { diff --git a/libs/application/templates/driving-license/src/lib/messages.ts b/libs/application/templates/driving-license/src/lib/messages.ts index 9ae2238b273b..8aabdf363f80 100644 --- a/libs/application/templates/driving-license/src/lib/messages.ts +++ b/libs/application/templates/driving-license/src/lib/messages.ts @@ -105,11 +105,6 @@ export const m = defineMessages({ defaultMessage: 'Sýslumannsembætti', description: 'Information', }, - pickupLocationTitle: { - id: 'dl.application:pickuplocation', - defaultMessage: 'Afhendingarstaður', - description: 'location for pickup', - }, informationApplicant: { id: 'dl.application:information.applicant', defaultMessage: 'Umsækjandi', @@ -432,6 +427,33 @@ export const m = defineMessages({ description: 'Your application for a full driving license has been received. Before a full driving license can be applied for, you must bring the following to the district commissioner.', }, + digitalLicenseInfoTitle: { + id: 'dl.application:digitalLicenseInfoTitle', + defaultMessage: 'Stafrænt ökuskírteini', + description: 'Digital driving license', + }, + digitalLicenseInfoDescription: { + id: 'dl.application:digitalLicenseInfoDescription', + defaultMessage: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + description: 'Digital driving license', + }, + digitalLicenseInfoAlertTitle: { + id: 'dl.application:digitalLicenseInfoAlertTitle', + defaultMessage: 'Athugið', + description: 'Digital driving license', + }, + digitalLicenseInfoAlertMessageBTemp: { + id: 'dl.application:digitalLicenseInfoAlertMessageBTemp#markdown', + defaultMessage: + 'Þú ert að sækja um bráðabirgðaökuskírteini. Ökuskírteini þitt verður einungis gefið út sem stafrænt ökuskírteini og verður aðgengilegt fyrir þig um leið og öll skilyrði fyrir bráðabirgðaökuskírteini eru uppfyllt.', + description: 'Digital driving license', + }, + digitalLicenseInfoAlertMessageBFull: { + id: 'dl.application:digitalLicenseInfoAlertMessageBFull#markdown', + defaultMessage: + 'Þú ert að sækja um fullnaðarökuskírteini. Ökuskírteini þitt verður núna einungis gefið út sem stafrænt ökuskírteini og verður aðgengilegt fyrir þig þegar þú hefur lokið þessari pöntun um fullnaðarökuskírteini. Fullnaðarökuskírteini þitt verður framleitt í plasti í byrjun febrúar 2025 og sent til þín með Póstinum, á skráð lögheimili þitt um leið og plastökuskírteinið er tilbúið.', + description: 'Digital driving license', + }, congratulationsTempHelpText: { id: 'dl.application:congratulationsTempHelpText', defaultMessage: @@ -559,11 +581,6 @@ export const m = defineMessages({ defaultMessage: 'Sýslumannsembætti', description: 'Title for district commissioner', }, - districtCommisionerPickup: { - id: 'dl.application:districtCommisionerPickup', - defaultMessage: 'Afhending', - description: 'Pickup for district commissioner', - }, chooseDistrictCommisionerForFullLicense: { id: 'dl.application:chooseDistrictCommisionerForFullLicense', defaultMessage: diff --git a/libs/application/templates/health-insurance-declaration/mockData/health-insurance-declaration.json b/libs/application/templates/health-insurance-declaration/mockData/health-insurance-declaration.json index feaf925be9f6..30cef8efeb8a 100644 --- a/libs/application/templates/health-insurance-declaration/mockData/health-insurance-declaration.json +++ b/libs/application/templates/health-insurance-declaration/mockData/health-insurance-declaration.json @@ -705,7 +705,7 @@ "responses": [ { "uuid": "a9384814-ffa3-4bdd-ae69-35d53bb264c7", - "body": "{\n \"canApply\": true,\n \"isInsured\": true,\n \"hasInsuranceStatement\": true,\n \"insuranceStatementValidUntil\": \"2024-12-05\",\n \"comment\": \"Umsækjandi á tryggingayfirlýsingu í gildi til 05.12.2024\"\n}", + "body": "{\n \"canApply\": true,\n \"isInsured\": false,\n \"hasInsuranceStatement\": false,\n \"insuranceStatementValidUntil\": \"2024-12-05\",\n \"comment\": \"Umsækjandi á tryggingayfirlýsingu í gildi til 05.12.2024\"\n}", "latency": 0, "statusCode": 200, "label": "", diff --git a/libs/application/templates/health-insurance-declaration/src/forms/HealthInsuranceDeclarationForm.ts b/libs/application/templates/health-insurance-declaration/src/forms/HealthInsuranceDeclarationForm.ts index 90d4a79d1590..ed46fa830f95 100644 --- a/libs/application/templates/health-insurance-declaration/src/forms/HealthInsuranceDeclarationForm.ts +++ b/libs/application/templates/health-insurance-declaration/src/forms/HealthInsuranceDeclarationForm.ts @@ -34,7 +34,8 @@ import { getCountryNameFromCode, getFullNameFromExternalData, getInsuranceStatus, - getSelectedFamily, + getSelectedApplicants, + getApplicantAsOption, getSpouseAsOptions, hasFamilySelected, } from '../utils' @@ -268,6 +269,14 @@ export const HealthInsuranceDeclarationForm: Form = buildForm({ id: 'registerPersonsMultiFiled', title: m.application.registerPersons.sectionDescription, children: [ + buildCheckboxField({ + id: 'selectedApplicants.registerPersonsApplicantCheckboxField', + title: m.application.registerPersons.applicantTitle, + defaultValue: (application: any) => [ + getApplicantAsOption(application.externalData)[0]?.value, + ], + options: ({ externalData }) => getApplicantAsOption(externalData), + }), buildCheckboxField({ id: 'selectedApplicants.registerPersonsSpouseCheckboxField', title: m.application.registerPersons.spousetitle, @@ -489,11 +498,11 @@ export const HealthInsuranceDeclarationForm: Form = buildForm({ ), }), buildDividerField({}), - // Family table + // Applicants table buildStaticTableField({ - title: m.application.overview.familyTableTitle, + title: m.application.overview.applicantsTableTitle, rows: ({ answers, externalData }) => - getSelectedFamily( + getSelectedApplicants( answers as HealthInsuranceDeclaration, externalData, ), @@ -502,8 +511,6 @@ export const HealthInsuranceDeclarationForm: Form = buildForm({ applicantInformationMessages.labels.nationalId, 'Tengsl', ], - condition: (answers) => - hasFamilySelected(answers as HealthInsuranceDeclaration), }), buildDividerField({ condition: (answers) => diff --git a/libs/application/templates/health-insurance-declaration/src/lib/dataSchema.ts b/libs/application/templates/health-insurance-declaration/src/lib/dataSchema.ts index afaa2c264e33..6acbf5a43996 100644 --- a/libs/application/templates/health-insurance-declaration/src/lib/dataSchema.ts +++ b/libs/application/templates/health-insurance-declaration/src/lib/dataSchema.ts @@ -23,16 +23,21 @@ export const HealthInsuranceDeclarationSchema = z.object({ .refine((v) => !!v), selectedApplicants: z .object({ + registerPersonsApplicantCheckboxField: z.string().array().optional(), registerPersonsSpouseCheckboxField: z.string().array().optional(), registerPersonsChildrenCheckboxField: z.string().array().optional(), - isHealthInsured: z.boolean(), }) .superRefine((v, ctx) => { if ( - !v.isHealthInsured && + !v.registerPersonsApplicantCheckboxField?.length && !v.registerPersonsSpouseCheckboxField?.length && !v.registerPersonsChildrenCheckboxField?.length ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['registerPersonsApplicantCheckboxField'], + params: errors.fields.noSelectedApplicant, + }) ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['registerPersonsChildrenCheckboxField'], diff --git a/libs/application/templates/health-insurance-declaration/src/lib/messages/application.ts b/libs/application/templates/health-insurance-declaration/src/lib/messages/application.ts index b1d5596b7170..155a0adffb01 100644 --- a/libs/application/templates/health-insurance-declaration/src/lib/messages/application.ts +++ b/libs/application/templates/health-insurance-declaration/src/lib/messages/application.ts @@ -43,12 +43,12 @@ export const application = { studentOrTourist: defineMessages({ sectionTitle: { id: 'hid.application:studentOrTourist.section.title', - defaultMessage: 'Námsmaður/ferðamaður', + defaultMessage: 'Nám/ferðir', description: 'Student or Tourist section title', }, sectionDescription: { id: 'hid.application:studentOrTourist.section.description', - defaultMessage: 'Ertu ferðamaður eða námsmaður?', + defaultMessage: 'Er sótt um vegna ferða eða náms?', description: 'Student or Tourist section description', }, touristRadioFieldText: { @@ -79,9 +79,14 @@ export const application = { }, sectionDescription: { id: 'hid.application:registerPersons.section.description', - defaultMessage: 'Ég er einnig að sækja um fyrir', + defaultMessage: 'Ég er að sækja um fyrir', description: 'Register persons section description', }, + applicantTitle: { + id: 'hid.application:registerPersons.section.applicantTitle', + defaultMessage: 'Umsækjandi', + description: 'Register persons title', + }, spousetitle: { id: 'hid.application:registerPersons.section.spouseTitle', defaultMessage: 'Maki', @@ -116,7 +121,7 @@ export const application = { }, studentSectionPlaceholderText: { id: 'hid.application:residency.section.student.placeholderSelectText', - defaultMessage: 'Veldur land sem þú ferðast til', + defaultMessage: 'Veldu land sem ferðast á til', description: 'Student residency selection placeholder text', }, }), @@ -200,17 +205,17 @@ export const application = { }, studentOrTouristTitle: { id: 'hid.application:overview.section.studentOrTouristTitle', - defaultMessage: 'Ertu ferðamaður eða námsmaður', + defaultMessage: 'Er sótt um vegna ferða eða náms', description: 'Overview section Student or Tourist title', }, studentOrTouristTouristText: { id: 'hid.application:overview.section.studentOrTouristTouristText', - defaultMessage: 'Ferðamaður', + defaultMessage: 'Ferða', description: 'Overview section Student or Tourist: Tourist text', }, studentOrTouristStudentText: { id: 'hid.application:overview.section.studentOrTouristStudentText', - defaultMessage: 'Námsmaður', + defaultMessage: 'Náms', description: 'Overview section Student or Tourist: Student text', }, applicantInfoTitle: { @@ -218,25 +223,30 @@ export const application = { defaultMessage: 'Persónu upplýsingar', description: 'Overview section applicant title', }, - familyTableTitle: { - id: 'hid.application:overview.section.familyTableHeader', - defaultMessage: 'Maki og börn', - description: 'Overview section family table title', + applicantsTableTitle: { + id: 'hid.application:overview.section.applicantsTableHeader', + defaultMessage: 'Sótt er um fyrir', + description: 'Overview section applicant table title', }, familyTableRelationHeader: { id: 'hid.application:overview.section.familyTableHeaderRelationText', defaultMessage: 'Tengsl', - description: 'Overview section family table title', + description: 'Overview section applicant table title', + }, + familyTableRelationApplicantText: { + id: 'hid.application:overview.section.applicantTableApplicantRelationText', + defaultMessage: 'Umsækjandi', + description: 'Overview section applicant table applicant text', }, familyTableRelationSpouseText: { - id: 'hid.application:overview.section.familyTableSpouseRelationText', + id: 'hid.application:overview.section.applicantTableSpouseRelationText', defaultMessage: 'Maki', - description: 'Overview section family table spouse relation text', + description: 'Overview section applicant table spouse relation text', }, familyTableRelationChildText: { - id: 'hid.application:overview.section.familyTableChildRelationText', + id: 'hid.application:overview.section.applicantTableChildRelationText', defaultMessage: 'Barn', - description: 'Overview section family table child relation text', + description: 'Overview section applicant table child relation text', }, dateTitle: { id: 'hid.application:overview.section.DateTitle', diff --git a/libs/application/templates/health-insurance-declaration/src/utils/data.ts b/libs/application/templates/health-insurance-declaration/src/utils/data.ts index 392d63665dec..3fa4ed06afc3 100644 --- a/libs/application/templates/health-insurance-declaration/src/utils/data.ts +++ b/libs/application/templates/health-insurance-declaration/src/utils/data.ts @@ -134,6 +134,23 @@ export const getChildrenAsOptions = (externalData: ExternalData): Option[] => { return [] } +export const getApplicantFromExternalData = ( + externalData: ExternalData, +): NationalRegistryIndividual => { + return externalData.nationalRegistry?.data as NationalRegistryIndividual +} + +export const getApplicantAsOption = (externalData: ExternalData): Option[] => { + const individual = getApplicantFromExternalData(externalData) + return [ + { + value: individual?.nationalId, + label: individual?.fullName, + subLabel: `${format(individual?.nationalId)}`, + }, + ] +} + export const getSpouseAsOptions = (externalData: ExternalData): Option[] => { const spouse = getSpouseFromExternalData(externalData) @@ -150,16 +167,33 @@ export const getSpouseAsOptions = (externalData: ExternalData): Option[] => { return [] } -export const getSelectedFamily = ( +export const getSelectedApplicants = ( answers: HealthInsuranceDeclaration, externalData: ExternalData, ) => { + const applicant = getApplicantFromExternalData(externalData) const spouse = getSpouseFromExternalData(externalData) const children = getChildrenFromExternalData(externalData) - let selectedFamily: StaticText[][] = [] + let selectedApplicants: StaticText[][] = [] + + selectedApplicants = selectedApplicants.concat( + answers.selectedApplicants?.registerPersonsApplicantCheckboxField + ? answers.selectedApplicants?.registerPersonsApplicantCheckboxField?.map( + (a) => { + if (a === applicant.nationalId) { + return [ + applicant.fullName, + applicant.nationalId, + m.overview.familyTableRelationApplicantText, + ] + } else return [] + }, + ) + : [], + ) if (spouse) { - selectedFamily = selectedFamily.concat( + selectedApplicants = selectedApplicants.concat( answers.selectedApplicants?.registerPersonsSpouseCheckboxField ? answers.selectedApplicants?.registerPersonsSpouseCheckboxField?.map( (s) => { @@ -187,11 +221,10 @@ export const getSelectedFamily = ( ] }, ) - if (selectedChildren) { - selectedFamily.concat(selectedChildren) + selectedApplicants = selectedApplicants.concat(selectedChildren) } - return selectedFamily + return selectedApplicants } export const getInsuranceStatementDataFromExternalData = ( diff --git a/libs/application/templates/id-card/project.json b/libs/application/templates/id-card/project.json index eb0e10f3fd2f..7079ae061564 100644 --- a/libs/application/templates/id-card/project.json +++ b/libs/application/templates/id-card/project.json @@ -20,18 +20,18 @@ "options": { "jestConfig": "libs/application/templates/id-card/jest.config.ts", "passWithNoTests": true - }, - "extract-strings": { - "executor": "nx:run-commands", - "options": { - "command": "yarn ts-node -P libs/localization/tsconfig.lib.json libs/localization/scripts/extract 'libs/application/templates/id-card/src/**/*.{js,ts,tsx}'" - } - }, - "configurations": { - "ci": { - "ci": true, - "codeCoverage": true - } + } + }, + "extract-strings": { + "executor": "nx:run-commands", + "options": { + "command": "yarn ts-node -P libs/localization/tsconfig.lib.json libs/localization/scripts/extract 'libs/application/templates/id-card/src/**/*.{js,ts,tsx}'" + } + }, + "configurations": { + "ci": { + "ci": true, + "codeCoverage": true } } } diff --git a/libs/application/templates/id-card/src/dataProviders/index.ts b/libs/application/templates/id-card/src/dataProviders/index.ts index 8af9d63fe37a..c2417a209cd8 100644 --- a/libs/application/templates/id-card/src/dataProviders/index.ts +++ b/libs/application/templates/id-card/src/dataProviders/index.ts @@ -33,7 +33,7 @@ const defaultParams = { summary: error.invalidAgeDescription, }, icelandicCitizenship: true, - allowPassOnChild: true, + allowIfChildHasCitizenship: true, } export const NationalRegistryUser = NationalRegistryUserApi.configure({ @@ -45,6 +45,7 @@ export const NationalRegistryUserParentB = NationalRegistryUserApi.configure({ ...defaultParams, icelandicCitizenship: false, }, + externalDataId: 'nationalRegistryParentB', }) export const SyslumadurPaymentCatalogApi = PaymentCatalogApi.configure({ diff --git a/libs/application/templates/id-card/src/fields/ClearAnswers/index.tsx b/libs/application/templates/id-card/src/fields/ClearAnswers/index.tsx index 927ad8a65644..a5c057b5ee29 100644 --- a/libs/application/templates/id-card/src/fields/ClearAnswers/index.tsx +++ b/libs/application/templates/id-card/src/fields/ClearAnswers/index.tsx @@ -18,13 +18,13 @@ export const ClearAnswers: FC<React.PropsWithChildren<FieldBaseProps>> = ({ setBeforeSubmitCallback && setBeforeSubmitCallback(async () => { const chosenApplicants = getValues(Routes.CHOSENAPPLICANTS) - + const newAnswers = updateAnswers(application, chosenApplicants, setValue) try { await updateApplication({ variables: { input: { id: application.id, - answers: updateAnswers(application, chosenApplicants, setValue), + answers: newAnswers, }, locale, }, diff --git a/libs/application/templates/id-card/src/fields/RejectApproveButtons/index.tsx b/libs/application/templates/id-card/src/fields/RejectApproveButtons/index.tsx index 4217888de436..1d3a19a11e16 100644 --- a/libs/application/templates/id-card/src/fields/RejectApproveButtons/index.tsx +++ b/libs/application/templates/id-card/src/fields/RejectApproveButtons/index.tsx @@ -51,7 +51,10 @@ export const RejectApproveButtons: FC< input: { id: application.id, event: DefaultEvents.SUBMIT, - answers: {}, + answers: { + ...application.answers, + 'secondGuardianInformation.approved': true, + }, }, }, }) diff --git a/libs/application/templates/id-card/src/forms/Approved.ts b/libs/application/templates/id-card/src/forms/Approved.ts index c7d1b9974497..0f6d01423b21 100644 --- a/libs/application/templates/id-card/src/forms/Approved.ts +++ b/libs/application/templates/id-card/src/forms/Approved.ts @@ -1,16 +1,14 @@ import { buildForm, - buildSection, - buildMultiField, buildAlertMessageField, - buildMessageWithLinkButtonField, - buildExpandableDescriptionField, - coreMessages, + getValueViaPath, } from '@island.is/application/core' import { Form, FormModes } from '@island.is/application/types' // import { Logo } from '../../assets/Logo' import { buildFormConclusionSection } from '@island.is/application/ui-forms' import { reviewConfirmation } from '../lib/messages' +import { getChosenApplicant, hasSecondGuardian } from '../utils' +import { IdentityDocumentChild } from '@island.is/clients/passports' export const Approved: Form = buildForm({ id: 'Approved', @@ -18,51 +16,6 @@ export const Approved: Form = buildForm({ // logo: Logo, mode: FormModes.APPROVED, children: [ - buildSection({ - id: 'uiForms.conclusionSection', - title: reviewConfirmation.general.sectionTitle, - children: [ - buildMultiField({ - id: 'uiForms.conclusionMultifield', - title: reviewConfirmation.general.sectionTitle, - children: [ - buildAlertMessageField({ - id: 'uiForms.conclusionAlert', - title: reviewConfirmation.general.alertTitle, - alertType: 'success', - }), - buildExpandableDescriptionField({ - id: 'uiForms.conclusionExpandableDescription', - title: reviewConfirmation.general.accordionTitle, - introText: '', - description: reviewConfirmation.general.accordionText, - startExpanded: true, - }), - buildAlertMessageField({ - id: 'uiForms.conclusionAlertInfo1', - title: '', - alertType: 'info', - message: reviewConfirmation.general.infoMessageText2, - marginBottom: 0, - }), - buildAlertMessageField({ - id: 'uiForms.conclusionAlertInfo2', - title: '', - alertType: 'info', - message: reviewConfirmation.general.infoMessageText2, - }), - buildMessageWithLinkButtonField({ - id: 'uiForms.conclusionBottomLink', - title: '', - url: '/minarsidur/umsoknir', - buttonTitle: coreMessages.openServicePortalButtonTitle, - message: reviewConfirmation.general.bottomButtonMessage, - marginBottom: [4, 4, 12], - }), - ], - }), - ], - }), buildFormConclusionSection({ sectionTitle: reviewConfirmation.general.sectionTitle, multiFieldTitle: reviewConfirmation.general.sectionTitle, @@ -70,8 +23,74 @@ export const Approved: Form = buildForm({ alertMessage: '', expandableHeader: reviewConfirmation.general.accordionTitle, expandableIntro: '', - expandableDescription: reviewConfirmation.general.accordionText, + expandableDescription: (application) => { + const applicantNationalId = getValueViaPath( + application.answers, + 'chosenApplicants', + '', + ) as string + const chosenApplicant = getChosenApplicant( + application.answers, + application.externalData, + applicantNationalId, + ) + + return !chosenApplicant.isApplicant + ? reviewConfirmation.general.accordionTextForChild + : reviewConfirmation.general.accordionText + }, bottomButtonMessage: reviewConfirmation.general.bottomButtonMessage, }), + buildAlertMessageField({ + id: 'uiForms.conclusionAlertInfo1', + title: '', + alertType: 'info', + message: reviewConfirmation.general.infoMessageText1, + marginBottom: 0, + condition: (formValue, externalData) => { + const applicantNationalId = getValueViaPath( + formValue, + 'chosenApplicants', + '', + ) as string + const chosenApplicant = getChosenApplicant( + formValue, + externalData, + applicantNationalId, + ) + + const applicantHasSecondGuardian = hasSecondGuardian( + formValue, + externalData, + ) + + return !chosenApplicant.isApplicant && applicantHasSecondGuardian + }, + }), + buildAlertMessageField({ + id: 'uiForms.conclusionAlertInfo2', + title: '', + alertType: 'info', + message: reviewConfirmation.general.infoMessageText2, + condition: (formValue, externalData) => { + const applicantNationalId = getValueViaPath( + formValue, + 'chosenApplicants', + '', + ) as string + const chosenApplicant = getChosenApplicant( + formValue, + externalData, + applicantNationalId, + ) + + const applicantHasSecondGuardian = hasSecondGuardian( + formValue, + externalData, + ) + + return !chosenApplicant.isApplicant && applicantHasSecondGuardian + }, + }), ], }) diff --git a/libs/application/templates/id-card/src/forms/IdCardForm/ApplicantInformation/index.ts b/libs/application/templates/id-card/src/forms/IdCardForm/ApplicantInformation/index.ts index a2c64e1e4d42..027d4df5a249 100644 --- a/libs/application/templates/id-card/src/forms/IdCardForm/ApplicantInformation/index.ts +++ b/libs/application/templates/id-card/src/forms/IdCardForm/ApplicantInformation/index.ts @@ -50,11 +50,6 @@ export const ApplicanInformationSubSection = buildSection({ title: applicantInformation.labels.applicantName, readOnly: true, width: 'half', - defaultValue: (application: Application) => { - const chosenApplicant = getChosenApplicant(application) - - return chosenApplicant.name - }, }), buildTextField({ id: `${Routes.APPLICANTSINFORMATION}.nationalId`, @@ -62,11 +57,6 @@ export const ApplicanInformationSubSection = buildSection({ readOnly: true, width: 'half', format: '######-####', - defaultValue: (application: Application) => { - const chosenApplicant = getChosenApplicant(application) - - return chosenApplicant.nationalId - }, }), buildTextField({ id: `${Routes.APPLICANTSINFORMATION}.email`, @@ -75,15 +65,6 @@ export const ApplicanInformationSubSection = buildSection({ condition: (answers, externalData) => { return !isChild(answers, externalData) }, - defaultValue: (application: Application) => { - const applicantUserProfile = getValueViaPath( - application.externalData, - 'userProfile.data', - undefined, - ) as UserProfile | undefined - - return applicantUserProfile?.email - }, }), buildPhoneField({ id: `${Routes.APPLICANTSINFORMATION}.phoneNumber`, @@ -92,15 +73,6 @@ export const ApplicanInformationSubSection = buildSection({ condition: (answers, externalData) => { return !isChild(answers, externalData) }, - defaultValue: (application: Application) => { - const applicantUserProfile = getValueViaPath( - application.externalData, - 'userProfile.data', - undefined, - ) as UserProfile | undefined - - return applicantUserProfile?.mobilePhoneNumber - }, }), /*** FIRST GUARDIAN ***/ diff --git a/libs/application/templates/id-card/src/forms/IdCardForm/IdInformation/ChosenApplicantsSubSection.ts b/libs/application/templates/id-card/src/forms/IdCardForm/IdInformation/ChosenApplicantsSubSection.ts index 0593e95d4b83..65f033a20439 100644 --- a/libs/application/templates/id-card/src/forms/IdCardForm/IdInformation/ChosenApplicantsSubSection.ts +++ b/libs/application/templates/id-card/src/forms/IdCardForm/IdInformation/ChosenApplicantsSubSection.ts @@ -2,16 +2,14 @@ import { buildMultiField, buildSubSection, buildRadioField, - getValueViaPath, buildCustomField, } from '@island.is/application/core' -import { - IdentityDocument, - IdentityDocumentChild, - Routes, -} from '../../../lib/constants' +import { IdentityDocument, Routes } from '../../../lib/constants' import { idInformation } from '../../../lib/messages/idInformation' -import { isAvailableForApplication } from '../../../utils' +import { + getCombinedApplicantInformation, + isAvailableForApplication, +} from '../../../utils' import { formatDate } from '../../../utils/formatDate' export const ChosenApplicantsSubSection = buildSubSection({ @@ -27,88 +25,61 @@ export const ChosenApplicantsSubSection = buildSubSection({ id: Routes.CHOSENAPPLICANTS, title: '', largeButtons: true, + required: true, options: (application) => { - const applicantName = getValueViaPath( - application.externalData, - 'nationalRegistry.data.fullName', - '', - ) as string - - const applicantNationalId = getValueViaPath( + const applicantInformation = getCombinedApplicantInformation( application.externalData, - 'nationalRegistry.data.nationalId', - '', - ) as string + ) - const applicantPassport = getValueViaPath( - application.externalData, - 'identityDocument.data.userPassport', - undefined, - ) as IdentityDocument | undefined + const applicantIIDisabled = !isAvailableForApplication( + 'II', + applicantInformation, + ) - const applicantChildren = getValueViaPath( - application.externalData, - 'identityDocument.data.childPassports', - [], - ) as Array<IdentityDocumentChild> - - const applicantIIDisabled = applicantPassport - ? !isAvailableForApplication( - applicantPassport.expirationDate, - 'II', - `${applicantPassport.type}${applicantPassport.subType}`, - ) - : false - - const applicantIDDisabled = applicantPassport - ? !isAvailableForApplication( - applicantPassport.expirationDate, - 'ID', - `${applicantPassport.type}${applicantPassport.subType}`, - ) - : false + const applicantIDDisabled = !isAvailableForApplication( + 'ID', + applicantInformation, + ) const passportList: Array<any> = [ { - label: applicantName, - subLabel: applicantPassport + label: applicantInformation.name, + subLabel: applicantInformation.passport ? { ...idInformation.labels.idNumber, values: { - passportNumber: applicantPassport?.number, - expirationDate: formatDate( - new Date(applicantPassport.expirationDate), - ), + passportNumber: applicantInformation.passport?.number, + expirationDate: + applicantInformation.passport.expirationDate && + formatDate( + new Date( + applicantInformation.passport.expirationDate, + ), + ), }, } : { ...idInformation.labels.noIdNumber, }, - value: applicantNationalId, + value: applicantInformation.nationalId, disabled: applicantIIDisabled && applicantIDDisabled, }, ] - applicantChildren.map((item) => { + applicantInformation.children.map((item) => { + const isDisabledDueToCitizenship = item.citizenship?.kodi !== 'IS' const idDocument = item.passports && item.passports.length > 0 ? (item.passports[0] as IdentityDocument) : undefined - const IIDisabled = idDocument - ? !isAvailableForApplication( - idDocument.expirationDate, - 'II', - `${idDocument.type}${idDocument.subType}`, - ) - : false - const IDDisabled = idDocument - ? !isAvailableForApplication( - idDocument.expirationDate, - 'ID', - `${idDocument.type}${idDocument.subType}`, - ) - : false + const IIDisabled = !isAvailableForApplication('II', { + passport: idDocument, + }) + + const IDDisabled = !isAvailableForApplication('ID', { + passport: idDocument, + }) return passportList.push({ label: item.childName, subLabel: idDocument @@ -116,9 +87,9 @@ export const ChosenApplicantsSubSection = buildSubSection({ ...idInformation.labels.idNumber, values: { passportNumber: idDocument.number, - expirationDate: formatDate( - new Date(idDocument.expirationDate), - ), + expirationDate: + idDocument.expirationDate && + formatDate(new Date(idDocument.expirationDate)), }, } : { @@ -126,7 +97,8 @@ export const ChosenApplicantsSubSection = buildSubSection({ }, value: item.childNationalId, - disabled: IIDisabled && IDDisabled, + disabled: + (IIDisabled && IDDisabled) || isDisabledDueToCitizenship, }) }) diff --git a/libs/application/templates/id-card/src/forms/IdCardForm/IdInformation/ConditionInformation.ts b/libs/application/templates/id-card/src/forms/IdCardForm/IdInformation/ConditionInformation.ts index e82531b40e06..fc1297f675e7 100644 --- a/libs/application/templates/id-card/src/forms/IdCardForm/IdInformation/ConditionInformation.ts +++ b/libs/application/templates/id-card/src/forms/IdCardForm/IdInformation/ConditionInformation.ts @@ -1,28 +1,19 @@ import { buildSubSection, - getValueViaPath, - buildMultiField, buildDescriptionField, } from '@island.is/application/core' import { Routes } from '../../../lib/constants' import { idInformation } from '../../../lib/messages/idInformation' +import { getChosenApplicant, hasReviewer } from '../../../utils' export const ConditionInformationSection = buildSubSection({ id: Routes.CONDITIONINFORMATION, title: idInformation.general.conditionSectionTitle, - condition: (formvalue, externalData) => { - const chosenApplicantNationalId = getValueViaPath( - formvalue, - Routes.CHOSENAPPLICANTS, - '', - ) as string + condition: (formValue, externalData) => { + const chosenApplicant = getChosenApplicant(formValue, externalData) + const applicantHasReviewer = hasReviewer(formValue, externalData) - const applicantNationalId = getValueViaPath( - externalData, - 'nationalRegistry.data.nationalId', - '', - ) as string - return chosenApplicantNationalId !== applicantNationalId + return !chosenApplicant.isApplicant && applicantHasReviewer }, children: [ buildDescriptionField({ diff --git a/libs/application/templates/id-card/src/forms/IdCardForm/IdInformation/TypeOfIdSubSection.ts b/libs/application/templates/id-card/src/forms/IdCardForm/IdInformation/TypeOfIdSubSection.ts index a031ead21e05..d6a0ad0ec889 100644 --- a/libs/application/templates/id-card/src/forms/IdCardForm/IdInformation/TypeOfIdSubSection.ts +++ b/libs/application/templates/id-card/src/forms/IdCardForm/IdInformation/TypeOfIdSubSection.ts @@ -5,14 +5,13 @@ import { buildAlertMessageField, getValueViaPath, } from '@island.is/application/core' -import { - IdentityDocument, - IdentityDocumentChild, - Routes, -} from '../../../lib/constants' +import { IdentityDocumentChild, Routes } from '../../../lib/constants' import { idInformation } from '../../../lib/messages/idInformation' -import { getChosenApplicant, isAvailableForApplication } from '../../../utils' -import { NationalRegistryIndividual } from '@island.is/application/types' +import { + getChosenApplicant, + getCombinedApplicantInformation, + isAvailableForApplication, +} from '../../../utils' export const TypeOfIdSubSection = buildSubSection({ id: Routes.TYPEOFID, @@ -29,67 +28,81 @@ export const TypeOfIdSubSection = buildSubSection({ width: 'half', required: true, options: (application) => { - const applicantNationalRegistry = getValueViaPath( + const combinedAppplicantInformation = + getCombinedApplicantInformation(application.externalData) + const chosenApplicant = getChosenApplicant( + application.answers, application.externalData, - 'nationalRegistry.data', - {}, - ) as NationalRegistryIndividual - const chosenApplicant = getChosenApplicant(application) - let applicantPassport: IdentityDocument | undefined - if ( - chosenApplicant.nationalId === - applicantNationalRegistry.nationalId - ) { - applicantPassport = getValueViaPath( - application.externalData, - 'identityDocument.data.userPassport', - undefined, - ) as IdentityDocument | undefined - } else { + ) + + if (!chosenApplicant.isApplicant) { const childPassports = getValueViaPath( application.externalData, 'identityDocument.data.childPassports', undefined, ) as Array<IdentityDocumentChild> | undefined - applicantPassport = - childPassports && - childPassports.find( - (x) => x.childNationalId === chosenApplicant.nationalId, - )?.passports?.[0] + combinedAppplicantInformation.passport = childPassports?.find( + (x) => x.childNationalId === chosenApplicant.nationalId, + )?.passports?.[0] } - const IIDisabled = applicantPassport - ? !isAvailableForApplication( - applicantPassport.expirationDate, - 'II', - `${applicantPassport.type}${applicantPassport.subType}`, - applicantNationalRegistry.age, - ) - : false - const IDDisabled = applicantPassport - ? !isAvailableForApplication( - applicantPassport.expirationDate, - 'ID', - `${applicantPassport.type}${applicantPassport.subType}`, - applicantNationalRegistry.age, - ) - : false + const IIDisabled = !isAvailableForApplication( + 'II', + combinedAppplicantInformation, + ) + + const IDDisabled = !isAvailableForApplication( + 'ID', + combinedAppplicantInformation, + ) return [ //II = Nafnskírteini ekki sem ferðaskilríki, ID = Nafnskírteini sem ferðaskilríki - { - label: idInformation.labels.typeOfIdRadioAnswerOne, - value: 'II', - disabled: IIDisabled, - }, { label: idInformation.labels.typeOfIdRadioAnswerTwo, value: 'ID', disabled: IDDisabled, }, + { + label: idInformation.labels.typeOfIdRadioAnswerOne, + value: 'II', + disabled: IIDisabled, + }, ] }, }), + buildAlertMessageField({ + id: `${Routes.TYPEOFID}.alertField`, + title: '', + alertType: 'warning', + message: idInformation.labels.warningText, + condition: (answers, externalData) => { + const combinedAppplicantInformation = + getCombinedApplicantInformation(externalData) + const chosenApplicant = getChosenApplicant(answers, externalData) + if (!chosenApplicant.isApplicant) { + const childPassports = getValueViaPath( + externalData, + 'identityDocument.data.childPassports', + undefined, + ) as Array<IdentityDocumentChild> | undefined + + combinedAppplicantInformation.passport = childPassports?.find( + (x) => x.childNationalId === chosenApplicant.nationalId, + )?.passports?.[0] + } + const IIDisabled = !isAvailableForApplication( + 'II', + combinedAppplicantInformation, + ) + + const IDDisabled = !isAvailableForApplication( + 'ID', + combinedAppplicantInformation, + ) + return IIDisabled || IDDisabled + }, + }), buildAlertMessageField({ id: `${Routes.TYPEOFID}.alertField`, title: '', diff --git a/libs/application/templates/id-card/src/forms/IdCardForm/PriceList/index.ts b/libs/application/templates/id-card/src/forms/IdCardForm/PriceList/index.ts index 65d147680fb7..bf877be3dc6c 100644 --- a/libs/application/templates/id-card/src/forms/IdCardForm/PriceList/index.ts +++ b/libs/application/templates/id-card/src/forms/IdCardForm/PriceList/index.ts @@ -5,14 +5,11 @@ import { buildRadioField, buildSelectField, } from '@island.is/application/core' -import { - DistrictCommissionerAgencies, - Routes, - Services, -} from '../../../lib/constants' +import { DistrictCommissionerAgencies, Routes } from '../../../lib/constants' import { priceList } from '../../../lib/messages/priceList' -import { checkForDiscount } from '../../../utils' +import { checkForDiscount, formatIsk, getPriceList } from '../../../utils' import { Application } from '@island.is/application/types' +import { Services } from '../../../shared/types' export const PriceListSubSection = buildSection({ id: Routes.PRICELIST, @@ -29,49 +26,61 @@ export const PriceListSubSection = buildSection({ width: 'half', options: (application: Application) => { const hasDiscount = checkForDiscount(application) + + const applicationPrices = getPriceList(application) + return [ { label: !hasDiscount - ? priceList.labels.regularPriceTitle - : priceList.labels.discountRegularPriceTitle, + ? { + id: priceList.labels.regularPriceTitle.id, + values: { + price: + applicationPrices.regularPrice?.priceAmount && + formatIsk( + applicationPrices.regularPrice?.priceAmount, + ), + }, + } + : { + id: priceList.labels.discountRegularPriceTitle.id, + values: { + price: + applicationPrices.regularDiscountPrice?.priceAmount && + formatIsk( + applicationPrices.regularDiscountPrice?.priceAmount, + ), + }, + }, subLabel: priceList.labels.regularPriceDescription, value: Services.REGULAR, }, { label: !hasDiscount - ? priceList.labels.fastPriceTitle - : priceList.labels.discountFastPriceTitle, + ? { + id: priceList.labels.fastPriceTitle.id, + values: { + price: + applicationPrices.fastPrice?.priceAmount && + formatIsk(applicationPrices.fastPrice?.priceAmount), + }, + } + : { + id: priceList.labels.discountFastPriceTitle.id, + values: { + price: + applicationPrices.fastDiscountPrice?.priceAmount && + formatIsk( + applicationPrices.fastDiscountPrice?.priceAmount, + ), + }, + }, subLabel: priceList.labels.fastPriceDescription, value: Services.EXPRESS, }, ] }, }), - buildDescriptionField({ - id: `${Routes.PRICELIST}.locationTitle`, - title: priceList.labels.locationTitle, - description: priceList.labels.locationDescription, - titleVariant: 'h3', - marginBottom: 'gutter', - marginTop: 'gutter', - }), - buildSelectField({ - id: `${Routes.PRICELIST}.location`, - title: priceList.labels.locationTitle, - placeholder: priceList.labels.locationPlaceholder, - options: ({ - externalData: { - deliveryAddress: { data }, - }, - }) => { - return (data as DistrictCommissionerAgencies[])?.map( - ({ key, name }) => ({ - value: key, - label: name, - }), - ) - }, - }), ], }), ], diff --git a/libs/application/templates/id-card/src/forms/IdCardForm/index.ts b/libs/application/templates/id-card/src/forms/IdCardForm/index.ts index ddae49d78774..31e64fbb55b0 100644 --- a/libs/application/templates/id-card/src/forms/IdCardForm/index.ts +++ b/libs/application/templates/id-card/src/forms/IdCardForm/index.ts @@ -10,7 +10,7 @@ import { getChargeItemCodes } from '../../utils' // import { Logo } from '../../assets/Logo' export const IdCardForm: Form = buildForm({ - id: 'UniversityFormDraft', + id: 'IdCardFormDraft', title: '', // logo: Logo, mode: FormModes.DRAFT, diff --git a/libs/application/templates/id-card/src/forms/ParentB.ts b/libs/application/templates/id-card/src/forms/ParentB.ts new file mode 100644 index 000000000000..37012d385f1e --- /dev/null +++ b/libs/application/templates/id-card/src/forms/ParentB.ts @@ -0,0 +1,61 @@ +import { + buildDataProviderItem, + buildDescriptionField, + buildExternalDataProvider, + buildForm, + buildMultiField, + buildSection, +} from '@island.is/application/core' +import { Form, FormModes } from '@island.is/application/types' +import { NationalRegistryUserParentB } from '../dataProviders' +import { externalData } from '../lib/messages' +import { StateParentBSection } from './Review/State/StateParentB' +import { OverviewSection } from './Review/Overview' + +export const ParentB: Form = buildForm({ + id: 'IdCardApplicationParentB', + title: '', + mode: FormModes.IN_PROGRESS, + renderLastScreenButton: true, + renderLastScreenBackButton: true, + children: [ + buildSection({ + id: 'preInformation', + title: externalData.preInformation.sectionTitle, + children: [ + buildMultiField({ + id: 'preInformation.multifield', + title: externalData.preInformation.title, + children: [ + buildDescriptionField({ + id: 'preInformation.parentB', + title: '', + description: externalData.preInformation.parentBIntroText, + }), + ], + }), + ], + }), + buildSection({ + id: 'externalDataSection', + title: externalData.dataProvider.sectionTitle, + children: [ + buildExternalDataProvider({ + id: 'approveExternalDataParentB', + title: externalData.dataProvider.pageTitle, + subTitle: externalData.dataProvider.subTitle, + checkboxLabel: externalData.dataProvider.checkboxLabel, + dataProviders: [ + buildDataProviderItem({ + provider: NationalRegistryUserParentB, + title: externalData.nationalRegistry.title, + subTitle: externalData.nationalRegistry.subTitle, + }), + ], + }), + ], + }), + StateParentBSection, + OverviewSection, + ], +}) diff --git a/libs/application/templates/id-card/src/forms/Prerequisites/index.ts b/libs/application/templates/id-card/src/forms/Prerequisites/index.ts index fefe4e6d0573..f9bfb2f5535a 100644 --- a/libs/application/templates/id-card/src/forms/Prerequisites/index.ts +++ b/libs/application/templates/id-card/src/forms/Prerequisites/index.ts @@ -40,6 +40,7 @@ export const Prerequisites: Form = buildForm({ buildSection({ id: 'preInformation', title: externalData.preInformation.sectionTitle, + children: [ buildMultiField({ id: 'preInformation.multifield', @@ -73,6 +74,7 @@ export const Prerequisites: Form = buildForm({ title: externalData.dataProvider.pageTitle, subTitle: externalData.dataProvider.subTitle, checkboxLabel: externalData.dataProvider.checkboxLabel, + enableMockPayment: true, submitField: buildSubmitField({ id: 'submit', placement: 'footer', diff --git a/libs/application/templates/id-card/src/forms/Review/Overview/index.ts b/libs/application/templates/id-card/src/forms/Review/Overview/index.ts index 4684ca5da7ed..b9749c2e585e 100644 --- a/libs/application/templates/id-card/src/forms/Review/Overview/index.ts +++ b/libs/application/templates/id-card/src/forms/Review/Overview/index.ts @@ -8,13 +8,17 @@ import { buildCustomField, } from '@island.is/application/core' import { format as formatNationalId } from 'kennitala' -import { - DistrictCommissionerAgencies, - Routes, - Services, -} from '../../../lib/constants' +import { DistrictCommissionerAgencies, Routes } from '../../../lib/constants' import { review, idInformation, priceList } from '../../../lib/messages' -import { isChild, formatPhoneNumber, hasSecondGuardian } from '../../../utils' +import { + isChild, + formatPhoneNumber, + hasSecondGuardian, + checkForDiscount, + getPriceList, + formatIsk, +} from '../../../utils' +import { Services } from '../../../shared/types' export const OverviewSection = buildSection({ id: 'reviewOverview', @@ -48,8 +52,7 @@ export const OverviewSection = buildSection({ colSpan: '6/12', paddingBottom: 3, value: ({ answers }) => - (getValueViaPath(answers, 'typeOfId', '') as string) === - 'WithTravel' + (getValueViaPath(answers, 'typeOfId', '') as string) === 'II' ? idInformation.labels.typeOfIdRadioAnswerOne : idInformation.labels.typeOfIdRadioAnswerTwo, }), @@ -225,46 +228,63 @@ export const OverviewSection = buildSection({ buildKeyValueField({ label: review.labels.deliveryOption, colSpan: '6/12', - value: ({ answers }) => { + value: (application) => { + const { answers } = application const priceChoice = getValueViaPath( answers, `${Routes.PRICELIST}.priceChoice`, ) as string - // TODO: Add priceAmount when we have all chargeItemCodes! - // And change the priceList messages - return priceChoice === Services.EXPRESS - ? [priceList.labels.fastPriceTitle, '**4.600 kr.**'] - : priceChoice === Services.EXPRESS_DISCOUNT - ? [priceList.labels.discountFastPriceTitle, '**4.600 kr.**'] - : priceChoice === Services.REGULAR - ? [priceList.labels.regularPriceTitle, '**4.600 kr.**'] - : priceChoice === Services.REGULAR_DISCOUNT - ? [priceList.labels.discountRegularPriceTitle, '**4.600 kr.**'] + const hasDiscount = checkForDiscount(application) + const applicationPrices = getPriceList(application) + return priceChoice === Services.EXPRESS && !hasDiscount + ? { + id: priceList.labels.fastPriceTitle.id, + values: { + price: + applicationPrices.fastPrice?.priceAmount && + formatIsk(applicationPrices.fastPrice?.priceAmount), + }, + } + : priceChoice === Services.EXPRESS && hasDiscount + ? [ + { + id: priceList.labels.discountFastPriceTitle, + values: { + price: + applicationPrices.fastDiscountPrice?.priceAmount && + formatIsk( + applicationPrices.fastDiscountPrice?.priceAmount, + ), + }, + }, + ] + : priceChoice === Services.REGULAR && !hasDiscount + ? [ + { + id: priceList.labels.regularPriceTitle.id, + values: { + price: + applicationPrices.regularPrice?.priceAmount && + formatIsk(applicationPrices.regularPrice?.priceAmount), + }, + }, + ] + : priceChoice === Services.REGULAR && hasDiscount + ? [ + { + id: priceList.labels.discountRegularPriceTitle.id, + values: { + price: + applicationPrices.regularDiscountPrice?.priceAmount && + formatIsk( + applicationPrices.regularDiscountPrice?.priceAmount, + ), + }, + }, + ] : '' }, }), - buildKeyValueField({ - label: review.labels.deliveryLocation, - colSpan: '6/12', - value: ({ - answers, - externalData: { - deliveryAddress: { data }, - }, - }) => { - const deliveryAddress = ( - data as DistrictCommissionerAgencies[] - )?.find( - ({ key }) => - key === - (getValueViaPath( - answers, - `${Routes.PRICELIST}.location`, - ) as string), - ) - return `${deliveryAddress?.name} - ${deliveryAddress?.street}, ${deliveryAddress?.zip} ${deliveryAddress?.city}` - }, - }), /* SUBMIT OR DECLINE */ buildCustomField({ diff --git a/libs/application/templates/id-card/src/forms/Review/State/StateParentB.ts b/libs/application/templates/id-card/src/forms/Review/State/StateParentB.ts new file mode 100644 index 000000000000..7d3e7216408d --- /dev/null +++ b/libs/application/templates/id-card/src/forms/Review/State/StateParentB.ts @@ -0,0 +1,70 @@ +import { + buildActionCardListField, + buildMultiField, + buildSection, + getValueViaPath, + buildAlertMessageField, +} from '@island.is/application/core' +import { Routes } from '../../../lib/constants' +import { state } from '../../../lib/messages' + +export const StateParentBSection = buildSection({ + id: 'reviewStateParentB', + title: state.general.sectionTitle, + children: [ + buildMultiField({ + id: `reviewStateMultiField`, + title: state.general.pageTitle, + description: ({ answers }) => ({ + ...state.general.description, + values: { + guardianName: getValueViaPath( + answers, + `${Routes.FIRSTGUARDIANINFORMATION}.name`, + '', + ) as string, + childName: getValueViaPath( + answers, + `${Routes.APPLICANTSINFORMATION}.name`, + '', + ) as string, + }, + }), + children: [ + buildAlertMessageField({ + id: 'stateAlertMessage', + title: '', + message: state.labels.alertMessage, + alertType: 'info', + }), + buildActionCardListField({ + id: 'approvalActionCard', + doesNotRequireAnswer: true, + marginTop: 2, + title: '', + items: (application) => { + const chosenApplicantName = getValueViaPath( + application.answers, + `${Routes.APPLICANTSINFORMATION}.name`, + '', + ) as string + return [ + { + heading: state.labels.actionCardTitle, + tag: { + label: state.labels.actionCardTag, + outlined: false, + variant: 'purple', + }, + text: { + id: state.labels.actionCardDescription.id, + values: { name: chosenApplicantName }, + }, + }, + ] + }, + }), + ], + }), + ], +}) diff --git a/libs/application/templates/id-card/src/forms/Review/State/index.ts b/libs/application/templates/id-card/src/forms/Review/State/index.ts index 140a7ba18b65..ed9050aeeb31 100644 --- a/libs/application/templates/id-card/src/forms/Review/State/index.ts +++ b/libs/application/templates/id-card/src/forms/Review/State/index.ts @@ -3,11 +3,9 @@ import { buildMultiField, buildSection, getValueViaPath, - buildAlertMessageField, } from '@island.is/application/core' import { Routes } from '../../../lib/constants' import { state } from '../../../lib/messages' -// import { GetFormattedText } from '../../../utils' export const StateSection = buildSection({ id: 'reviewState', @@ -32,12 +30,6 @@ export const StateSection = buildSection({ }, }), children: [ - buildAlertMessageField({ - id: 'stateAlertMessage', - title: '', - message: state.labels.alertMessage, - alertType: 'info', - }), buildActionCardListField({ id: 'approvalActionCard', doesNotRequireAnswer: true, @@ -49,21 +41,18 @@ export const StateSection = buildSection({ `${Routes.APPLICANTSINFORMATION}.name`, '', ) as string - // const heading = GetFormattedText(state.labels.actionCardTitle) - // const description = GetFormattedText( - // state.labels.actionCardDescription, - // ) - // const label = GetFormattedText(state.labels.actionCardTag) - // TODO get a different way of rendering translation text return [ { - heading: state.labels.actionCardTitle.defaultMessage, + heading: state.labels.actionCardTitle, tag: { - label: state.labels.actionCardTag.defaultMessage, + label: state.labels.actionCardTag, outlined: false, variant: 'purple', }, - text: `${state.labels.actionCardDescription.defaultMessage} ${chosenApplicantName}`, + text: { + id: state.labels.actionCardDescription.id, + values: { name: chosenApplicantName }, + }, }, ] }, diff --git a/libs/application/templates/id-card/src/lib/IdCardTemplate.ts b/libs/application/templates/id-card/src/lib/IdCardTemplate.ts index 68d173ef1486..953b7f3d1773 100644 --- a/libs/application/templates/id-card/src/lib/IdCardTemplate.ts +++ b/libs/application/templates/id-card/src/lib/IdCardTemplate.ts @@ -1,5 +1,6 @@ import { coreHistoryMessages, + corePendingActionMessages, EphemeralStateLifeCycle, getValueViaPath, pruneAfterDays, @@ -17,35 +18,80 @@ import { DistrictsApi, InstitutionNationalIds, PassportsApi, + PendingAction, + StaticText, } from '@island.is/application/types' import { Features } from '@island.is/feature-flags' import { assign } from 'xstate' import { - // SyslumadurPaymentCatalogApi, DeliveryAddressApi, UserInfoApi, NationalRegistryUser, SyslumadurPaymentCatalogApi, IdentityDocumentApi, + NationalRegistryUserParentB, } from '../dataProviders' import { application as applicationMessage } from './messages' import { Events, Roles, States, ApiActions, Routes } from './constants' import { IdCardSchema } from './dataSchema' import { buildPaymentState } from '@island.is/application/utils' -import { getChargeItemCodes, hasReviewer } from '../utils' +import { getChargeItemCodes, hasReviewer, hasReviewerApproved } from '../utils' export const needsReview = (context: ApplicationContext) => { const { answers, externalData } = context.application return hasReviewer(answers, externalData) } +export const determineMessageFromApplicationAnswers = ( + application: Application, +): StaticText => { + const name = getValueViaPath( + application.answers, + 'applicantInformation.name', + ' ', + ) as string + + const nameObject = { id: applicationMessage.name.id, values: { name: name } } + + return nameObject +} + +const reviewStatePendingAction = ( + application: Application, + nationalId: string, +): PendingAction => { + const firstGuardianNationalId = getValueViaPath( + application.answers, + 'firstGuardianInformation.nationalId', + undefined, + ) as string | undefined + + if ( + nationalId && + firstGuardianNationalId !== nationalId && + !hasReviewerApproved(application.answers) + ) { + return { + title: corePendingActionMessages.waitingForReviewTitle, + content: corePendingActionMessages.youNeedToReviewDescription, + displayStatus: 'warning', + } + } else { + return { + title: corePendingActionMessages.waitingForReviewTitle, + content: corePendingActionMessages.waitingForReviewDescription, + displayStatus: 'info', + } + } +} + const IdCardTemplate: ApplicationTemplate< ApplicationContext, ApplicationStateSchema<Events>, Events > = { type: ApplicationTypes.ID_CARD, - name: applicationMessage.name, + name: applicationMessage.name, // TODO make dynamic if possible featureFlag: Features.idCardApplication, dataSchema: IdCardSchema, translationNamespaces: [ApplicationConfigurations.IdCard.translation], @@ -149,7 +195,7 @@ const IdCardTemplate: ApplicationTemplate< entry: 'assignToParentB', meta: { name: 'ParentB', - status: 'draft', + status: 'inprogress', lifecycle: pruneAfterDays(7), onEntry: defineTemplateApi({ action: ApiActions.assignParentB, @@ -166,22 +212,28 @@ const IdCardTemplate: ApplicationTemplate< { id: Roles.ASSIGNEE, formLoader: () => - import('../forms/Review').then((val) => - Promise.resolve(val.Review), + import('../forms/ParentB').then((val) => + Promise.resolve(val.ParentB), ), actions: [ { event: DefaultEvents.SUBMIT, name: '', type: 'primary' }, ], write: 'all', + api: [NationalRegistryUserParentB], }, ], actionCard: { + tag: { + label: applicationMessage.actionCardDraft, + variant: 'blue', + }, historyLogs: [ { logMessage: applicationMessage.historyWaitingForParentB, onEvent: DefaultEvents.SUBMIT, }, ], + pendingAction: reviewStatePendingAction, }, }, on: { @@ -193,7 +245,7 @@ const IdCardTemplate: ApplicationTemplate< meta: { name: 'Rejected', status: 'rejected', - lifecycle: pruneAfterDays(3 * 30), // TODO HOW MANY DAYS SHOULD THIS BE? + lifecycle: pruneAfterDays(3 * 30), onEntry: defineTemplateApi({ action: ApiActions.rejectApplication, }), @@ -219,7 +271,7 @@ const IdCardTemplate: ApplicationTemplate< meta: { name: 'Completed', status: 'completed', - lifecycle: pruneAfterDays(3 * 30), // TODO HOW MANY DAYS SHOULD THIS BE? + lifecycle: pruneAfterDays(3 * 30), onEntry: defineTemplateApi({ action: ApiActions.submitApplication, }), diff --git a/libs/application/templates/id-card/src/lib/constants.ts b/libs/application/templates/id-card/src/lib/constants.ts index 1ad7ca4048c5..3658c390d3cc 100644 --- a/libs/application/templates/id-card/src/lib/constants.ts +++ b/libs/application/templates/id-card/src/lib/constants.ts @@ -1,4 +1,5 @@ import { DefaultEvents } from '@island.is/application/types' +import { Services } from '../shared/types' export type Events = | { type: DefaultEvents.ASSIGN } @@ -31,13 +32,6 @@ export enum Roles { ASSIGNEE = 'assignee', //second guardian } -export enum Services { - REGULAR = 'regular', - EXPRESS = 'express', - REGULAR_DISCOUNT = 'regularDiscount', - EXPRESS_DISCOUNT = 'expressDiscount', -} - export enum ApiActions { assignParentB = 'assignParentB', submitApplication = 'submitApplication', @@ -49,6 +43,12 @@ export enum ApiActions { export const EXPIRATION_LIMIT_MONTHS = 9 +export type PaymentItem = { + chargeType: string + priceAmount: number + chargeItemCode: string +} + export type Service = { type: Services dropLocation: string @@ -97,27 +97,35 @@ export type ChildsPersonalInfo = { guardian2: Guardian } +export type Gender = 'F' | 'M' | 'X' + +export type ExpiryStatus = 'EXPIRED' | 'LOST' + export type IdentityDocument = { - number: string - type: string - verboseType: string - subType: string - status: string - issuingDate: string - expirationDate: string - displayFirstName: string - displayLastName: string - mrzFirstName: string - mrzLastName: string - sex: string + number?: string | null + type?: string | null + verboseType?: string | null + subType?: string | null + status?: string | null + issuingDate?: Date | null + expirationDate?: Date | null + displayFirstName?: string | null + displayLastName?: string | null + mrzFirstName?: string | null + mrzLastName?: string | null + sex?: Gender | null + numberWithType?: string + expiryStatus?: ExpiryStatus + expiresWithinNoticeTime?: boolean } export interface IdentityDocumentChild { - childNationalId: string - secondParent: string - secondParentName: string - childName: string + childNationalId?: string | null + secondParent?: string | null + secondParentName?: string | null + childName?: string | null passports?: IdentityDocument[] + citizenship?: Citizenship | null } export interface IdentityDocumentData { @@ -125,6 +133,19 @@ export interface IdentityDocumentData { childPassports: IdentityDocumentChild[] } +export interface Citizenship { + kodi?: string | null + land?: string | null +} + +export interface CombinedApplicantInformation { + name?: string + age?: number + nationalId?: string + passport?: IdentityDocument + children?: Array<IdentityDocumentChild> +} + export const twoDays = 24 * 3600 * 1000 * 2 export const sixtyDays = 24 * 3600 * 1000 * 60 export const sevenDays = 24 * 3600 * 1000 * 7 diff --git a/libs/application/templates/id-card/src/lib/dataSchema.ts b/libs/application/templates/id-card/src/lib/dataSchema.ts index b119c889f81a..f9f2f7508fcb 100644 --- a/libs/application/templates/id-card/src/lib/dataSchema.ts +++ b/libs/application/templates/id-card/src/lib/dataSchema.ts @@ -1,7 +1,7 @@ import { z } from 'zod' import { parsePhoneNumberFromString } from 'libphonenumber-js' import { error } from './messages' -import { Services } from './constants' +import { Services } from '../shared/types' const nationalIdRegex = /([0-9]){6}-?([0-9]){4}/ const emailRegex = @@ -31,7 +31,7 @@ const personInfo = z export const IdCardSchema = z.object({ approveExternalData: z.boolean().refine((v) => v), typeOfId: z.enum(['II', 'ID']), //II = Nafnskírteini sem ferðaskilríki, ID = Nafnskírteini ekki sem ferðaskilríki - chosenApplicants: z.string(), + chosenApplicants: z.string().min(1), applicantInformation: personInfo, firstGuardianInformation: personInfo, secondGuardianInformation: z.object({ @@ -43,10 +43,10 @@ export const IdCardSchema = z.object({ phoneNumber: z.string().refine((v) => isValidPhoneNumber(v) || v === '', { params: error.invalidValue, }), + approved: z.boolean().optional(), }), priceList: z.object({ priceChoice: z.enum([Services.EXPRESS, Services.REGULAR]), - location: z.string(), }), }) diff --git a/libs/application/templates/id-card/src/lib/messages/application.ts b/libs/application/templates/id-card/src/lib/messages/application.ts index 8cbc885eb137..be975b8c237b 100644 --- a/libs/application/templates/id-card/src/lib/messages/application.ts +++ b/libs/application/templates/id-card/src/lib/messages/application.ts @@ -2,8 +2,8 @@ import { defineMessages } from 'react-intl' export const application = defineMessages({ name: { - id: 'id.application:name', - defaultMessage: 'Umsókn um nafnskírteini', + id: 'id.application:name#markdown', + defaultMessage: 'Umsókn um nafnskírteini {name}', description: `Application's name`, }, institutionName: { diff --git a/libs/application/templates/id-card/src/lib/messages/confirmation.ts b/libs/application/templates/id-card/src/lib/messages/confirmation.ts index e131a3d80da0..12ec1ea81c91 100644 --- a/libs/application/templates/id-card/src/lib/messages/confirmation.ts +++ b/libs/application/templates/id-card/src/lib/messages/confirmation.ts @@ -43,14 +43,22 @@ export const reviewConfirmation = { description: 'Review confirmation accordion title', }, accordionText: { - id: 'id.application:reviewConfirmation.general.accordionText', + id: 'id.application:reviewConfirmation.general.accordionText#markdown', + defaultMessage: `* Þú þarft að mæta í myndatöku á næsta umsóknarstað. + \n* Umsókn er opin í 60 daga eftir forskráningu og mæta þarf til myndatöku innan þess tíma. Ef af einhverjum ástæðum er ekki mætt í myndatöku, eða hætt er við umsókn að þeim tíma liðnum, þarf umsækjandi sjálfur að óska eftir endurgreiðslu með því að senda póst á endurgreidsla@island.is + \n* Þú færð senda tilkynningu á Mínar síður þegar nafnskírteinið er tilbúið og hvenær hægt verður að sækja það á þann afhendingarstað sem þú valdir.\n`, + description: 'Review confirmation accordion text', + }, + accordionTextForChild: { + id: 'id.application:reviewConfirmation.general.accordionTextForChild#markdown', defaultMessage: `* Forsjáraðili þarf að mæta með barni í myndatöku á [umsóknarstað](https://island.is/nafnskirteini). \n* Hafi báðir forsjá raðiliar samþykkt útgáfu nafnskírteinis barns er hún opin í 60 daga. Ef að einhverjum ástæðum er ekki mætt í myndatöku að þeim tíma liðnum þarf að sækja um endurgreiðslu með því að senda póst á [endurgreidsla@island.is](endurgreidsla@island.is). \n* Geti FORSJÁRAÐILI ekki mætt með barni í myndatöku þarf að veita þriðja aðila [umboð til þess](https://island.is/nafnskirteini).\n`, - description: 'Review confirmation accordion text', + description: + 'Review confirmation accordion text when applying for a child', }, infoMessageText1: { id: 'id.application:reviewConfirmation.general.infoMessageText1', @@ -59,7 +67,7 @@ export const reviewConfirmation = { description: 'Review confirmation first information message', }, infoMessageText2: { - id: 'id.application:reviewConfirmation.general.infoMessageText1', + id: 'id.application:reviewConfirmation.general.infoMessageText2', defaultMessage: 'Athugaðu að eftir að báðir forsjáraðilar hafa samþykkt umsókn er hún opin í 60 daga inni á island.is - að þeim tíma liðnum þarf að sækja um endurgreiðslu ef ekki er mætt í myndatöku.', description: 'Review confirmation second information message', diff --git a/libs/application/templates/id-card/src/lib/messages/externalData.ts b/libs/application/templates/id-card/src/lib/messages/externalData.ts index 7f283da3f7f0..ee87f369e5d7 100644 --- a/libs/application/templates/id-card/src/lib/messages/externalData.ts +++ b/libs/application/templates/id-card/src/lib/messages/externalData.ts @@ -57,6 +57,12 @@ export const externalData = { 'Vinsamlega athugaðu að ef eldra skírteini hefur glatast þarf að [tilkynna](https://www.skra.is/umsoknir/eydublod-umsoknir-og-vottord/stok-vara/?productid=7a8b6878-757d-11e9-9452-005056851dd2) það.', description: 'Pre information has lost old card alert message', }, + parentBIntroText: { + id: 'id.application:externalData.preInformation.parentBIntroText#markdown', + defaultMessage: + 'Í þessu ferli samþykkir þú sem forsjáraðili umsókn **{guardianName}** um vegabréf fyrir **{childsName}**. Þegar þessi umsókn hefur verið samþykkt þarf viðkomandi að mæta í myndatöku hjá næsta sýslumanni til þess að vegabréfið geti farið í framleiðslu. Þegar vegabréfið er tilbúið verður hægt að sækja það hjá því sýslumannsembætti sem tilgreint var í umsóknarferlinu. Þetta ferli vistast sjálfkrafa á Mínar síður á Ísland.is. Þar getur þú einnig fylgst með stöðu umsóknar eftir að öll gögn hafa verið send inn.', + description: 'parent B preInformation description', + }, }), nationalRegistry: defineMessages({ title: { diff --git a/libs/application/templates/id-card/src/lib/messages/idInformation.ts b/libs/application/templates/id-card/src/lib/messages/idInformation.ts index 32bb3483e567..021508177acb 100644 --- a/libs/application/templates/id-card/src/lib/messages/idInformation.ts +++ b/libs/application/templates/id-card/src/lib/messages/idInformation.ts @@ -50,6 +50,12 @@ export const idInformation = { defaultMessage: 'Nafnskírteini sem ferðaskilríki', description: 'Type of id radio answer id card travel rights', }, + warningText: { + id: 'id.application:idInformation.labels.warningText', + defaultMessage: + 'Þú uppfyllir ekki skilyrði fyrir báðum útgáfum af nafnskírteini', + description: 'Warning alert for type of ID', + }, infoAlert: { id: 'id.application:idInformation.labels.infoAlert', defaultMessage: @@ -57,7 +63,7 @@ export const idInformation = { description: 'Information alert for type of ID', }, chosenApplicantsDescription: { - id: 'id.application:idInformation.labels.chosenApplicantsDescription', + id: 'id.application:idInformation.labels.chosenApplicantsDescription#markdown', defaultMessage: 'Þú getur sótt um nafnskírteini / ferðaskilríki fyrir þig og eftirfarandi einstaklinga í þinni umsjón. Veldu þann einstakling sem þú vilt hefja umsókn fyrir og haltu síðan áfram í næsta skref.', description: 'description of chosen applicants page', diff --git a/libs/application/templates/id-card/src/lib/messages/priceList.ts b/libs/application/templates/id-card/src/lib/messages/priceList.ts index 5650d70db3c3..1d228d5cd949 100644 --- a/libs/application/templates/id-card/src/lib/messages/priceList.ts +++ b/libs/application/templates/id-card/src/lib/messages/priceList.ts @@ -31,8 +31,8 @@ export const priceList = { description: 'Location select dropdown placeholder', }, regularPriceTitle: { - id: 'id.application:priceList.labels.regularPriceTitle', - defaultMessage: 'Almenn afgreiðsla: 18-66 ára - 9.200 kr.', + id: 'id.application:priceList.labels.regularPriceTitle#markdown', + defaultMessage: 'Almenn afgreiðsla: 18-66 ára - {price}', description: 'Regular price radio button title', }, regularPriceDescription: { @@ -41,8 +41,8 @@ export const priceList = { description: 'Regular price radio button description', }, fastPriceTitle: { - id: 'id.application:priceList.labels.fastPriceTitle', - defaultMessage: 'Hraðafgreiðsla: 18-66 ára 18.400 kr.', + id: 'id.application:priceList.labels.fastPriceTitle#markdown', + defaultMessage: 'Hraðafgreiðsla: 18-66 ára - {price}', description: 'Fast price radio button title', }, fastPriceDescription: { @@ -51,13 +51,13 @@ export const priceList = { description: 'Fast price radio button description', }, discountRegularPriceTitle: { - id: 'id.application:priceList.labels.discountRegularPriceTitle', - defaultMessage: 'Almenn afgreiðsla: börn, aldraðir, öryrkjar 4.600 kr.', + id: 'id.application:priceList.labels.discountRegularPriceTitle#markdown', + defaultMessage: 'Almenn afgreiðsla: börn, aldraðir, öryrkjar - {price}', description: 'Discount Regular price radio button title', }, discountFastPriceTitle: { - id: 'id.application:priceList.labels.discountFastPriceTitle', - defaultMessage: 'Hraðafgreiðsla: börn, aldraðir, öryrkjar - 9.200 kr.', + id: 'id.application:priceList.labels.discountFastPriceTitle#markdown', + defaultMessage: 'Hraðafgreiðsla: börn, aldraðir, öryrkjar - {price}', description: 'Discount Fast price radio button title', }, }), diff --git a/libs/application/templates/id-card/src/lib/messages/state.ts b/libs/application/templates/id-card/src/lib/messages/state.ts index b856b94c0a61..c844e817a946 100644 --- a/libs/application/templates/id-card/src/lib/messages/state.ts +++ b/libs/application/templates/id-card/src/lib/messages/state.ts @@ -13,7 +13,7 @@ export const state = { description: 'Review state page title', }, description: { - id: 'id.application:state.general.description', + id: 'id.application:state.general.description#markdown', defaultMessage: `{guardianName} hefur sótt um nafnkskírteini sem er ferðaskilríki fyrir: {childName}. Samþykki beggja forsjáraðila þarf til að útgáfa þess sé heimil. Í þessu ferli getur þú afgreitt @@ -35,9 +35,9 @@ export const state = { description: 'Action card title', }, actionCardDescription: { - id: 'id.application:state.labels.actionCardDescription', + id: 'id.application:state.labels.actionCardDescription#markdown', defaultMessage: - 'Beðið er eftir að samþykki fyrir umsókn um nafnskírteini fyrir: ', + 'Beðið er eftir að samþykki fyrir umsókn um nafnskírteini fyrir: {name}', description: 'Action card description', }, actionCardTag: { diff --git a/libs/application/templates/id-card/src/utils/formatText.ts b/libs/application/templates/id-card/src/utils/formatText.ts deleted file mode 100644 index 75216ce017f6..000000000000 --- a/libs/application/templates/id-card/src/utils/formatText.ts +++ /dev/null @@ -1,8 +0,0 @@ -// import { useLocale } from '@island.is/localization' -// import { MessageDescriptor } from 'react-intl' - -// export const GetFormattedText = (messageId: MessageDescriptor) => { -// const { formatMessage } = useLocale() -// const message = formatMessage(messageId) -// return `${message}` -// } diff --git a/libs/application/templates/id-card/src/utils/getChargeItemCodes.ts b/libs/application/templates/id-card/src/utils/getChargeItemCodes.ts index f0f074e14aa3..f2f3d9f40de5 100644 --- a/libs/application/templates/id-card/src/utils/getChargeItemCodes.ts +++ b/libs/application/templates/id-card/src/utils/getChargeItemCodes.ts @@ -1,112 +1,24 @@ import { getValueViaPath } from '@island.is/application/core' -import { - Application, - NationalRegistryIndividual, -} from '@island.is/application/types' +import { Application } from '@island.is/application/types' import { ChargeItemCode } from '@island.is/shared/constants' -import { Routes, Services } from '../lib/constants' -import { isChild } from './isChild' +import { Routes } from '../lib/constants' +import { checkForDiscount } from './hasDiscount' +import { Services } from '../shared/types' export const getChargeItemCodes = (application: Application): Array<string> => { const chosenPaymentForm = getValueViaPath( application.answers, `${Routes.PRICELIST}.priceChoice`, ) - - const chosenTypeOfId = getValueViaPath(application.answers, Routes.TYPEOFID) - - const applicantInformation = getValueViaPath( - application.externalData, - 'nationalRegistry,data', - ) as NationalRegistryIndividual - - const paymentAsChild = isUnderAge(application, applicantInformation.age) - const hasDisability = getValueViaPath<boolean>( - application.answers, - 'applicantInformation.hasDisabilityLicense', - ) as boolean | undefined - const isOver66 = applicantInformation.age > 66 - - if (isOver66) { - if (chosenPaymentForm === Services.REGULAR) { - if (chosenTypeOfId === 'II') { - return [ChargeItemCode.ID_CARD_OLDER_TRAVEL_REGULAR.toString()] - } - if (chosenTypeOfId === 'ID') { - return [ChargeItemCode.ID_CARD_OLDER_REGULAR.toString()] - } - } - if (chosenPaymentForm === Services.EXPRESS) { - if (chosenTypeOfId === 'II') { - return [ChargeItemCode.ID_CARD_OLDER_TRAVEL_EXPRESS.toString()] - } - if (chosenTypeOfId === 'ID') { - return [ChargeItemCode.ID_CARD_OLDER_EXPRESS.toString()] - } - } - } else if (paymentAsChild) { - if (chosenPaymentForm === Services.REGULAR) { - if (chosenTypeOfId === 'II') { - return [ChargeItemCode.ID_CARD_CHILDREN_TRAVEL_REGULAR.toString()] - } - if (chosenTypeOfId === 'ID') { - return [ChargeItemCode.ID_CARD_CHILDREN_REGULAR.toString()] - } - } - if (chosenPaymentForm === Services.EXPRESS) { - if (chosenTypeOfId === 'II') { - return [ChargeItemCode.ID_CARD_CHILDREN_TRAVEL_EXPRESS.toString()] - } - if (chosenTypeOfId === 'ID') { - return [ChargeItemCode.ID_CARD_CHILDREN_EXPRESS.toString()] - } - } - } else if (hasDisability) { - if (chosenPaymentForm === Services.REGULAR) { - if (chosenTypeOfId === 'II') { - return [ChargeItemCode.ID_CARD_DISABILITY_TRAVEL_REGULAR.toString()] - } - if (chosenTypeOfId === 'ID') { - return [ChargeItemCode.ID_CARD_DISABILITY_REGULAR.toString()] - } - } - if (chosenPaymentForm === Services.EXPRESS) { - if (chosenTypeOfId === 'II') { - return [ChargeItemCode.ID_CARD_DISABILITY_TRAVEL_EXPRESS.toString()] - } - if (chosenTypeOfId === 'ID') { - return [ChargeItemCode.ID_CARD_DISABILITY_EXPRESS.toString()] - } - } - } else { - if (chosenPaymentForm === Services.REGULAR) { - if (chosenTypeOfId === 'II') { - return [ChargeItemCode.ID_CARD_TRAVEL_REGULAR.toString()] - } - if (chosenTypeOfId === 'ID') { - return [ChargeItemCode.ID_CARD_REGULAR.toString()] - } - } - if (chosenPaymentForm === Services.EXPRESS) { - if (chosenTypeOfId === 'II') { - return [ChargeItemCode.ID_CARD_TRAVEL_EXPRESS.toString()] - } - if (chosenTypeOfId === 'ID') { - return [ChargeItemCode.ID_CARD_EXPRESS.toString()] - } - } + const hasDiscount = checkForDiscount(application) + if (chosenPaymentForm === Services.REGULAR && !hasDiscount) { + return [ChargeItemCode.ID_CARD_REGULAR.toString()] + } else if (chosenPaymentForm === Services.REGULAR && hasDiscount) { + return [ChargeItemCode.ID_CARD_OTHERS_REGULAR.toString()] + } else if (chosenPaymentForm === Services.EXPRESS && !hasDiscount) { + return [ChargeItemCode.ID_CARD_EXPRESS.toString()] + } else if (chosenPaymentForm === Services.EXPRESS && hasDiscount) { + return [ChargeItemCode.ID_CARD_OTHERS_EXPRESS.toString()] } - return [] } - -const isUnderAge = (application: Application, age: number) => { - if (age < 18) return true - - const applicantIsApplyingForChild = isChild( - application.answers, - application.externalData, - ) - - return applicantIsApplyingForChild -} diff --git a/libs/application/templates/id-card/src/utils/getChosenApplicant.ts b/libs/application/templates/id-card/src/utils/getChosenApplicant.ts index e44a15a8c6e3..62a534ddcad3 100644 --- a/libs/application/templates/id-card/src/utils/getChosenApplicant.ts +++ b/libs/application/templates/id-card/src/utils/getChosenApplicant.ts @@ -1,54 +1,47 @@ import { getValueViaPath } from '@island.is/application/core' import { - Application, + FormValue, NationalRegistryIndividual, } from '@island.is/application/types' -import { IdentityDocumentChild, Routes } from '../lib/constants' +import { IdentityDocumentChild } from '../lib/constants' export interface ChosenApplicant { - nationalId: string - name: string + name?: string | null + isApplicant: boolean + nationalId?: string | null } export const getChosenApplicant = ( - application: Application, - nationalId?: string, + answers: FormValue, + externalData: any, + nationalId?: string | null, ): ChosenApplicant => { const applicantIdentity = getValueViaPath( - application.externalData, + externalData, 'nationalRegistry.data', {}, ) as NationalRegistryIndividual const applicantChildren = getValueViaPath( - application.externalData, + externalData, 'identityDocument.data.childPassports', [], ) as Array<IdentityDocumentChild> - const chosenApplicantNationalId = - nationalId ?? - (getValueViaPath( - application.answers, - Routes.CHOSENAPPLICANTS, - '', - ) as string) - - if ( - chosenApplicantNationalId === '' || - applicantIdentity?.nationalId === chosenApplicantNationalId - ) { + if (!nationalId || applicantIdentity?.nationalId === nationalId) { return { - nationalId: applicantIdentity?.nationalId, name: applicantIdentity?.fullName, + isApplicant: true, + nationalId: applicantIdentity.nationalId, } } else { const chosenChild = applicantChildren.filter( - (x) => x.childNationalId === chosenApplicantNationalId, + (x) => x.childNationalId === nationalId, )?.[0] return { - nationalId: chosenChild.childNationalId, name: chosenChild.childName, + isApplicant: false, + nationalId: chosenChild.childNationalId, } } } diff --git a/libs/application/templates/id-card/src/utils/getPriceList.ts b/libs/application/templates/id-card/src/utils/getPriceList.ts new file mode 100644 index 000000000000..a20522704fd2 --- /dev/null +++ b/libs/application/templates/id-card/src/utils/getPriceList.ts @@ -0,0 +1,34 @@ +import { getValueViaPath } from '@island.is/application/core' +import { Application } from '@island.is/application/types' +import { ChargeItemCode } from '@island.is/shared/constants' +import { PaymentItem } from '../lib/constants' + +export const getPriceList = (application: Application) => { + const applicationPrices = getValueViaPath( + application.externalData, + 'payment.data', + [], + ) as Array<PaymentItem> + + const regularPrice = applicationPrices.find( + (x) => x.chargeItemCode === ChargeItemCode.ID_CARD_REGULAR.toString(), + ) + const regularDiscountPrice = applicationPrices.find( + (x) => + x.chargeItemCode === ChargeItemCode.ID_CARD_OTHERS_REGULAR.toString(), + ) + const fastPrice = applicationPrices.find( + (x) => x.chargeItemCode === ChargeItemCode.ID_CARD_EXPRESS.toString(), + ) + const fastDiscountPrice = applicationPrices.find( + (x) => + x.chargeItemCode === ChargeItemCode.ID_CARD_OTHERS_EXPRESS.toString(), + ) + + return { + regularPrice, + regularDiscountPrice, + fastPrice, + fastDiscountPrice, + } +} diff --git a/libs/application/templates/id-card/src/utils/hasDiscount.ts b/libs/application/templates/id-card/src/utils/hasDiscount.ts index 6e70a99b4072..a4bea0bcbe25 100644 --- a/libs/application/templates/id-card/src/utils/hasDiscount.ts +++ b/libs/application/templates/id-card/src/utils/hasDiscount.ts @@ -5,6 +5,11 @@ import { info } from 'kennitala' export const checkForDiscount = (application: Application) => { const { answers } = application + //so applicant is applying for a child + if (application.applicant !== answers.chosenApplicants) { + return true + } + const hasDisability = getValueViaPath<boolean>( answers, 'applicantInformation.hasDisabilityLicense', diff --git a/libs/application/templates/id-card/src/utils/hasReviewer.ts b/libs/application/templates/id-card/src/utils/hasReviewer.ts index 36c23f9cf522..b24d3bde54f4 100644 --- a/libs/application/templates/id-card/src/utils/hasReviewer.ts +++ b/libs/application/templates/id-card/src/utils/hasReviewer.ts @@ -15,13 +15,23 @@ export const hasReviewer = (answers: FormValue, externalData: ExternalData) => { '', ) as string + const chosenTypeOfId = getValueViaPath(answers, Routes.TYPEOFID, '') as string + const applicantNationalId = getValueViaPath( externalData, 'nationalRegistry.data.nationalId', '', ) as string + + //logged in user is not the applicant if (chosenApplicantNationalId !== applicantNationalId) { - //logged in user is not the applicant + //if a parent is applying for nafnskírteini án ferðaskilríkja for a child, then there is no other reviewer needed + if (chosenTypeOfId === 'II') { + return false + } + + //if a parent is applying for nafnskíteini með ferðaskilríkjum for a child + //then check if there is another parent registered that needs to approve const chosenChild = applicantChildren.find( (x) => x.childNationalId === chosenApplicantNationalId, ) diff --git a/libs/application/templates/id-card/src/utils/index.ts b/libs/application/templates/id-card/src/utils/index.ts index 3e280dd3f8f1..9c3a25bbe5a5 100644 --- a/libs/application/templates/id-card/src/utils/index.ts +++ b/libs/application/templates/id-card/src/utils/index.ts @@ -1,5 +1,9 @@ import { parsePhoneNumberFromString } from 'libphonenumber-js' -// export * from './formatText' +import { getValueViaPath } from '@island.is/application/core' +import { NationalRegistryIndividual } from '@island.is/application/types' +import { IdentityDocument, IdentityDocumentChild } from '../lib/constants' +import { FormValue } from '@island.is/application/types' + export * from './getChosenApplicant' export * from './hasSecondGuardian' export * from './hasDiscount' @@ -8,8 +12,49 @@ export * from './getChargeItemCodes' export * from './updateAnswers' export * from './isChild' export * from './isAvailableForApplication' +export * from './getPriceList' export const formatPhoneNumber = (phoneNumber: string): string => { const phone = parsePhoneNumberFromString(phoneNumber, 'IS') return phone?.formatNational() || phoneNumber } + +export const hasReviewerApproved = (answers: FormValue): string => + getValueViaPath(answers, 'secondGuardianInformation.approved', '') as string + +export const getCombinedApplicantInformation = (externalData: any) => { + const applicantName = getValueViaPath( + externalData, + 'nationalRegistry.data.fullName', + '', + ) as string + + const applicantNationalRegistry = getValueViaPath( + externalData, + 'nationalRegistry.data', + {}, + ) as NationalRegistryIndividual + + const applicantPassport = getValueViaPath( + externalData, + 'identityDocument.data.userPassport', + undefined, + ) as IdentityDocument | undefined + + const applicantChildren = getValueViaPath( + externalData, + 'identityDocument.data.childPassports', + [], + ) as Array<IdentityDocumentChild> + + return { + name: applicantName, + age: applicantNationalRegistry.age, + nationalId: applicantNationalRegistry.nationalId, + passport: applicantPassport, + children: applicantChildren, + } +} + +export const formatIsk = (value: number): string => + value.toString().replace(/\B(?=(\d{3})+(?!\d))/g, '.') + ' kr.' diff --git a/libs/application/templates/id-card/src/utils/isAvailableForApplication.ts b/libs/application/templates/id-card/src/utils/isAvailableForApplication.ts index e7c819841548..dbe307eab433 100644 --- a/libs/application/templates/id-card/src/utils/isAvailableForApplication.ts +++ b/libs/application/templates/id-card/src/utils/isAvailableForApplication.ts @@ -1,29 +1,50 @@ -import { EXPIRATION_LIMIT_MONTHS } from '../lib/constants' +import { + CombinedApplicantInformation, + EXPIRATION_LIMIT_MONTHS, +} from '../lib/constants' export const isAvailableForApplication = ( - dateStr: string, idTypeChosen: string, - oldIdType: string, - age?: number, + applicantInformation: CombinedApplicantInformation, ) => { - if (age && age < 18 && idTypeChosen === 'ID') { + if ( + applicantInformation.age && + applicantInformation.age < 18 && + idTypeChosen === 'ID' + ) { return false } - const inputDate = new Date(dateStr) - const today = new Date() - today.setHours(0, 0, 0, 0) + const oldPassportType = + applicantInformation.passport && + `${applicantInformation.passport.type}${applicantInformation.passport.subType}` + const dateStr = + applicantInformation.passport && + applicantInformation.passport.expirationDate - // Calculate the date x months from today based on expiration limit - const xMonthsLater = new Date(today) - xMonthsLater.setMonth(xMonthsLater.getMonth() + EXPIRATION_LIMIT_MONTHS) - - // Check if the input date is within the expiration limit - const withinExpirationDate = inputDate >= today && inputDate <= xMonthsLater - if (idTypeChosen === 'ID' && (oldIdType === 'II' || oldIdType === 'IG')) { + if ( + (idTypeChosen === 'ID' && oldPassportType === 'II') || + oldPassportType === 'IG' + ) { // if types are not the same, then expiration date does not matter since you can apply for an id card type that you don't have regardless of old one return true } - return withinExpirationDate //else return if the date is within the expiration date + //applicant has old id and we need to calculate expiry date + if (dateStr) { + // Calculate the date x months from today based on expiration limit + const inputDate = new Date(dateStr) + const today = new Date() + today.setHours(0, 0, 0, 0) + + const xMonthsLater = new Date(today) + xMonthsLater.setMonth(xMonthsLater.getMonth() + EXPIRATION_LIMIT_MONTHS) + + // Check if the input date is within the expiration limit + const withinExpirationDate = inputDate >= today && inputDate <= xMonthsLater + + return withinExpirationDate //return if the date is within the expiration date + } + + return true } diff --git a/libs/application/templates/id-card/src/utils/updateAnswers.ts b/libs/application/templates/id-card/src/utils/updateAnswers.ts index 476c778a898a..582681c09b2e 100644 --- a/libs/application/templates/id-card/src/utils/updateAnswers.ts +++ b/libs/application/templates/id-card/src/utils/updateAnswers.ts @@ -8,7 +8,11 @@ export const updateAnswers = ( nationalId: string, setValue: (name: string, value: unknown, config?: Object) => void, ): Object => { - const chosenApplicants = getChosenApplicant(application, nationalId) + const chosenApplicants = getChosenApplicant( + application.answers, + application.externalData, + nationalId, + ) const applicantUserProfile = getValueViaPath( application.externalData, 'userProfile.data', diff --git a/libs/application/templates/inheritance-report/src/fields/CalculationsOfTotal/CalculateTotalAssets/index.tsx b/libs/application/templates/inheritance-report/src/fields/CalculationsOfTotal/CalculateTotalAssets/index.tsx deleted file mode 100644 index 2ac38968b7fc..000000000000 --- a/libs/application/templates/inheritance-report/src/fields/CalculationsOfTotal/CalculateTotalAssets/index.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { FieldBaseProps } from '@island.is/application/types' -import { formatCurrency } from '@island.is/application/ui-components' -import { Box, Text } from '@island.is/island-ui/core' -import { useLocale } from '@island.is/localization' -import { FC, useEffect, useState } from 'react' -import { useFormContext } from 'react-hook-form' -import { m } from '../../../lib/messages' -import { calculateTotalAssets } from '../../../lib/utils/calculateTotalAssets' - -export const CalculateTotalAssets: FC< - React.PropsWithChildren<FieldBaseProps> -> = ({ application }) => { - const { answers } = application - const { formatMessage } = useLocale() - const { setValue } = useFormContext() - - const acc = calculateTotalAssets(answers) - - const [total] = useState(acc) - - useEffect(() => { - setValue('assets.assetsTotal', total) - }, [total, setValue]) - - return ( - <Box - display={['block', 'block', 'flex']} - justifyContent="spaceBetween" - marginTop={4} - > - <Text variant="h3">{formatMessage(m.overviewTotal)}</Text> - <Text variant="h3">{formatCurrency(String(total))}</Text> - </Box> - ) -} - -export default CalculateTotalAssets diff --git a/libs/application/templates/inheritance-report/src/fields/CalculationsOfTotal/SetTotalAssets/index.tsx b/libs/application/templates/inheritance-report/src/fields/CalculationsOfTotal/SetTotalAssets/index.tsx new file mode 100644 index 000000000000..6d77a6b27c85 --- /dev/null +++ b/libs/application/templates/inheritance-report/src/fields/CalculationsOfTotal/SetTotalAssets/index.tsx @@ -0,0 +1,21 @@ +import { FieldBaseProps } from '@island.is/application/types' +import { FC, useEffect } from 'react' +import { useFormContext } from 'react-hook-form' +import { calculateTotalAssets } from '../../../lib/utils/calculateTotalAssets' + +export const SetTotalAssets: FC<React.PropsWithChildren<FieldBaseProps>> = ({ + application, +}) => { + const { answers } = application + const { setValue } = useFormContext() + + const total = calculateTotalAssets(answers) + + useEffect(() => { + setValue('assets.assetsTotal', total) + }, [total, setValue]) + + return null +} + +export default SetTotalAssets diff --git a/libs/application/templates/inheritance-report/src/fields/index.ts b/libs/application/templates/inheritance-report/src/fields/index.ts index 29c879ebe87e..61b6498cd211 100644 --- a/libs/application/templates/inheritance-report/src/fields/index.ts +++ b/libs/application/templates/inheritance-report/src/fields/index.ts @@ -7,7 +7,7 @@ export { OverviewHeirs } from './Overview/OverviewHeirs' export { FuneralCost } from './FuneralCost' export { OtherAssetsRepeater } from './OtherAssetsRepeater' export { DeceasedShareField } from './DeceasedShareField' -export { CalculateTotalAssets } from './CalculationsOfTotal/CalculateTotalAssets' +export { SetTotalAssets } from './CalculationsOfTotal/SetTotalAssets' export { CalculateTotalDebts } from './CalculationsOfTotal/CalculateTotalDebts' export { CalculateTotalBusiness } from './CalculationsOfTotal/CalculateTotalBusiness' export { CalculateFuneralCost } from './CalculationsOfTotal/CalculateFuneralCost' diff --git a/libs/application/templates/inheritance-report/src/forms/sections/assets.ts b/libs/application/templates/inheritance-report/src/forms/sections/assets.ts index 623d6042f2db..7946cf6e67aa 100644 --- a/libs/application/templates/inheritance-report/src/forms/sections/assets.ts +++ b/libs/application/templates/inheritance-report/src/forms/sections/assets.ts @@ -99,7 +99,7 @@ export const assets = buildSection({ }, ], assetKey: 'assets', - calcWithShareValue: true, + calcWithShareValue: false, repeaterButtonText: m.addRealEstate, sumField: 'propertyValuation', }, @@ -691,6 +691,11 @@ export const assets = buildSection({ doesNotRequireAnswer: true, component: 'OverviewAssets', }), + buildCustomField({ + title: '', + id: 'assets.assetsTotal', + component: 'SetTotalAssets', + }), buildDescriptionField({ id: 'space', title: '', diff --git a/libs/application/templates/official-journal-of-iceland/src/components/comments/AddComment.tsx b/libs/application/templates/official-journal-of-iceland/src/components/comments/AddComment.tsx index f173d7010eda..9676ed6fb1aa 100644 --- a/libs/application/templates/official-journal-of-iceland/src/components/comments/AddComment.tsx +++ b/libs/application/templates/official-journal-of-iceland/src/components/comments/AddComment.tsx @@ -1,60 +1,60 @@ -import { Box, Button, Input } from '@island.is/island-ui/core' +import { + AlertMessage, + Box, + Button, + Input, + Stack, +} from '@island.is/island-ui/core' import { useState } from 'react' import { comments } from '../../lib/messages/comments' import { useLocale } from '@island.is/localization' +import { useComments } from '../../hooks/useComments' type Props = { - onCommentChange?: (comment: string) => void - onAddComment?: (comment: string) => void + applicationId: string } -export const AddComment = ({ onCommentChange, onAddComment }: Props) => { - const { formatMessage } = useLocale() +export const AddComment = ({ applicationId }: Props) => { + const { formatMessage: f } = useLocale() + const { addComment, addCommentLoading, addCommentSuccess } = useComments({ + applicationId, + }) const [comment, setComment] = useState('') - const handleChange = ( - event: React.ChangeEvent<HTMLTextAreaElement | HTMLInputElement>, - ) => { - setComment(event.target.value) - if (onCommentChange) { - onCommentChange(event.target.value) - } - } - - const handleAdd = () => { + const onAddComment = () => { + addComment({ + comment: comment, + }) setComment('') - if (onAddComment) { - onAddComment(comment) - } } return ( - <Box - display="flex" - background="white" - padding={4} - flexDirection="column" - rowGap={4} - > + <Stack space={4}> + {addCommentSuccess === false && ( + <AlertMessage + type="error" + title={f(comments.warnings.postCommentFailedTitle)} + message={f(comments.warnings.postCommentFailedMessage)} + /> + )} <Box> <Input + disabled={addCommentLoading} name="add-comment" textarea - label={formatMessage(comments.inputs.addCommentTextarea.label)} - placeholder={formatMessage( - comments.inputs.addCommentTextarea.placeholder, - )} + label={f(comments.inputs.addCommentTextarea.label)} + placeholder={f(comments.inputs.addCommentTextarea.placeholder)} value={comment} rows={3} - onChange={handleChange} + onChange={(e) => setComment(e.target.value)} /> </Box> <Box display="flex" justifyContent="flexEnd"> - <Button size="small" onClick={handleAdd}> - {formatMessage(comments.inputs.addCommentButton.label)} + <Button size="small" loading={addCommentLoading} onClick={onAddComment}> + {f(comments.inputs.addCommentButton.label)} </Button> </Box> - </Box> + </Stack> ) } diff --git a/libs/application/templates/official-journal-of-iceland/src/components/comments/CommentList.tsx b/libs/application/templates/official-journal-of-iceland/src/components/comments/CommentList.tsx index 8e8a85500685..7da90fa08760 100644 --- a/libs/application/templates/official-journal-of-iceland/src/components/comments/CommentList.tsx +++ b/libs/application/templates/official-journal-of-iceland/src/components/comments/CommentList.tsx @@ -1,23 +1,29 @@ +import { SkeletonLoader } from '@island.is/island-ui/core' import type { Props as CommentProps } from './Comment' -import { Text } from '@island.is/island-ui/core' import { Comment } from './Comment' import * as styles from './Comments.css' -import { useLocale } from '@island.is/localization' -import { comments as messages } from '../../lib/messages/comments' +import { OJOI_INPUT_HEIGHT } from '../../lib/constants' type Props = { - comments: CommentProps[] + comments?: CommentProps[] + loading?: boolean } -export const CommentsList = ({ comments }: Props) => { - const { formatMessage: f } = useLocale() - if (!comments.length) { - return <Text>{f(messages.errors.emptyComments)}</Text> +export const CommentsList = ({ comments, loading }: Props) => { + if (loading) { + return ( + <SkeletonLoader + repeat={3} + space={1} + height={OJOI_INPUT_HEIGHT} + borderRadius="standard" + /> + ) } return ( <ul className={styles.commentsList}> - {comments.map((comment, index) => ( + {comments?.map((comment, index) => ( <Comment key={index} {...comment} as="li" /> ))} </ul> diff --git a/libs/application/templates/official-journal-of-iceland/src/components/communicationChannels/AddChannel.tsx b/libs/application/templates/official-journal-of-iceland/src/components/communicationChannels/AddChannel.tsx index 09073114c40e..5f5c7ecb3ebb 100644 --- a/libs/application/templates/official-journal-of-iceland/src/components/communicationChannels/AddChannel.tsx +++ b/libs/application/templates/official-journal-of-iceland/src/components/communicationChannels/AddChannel.tsx @@ -1,22 +1,53 @@ import { Box, Button, Input } from '@island.is/island-ui/core' import { useLocale } from '@island.is/localization' -import { useRef, useState } from 'react' +import { useEffect, useState } from 'react' import { general, publishing } from '../../lib/messages' import * as styles from './AddChannel.css' -import { Channel } from './Channel' import { FormGroup } from '../form/FormGroup' +import { useApplication } from '../../hooks/useUpdateApplication' +import set from 'lodash/set' +import { InputFields } from '../../lib/types' + type Props = { - onAdd: () => void - state: Channel - setState: React.Dispatch<React.SetStateAction<Channel>> + applicationId: string + defaultEmail?: string + defaultPhone?: string + defaultVisible?: boolean } -export const AddChannel = ({ onAdd, state, setState }: Props) => { +export const AddChannel = ({ + applicationId, + defaultEmail, + defaultPhone, + defaultVisible, +}: Props) => { + const { application, updateApplication } = useApplication({ + applicationId, + }) const { formatMessage: f } = useLocale() - const phoneRef = useRef<HTMLInputElement>(null) + const [email, setEmail] = useState('') + const [phone, setPhone] = useState('') + const [isVisible, setIsVisible] = useState(defaultVisible ?? false) + + useEffect(() => { + setEmail(defaultEmail ?? email) + setPhone(defaultPhone ?? phone) + setIsVisible(defaultVisible ?? false) + }, [defaultEmail, defaultPhone, defaultVisible]) + + const onAddChannel = () => { + const currentAnswers = structuredClone(application.answers) + const currentChannels = currentAnswers.advert?.channels ?? [] + const updatedAnswers = set(currentAnswers, InputFields.advert.channels, [ + ...currentChannels, + { email, phone }, + ]) - const [isVisible, setIsVisible] = useState(false) + updateApplication(updatedAnswers) + setEmail('') + setPhone('') + } return ( <FormGroup> @@ -32,19 +63,18 @@ export const AddChannel = ({ onAdd, state, setState }: Props) => { size="xs" name="email" type="email" - value={state.email} + value={email} label={f(general.email)} - onChange={(e) => setState({ ...state, email: e.target.value })} + onChange={(e) => setEmail(e.target.value)} /> </Box> <Box className={styles.phoneWrap}> <Input - ref={phoneRef} size="xs" name="tel" - value={state.phone} + value={phone} label={f(general.phoneNumber)} - onChange={(e) => setState({ ...state, phone: e.target.value })} + onChange={(e) => setPhone(e.target.value)} /> </Box> </Box> @@ -54,12 +84,13 @@ export const AddChannel = ({ onAdd, state, setState }: Props) => { variant="ghost" onClick={() => { setIsVisible(!isVisible) - setState({ email: '', phone: '' }) + setEmail('') + setPhone('') }} > {f(general.cancel)} </Button> - <Button onClick={onAdd} disabled={!state.email} size="small"> + <Button disabled={!email.length} size="small" onClick={onAddChannel}> {f(general.saveChanges)} </Button> </Box> diff --git a/libs/application/templates/official-journal-of-iceland/src/components/communicationChannels/Channel.tsx b/libs/application/templates/official-journal-of-iceland/src/components/communicationChannels/Channel.tsx deleted file mode 100644 index f87569b3e866..000000000000 --- a/libs/application/templates/official-journal-of-iceland/src/components/communicationChannels/Channel.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { Icon, Table as T } from '@island.is/island-ui/core' - -export type Channel = { - email: string - phone: string -} - -type Props = { - channel: Channel - onEditChannel: (channel: Channel) => void - onRemoveChannel: (channel: Channel) => void -} - -export const Channel = ({ channel, onEditChannel, onRemoveChannel }: Props) => { - return ( - <T.Row> - <T.Data>{channel.email}</T.Data> - <T.Data>{channel.phone}</T.Data> - <T.Data style={{ paddingInline: 0 }} align="center" width={1}> - <button type="button" onClick={() => onEditChannel(channel)}> - <Icon color="blue400" icon="pencil" /> - </button> - </T.Data> - <T.Data style={{ paddingInline: 0 }} align="center" width={1}> - <button type="button" onClick={() => onRemoveChannel(channel)}> - <Icon color="blue400" icon="trash" /> - </button> - </T.Data> - </T.Row> - ) -} diff --git a/libs/application/templates/official-journal-of-iceland/src/components/communicationChannels/ChannelList.tsx b/libs/application/templates/official-journal-of-iceland/src/components/communicationChannels/ChannelList.tsx index 09417e1c5519..76c5be4ddf79 100644 --- a/libs/application/templates/official-journal-of-iceland/src/components/communicationChannels/ChannelList.tsx +++ b/libs/application/templates/official-journal-of-iceland/src/components/communicationChannels/ChannelList.tsx @@ -1,21 +1,40 @@ -import { Table as T } from '@island.is/island-ui/core' -import { Channel } from './Channel' +import { Icon, Table as T } from '@island.is/island-ui/core' import { useLocale } from '@island.is/localization' import { general } from '../../lib/messages' +import { useApplication } from '../../hooks/useUpdateApplication' +import { InputFields } from '../../lib/types' +import set from 'lodash/set' type Props = { - channels: Channel[] - onEditChannel: (channel: Channel) => void - onRemoveChannel: (channel: Channel) => void + applicationId: string + onEditChannel: (email?: string, phone?: string) => void } -export const ChannelList = ({ - channels, - onEditChannel, - onRemoveChannel, -}: Props) => { +export const ChannelList = ({ applicationId, onEditChannel }: Props) => { const { formatMessage } = useLocale() - if (channels.length === 0) return null + + const { application, updateApplication } = useApplication({ + applicationId, + }) + + const channels = application.answers.advert?.channels || [] + + const onRemoveChannel = (email?: string) => { + const currentAnswers = structuredClone(application.answers) + const currentChannels = currentAnswers.advert?.channels ?? [] + + const updatedAnswers = set( + currentAnswers, + InputFields.advert.channels, + currentChannels.filter((channel) => channel.email !== email), + ) + + updateApplication(updatedAnswers) + } + + if (channels.length === 0) { + return null + } return ( <T.Table> @@ -29,12 +48,26 @@ export const ChannelList = ({ </T.Head> <T.Body> {channels.map((channel, i) => ( - <Channel - key={i} - channel={channel} - onEditChannel={onEditChannel} - onRemoveChannel={onRemoveChannel} - /> + <T.Row key={i}> + <T.Data>{channel.email}</T.Data> + <T.Data>{channel.phone}</T.Data> + <T.Data style={{ paddingInline: 0 }} align="center" width={1}> + <button + type="button" + onClick={() => onEditChannel(channel.email, channel.phone)} + > + <Icon color="blue400" icon="pencil" /> + </button> + </T.Data> + <T.Data style={{ paddingInline: 0 }} align="center" width={1}> + <button + type="button" + onClick={() => onRemoveChannel(channel.email)} + > + <Icon color="blue400" icon="trash" /> + </button> + </T.Data> + </T.Row> ))} </T.Body> </T.Table> diff --git a/libs/application/templates/official-journal-of-iceland/src/components/form/FormGroup.tsx b/libs/application/templates/official-journal-of-iceland/src/components/form/FormGroup.tsx index 7925a2fea463..aaff6f3fcf8d 100644 --- a/libs/application/templates/official-journal-of-iceland/src/components/form/FormGroup.tsx +++ b/libs/application/templates/official-journal-of-iceland/src/components/form/FormGroup.tsx @@ -1,22 +1,37 @@ import { Box, Text } from '@island.is/island-ui/core' import * as styles from './FormGroup.css' +import { MessageDescriptor } from 'react-intl' +import { useLocale } from '@island.is/localization' type Props = { - title?: string - intro?: string + title?: string | MessageDescriptor + intro?: string | MessageDescriptor children?: React.ReactNode } export const FormGroup = ({ title, intro, children }: Props) => { + const { formatMessage: f } = useLocale() + + const titleText = title + ? typeof title !== 'string' + ? f(title) + : title + : undefined + const introText = intro + ? typeof intro !== 'string' + ? f(intro) + : intro + : undefined + return ( <Box className={styles.formGroup}> {(title || intro) && ( <Box> - {title && ( - <Text marginBottom={intro ? 1 : 0} variant="h4"> - {title} + {titleText && ( + <Text marginBottom={introText ? 1 : 0} variant="h4"> + {titleText} </Text> )} - {intro && <Text>{intro}</Text>} + {introText && <Text>{introText}</Text>} </Box> )} {children} diff --git a/libs/application/templates/official-journal-of-iceland/src/components/htmlEditor/templates/signatures.ts b/libs/application/templates/official-journal-of-iceland/src/components/htmlEditor/templates/signatures.ts deleted file mode 100644 index b964b5c5dfe7..000000000000 --- a/libs/application/templates/official-journal-of-iceland/src/components/htmlEditor/templates/signatures.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { HTMLText } from '@island.is/regulations-tools/types' -import { - CommitteeSignatureState, - RegularSignatureState, -} from '../../../lib/types' -import is from 'date-fns/locale/is' -import en from 'date-fns/locale/en-US' -import format from 'date-fns/format' - -type RegularSignatureTemplateParams = { - signatureGroups?: RegularSignatureState - additionalSignature?: string - locale?: 'is' | 'en' -} - -type CommitteeSignatureTemplateParams = { - signature?: CommitteeSignatureState - additionalSignature?: string - locale?: 'is' | 'en' -} - -const signatureTemplate = ( - name?: string, - after?: string, - above?: string, - below?: string, -) => { - if (!name) return '' - return ` - <div class="signature"> - ${above ? `<p class="signature__above">${above}</p>` : ''} - <div class="signature__nameWrapper"> - <p class="signature__name">${name} - ${after ? `<span class="signature__after">${after}</span>` : ''} - </p> - ${below ? `<p class="signature__below">${below}</p>` : ''} - </div> - </div> - ` -} - -const additionalTemplate = (additional?: string) => { - if (!additional) return '' - - return `<p class="signature__additional">${additional}</p>` -} - -const titleTemplate = (title?: string, date?: string, locale = 'is') => { - if (!title && !date) return '' - - return ` - <p class="signature__title">${title}${title && date ? ', ' : ''}${ - date - ? format(new Date(date), 'd. MMMM yyyy', { - locale: locale === 'is' ? is : en, - }) - : '' - }</p>` -} - -export const regularSignatureTemplate = ({ - signatureGroups, - additionalSignature, - locale = 'is', -}: RegularSignatureTemplateParams): HTMLText => { - const results = signatureGroups - ?.map((signatureGroup) => { - const className = - signatureGroup?.members?.length === 1 - ? 'signatures single' - : signatureGroup?.members?.length === 2 - ? 'signatures double' - : signatureGroup?.members?.length === 3 - ? 'signatures triple' - : 'signatures' - - return ` - <div class="signature__group"> - ${titleTemplate( - signatureGroup.institution, - signatureGroup.date, - locale, - )} - <div class="${className}"> - ${signatureGroup?.members - ?.map((s) => signatureTemplate(s.name, s.after, s.above, s.below)) - .join('')} - </div> - </div>` - }) - - .join('') - - return additionalSignature - ? ((results + additionalTemplate(additionalSignature)) as HTMLText) - : (results as HTMLText) -} - -export const committeeSignatureTemplate = ({ - signature, - additionalSignature, - locale = 'is', -}: CommitteeSignatureTemplateParams): HTMLText => { - const className = - signature?.members?.length === 1 - ? 'signatures single' - : signature?.members?.length === 2 - ? 'signatures double' - : signature?.members?.length === 3 - ? 'signatures triple' - : 'signatures' - - const html = ` - <div class="signature__group"> - ${titleTemplate(signature?.institution, signature?.date, locale)} - <div class="signatures single chairman"> - ${signatureTemplate( - signature?.chairman.name, - signature?.chairman.after, - signature?.chairman.above, - signature?.chairman.below, - )} - </div> - <div class="${className}"> - ${signature?.members - ?.map((s) => signatureTemplate(s.name, '', '', s.below)) - .join('')} - </div> - </div> - ` - - return additionalSignature - ? ((html + additionalTemplate(additionalSignature)) as HTMLText) - : (html as HTMLText) -} diff --git a/libs/application/templates/official-journal-of-iceland/src/components/input/OJOIDateController.tsx b/libs/application/templates/official-journal-of-iceland/src/components/input/OJOIDateController.tsx new file mode 100644 index 000000000000..4e75c4a5c70e --- /dev/null +++ b/libs/application/templates/official-journal-of-iceland/src/components/input/OJOIDateController.tsx @@ -0,0 +1,100 @@ +import { SkeletonLoader } from '@island.is/island-ui/core' +import { useLocale } from '@island.is/localization' +import { DatePickerController } from '@island.is/shared/form-fields' +import { MessageDescriptor } from 'react-intl' +import { OJOI_INPUT_HEIGHT } from '../../lib/constants' +import { useApplication } from '../../hooks/useUpdateApplication' +import { useFormContext } from 'react-hook-form' +import set from 'lodash/set' + +type Props = { + name: string + label: string | MessageDescriptor + placeholder: string | MessageDescriptor + defaultValue?: string + loading?: boolean + applicationId: string + disabled?: boolean + excludeDates?: Date[] + minDate?: Date + maxDate?: Date + onChange?: (value: string) => void +} + +export const OJOIDateController = ({ + name, + label, + placeholder, + defaultValue, + loading, + applicationId, + disabled, + excludeDates, + minDate, + maxDate, + onChange, +}: Props) => { + const { formatMessage: f } = useLocale() + const { + debouncedOnUpdateApplicationHandler, + application, + updateApplication, + } = useApplication({ + applicationId, + }) + + const { setValue } = useFormContext() + + if (loading) { + return ( + <SkeletonLoader + borderRadius="standard" + display="block" + height={OJOI_INPUT_HEIGHT} + /> + ) + } + + // if defaultValue is passed and there is no value set we must set it in the application state + if (defaultValue && !application.answers.advert?.requestedDate) { + setValue(name, defaultValue) + const currentAnswers = structuredClone(application.answers) + const updatedAnswers = set(currentAnswers, name, defaultValue) + updateApplication(updatedAnswers) + } + + const placeholderText = + typeof placeholder === 'string' ? placeholder : f(placeholder) + + const labelText = typeof label === 'string' ? label : f(label) + + const handleChange = (value: string) => { + const currentAnswers = structuredClone(application.answers) + const newAnswers = set(currentAnswers, name, value) + + return newAnswers + } + + return ( + <DatePickerController + id={name} + name={name} + label={labelText} + placeholder={placeholderText} + size="sm" + locale="is" + backgroundColor="blue" + defaultValue={defaultValue} + disabled={disabled} + excludeDates={excludeDates} + minDate={minDate} + maxDate={maxDate} + onChange={(e) => + debouncedOnUpdateApplicationHandler( + handleChange(e), + onChange && (() => onChange(e)), + ) + } + /> + ) +} diff --git a/libs/application/templates/official-journal-of-iceland/src/components/input/OJOIHtmlController.tsx b/libs/application/templates/official-journal-of-iceland/src/components/input/OJOIHtmlController.tsx new file mode 100644 index 000000000000..d3426936f725 --- /dev/null +++ b/libs/application/templates/official-journal-of-iceland/src/components/input/OJOIHtmlController.tsx @@ -0,0 +1,72 @@ +import { HTMLText } from '@island.is/regulations' +import { Editor, EditorFileUploader } from '@island.is/regulations-tools/Editor' +import { useCallback, useEffect, useRef } from 'react' +import { classes, editorWrapper } from '../htmlEditor/HTMLEditor.css' +import { baseConfig } from '../htmlEditor/config/baseConfig' +import { Box } from '@island.is/island-ui/core' +import { useApplication } from '../../hooks/useUpdateApplication' +import set from 'lodash/set' + +type Props = { + applicationId: string + name: string + defaultValue?: string + onChange?: (value: HTMLText) => void + editorKey?: string +} + +export const OJOIHtmlController = ({ + applicationId, + name, + onChange, + defaultValue, + editorKey, +}: Props) => { + const { debouncedOnUpdateApplicationHandler, application } = useApplication({ + applicationId, + }) + + const valueRef = useRef(() => + defaultValue ? (defaultValue as HTMLText) : ('' as HTMLText), + ) + + const fileUploader = (): EditorFileUploader => async (blob) => { + throw new Error('Not implemented') + } + + const handleChange = (value: HTMLText) => { + const currentAnswers = structuredClone(application.answers) + const newAnswers = set(currentAnswers, name, value) + + onChange && onChange(value) + return newAnswers + } + + const onChangeHandler = () => { + return handleChange(valueRef.current()) + } + + return ( + <Box + className={editorWrapper({ + error: false, + })} + > + <Editor + key={editorKey} + config={baseConfig} + classes={classes} + fileUploader={fileUploader} + valueRef={valueRef} + onChange={() => { + // add little bit of delay for valueRef to update + setTimeout( + () => debouncedOnUpdateApplicationHandler(onChangeHandler()), + 100, + ) + }} + onBlur={() => debouncedOnUpdateApplicationHandler(onChangeHandler())} + /> + </Box> + ) +} diff --git a/libs/application/templates/official-journal-of-iceland/src/components/input/OJOIInputController.tsx b/libs/application/templates/official-journal-of-iceland/src/components/input/OJOIInputController.tsx new file mode 100644 index 000000000000..54455a555853 --- /dev/null +++ b/libs/application/templates/official-journal-of-iceland/src/components/input/OJOIInputController.tsx @@ -0,0 +1,82 @@ +import { SkeletonLoader } from '@island.is/island-ui/core' +import { useLocale } from '@island.is/localization' +import { InputController } from '@island.is/shared/form-fields' +import { MessageDescriptor } from 'react-intl' +import { OJOI_INPUT_HEIGHT } from '../../lib/constants' +import { useApplication } from '../../hooks/useUpdateApplication' +import set from 'lodash/set' + +type Props = { + name: string + label: string | MessageDescriptor + placeholder?: string | MessageDescriptor + defaultValue?: string + loading?: boolean + applicationId: string + disabled?: boolean + textarea?: boolean + onChange?: (value: string) => void +} + +export const OJOIInputController = ({ + name, + label, + placeholder, + defaultValue, + loading, + applicationId, + disabled, + textarea, + onChange, +}: Props) => { + const { formatMessage: f } = useLocale() + const { debouncedOnUpdateApplicationHandler, application } = useApplication({ + applicationId, + }) + + const placeholderText = placeholder + ? typeof placeholder === 'string' + ? placeholder + : f(placeholder) + : '' + + const labelText = typeof label === 'string' ? label : f(label) + + const handleChange = (value: string) => { + const currentAnswers = structuredClone(application.answers) + const newAnswers = set(currentAnswers, name, value) + + return newAnswers + } + + if (loading) { + return ( + <SkeletonLoader + borderRadius="standard" + display="block" + height={OJOI_INPUT_HEIGHT} + /> + ) + } + + return ( + <InputController + id={name} + name={name} + label={labelText} + placeholder={placeholderText} + size="sm" + backgroundColor="blue" + defaultValue={defaultValue} + disabled={disabled} + textarea={textarea} + rows={4} + onChange={(e) => + debouncedOnUpdateApplicationHandler( + handleChange(e.target.value), + onChange && (() => onChange(e.target.value)), + ) + } + /> + ) +} diff --git a/libs/application/templates/official-journal-of-iceland/src/components/input/OJOISelectController.tsx b/libs/application/templates/official-journal-of-iceland/src/components/input/OJOISelectController.tsx new file mode 100644 index 000000000000..3bb2ec98f523 --- /dev/null +++ b/libs/application/templates/official-journal-of-iceland/src/components/input/OJOISelectController.tsx @@ -0,0 +1,84 @@ +import { SkeletonLoader } from '@island.is/island-ui/core' +import { useLocale } from '@island.is/localization' +import { SelectController } from '@island.is/shared/form-fields' +import { MessageDescriptor } from 'react-intl' +import { OJOI_INPUT_HEIGHT } from '../../lib/constants' +import { useApplication } from '../../hooks/useUpdateApplication' +import set from 'lodash/set' +import { InputFields } from '../../lib/types' + +type OJOISelectControllerOption = { + label: string + value: string +} + +type Props = { + name: string + label: string | MessageDescriptor + placeholder: string | MessageDescriptor + options?: OJOISelectControllerOption[] + defaultValue?: string + loading?: boolean + applicationId: string + disabled?: boolean + onChange?: (value: string) => void +} + +export const OJOISelectController = ({ + name, + label, + placeholder, + options, + defaultValue, + loading, + applicationId, + disabled, + onChange, +}: Props) => { + const { formatMessage: f } = useLocale() + const { updateApplication, application } = useApplication({ applicationId }) + + const placeholderText = + typeof placeholder === 'string' ? placeholder : f(placeholder) + + const labelText = typeof label === 'string' ? label : f(label) + + const handleChange = (value: string) => { + const currentAnswers = structuredClone(application.answers) + const newAnswers = set(currentAnswers, name, value) + + // we must reset the selected typeId if the department changes + if (name === InputFields.advert.departmentId) { + set(newAnswers, InputFields.advert.typeId, '') + } + + updateApplication(newAnswers) + + onChange && onChange(value) + } + + if (loading) { + return ( + <SkeletonLoader + borderRadius="standard" + display="block" + height={OJOI_INPUT_HEIGHT} + /> + ) + } + + return ( + <SelectController + id={name} + name={name} + label={labelText} + placeholder={placeholderText} + size="sm" + backgroundColor="blue" + options={options} + defaultValue={defaultValue} + disabled={disabled} + onSelect={(opt) => handleChange(opt.value)} + /> + ) +} diff --git a/libs/application/templates/official-journal-of-iceland/src/components/property/Property.tsx b/libs/application/templates/official-journal-of-iceland/src/components/property/Property.tsx index f257fdf848cc..35d5a5915000 100644 --- a/libs/application/templates/official-journal-of-iceland/src/components/property/Property.tsx +++ b/libs/application/templates/official-journal-of-iceland/src/components/property/Property.tsx @@ -1,17 +1,31 @@ -import { Box, Text } from '@island.is/island-ui/core' +import { Box, SkeletonLoader, Text } from '@island.is/island-ui/core' import * as styles from './Property.css' +import { OJOI_INPUT_HEIGHT } from '../../lib/constants' type Props = { name?: string value?: string + loading?: boolean } -export const Property = ({ name, value }: Props) => ( - <Box className={styles.propertyWrap}> - <Box className={styles.property}> - <Text fontWeight="semiBold">{name}</Text> - </Box> - <Box className={styles.property}> - <Text>{value}</Text> +export const Property = ({ name, value, loading = false }: Props) => { + if (!value && !loading) { + return null + } + + return ( + <Box className={styles.propertyWrap}> + {loading ? ( + <SkeletonLoader height={OJOI_INPUT_HEIGHT} borderRadius="standard" /> + ) : ( + <> + <Box className={styles.property}> + <Text fontWeight="semiBold">{name}</Text> + </Box> + <Box className={styles.property}> + <Text>{value}</Text> + </Box> + </> + )} </Box> - </Box> -) + ) +} diff --git a/libs/application/templates/official-journal-of-iceland/src/components/signatures/AddCommitteeMember.tsx b/libs/application/templates/official-journal-of-iceland/src/components/signatures/AddCommitteeMember.tsx new file mode 100644 index 000000000000..d094f6b8e98e --- /dev/null +++ b/libs/application/templates/official-journal-of-iceland/src/components/signatures/AddCommitteeMember.tsx @@ -0,0 +1,77 @@ +import { Box, Button } from '@island.is/island-ui/core' +import { signatures } from '../../lib/messages/signatures' +import { useLocale } from '@island.is/localization' +import { useApplication } from '../../hooks/useUpdateApplication' +import { InputFields } from '../../lib/types' +import { + MAXIMUM_COMMITTEE_SIGNATURE_MEMBER_COUNT, + MINIMUM_COMMITTEE_SIGNATURE_MEMBER_COUNT, +} from '../../lib/constants' +import { getCommitteeAnswers, getEmptyMember } from '../../lib/utils' +import set from 'lodash/set' + +type Props = { + applicationId: string +} + +export const AddCommitteeMember = ({ applicationId }: Props) => { + const { formatMessage: f } = useLocale() + const { updateApplication, application, isLoading } = useApplication({ + applicationId, + }) + + const onAddCommitteeMember = () => { + const { signature, currentAnswers } = getCommitteeAnswers( + structuredClone(application.answers), + ) + + if (signature) { + const withExtraMember = { + ...signature, + members: [...(signature.members ?? []), getEmptyMember()], + } + + const updatedAnswers = set( + currentAnswers, + InputFields.signature.committee, + withExtraMember, + ) + + updateApplication(updatedAnswers) + } + } + + const getCurrentCount = () => { + const { signature } = getCommitteeAnswers( + structuredClone(application.answers), + ) + if (signature) { + return signature.members?.length ?? 0 + } + + return 0 + } + + const count = getCurrentCount() + + const isGreaterThanMinimum = count >= MINIMUM_COMMITTEE_SIGNATURE_MEMBER_COUNT + const isLessThanMaximum = count < MAXIMUM_COMMITTEE_SIGNATURE_MEMBER_COUNT + + const isDisabled = !isGreaterThanMinimum || !isLessThanMaximum + + return ( + <Box marginTop={2}> + <Button + onClick={() => onAddCommitteeMember()} + loading={isLoading} + disabled={isDisabled} + variant="utility" + size="small" + icon="add" + iconType="outline" + > + {f(signatures.buttons.addPerson)} + </Button> + </Box> + ) +} diff --git a/libs/application/templates/official-journal-of-iceland/src/components/signatures/AddRegularMember.tsx b/libs/application/templates/official-journal-of-iceland/src/components/signatures/AddRegularMember.tsx new file mode 100644 index 000000000000..4d6aeb79bfc4 --- /dev/null +++ b/libs/application/templates/official-journal-of-iceland/src/components/signatures/AddRegularMember.tsx @@ -0,0 +1,92 @@ +import { Box, Button } from '@island.is/island-ui/core' +import { signatures } from '../../lib/messages/signatures' +import { useLocale } from '@island.is/localization' +import { useApplication } from '../../hooks/useUpdateApplication' +import { InputFields } from '../../lib/types' +import { + MAXIMUM_REGULAR_SIGNATURE_MEMBER_COUNT, + MINIMUM_REGULAR_SIGNATURE_MEMBER_COUNT, +} from '../../lib/constants' +import { getEmptyMember, getRegularAnswers } from '../../lib/utils' +import set from 'lodash/set' + +type Props = { + applicationId: string + signatureIndex: number +} + +export const AddRegularMember = ({ applicationId, signatureIndex }: Props) => { + const { formatMessage: f } = useLocale() + const { updateApplication, application, isLoading } = useApplication({ + applicationId, + }) + + const onAddMember = () => { + const { signature, currentAnswers } = getRegularAnswers( + structuredClone(application.answers), + ) + + if (signature) { + const doesSignatureExist = signature.at(signatureIndex) + + if (doesSignatureExist !== undefined) { + const updatedRegularSignature = signature.map((signature, index) => { + if (index === signatureIndex) { + return { + ...signature, + members: [...(signature.members ?? []), getEmptyMember()], + } + } + + return signature + }) + + const updatedAnswers = set( + currentAnswers, + InputFields.signature.regular, + updatedRegularSignature, + ) + + updateApplication(updatedAnswers) + } + } + } + + const getCurrentCount = () => { + const { signature } = getRegularAnswers( + structuredClone(application.answers), + ) + if (signature) { + const doesSignatureExist = signature?.at(signatureIndex) + + if (doesSignatureExist !== undefined) { + return doesSignatureExist.members?.length ?? 0 + } + } + + return 0 + } + + const count = getCurrentCount() + + const isGreaterThanMinimum = count >= MINIMUM_REGULAR_SIGNATURE_MEMBER_COUNT + const isLessThanMaximum = count < MAXIMUM_REGULAR_SIGNATURE_MEMBER_COUNT + + const isDisabled = !isGreaterThanMinimum || !isLessThanMaximum + + return ( + <Box marginTop={2}> + <Button + onClick={() => onAddMember()} + loading={isLoading} + disabled={isDisabled} + variant="utility" + size="small" + icon="add" + iconType="outline" + > + {f(signatures.buttons.addPerson)} + </Button> + </Box> + ) +} diff --git a/libs/application/templates/official-journal-of-iceland/src/components/signatures/AddRegularSignature.tsx b/libs/application/templates/official-journal-of-iceland/src/components/signatures/AddRegularSignature.tsx new file mode 100644 index 000000000000..1d3ce373b3ed --- /dev/null +++ b/libs/application/templates/official-journal-of-iceland/src/components/signatures/AddRegularSignature.tsx @@ -0,0 +1,81 @@ +import { useLocale } from '@island.is/localization' +import { signatures } from '../../lib/messages/signatures' +import { Box, Button } from '@island.is/island-ui/core' +import { useApplication } from '../../hooks/useUpdateApplication' +import { getValueViaPath } from '@island.is/application/core' +import { InputFields } from '../../lib/types' +import { + getRegularAnswers, + getRegularSignature, + isRegularSignature, +} from '../../lib/utils' +import set from 'lodash/set' +import { + DEFAULT_REGULAR_SIGNATURE_MEMBER_COUNT, + MAXIMUM_REGULAR_SIGNATURE_COUNT, + ONE, +} from '../../lib/constants' + +type Props = { + applicationId: string +} + +export const AddRegularSignature = ({ applicationId }: Props) => { + const { formatMessage: f } = useLocale() + const { updateApplication, application, isLoading } = useApplication({ + applicationId, + }) + + const onAddInstitution = () => { + const { signature, currentAnswers } = getRegularAnswers( + structuredClone(application.answers), + ) + + if (signature) { + const newSignature = getRegularSignature( + ONE, + DEFAULT_REGULAR_SIGNATURE_MEMBER_COUNT, + )?.pop() + + const updatedAnswers = set( + currentAnswers, + InputFields.signature.regular, + [...signature, newSignature], + ) + + updateApplication(updatedAnswers) + } + } + + const getCount = () => { + const currentAnswers = structuredClone(application.answers) + const signature = getValueViaPath( + currentAnswers, + InputFields.signature.regular, + ) + + if (isRegularSignature(signature)) { + return signature?.length ?? 0 + } + + return 0 + } + + const count = getCount() + + return ( + <Box marginTop={2}> + <Button + disabled={count >= MAXIMUM_REGULAR_SIGNATURE_COUNT} + loading={isLoading} + onClick={onAddInstitution} + variant="utility" + size="small" + icon="add" + iconType="outline" + > + {f(signatures.buttons.addInstitution)} + </Button> + </Box> + ) +} diff --git a/libs/application/templates/official-journal-of-iceland/src/components/signatures/Additional.tsx b/libs/application/templates/official-journal-of-iceland/src/components/signatures/Additional.tsx index 9772604d5949..12148dc93a80 100644 --- a/libs/application/templates/official-journal-of-iceland/src/components/signatures/Additional.tsx +++ b/libs/application/templates/official-journal-of-iceland/src/components/signatures/Additional.tsx @@ -1,22 +1,15 @@ import { Box, Text } from '@island.is/island-ui/core' import * as styles from './Signatures.css' -import { InputFields, OJOIFieldBaseProps } from '../../lib/types' -import { InputController } from '@island.is/shared/form-fields' -import { getErrorViaPath } from '@island.is/application/core' import { useLocale } from '@island.is/localization' import { signatures } from '../../lib/messages/signatures' +import { OJOIInputController } from '../input/OJOIInputController' -type Props = Pick<OJOIFieldBaseProps, 'application' | 'errors'> & { - signature: string - setSignature: (state: string) => void +type Props = { + applicationId: string + name: string } -export const AdditionalSignature = ({ - application, - errors, - signature, - setSignature, -}: Props) => { +export const AdditionalSignature = ({ applicationId, name }: Props) => { const { formatMessage: f } = useLocale() return ( @@ -25,18 +18,10 @@ export const AdditionalSignature = ({ {f(signatures.headings.additionalSignature)} </Text> <Box display="flex" justifyContent="flexStart"> - <InputController - name={InputFields.signature.additonalSignature} - id={InputFields.signature.additonalSignature} - onChange={(event) => setSignature(event.target.value)} + <OJOIInputController + name={name} label={f(signatures.inputs.name.label)} - defaultValue={signature} - size="sm" - backgroundColor="blue" - error={ - errors && - getErrorViaPath(errors, InputFields.signature.additonalSignature) - } + applicationId={applicationId} /> </Box> </Box> diff --git a/libs/application/templates/official-journal-of-iceland/src/components/signatures/Chairman.tsx b/libs/application/templates/official-journal-of-iceland/src/components/signatures/Chairman.tsx new file mode 100644 index 000000000000..91b23f17dcaf --- /dev/null +++ b/libs/application/templates/official-journal-of-iceland/src/components/signatures/Chairman.tsx @@ -0,0 +1,106 @@ +import { Box, Text } from '@island.is/island-ui/core' +import { useApplication } from '../../hooks/useUpdateApplication' +import { useLocale } from '@island.is/localization' +import { signatures } from '../../lib/messages/signatures' +import { InputFields } from '../../lib/types' +import { getCommitteeAnswers, getEmptyMember } from '../../lib/utils' +import { memberItemSchema } from '../../lib/dataSchema' +import { SignatureMember } from './Member' +import set from 'lodash/set' +import * as styles from './Signatures.css' +import * as z from 'zod' + +type Props = { + applicationId: string + member?: z.infer<typeof memberItemSchema> +} + +type MemberProperties = ReturnType<typeof getEmptyMember> + +export const Chairman = ({ applicationId, member }: Props) => { + const { formatMessage: f } = useLocale() + const { application, debouncedOnUpdateApplicationHandler } = useApplication({ + applicationId, + }) + + const handleChairmanChange = (value: string, key: keyof MemberProperties) => { + const { signature, currentAnswers } = getCommitteeAnswers( + application.answers, + ) + + if (signature) { + const updatedCommitteeSignature = { + ...signature, + chairman: { ...signature.chairman, [key]: value }, + } + + const updatedSignatures = set( + currentAnswers, + InputFields.signature.committee, + updatedCommitteeSignature, + ) + + return updatedSignatures + } + + return currentAnswers + } + + if (!member) { + return null + } + + return ( + <Box className={styles.wrapper}> + <Text variant="h5" marginBottom={2}> + {f(signatures.headings.chairman)} + </Text> + <Box className={styles.inputGroup}> + <Box className={styles.inputWrapper}> + <SignatureMember + name={`signature.comittee.member.above.chairman`} + label={f(signatures.inputs.above.label)} + defaultValue={member.above} + onChange={(e) => + debouncedOnUpdateApplicationHandler( + handleChairmanChange(e.target.value, 'above'), + ) + } + /> + <SignatureMember + name={`signature.comittee.member.after`} + label={f(signatures.inputs.after.label)} + defaultValue={member.after} + onChange={(e) => + debouncedOnUpdateApplicationHandler( + handleChairmanChange(e.target.value, 'after'), + ) + } + /> + </Box> + <Box className={styles.inputWrapper}> + <SignatureMember + name={`signature.comittee.member.name`} + label={f(signatures.inputs.name.label)} + defaultValue={member.name} + onChange={(e) => + debouncedOnUpdateApplicationHandler( + handleChairmanChange(e.target.value, 'name'), + ) + } + /> + <SignatureMember + name={`signature.comittee.member.below`} + label={f(signatures.inputs.below.label)} + defaultValue={member.below} + onChange={(e) => + debouncedOnUpdateApplicationHandler( + handleChairmanChange(e.target.value, 'below'), + ) + } + /> + </Box> + </Box> + </Box> + ) +} diff --git a/libs/application/templates/official-journal-of-iceland/src/components/signatures/Committee.tsx b/libs/application/templates/official-journal-of-iceland/src/components/signatures/Committee.tsx index a941b1e3d9f8..0f7163017690 100644 --- a/libs/application/templates/official-journal-of-iceland/src/components/signatures/Committee.tsx +++ b/libs/application/templates/official-journal-of-iceland/src/components/signatures/Committee.tsx @@ -1,282 +1,75 @@ -import { Box, Text, Button } from '@island.is/island-ui/core' +import { Box, Text } from '@island.is/island-ui/core' import * as styles from './Signatures.css' +import { InstitutionSignature } from './Institution' +import { SignatureTypes } from '../../lib/constants' +import { CommitteeMember } from './CommitteeMember' +import { useApplication } from '../../hooks/useUpdateApplication' +import { isCommitteeSignature } from '../../lib/utils' +import { Chairman } from './Chairman' +import { getValueViaPath } from '@island.is/application/core' +import { InputFields } from '../../lib/types' +import { AdditionalSignature } from './Additional' import { useLocale } from '@island.is/localization' -import { - CommitteeSignatureState, - InputFields, - OJOIFieldBaseProps, -} from '../../lib/types' -import cloneDeep from 'lodash/cloneDeep' -import { - DatePickerController, - InputController, -} from '@island.is/shared/form-fields' -import { INITIAL_ANSWERS, MEMBER_INDEX } from '../../lib/constants' -import { getErrorViaPath } from '@island.is/application/core' import { signatures } from '../../lib/messages/signatures' +import { AddCommitteeMember } from './AddCommitteeMember' -type ChairmanKey = keyof NonNullable<CommitteeSignatureState>['chairman'] -type MemberKey = keyof NonNullable<CommitteeSignatureState>['members'][0] - -type LocalState = typeof INITIAL_ANSWERS['signature'] -type Props = Pick<OJOIFieldBaseProps, 'errors'> & { - state: LocalState - setState: (state: LocalState) => void - addSignature?: boolean +type Props = { + applicationId: string } -export const CommitteeSignature = ({ state, setState, errors }: Props) => { +export const CommitteeSignature = ({ applicationId }: Props) => { const { formatMessage: f } = useLocale() + const { application } = useApplication({ + applicationId, + }) - const onCommitteeChairmanChange = (key: ChairmanKey, value: string) => { - const newState = cloneDeep(state) - newState.committee.chairman[key] = value - setState(newState) - } + const getSignature = () => { + const currentAnswers = getValueViaPath( + application.answers, + InputFields.signature.committee, + ) - const onCommitteMemberChange = ( - index: number, - key: MemberKey, - value: string, - ) => { - const newState = cloneDeep(state) - if (!newState.committee.members) return - newState.committee.members[index][key] = value - setState(newState) + if (isCommitteeSignature(currentAnswers)) { + return currentAnswers + } } - const onCommitteeChange = ( - key: keyof Omit<CommitteeSignatureState, 'members' | 'chairman'>, - value: string, - ) => { - const newState = cloneDeep(state) - newState.committee[key] = value - setState(newState) - } - - const onAddCommitteeMember = () => { - const newState = cloneDeep(state) - if (!newState.committee.members) return - newState.committee.members.push({ - name: '', - below: '', - }) - setState(newState) - } + const signature = getSignature() - const onRemoveCommitteeMember = (index: number) => { - const newState = cloneDeep(state) - if (!newState.committee.members) return - newState.committee.members.splice(index, 1) - setState(newState) + if (!signature) { + return null } return ( - <Box className={styles.signatureWrapper}> - <Box - display="flex" - flexDirection="row" - flexWrap="wrap" - rowGap={2} - columnGap={2} - marginBottom={2} - > - <Box flexGrow={1}> - <InputController - id={InputFields.signature.committee.institution} - required={true} - name={InputFields.signature.committee.institution} - label={f(signatures.inputs.institution.label)} - error={ - errors && - getErrorViaPath( - errors, - InputFields.signature.committee.institution, - ) - } - defaultValue={state.committee.institution} - backgroundColor="blue" - onChange={(e) => onCommitteeChange('institution', e.target.value)} - size="sm" - /> - </Box> - <Box flexGrow={1}> - <DatePickerController - id={InputFields.signature.committee.date} - name={InputFields.signature.committee.date} - label={f(signatures.inputs.date.label)} - placeholder={f(signatures.inputs.date.placeholder)} - backgroundColor="blue" - size="sm" - locale="is" - defaultValue={state.committee.date} - onChange={(date) => onCommitteeChange('date', date)} - /> - </Box> - </Box> - <Box className={styles.wrapper}> - <Text variant="h5" marginBottom={2}> - {f(signatures.headings.chairman)} - </Text> - <Box className={styles.committeeInputGroup}> - <Box className={styles.committeInputWrapper}> - <Box flexGrow={1}> - <InputController - id={InputFields.signature.committee.chairman.above} - name={InputFields.signature.committee.chairman.above} - label={f(signatures.inputs.above.label)} - defaultValue={state.committee.chairman.above} - backgroundColor="blue" - size="sm" - onChange={(e) => - onCommitteeChairmanChange('above', e.target.value) - } - error={ - errors && - getErrorViaPath( - errors, - InputFields.signature.committee.chairman.above, - ) - } - /> - </Box> - <Box flexGrow={1}> - <InputController - id={InputFields.signature.committee.chairman.name} - name={InputFields.signature.committee.chairman.name} - required={true} - label={f(signatures.inputs.name.label)} - defaultValue={state.committee.chairman.name} - backgroundColor="blue" - size="sm" - onChange={(e) => - onCommitteeChairmanChange('name', e.target.value) - } - error={ - errors && - getErrorViaPath( - errors, - InputFields.signature.committee.chairman.name, - ) - } + <> + <Box className={styles.signatureWrapper}> + <InstitutionSignature + applicationId={applicationId} + type={SignatureTypes.COMMITTEE} + /> + <Chairman applicationId={applicationId} member={signature.chairman} /> + <Box className={styles.wrapper}> + <Text variant="h5" marginBottom={2}> + {f(signatures.headings.committeeMembers)} + </Text> + <Box className={styles.committeeInputGroupWrapper}> + {signature?.members?.map((member, index) => ( + <CommitteeMember + key={index} + applicationId={applicationId} + memberIndex={index} + member={member} /> - </Box> + ))} + <AddCommitteeMember applicationId={applicationId} /> </Box> - <Box className={styles.committeInputWrapper}> - <Box flexGrow={1}> - <InputController - id={InputFields.signature.committee.chairman.after} - name={InputFields.signature.committee.chairman.after} - label={f(signatures.inputs.after.label)} - defaultValue={state.committee.chairman.after} - backgroundColor="blue" - size="sm" - onChange={(e) => - onCommitteeChairmanChange('after', e.target.value) - } - error={ - errors && - getErrorViaPath( - errors, - InputFields.signature.committee.chairman.after, - ) - } - /> - </Box> - <Box flexGrow={1}> - <InputController - id={InputFields.signature.committee.chairman.below} - name={InputFields.signature.committee.chairman.below} - label={f(signatures.inputs.below.label)} - defaultValue={state.committee.chairman.below} - backgroundColor="blue" - size="sm" - onChange={(e) => - onCommitteeChairmanChange('below', e.target.value) - } - error={ - errors && - getErrorViaPath( - errors, - InputFields.signature.committee.chairman.below, - ) - } - /> - </Box> - </Box> - </Box> - </Box> - <Box className={styles.wrapper} marginTop={2}> - <Text variant="h5" marginBottom={2}> - {f(signatures.headings.committeeMembers)} - </Text> - <Box className={styles.committeeInputGroupWrapper}> - {state.committee.members?.map((member, index) => { - const namePath = - InputFields.signature.committee.members.name.replace( - MEMBER_INDEX, - `${index}`, - ) - - const belowPath = - InputFields.signature.committee.members.below.replace( - MEMBER_INDEX, - `${index}`, - ) - return ( - <Box key={index} className={styles.committeeInputGroup}> - <Box className={styles.committeInputWrapper}> - <Box flexGrow={1}> - <InputController - id={namePath} - name={namePath} - label={f(signatures.inputs.name.label)} - defaultValue={member.name} - backgroundColor="blue" - size="sm" - required={true} - onChange={(e) => - onCommitteMemberChange(index, 'name', e.target.value) - } - error={errors && getErrorViaPath(errors, namePath)} - /> - </Box> - <Box flexGrow={1}> - <InputController - id={belowPath} - name={belowPath} - label={f(signatures.inputs.below.label)} - defaultValue={member.below} - backgroundColor="blue" - size="sm" - onChange={(e) => - onCommitteMemberChange(index, 'below', e.target.value) - } - error={errors && getErrorViaPath(errors, belowPath)} - /> - </Box> - <Box className={styles.removeInputGroup}> - {index > 1 && ( - <Button - variant="utility" - icon="trash" - onClick={() => onRemoveCommitteeMember(index)} - /> - )} - </Box> - </Box> - </Box> - ) - })} - </Box> - - <Box marginTop={2}> - <Button onClick={onAddCommitteeMember} variant="utility" icon="add"> - {f(signatures.buttons.addCommitteeMember)} - </Button> </Box> </Box> - </Box> + <AdditionalSignature + applicationId={applicationId} + name={InputFields.signature.additionalSignature.committee} + /> + </> ) } diff --git a/libs/application/templates/official-journal-of-iceland/src/components/signatures/CommitteeMember.tsx b/libs/application/templates/official-journal-of-iceland/src/components/signatures/CommitteeMember.tsx new file mode 100644 index 000000000000..c153a116f289 --- /dev/null +++ b/libs/application/templates/official-journal-of-iceland/src/components/signatures/CommitteeMember.tsx @@ -0,0 +1,123 @@ +import { Box } from '@island.is/island-ui/core' +import * as styles from './Signatures.css' +import { useApplication } from '../../hooks/useUpdateApplication' +import { useLocale } from '@island.is/localization' +import { signatures } from '../../lib/messages/signatures' +import { InputFields } from '../../lib/types' +import set from 'lodash/set' +import { getCommitteeAnswers, getEmptyMember } from '../../lib/utils' +import { memberItemSchema } from '../../lib/dataSchema' +import { SignatureMember } from './Member' +import * as z from 'zod' +import { RemoveCommitteeMember } from './RemoveComitteeMember' +import { getValueViaPath } from '@island.is/application/core' + +type Props = { + applicationId: string + memberIndex: number + member?: z.infer<typeof memberItemSchema> +} + +type MemberProperties = ReturnType<typeof getEmptyMember> + +export const CommitteeMember = ({ + applicationId, + memberIndex, + member, +}: Props) => { + const { formatMessage: f } = useLocale() + const { debouncedOnUpdateApplicationHandler, application } = useApplication({ + applicationId, + }) + + const handleMemberChange = ( + value: string, + key: keyof MemberProperties, + memberIndex: number, + ) => { + const { signature, currentAnswers } = getCommitteeAnswers( + application.answers, + ) + + if (signature) { + const updatedCommitteeSignature = { + ...signature, + members: signature?.members?.map((m, i) => { + if (i === memberIndex) { + return { + ...m, + [key]: value, + } + } + + return m + }), + } + + const updatedSignatures = set( + currentAnswers, + InputFields.signature.committee, + updatedCommitteeSignature, + ) + + return updatedSignatures + } + + return currentAnswers + } + + if (!member) { + return null + } + + const getMemberCount = () => { + const { signature } = getCommitteeAnswers(application.answers) + + if (signature) { + return signature.members?.length ?? 0 + } + + return 0 + } + + const isLast = memberIndex === getMemberCount() - 1 + + return ( + <Box className={styles.committeeInputGroup}> + <Box + className={ + isLast ? styles.committeInputWrapperLast : styles.committeInputWrapper + } + > + <Box flexGrow={1}> + <SignatureMember + name={`signature.committee.member.name.${memberIndex}`} + label={f(signatures.inputs.name.label)} + defaultValue={member.name} + onChange={(e) => + debouncedOnUpdateApplicationHandler( + handleMemberChange(e.target.value, 'name', memberIndex), + ) + } + /> + </Box> + <Box flexGrow={1}> + <SignatureMember + name={`signature.committee.member.below.${memberIndex}`} + label={f(signatures.inputs.below.label)} + defaultValue={member.below} + onChange={(e) => + debouncedOnUpdateApplicationHandler( + handleMemberChange(e.target.value, 'below', memberIndex), + ) + } + /> + </Box> + <RemoveCommitteeMember + applicationId={applicationId} + memberIndex={memberIndex} + /> + </Box> + </Box> + ) +} diff --git a/libs/application/templates/official-journal-of-iceland/src/components/signatures/Institution.tsx b/libs/application/templates/official-journal-of-iceland/src/components/signatures/Institution.tsx new file mode 100644 index 000000000000..06a94dbf7502 --- /dev/null +++ b/libs/application/templates/official-journal-of-iceland/src/components/signatures/Institution.tsx @@ -0,0 +1,163 @@ +import { + Box, + DatePicker, + Input, + SkeletonLoader, +} from '@island.is/island-ui/core' +import { + OJOI_INPUT_HEIGHT, + SignatureType, + SignatureTypes, +} from '../../lib/constants' +import { useLocale } from '@island.is/localization' +import { signatures } from '../../lib/messages/signatures' +import { useApplication } from '../../hooks/useUpdateApplication' +import set from 'lodash/set' +import { getValueViaPath } from '@island.is/application/core' +import { InputFields } from '../../lib/types' +import * as styles from './Signatures.css' +import { + getCommitteeAnswers, + getRegularAnswers, + getSignatureDefaultValues, + isCommitteeSignature, + isRegularSignature, +} from '../../lib/utils' +import { z } from 'zod' +import { signatureInstitutionSchema } from '../../lib/dataSchema' +import { RemoveRegularSignature } from './RemoveRegularSignature' +type Props = { + applicationId: string + type: SignatureType + signatureIndex?: number +} + +type SignatureInstitutionKeys = z.infer<typeof signatureInstitutionSchema> + +export const InstitutionSignature = ({ + applicationId, + type, + signatureIndex, +}: Props) => { + const { formatMessage: f } = useLocale() + const { + debouncedOnUpdateApplicationHandler, + application, + applicationLoading, + } = useApplication({ + applicationId, + }) + + const handleInstitutionChange = ( + value: string, + key: SignatureInstitutionKeys, + signatureIndex?: number, + ) => { + const { signature, currentAnswers } = + type === SignatureTypes.COMMITTEE + ? getCommitteeAnswers(application.answers) + : getRegularAnswers(application.answers) + + if (isRegularSignature(signature)) { + const updatedRegularSignature = signature?.map((signature, index) => { + if (index === signatureIndex) { + return { + ...signature, + [key]: value, + } + } + + return signature + }) + + const updatedSignatures = set( + currentAnswers, + InputFields.signature[type], + updatedRegularSignature, + ) + + return updatedSignatures + } + + if (isCommitteeSignature(signature)) { + const updatedCommitteeSignature = set( + currentAnswers, + InputFields.signature[type], + { + ...signature, + [key]: value, + }, + ) + + return updatedCommitteeSignature + } + + return currentAnswers + } + + if (applicationLoading) { + return <SkeletonLoader repeat={5} height={OJOI_INPUT_HEIGHT} space={2} /> + } + + const { institution, date } = getSignatureDefaultValues( + getValueViaPath(application.answers, InputFields.signature[type]), + signatureIndex, + ) + + return ( + <Box className={styles.institutionWrapper}> + <Box className={styles.institution}> + <Box flexGrow={1}> + <Input + name={`signature.${type}.institution${ + signatureIndex ? `.${signatureIndex}` : '' + }`} + label={f(signatures.inputs.institution.label)} + placeholder={f(signatures.inputs.institution.placeholder)} + size="sm" + defaultValue={institution} + backgroundColor="blue" + onChange={(e) => + debouncedOnUpdateApplicationHandler( + handleInstitutionChange( + e.target.value, + 'institution', + signatureIndex, + ), + ) + } + /> + </Box> + <Box flexGrow={1}> + <DatePicker + name={`signature.${type}.date${ + signatureIndex ? `.${signatureIndex}` : '' + }`} + label={f(signatures.inputs.date.label)} + placeholderText={f(signatures.inputs.date.placeholder)} + size="sm" + locale="is" + backgroundColor="blue" + selected={date ? new Date(date) : undefined} + handleCloseCalendar={(date) => + date && + debouncedOnUpdateApplicationHandler( + handleInstitutionChange( + date.toISOString(), + 'date', + signatureIndex, + ), + ) + } + /> + </Box> + {signatureIndex !== undefined && ( + <RemoveRegularSignature + applicationId={applicationId} + signatureIndex={signatureIndex} + /> + )} + </Box> + </Box> + ) +} diff --git a/libs/application/templates/official-journal-of-iceland/src/components/signatures/Member.tsx b/libs/application/templates/official-journal-of-iceland/src/components/signatures/Member.tsx new file mode 100644 index 000000000000..fe26e50abf9c --- /dev/null +++ b/libs/application/templates/official-journal-of-iceland/src/components/signatures/Member.tsx @@ -0,0 +1,28 @@ +import { Input } from '@island.is/island-ui/core' + +type Props = { + name: string + label: string + defaultValue?: string + onChange: ( + e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>, + ) => void +} + +export const SignatureMember = ({ + name, + label, + defaultValue, + onChange, +}: Props) => { + return ( + <Input + name={name} + label={label} + size="sm" + backgroundColor="blue" + defaultValue={defaultValue} + onChange={onChange} + /> + ) +} diff --git a/libs/application/templates/official-journal-of-iceland/src/components/signatures/Regular.tsx b/libs/application/templates/official-journal-of-iceland/src/components/signatures/Regular.tsx index 6619c1173d69..0b314fd77ff8 100644 --- a/libs/application/templates/official-journal-of-iceland/src/components/signatures/Regular.tsx +++ b/libs/application/templates/official-journal-of-iceland/src/components/signatures/Regular.tsx @@ -1,296 +1,88 @@ -import { Box, Button, Text } from '@island.is/island-ui/core' +import { Box, Text } from '@island.is/island-ui/core' import * as styles from './Signatures.css' +import { InputFields } from '../../lib/types' +import { useApplication } from '../../hooks/useUpdateApplication' +import { getValueViaPath } from '@island.is/application/core' +import { InstitutionSignature } from './Institution' +import { RegularMember } from './RegularMember' +import { isRegularSignature } from '../../lib/utils' +import { AddRegularMember } from './AddRegularMember' +import { AddRegularSignature } from './AddRegularSignature' +import { AdditionalSignature } from './Additional' +import { signatures as messages } from '../../lib/messages/signatures' import { useLocale } from '@island.is/localization' -import { - InputFields, - OJOIFieldBaseProps, - RegularSignatureState, -} from '../../lib/types' -import cloneDeep from 'lodash/cloneDeep' -import { - INITIAL_ANSWERS, - INSTITUTION_INDEX, - MEMBER_INDEX, -} from '../../lib/constants' -import { getErrorViaPath } from '@island.is/application/core' -import { signatures } from '../../lib/messages/signatures' -import { - DatePickerController, - InputController, -} from '@island.is/shared/form-fields' -type LocalState = typeof INITIAL_ANSWERS['signature'] - -type Props = Pick<OJOIFieldBaseProps, 'errors'> & { - state: LocalState - setState: (state: LocalState) => void - addSignature?: boolean +type Props = { + applicationId: string } -type Institution = NonNullable<RegularSignatureState>[0] - -type InstitutionMember = NonNullable<Institution['members']>[0] - -type MemberKey = keyof InstitutionMember - -type InstitutionKey = keyof Omit<Institution, 'members'> - -export const RegularSignature = ({ state, setState, errors }: Props) => { +export const RegularSignature = ({ applicationId }: Props) => { const { formatMessage: f } = useLocale() - - const onChangeMember = ( - institutionIndex: number, - memberIndex: number, - key: MemberKey, - value: string, - ) => { - const clonedState = cloneDeep(state) - const institution = clonedState.regular.find( - (_, index) => index === institutionIndex, + const { application } = useApplication({ + applicationId, + }) + + const getSignature = () => { + const currentAnswers = getValueViaPath( + application.answers, + InputFields.signature.regular, ) - if (!institution) return - - const member = institution?.members.find( - (_, index) => index === memberIndex, - ) - - if (!member) return - - const updatedMember = { ...member, [key]: value } - institution.members.splice(memberIndex, 1, updatedMember) - clonedState.regular.splice(institutionIndex, 1, institution) - setState(clonedState) + if (isRegularSignature(currentAnswers)) { + return currentAnswers + } } - const onRemoveMember = (institutionIndex: number, memberIndex: number) => { - const clonedState = cloneDeep(state) - const institution = clonedState.regular.find( - (_, index) => index === institutionIndex, - ) + const signatures = getSignature() - if (!institution) return - - institution.members.splice(memberIndex, 1) - clonedState.regular.splice(institutionIndex, 1, institution) - setState(clonedState) - } - - const onAddMember = (institutionIndex: number) => { - const clonedState = cloneDeep(state) - const institution = clonedState.regular.find( - (_, index) => index === institutionIndex, - ) - - if (!institution) return - - institution.members.push({ - above: '', - name: '', - after: '', - below: '', - }) - clonedState.regular.splice(institutionIndex, 1, institution) - setState(clonedState) - } - - const onChangeInstitution = ( - institutionIndex: number, - key: InstitutionKey, - value: string, - ) => { - const clonedState = cloneDeep(state) - const institution = clonedState.regular.find( - (_, index) => index === institutionIndex, - ) - - if (!institution) return - - const updatedInstitution = { ...institution, [key]: value } - clonedState.regular.splice(institutionIndex, 1, updatedInstitution) - setState(clonedState) - } - - const onRemoveInstitution = (institutionIndex: number) => { - const clonedState = cloneDeep(state) - clonedState.regular.splice(institutionIndex, 1) - setState(clonedState) - } - - const onAddInstitution = () => { - const clonedState = cloneDeep(state) - clonedState.regular.push({ - institution: '', - date: '', - members: [ - { - above: '', - name: '', - after: '', - below: '', - }, - ], - }) - setState(clonedState) + if (!signatures) { + return null } return ( - <Box className={styles.signatureWrapper}> - {state.regular.map((institution, index) => { - const institutionPath = - InputFields.signature.regular.institution.replace( - INSTITUTION_INDEX, - `${index}`, - ) - - const datePath = InputFields.signature.regular.date.replace( - INSTITUTION_INDEX, - `${index}`, - ) - - return ( - <Box className={styles.institutionWrapper} key={index}> - <Box className={styles.institution}> - <Box flexGrow={1}> - <InputController - id={institutionPath} - name={institutionPath} - label={f(signatures.inputs.institution.label)} - defaultValue={institution.institution} - backgroundColor="blue" - onChange={(e) => - onChangeInstitution(index, 'institution', e.target.value) - } - error={errors && getErrorViaPath(errors, institutionPath)} - size="sm" - /> - </Box> - <Box flexGrow={1}> - <DatePickerController - id={datePath} - name={datePath} - label={f(signatures.inputs.date.label)} - placeholder={f(signatures.inputs.date.placeholder)} - backgroundColor="blue" - size="sm" - locale="is" - defaultValue={institution.date} - onChange={(date) => onChangeInstitution(index, 'date', date)} - error={errors && getErrorViaPath(errors, datePath)} + <> + <Box> + {signatures?.map((signature, index) => { + return ( + <Box className={styles.signatureContainer}> + <Box key={index} className={styles.signatureWrapper}> + <InstitutionSignature + applicationId={applicationId} + type="regular" + signatureIndex={index} /> - </Box> - <Box className={styles.removeInputGroup}> - {index > 0 && ( - <Button - variant="utility" - icon="trash" - iconType="outline" - onClick={() => onRemoveInstitution(index)} - /> - )} - </Box> - </Box> - <Box className={styles.wrapper}> - <Text variant="h5" marginBottom={2}> - {f(signatures.headings.signedBy)} - </Text> - {institution.members?.map((signature, i) => { - const abovePath = InputFields.signature.regular.members.above - .replace(INSTITUTION_INDEX, `${index}`) - .replace(MEMBER_INDEX, `${i}`) - const namePath = InputFields.signature.regular.members.name - .replace(INSTITUTION_INDEX, `${index}`) - .replace(MEMBER_INDEX, `${i}`) - const afterPath = InputFields.signature.regular.members.after - .replace(INSTITUTION_INDEX, `${index}`) - .replace(MEMBER_INDEX, `${i}`) - const belowPath = InputFields.signature.regular.members.below - .replace(INSTITUTION_INDEX, `${index}`) - .replace(MEMBER_INDEX, `${i}`) - - return ( - <Box className={styles.inputGroup} key={`${index}-${i}`}> - <Box className={styles.inputWrapper}> - <InputController - id={abovePath} - label={f(signatures.inputs.above.label)} - defaultValue={signature.above} - backgroundColor="blue" - size="sm" - onChange={(e) => - onChangeMember(index, i, 'above', e.target.value) - } - error={errors && getErrorViaPath(errors, abovePath)} + <Box className={styles.wrapper}> + <Text variant="h5" marginBottom={2}> + {f(messages.headings.signedBy)} + </Text> + {signature.members?.map((_, memberIndex) => { + const member = signature.members?.at(memberIndex) + return ( + <RegularMember + key={`signature.${index}.member.${memberIndex}`} + applicationId={applicationId} + signatureIndex={index} + memberIndex={memberIndex} + member={member} /> - <InputController - id={afterPath} - label={f(signatures.inputs.after.label)} - defaultValue={signature.after} - backgroundColor="blue" - size="sm" - onChange={(e) => - onChangeMember(index, i, 'after', e.target.value) - } - error={errors && getErrorViaPath(errors, afterPath)} - /> - </Box> - <Box className={styles.inputWrapper}> - <InputController - id={namePath} - label={f(signatures.inputs.name.label)} - defaultValue={signature.name} - backgroundColor="blue" - size="sm" - onChange={(e) => - onChangeMember(index, i, 'name', e.target.value) - } - error={errors && getErrorViaPath(errors, namePath)} - /> - <InputController - id={belowPath} - label={f(signatures.inputs.below.label)} - defaultValue={signature.below} - backgroundColor="blue" - size="sm" - onChange={(e) => - onChangeMember(index, i, 'below', e.target.value) - } - error={errors && getErrorViaPath(errors, belowPath)} - /> - </Box> - <Box className={styles.removeInputGroup}> - {i > 0 && ( - <Button - variant="utility" - icon="trash" - iconType="outline" - onClick={() => onRemoveMember(index, i)} - /> - )} - </Box> - </Box> - ) - })} - <Box marginTop={2}> - <Button - size="small" - icon="add" - variant="utility" - onClick={() => onAddMember(index)} - > - {f(signatures.buttons.addPerson)} - </Button> + ) + })} + <AddRegularMember + applicationId={applicationId} + signatureIndex={index} + /> + </Box> </Box> </Box> - </Box> - ) - })} - <Box marginTop={2}> - <Button variant="utility" icon="add" onClick={onAddInstitution}> - {f(signatures.buttons.addInstitution)} - </Button> + ) + })} </Box> - </Box> + <AddRegularSignature applicationId={applicationId} /> + <AdditionalSignature + applicationId={applicationId} + name={InputFields.signature.additionalSignature.regular} + /> + </> ) } diff --git a/libs/application/templates/official-journal-of-iceland/src/components/signatures/RegularMember.tsx b/libs/application/templates/official-journal-of-iceland/src/components/signatures/RegularMember.tsx new file mode 100644 index 000000000000..01ce1ba0ed42 --- /dev/null +++ b/libs/application/templates/official-journal-of-iceland/src/components/signatures/RegularMember.tsx @@ -0,0 +1,153 @@ +import { Box } from '@island.is/island-ui/core' +import * as styles from './Signatures.css' +import { useApplication } from '../../hooks/useUpdateApplication' +import { useLocale } from '@island.is/localization' +import { signatures } from '../../lib/messages/signatures' +import { InputFields } from '../../lib/types' +import set from 'lodash/set' +import { getEmptyMember, getRegularAnswers } from '../../lib/utils' +import { memberItemSchema } from '../../lib/dataSchema' +import { SignatureMember } from './Member' +import * as z from 'zod' +import { RemoveRegularMember } from './RemoveRegularMember' + +type Props = { + applicationId: string + signatureIndex: number + memberIndex: number + member?: z.infer<typeof memberItemSchema> +} + +type MemberProperties = ReturnType<typeof getEmptyMember> + +export const RegularMember = ({ + applicationId, + signatureIndex, + memberIndex, + member, +}: Props) => { + const { formatMessage: f } = useLocale() + const { debouncedOnUpdateApplicationHandler, application } = useApplication({ + applicationId, + }) + + const handleMemberChange = ( + value: string, + key: keyof MemberProperties, + si: number, + mi: number, + ) => { + const { signature, currentAnswers } = getRegularAnswers(application.answers) + + if (signature) { + const updatedRegularSignature = signature.map((s, index) => { + if (index === si) { + return { + ...s, + members: s.members?.map((member, memberIndex) => { + if (memberIndex === mi) { + return { + ...member, + [key]: value, + } + } + + return member + }), + } + } + + return s + }) + + const updatedSignatures = set( + currentAnswers, + InputFields.signature.regular, + updatedRegularSignature, + ) + + return updatedSignatures + } + + return currentAnswers + } + + if (!member) { + return null + } + + return ( + <Box className={styles.inputGroup}> + <Box className={styles.inputWrapper}> + <SignatureMember + name={`signature.regular.member.above.${signatureIndex}.${memberIndex}`} + label={f(signatures.inputs.above.label)} + defaultValue={member.above} + onChange={(e) => + debouncedOnUpdateApplicationHandler( + handleMemberChange( + e.target.value, + 'above', + signatureIndex, + memberIndex, + ), + ) + } + /> + <SignatureMember + name={`signature.regular.member.after.${signatureIndex}.${memberIndex}`} + label={f(signatures.inputs.after.label)} + defaultValue={member.after} + onChange={(e) => + debouncedOnUpdateApplicationHandler( + handleMemberChange( + e.target.value, + 'after', + signatureIndex, + memberIndex, + ), + ) + } + /> + </Box> + <Box className={styles.inputWrapper}> + <SignatureMember + name={`signature.regular.member.name.${signatureIndex}.${memberIndex}`} + label={f(signatures.inputs.name.label)} + defaultValue={member.name} + onChange={(e) => + debouncedOnUpdateApplicationHandler( + handleMemberChange( + e.target.value, + 'name', + signatureIndex, + memberIndex, + ), + ) + } + /> + <SignatureMember + name={`signature.regular.member.below.${signatureIndex}.${memberIndex}`} + label={f(signatures.inputs.below.label)} + defaultValue={member.below} + onChange={(e) => + debouncedOnUpdateApplicationHandler( + handleMemberChange( + e.target.value, + 'below', + signatureIndex, + memberIndex, + ), + ) + } + /> + </Box> + <RemoveRegularMember + key={`signature.${signatureIndex}.remove.${memberIndex}`} + applicationId={applicationId} + signatureIndex={signatureIndex} + memberIndex={memberIndex} + /> + </Box> + ) +} diff --git a/libs/application/templates/official-journal-of-iceland/src/components/signatures/RemoveComitteeMember.tsx b/libs/application/templates/official-journal-of-iceland/src/components/signatures/RemoveComitteeMember.tsx new file mode 100644 index 000000000000..43d6f6ba4295 --- /dev/null +++ b/libs/application/templates/official-journal-of-iceland/src/components/signatures/RemoveComitteeMember.tsx @@ -0,0 +1,55 @@ +import { Box, Button } from '@island.is/island-ui/core' +import { useApplication } from '../../hooks/useUpdateApplication' +import { InputFields } from '../../lib/types' +import { MINIMUM_COMMITTEE_SIGNATURE_MEMBER_COUNT } from '../../lib/constants' +import set from 'lodash/set' +import * as styles from './Signatures.css' +import { getCommitteeAnswers, isCommitteeSignature } from '../../lib/utils' + +type Props = { + applicationId: string + memberIndex: number +} + +export const RemoveCommitteeMember = ({ + applicationId, + memberIndex, +}: Props) => { + const { updateApplication, application, isLoading } = useApplication({ + applicationId, + }) + + const onRemoveMember = () => { + const { currentAnswers, signature } = getCommitteeAnswers( + application.answers, + ) + + if (isCommitteeSignature(signature)) { + const updatedCommitteeSignature = { + ...signature, + members: signature.members?.filter((_, mi) => mi !== memberIndex), + } + + const updatedAnswers = set( + currentAnswers, + InputFields.signature.committee, + updatedCommitteeSignature, + ) + + updateApplication(updatedAnswers) + } + } + + return ( + <Box className={styles.removeInputGroup}> + <Button + disabled={memberIndex < MINIMUM_COMMITTEE_SIGNATURE_MEMBER_COUNT} + loading={isLoading} + variant="utility" + icon="trash" + iconType="outline" + onClick={() => onRemoveMember()} + /> + </Box> + ) +} diff --git a/libs/application/templates/official-journal-of-iceland/src/components/signatures/RemoveRegularMember.tsx b/libs/application/templates/official-journal-of-iceland/src/components/signatures/RemoveRegularMember.tsx new file mode 100644 index 000000000000..bba44b95ff35 --- /dev/null +++ b/libs/application/templates/official-journal-of-iceland/src/components/signatures/RemoveRegularMember.tsx @@ -0,0 +1,65 @@ +import { Box, Button } from '@island.is/island-ui/core' +import { useApplication } from '../../hooks/useUpdateApplication' +import { InputFields } from '../../lib/types' +import { MINIMUM_REGULAR_SIGNATURE_MEMBER_COUNT } from '../../lib/constants' +import set from 'lodash/set' +import * as styles from './Signatures.css' +import { getRegularAnswers } from '../../lib/utils' + +type Props = { + applicationId: string + signatureIndex: number + memberIndex: number +} + +export const RemoveRegularMember = ({ + applicationId, + signatureIndex, + memberIndex, +}: Props) => { + const { updateApplication, application, isLoading } = useApplication({ + applicationId, + }) + + const onRemoveMember = () => { + const { currentAnswers, signature } = getRegularAnswers(application.answers) + + if (signature) { + const doesSignatureExist = signature.at(signatureIndex) + + if (doesSignatureExist !== undefined) { + const updatedRegularSignature = signature.map((signature, index) => { + if (index === signatureIndex) { + return { + ...signature, + members: signature.members?.filter((_, mi) => mi !== memberIndex), + } + } + + return signature + }) + + const updatedAnswers = set( + currentAnswers, + InputFields.signature.regular, + updatedRegularSignature, + ) + + updateApplication(updatedAnswers) + } + } + } + + return ( + <Box className={styles.removeInputGroup}> + <Button + disabled={memberIndex < MINIMUM_REGULAR_SIGNATURE_MEMBER_COUNT} + loading={isLoading} + variant="utility" + icon="trash" + iconType="outline" + onClick={() => onRemoveMember()} + /> + </Box> + ) +} diff --git a/libs/application/templates/official-journal-of-iceland/src/components/signatures/RemoveRegularSignature.tsx b/libs/application/templates/official-journal-of-iceland/src/components/signatures/RemoveRegularSignature.tsx new file mode 100644 index 000000000000..27daca697399 --- /dev/null +++ b/libs/application/templates/official-journal-of-iceland/src/components/signatures/RemoveRegularSignature.tsx @@ -0,0 +1,53 @@ +import { Button } from '@island.is/island-ui/core' +import { useApplication } from '../../hooks/useUpdateApplication' +import { getValueViaPath } from '@island.is/application/core' +import { InputFields } from '../../lib/types' +import { isRegularSignature } from '../../lib/utils' +import set from 'lodash/set' + +type Props = { + applicationId: string + signatureIndex: number +} + +export const RemoveRegularSignature = ({ + applicationId, + signatureIndex, +}: Props) => { + const { updateApplication, application } = useApplication({ + applicationId, + }) + + const onRemove = () => { + const currentAnswers = structuredClone(application.answers) + const signature = getValueViaPath( + currentAnswers, + InputFields.signature.regular, + ) + + if (isRegularSignature(signature)) { + const updatedRegularSignature = signature?.filter( + (_, index) => index !== signatureIndex, + ) + + const updatedSignatures = set( + currentAnswers, + InputFields.signature.regular, + updatedRegularSignature, + ) + + updateApplication(updatedSignatures) + } + } + + return ( + <Button + variant="utility" + size="small" + icon="trash" + iconType="outline" + disabled={signatureIndex === 0} + onClick={onRemove} + /> + ) +} diff --git a/libs/application/templates/official-journal-of-iceland/src/components/signatures/Signatures.css.ts b/libs/application/templates/official-journal-of-iceland/src/components/signatures/Signatures.css.ts index 400522487171..8350d6d67cf5 100644 --- a/libs/application/templates/official-journal-of-iceland/src/components/signatures/Signatures.css.ts +++ b/libs/application/templates/official-journal-of-iceland/src/components/signatures/Signatures.css.ts @@ -7,9 +7,34 @@ export const tabWrapper = style({ background: theme.color.white, }) +export const signatureContainer = style({ + position: 'relative', + marginBottom: spacing * 2, + selectors: { + '&:last-child': { + marginBottom: 0, + }, + '&::after': { + content: '', + position: 'absolute', + width: '100%', + bottom: -theme.spacing[3], + left: 0, + height: 1, + backgroundColor: theme.color.blue200, + }, + '&:last-child::after': { + display: 'none', + }, + }, +}) + export const signatureWrapper = style({ - paddingBlockStart: spacing * 2, + paddingBlockStart: spacing, background: theme.color.white, + display: 'flex', + flexDirection: 'column', + gap: spacing, }) export const wrapper = style({ @@ -24,7 +49,6 @@ export const institutionWrapper = style({}) export const institution = style({ display: 'flex', gap: spacing, - marginBottom: spacing, '@media': { [`screen and (max-width: ${theme.breakpoints.lg}px)`]: { @@ -47,14 +71,40 @@ export const inputGroup = style({ export const committeeInputGroupWrapper = style({ display: 'flex', flexDirection: 'column', - gap: spacing, }) -export const committeeInputGroup = style({ - display: 'flex', - flexDirection: 'column', - gap: spacing, -}) +export const committeeInputGroup = style( + { + display: 'flex', + flexDirection: 'column', + }, + 'committeeInputGroup', +) + +export const committeInputWrapper = style( + { + display: 'flex', + flexWrap: 'wrap', + justifyContent: 'space-between', + gap: spacing, + marginBottom: theme.spacing[2], + paddingBottom: theme.spacing[2], + borderBottom: `1px solid ${theme.color.blue200}`, + }, + 'committeInputWrapper', +) + +export const committeInputWrapperLast = style( + [ + committeInputWrapper, + { + marginBottom: 0, + paddingBottom: 0, + borderBottom: 'none', + }, + ], + 'committeInputWrapperLast', +) export const inputWrapper = style({ display: 'flex', @@ -64,13 +114,6 @@ export const inputWrapper = style({ gap: spacing, }) -export const committeInputWrapper = style({ - display: 'flex', - flexWrap: 'wrap', - justifyContent: 'space-between', - gap: spacing, -}) - export const removeInputGroup = style({ display: 'flex', flexDirection: 'column', diff --git a/libs/application/templates/official-journal-of-iceland/src/fields/Advert.tsx b/libs/application/templates/official-journal-of-iceland/src/fields/Advert.tsx index 7090806cfaf7..f318dc693d87 100644 --- a/libs/application/templates/official-journal-of-iceland/src/fields/Advert.tsx +++ b/libs/application/templates/official-journal-of-iceland/src/fields/Advert.tsx @@ -1,272 +1,116 @@ -import { useMutation, useLazyQuery } from '@apollo/client' -import { UPDATE_APPLICATION } from '@island.is/application/graphql' -import { useLocale } from '@island.is/localization' -import { useCallback, useEffect, useState } from 'react' -import { ADVERT_QUERY, TYPES_QUERY } from '../graphql/queries' -import { DEBOUNCE_INPUT_TIMER, INITIAL_ANSWERS } from '../lib/constants' -import { - InputFields, - OfficialJournalOfIcelandGraphqlResponse, - OJOIFieldBaseProps, -} from '../lib/types' -import { Box, SkeletonLoader } from '@island.is/island-ui/core' +import { useCallback, useState } from 'react' +import { InputFields, OJOIFieldBaseProps } from '../lib/types' +import { Box } from '@island.is/island-ui/core' import { FormGroup } from '../components/form/FormGroup' import { advert } from '../lib/messages' -import debounce from 'lodash/debounce' -import { HTMLText } from '@island.is/regulations-tools/types' -import { getErrorViaPath } from '@island.is/application/core' -import { - InputController, - SelectController, -} from '@island.is/shared/form-fields' -import { HTMLEditor } from '../components/htmlEditor/HTMLEditor' -import { OfficialJournalOfIcelandAdvert } from '@island.is/api/schema' -import { useFormContext } from 'react-hook-form' import * as styles from './Advert.css' - -type LocalState = typeof INITIAL_ANSWERS['advert'] -type TypeResonse = OfficialJournalOfIcelandGraphqlResponse<'types'> - -type SelectedAdvertResponse = OfficialJournalOfIcelandGraphqlResponse< - 'advert', - OfficialJournalOfIcelandAdvert -> +import { useDepartments } from '../hooks/useDepartments' +import { OJOISelectController } from '../components/input/OJOISelectController' +import { useTypes } from '../hooks/useTypes' +import { OJOIInputController } from '../components/input/OJOIInputController' +import { OJOIHtmlController } from '../components/input/OJOIHtmlController' +import { useFormContext } from 'react-hook-form' +import { useApplication } from '../hooks/useUpdateApplication' type Props = OJOIFieldBaseProps & { - selectedAdvertId: string | null + timeStamp: string } -export const Advert = ({ application, errors, selectedAdvertId }: Props) => { - const { formatMessage: f, locale } = useLocale() - const { answers, externalData } = application - - const inputHeight = 64 - - const departments = externalData.departments.data.departments - .map((d) => { - return { - slug: d.slug, - label: d.title, - value: d.id, - } - }) - .filter((d) => d.slug !== 'tolublod') - +export const Advert = ({ application, timeStamp }: Props) => { const { setValue } = useFormContext() - - const [updateApplication] = useMutation(UPDATE_APPLICATION) - const [lazyTypesQuery, { loading: loadingTypes }] = - useLazyQuery<TypeResonse>(TYPES_QUERY) - - const [lazyAdvertQuery, { loading: loadingAdvert }] = - useLazyQuery<SelectedAdvertResponse>(ADVERT_QUERY) - - const [types, setTypes] = useState<{ label: string; value: string }[]>([]) - - const [state, setState] = useState<LocalState>({ - department: answers.advert?.department ?? '', - type: answers.advert?.type ?? '', - title: answers.advert?.title ?? '', - document: answers.advert?.document ?? '', - subType: answers.advert?.subType ?? '', - template: answers.advert?.template ?? '', + const { application: currentApplication } = useApplication({ + applicationId: application.id, + }) + const { departments, loading: loadingDepartments } = useDepartments() + const { + useLazyTypes, + types, + loading: loadingTypes, + } = useTypes({ + initalDepartmentId: application.answers?.advert?.departmentId, }) - const updateState = useCallback((newState: typeof state) => { - setState((prev) => ({ ...prev, ...newState })) - }, []) - - const updateHandler = useCallback(async () => { - await updateApplication({ - variables: { - locale, - input: { - skipValidation: true, - id: application.id, - answers: { - ...application.answers, - advert: state, - }, - }, - }, - }) - }, [application.answers, application.id, locale, state, updateApplication]) - - useEffect(() => { - updateHandler() - }, [updateHandler]) - - const debouncedStateUpdate = debounce(updateState, DEBOUNCE_INPUT_TIMER) - - useEffect(() => { - const fetchTypes = async () => { - await lazyTypesQuery({ - variables: { - params: { - department: state.department, - }, - }, - onCompleted: (data) => { - return setTypes( - data.officialJournalOfIcelandTypes.types.map((t) => ({ - label: t.title, - value: t.id, - })), - ) + const [localTypeId, setLocalTypeId] = useState<string | undefined>( + application.answers?.advert?.typeId, + ) + const handleDepartmentChange = useCallback( + (value: string) => { + // eslint-disable-next-line react-hooks/rules-of-hooks + useLazyTypes({ + params: { + department: value, + pageSize: 100, }, }) - } - - fetchTypes() - }, [lazyTypesQuery, state.department]) - - useEffect(() => { - const fetchAdvert = async () => { - if (selectedAdvertId) { - const { data } = await lazyAdvertQuery({ - variables: { - params: { - id: selectedAdvertId, - }, - }, - }) - - if (data) { - const { advert } = data.officialJournalOfIcelandAdvert - setState({ - department: advert.department.id, - type: advert.type.id, - title: advert.title, - document: advert.document.html, - subType: '', - template: '', - }) - - setValue(InputFields.advert.department, advert.department.id) - setValue(InputFields.advert.type, advert.type.id) - setValue(InputFields.advert.title, advert.title) - setValue(InputFields.advert.document, advert.document.html) - } - } - } - - if (selectedAdvertId) { - fetchAdvert() - } - }, [lazyAdvertQuery, selectedAdvertId, setValue]) - - if (loadingAdvert) { - return ( - <SkeletonLoader - space={2} - repeat={5} - borderRadius="standard" - display="block" - height={inputHeight} - /> - ) - } + setLocalTypeId(undefined) + }, + [useLazyTypes], + ) return ( <> <FormGroup> <Box className={styles.inputWrapper}> - <SelectController - key={state.department} - id={InputFields.advert.department} - name={InputFields.advert.department} - label={f(advert.inputs.department.label)} - placeholder={f(advert.inputs.department.placeholder)} - options={departments} - defaultValue={state.department} - size="sm" - backgroundColor="blue" - onSelect={(opt) => { - return setState((prev) => ({ - ...prev, - department: opt.value, - type: '', - })) - }} - error={ - errors && getErrorViaPath(errors, InputFields.advert.department) - } + <OJOISelectController + applicationId={application.id} + name={InputFields.advert.departmentId} + label={advert.inputs.department.label} + placeholder={advert.inputs.department.placeholder} + loading={loadingDepartments} + options={departments?.map((d) => ({ + label: d.title, + value: d.id, + }))} + onChange={(value) => handleDepartmentChange(value)} + /> + </Box> + <Box className={styles.inputWrapper}> + <OJOISelectController + applicationId={application.id} + name={InputFields.advert.typeId} + label={advert.inputs.type.label} + placeholder={advert.inputs.type.placeholder} + loading={loadingTypes} + disabled={!types} + defaultValue={localTypeId} + options={types?.map((d) => ({ + label: d.title, + value: d.id, + }))} + onChange={(value) => setLocalTypeId(value)} /> </Box> - {loadingTypes ? ( - <Box className={styles.inputWrapper}> - <SkeletonLoader - borderRadius="standard" - display="block" - height={inputHeight} - /> - </Box> - ) : ( - <Box className={styles.inputWrapper}> - <SelectController - id={InputFields.advert.type} - name={InputFields.advert.type} - label={f(advert.inputs.type.label)} - placeholder={f(advert.inputs.type.placeholder)} - options={types} - defaultValue={state.type} - size="sm" - backgroundColor="blue" - onSelect={(opt) => - setState((prev) => ({ ...prev, type: opt.value })) - } - error={errors && getErrorViaPath(errors, InputFields.advert.type)} - /> - </Box> - )} <Box> - <InputController - id={InputFields.advert.title} + <OJOIInputController + applicationId={application.id} name={InputFields.advert.title} - label={f(advert.inputs.title.label)} - placeholder={f(advert.inputs.title.placeholder)} - defaultValue={state.title} - size="sm" - textarea - rows={4} - backgroundColor="blue" - onChange={(e) => - debouncedStateUpdate({ ...state, title: e.target.value }) - } - error={errors && getErrorViaPath(errors, InputFields.advert.title)} + label={advert.inputs.title.label} + defaultValue={application.answers?.advert?.title} + placeholder={advert.inputs.title.placeholder} + textarea={true} /> </Box> </FormGroup> - <FormGroup title={f(advert.headings.materialForPublication)}> + + <FormGroup title={advert.headings.materialForPublication}> <Box className={styles.inputWrapper}> - <InputController - id={InputFields.advert.template} - name={InputFields.advert.template} - label={f(advert.inputs.template.label)} - placeholder={f(advert.inputs.template.placeholder)} - defaultValue={state.template} - size="sm" - backgroundColor="blue" - onChange={(e) => - debouncedStateUpdate({ - ...state, - template: e.target.value, - }) - } - error={ - errors && getErrorViaPath(errors, InputFields.advert.template) - } + <OJOISelectController + name={InputFields.misc.selectedTemplate} + label={advert.inputs.template.label} + placeholder={advert.inputs.template.placeholder} + applicationId={application.id} + disabled={true} /> </Box> <Box> - <HTMLEditor - title={f(advert.inputs.editor.label)} - name={InputFields.advert.document} - value={state.document as HTMLText} - onChange={(value) => setState({ ...state, document: value })} - error={ - errors && getErrorViaPath(errors, InputFields.advert.document) - } + <OJOIHtmlController + applicationId={application.id} + name={InputFields.advert.html} + defaultValue={currentApplication.answers?.advert?.html} + editorKey={timeStamp} + // we have use setValue from useFormContext to update the value + // because this is not a controlled component + onChange={(value) => setValue(InputFields.advert.html, value)} /> </Box> </FormGroup> diff --git a/libs/application/templates/official-journal-of-iceland/src/fields/AdvertModal.tsx b/libs/application/templates/official-journal-of-iceland/src/fields/AdvertModal.tsx index 60e3251d1dea..ba50ca2999dc 100644 --- a/libs/application/templates/official-journal-of-iceland/src/fields/AdvertModal.tsx +++ b/libs/application/templates/official-journal-of-iceland/src/fields/AdvertModal.tsx @@ -1,4 +1,5 @@ import { + AlertMessage, Box, Button, Icon, @@ -13,54 +14,143 @@ import { import * as styles from './Advert.css' import { useLocale } from '@island.is/localization' import { advert, error, general } from '../lib/messages' -import { useQuery } from '@apollo/client' -import { ADVERTS_QUERY } from '../graphql/queries' +import { useApplication } from '../hooks/useUpdateApplication' +import { useAdverts } from '../hooks/useAdverts' +import { useState } from 'react' +import { + DEBOUNCE_INPUT_TIMER, + DEFAULT_PAGE, + DEFAULT_PAGE_SIZE, + OJOI_INPUT_HEIGHT, +} from '../lib/constants' +import { useAdvert } from '../hooks/useAdvert' import debounce from 'lodash/debounce' -import { DEBOUNCE_INPUT_TIMER } from '../lib/constants' -import { ChangeEvent, ReactNode, useState } from 'react' -import { OfficialJournalOfIcelandGraphqlResponse } from '../lib/types' +import set from 'lodash/set' +import { InputFields } from '../lib/types' +import { useFormContext } from 'react-hook-form' type Props = { + applicationId: string visible: boolean - setVisibility: (visibility: boolean) => void - setSelectedAdvertId: React.Dispatch<React.SetStateAction<string | null>> + setVisible: (visible: boolean) => void + onConfirmChange?: () => void +} + +type UpdateAdvertFields = { + title: string + departmentId: string + typeId: string + html: string + categories: string[] } + export const AdvertModal = ({ + applicationId, visible, - setVisibility, - setSelectedAdvertId, + setVisible, + onConfirmChange, }: Props) => { + const [page, setPage] = useState(DEFAULT_PAGE) + const [search, setSearch] = useState('') + const [selectedAdvertId, setSelectedAdvertId] = useState<string | null>(null) + const { formatMessage: f } = useLocale() + const { setValue } = useFormContext() + const { application, updateApplication } = useApplication({ + applicationId, + }) + + const { adverts, paging, loading } = useAdverts({ + page: page, + search: search, + }) - const [localSelectedAdvertId, setLocalSelectedAdvertId] = useState< - string | null - >(null) + const [updateAdvertFields, setUpdateAdvertFields] = + useState<UpdateAdvertFields | null>(null) - const [page, setPage] = useState(1) - const [search, setSearch] = useState('') + const { loading: loadingAdvert, error: advertError } = useAdvert({ + advertId: selectedAdvertId, + onCompleted: (ad) => { + setUpdateAdvertFields({ + title: ad.title, + departmentId: ad.department.id, + typeId: ad.type.id, + html: ad.document.html, + categories: ad.categories.map((c) => c.id), + }) + }, + }) + + const disableConfirmButton = !selectedAdvertId || !!advertError + + const onSelectAdvert = (advertId: string) => { + setSelectedAdvertId(advertId) + } + + const onSearchChange = (value: string) => { + setSearch(value) + } - const updateSearch = ( - e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>, + const handleSearchChange = ( + e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>, ) => { - setSearch(e.target.value) + debouncedSearch.cancel() + debouncedSearch(e.target.value) } - const { data, loading } = useQuery< - OfficialJournalOfIcelandGraphqlResponse<'adverts'> - >(ADVERTS_QUERY, { - variables: { input: { page: page, search: search, pageSize: 10 } }, - }) + const onConfirm = () => { + if (!updateAdvertFields) { + return + } + + const currentAnswers = structuredClone(application.answers) + + let updatedAnswers = set( + currentAnswers, + InputFields.advert.title, + updateAdvertFields.title, + ) + updatedAnswers = set( + updatedAnswers, + InputFields.advert.departmentId, + updateAdvertFields.departmentId, + ) + updatedAnswers = set( + updatedAnswers, + InputFields.advert.typeId, + updateAdvertFields.typeId, + ) + updatedAnswers = set( + updatedAnswers, + InputFields.advert.html, + updateAdvertFields.html, + ) + updatedAnswers = set( + updatedAnswers, + InputFields.advert.categories, + updateAdvertFields.categories, + ) + + setValue(InputFields.advert.title, updateAdvertFields.title) + setValue(InputFields.advert.departmentId, updateAdvertFields.departmentId) + setValue(InputFields.advert.typeId, updateAdvertFields.typeId) + setValue(InputFields.advert.html, updateAdvertFields.html) + setValue(InputFields.advert.categories, updateAdvertFields.categories) - const debouncedSearch = debounce(updateSearch, DEBOUNCE_INPUT_TIMER) + updateApplication(updatedAnswers) + onConfirmChange && onConfirmChange() + setVisible(false) + } + + const debouncedSearch = debounce(onSearchChange, DEBOUNCE_INPUT_TIMER) return ( <ModalBase baseId="advertModal" - isVisible={visible} className={styles.modalBase} - onVisibilityChange={(visibility) => { - if (!visibility) { - setSearch('') - setVisibility(visibility) + isVisible={visible} + onVisibilityChange={(isVisible) => { + if (!isVisible) { + setVisible(false) } }} > @@ -80,42 +170,53 @@ export const AdvertModal = ({ name="modal-search" backgroundColor="blue" placeholder={f(advert.modal.search)} - onChange={debouncedSearch} + onChange={handleSearchChange} /> </Box> - {data?.officialJournalOfIcelandAdverts.adverts.length ? ( - <Box - paddingY={1} - borderColor="blue200" - borderTopWidth="standard" - borderBottomWidth="standard" - > + {!!advertError && ( + <AlertMessage + type="error" + title={f(error.fetchAdvertFailed)} + message={f(error.fetchAdvertFailedMessage)} + /> + )} + <Box + paddingY={1} + borderColor="blue200" + borderTopWidth="standard" + borderBottomWidth="standard" + > + {loading && ( + <SkeletonLoader + repeat={DEFAULT_PAGE_SIZE} + height={OJOI_INPUT_HEIGHT} + space={1} + borderRadius="standard" + /> + )} + {adverts?.length !== 0 ? ( <Stack space={2} dividers="regular"> - {data.officialJournalOfIcelandAdverts.adverts.map( - (advert, i) => ( - <RadioButton - name={advert.id} - key={i} - label={advert.title} - checked={localSelectedAdvertId === advert.id} - onChange={() => setLocalSelectedAdvertId(advert.id)} - /> - ), - )} + {adverts?.map((advert, i) => ( + <RadioButton + key={i} + name={advert.id} + label={advert.title} + checked={selectedAdvertId === advert.id} + onChange={() => onSelectAdvert(advert.id)} + /> + ))} </Stack> - </Box> - ) : loading ? ( - <SkeletonLoader height={80} repeat={3} space={2} /> - ) : ( - <Text>{f(error.noResults)}</Text> - )} + ) : ( + <Box paddingY={2}> + <Text>{f(error.noResults)}</Text> + </Box> + )} + </Box> </Box> <Box> <Pagination page={page} - totalPages={ - data?.officialJournalOfIcelandAdverts.paging.totalPages - } + totalPages={paging?.totalPages} renderLink={(page, className, children) => ( <Box cursor="pointer" @@ -132,12 +233,9 @@ export const AdvertModal = ({ {f(general.cancel)} </Button> <Button - onClick={() => { - setSelectedAdvertId(localSelectedAdvertId) - setLocalSelectedAdvertId(null) - setSearch('') - setVisibility(false) - }} + disabled={disableConfirmButton} + loading={loadingAdvert} + onClick={onConfirm} > {f(general.confirm)} </Button> diff --git a/libs/application/templates/official-journal-of-iceland/src/fields/Attachments.tsx b/libs/application/templates/official-journal-of-iceland/src/fields/Attachments.tsx index 864cf1b6d285..c2bd139df826 100644 --- a/libs/application/templates/official-journal-of-iceland/src/fields/Attachments.tsx +++ b/libs/application/templates/official-journal-of-iceland/src/fields/Attachments.tsx @@ -1,65 +1,33 @@ -import { Box } from '@island.is/island-ui/core' -import { FormGroup } from '../components/form/FormGroup' -import { UPLOAD_ACCEPT, FILE_SIZE_LIMIT, FileNames } from '../lib/constants' -import { attachments } from '../lib/messages' -import { InputFields, OJOIFieldBaseProps } from '../lib/types' -import { FileUploadController } from '@island.is/application/ui-components' -import { Application } from '@island.is/application/types' -import { RadioController } from '@island.is/shared/form-fields' +import { OJOIFieldBaseProps } from '../lib/types' +import { Box, InputFileUpload } from '@island.is/island-ui/core' +import { useFileUpload } from '../hooks/useFileUpload' +import { ALLOWED_FILE_TYPES, ApplicationAttachmentType } from '../lib/constants' import { useLocale } from '@island.is/localization' -import { getErrorViaPath } from '@island.is/application/core' +import { attachments } from '../lib/messages/attachments' -export const Attachments = ({ application, errors }: OJOIFieldBaseProps) => { +export const Attachments = ({ application }: OJOIFieldBaseProps) => { const { formatMessage: f } = useLocale() - - // TODO: Create wrapper around file upload component to handle file upload + const { files, onChange, onRemove } = useFileUpload({ + applicationId: application.id, + attachmentType: ApplicationAttachmentType.ADDITIONS, + }) return ( - <> - <FormGroup> - <Box width="full"> - <FileUploadController - application={application as unknown as Application} - id={InputFields.attachments.files} - accept={UPLOAD_ACCEPT} - maxSize={FILE_SIZE_LIMIT} - header={f(attachments.inputs.fileUpload.header)} - description={f(attachments.inputs.fileUpload.description)} - buttonLabel={f(attachments.inputs.fileUpload.buttonLabel)} - error={ - errors && getErrorViaPath(errors, InputFields.attachments.files) - } - /> - </Box> - </FormGroup> - <FormGroup title={f(attachments.headings.fileNames)}> - <Box width="full"> - <Box marginBottom={2}> - <RadioController - largeButtons={false} - id={InputFields.attachments.fileNames} - defaultValue={ - application.answers?.attachments?.fileNames || - FileNames.DOCUMENT - } - options={[ - { - value: FileNames.DOCUMENT, - label: f(attachments.inputs.radio.additions.label), - }, - { - value: FileNames.ADDITIONS, - label: f(attachments.inputs.radio.documents.label), - }, - ]} - error={ - errors && - getErrorViaPath(errors, InputFields.attachments.fileNames) - } - /> - </Box> - </Box> - </FormGroup> - </> + <Box> + <InputFileUpload + header={f(attachments.inputs.fileUpload.header)} + description={f(attachments.inputs.fileUpload.description)} + buttonLabel={f(attachments.inputs.fileUpload.buttonLabel)} + fileList={files} + accept={ALLOWED_FILE_TYPES} + onChange={onChange} + onRemove={onRemove} + defaultFileBackgroundColor={{ + background: 'blue100', + border: 'blue200', + icon: 'blue200', + }} + /> + </Box> ) } diff --git a/libs/application/templates/official-journal-of-iceland/src/fields/Comments.tsx b/libs/application/templates/official-journal-of-iceland/src/fields/Comments.tsx index 2106b0d876b1..e5a25426b2fb 100644 --- a/libs/application/templates/official-journal-of-iceland/src/fields/Comments.tsx +++ b/libs/application/templates/official-journal-of-iceland/src/fields/Comments.tsx @@ -1,56 +1,80 @@ -import { AlertMessage, Box, SkeletonLoader } from '@island.is/island-ui/core' +import { + AlertMessage, + Box, + SkeletonLoader, + Stack, +} from '@island.is/island-ui/core' import { OJOIFieldBaseProps } from '../lib/types' import { CommentsList } from '../components/comments/CommentList' import { FormGroup } from '../components/form/FormGroup' import { useComments } from '../hooks/useComments' import { useLocale } from '@island.is/localization' -import { comments as messages } from '../lib/messages/comments' +import { + error as errorMessages, + comments as commentMessages, +} from '../lib/messages' +import { OJOI_INPUT_HEIGHT } from '../lib/constants' +import { AddComment } from '../components/comments/AddComment' export const Comments = ({ application }: OJOIFieldBaseProps) => { const { formatMessage: f } = useLocale() - const { comments, error, loading } = useComments({ + const { comments, loading, error } = useComments({ applicationId: application.id, }) - if (error) { - return ( - <FormGroup> - <AlertMessage - type="error" - title={f(messages.errors.fetchComments)} - message={f(messages.errors.fetchCommentsMessage)} - /> - </FormGroup> - ) - } + const showCommentsList = comments && comments.length > 0 if (loading) { return ( - <FormGroup> - <SkeletonLoader - height={32} - repeat={4} - borderRadius="standard" - space={2} - /> - </FormGroup> + <SkeletonLoader + repeat={3} + height={OJOI_INPUT_HEIGHT} + space={2} + borderRadius="standard" + /> ) } return ( - <FormGroup title="Athugasemdir"> - <Box - display="flex" - flexDirection="column" - rowGap={4} - paddingX={5} - paddingTop={2} - paddingBottom={5} - background="blue100" - > - <CommentsList comments={comments} /> - {/* <AddComment onAddComment={(c) => handleAddComment(c)} /> */} - </Box> + <FormGroup title={f(commentMessages.general.title)}> + <Stack space={2}> + {error && ( + <AlertMessage + type="error" + title={f(errorMessages.fetchCommentsFailedTitle)} + message={f(errorMessages.fetchCommentsFailedMessage)} + /> + )} + {!showCommentsList && ( + <AlertMessage + type="info" + title={f(commentMessages.warnings.noCommentsTitle)} + message={f(commentMessages.warnings.noCommentsMessage)} + /> + )} + {showCommentsList && ( + <Box + display="flex" + flexDirection="column" + rowGap={4} + paddingX={5} + paddingTop={2} + paddingBottom={5} + background="blue100" + > + <CommentsList + comments={comments?.map((comment) => ({ + task: comment.task.title, + comment: comment.task.comment as string, + from: comment.task.from ?? undefined, + date: comment.createdAt, + type: 'received', // TODO: Implement sent comments + }))} + /> + </Box> + )} + <AddComment applicationId={application.id} /> + </Stack> </FormGroup> ) } diff --git a/libs/application/templates/official-journal-of-iceland/src/fields/CommunicationChannels.tsx b/libs/application/templates/official-journal-of-iceland/src/fields/CommunicationChannels.tsx new file mode 100644 index 000000000000..8cad52cf0c78 --- /dev/null +++ b/libs/application/templates/official-journal-of-iceland/src/fields/CommunicationChannels.tsx @@ -0,0 +1,37 @@ +import { useLocale } from '@island.is/localization' +import { OJOIFieldBaseProps } from '../lib/types' +import { FormGroup } from '../components/form/FormGroup' +import { publishing } from '../lib/messages' +import { ChannelList } from '../components/communicationChannels/ChannelList' +import { AddChannel } from '../components/communicationChannels/AddChannel' +import { useState } from 'react' + +export const CommunicationChannels = ({ application }: OJOIFieldBaseProps) => { + const { formatMessage: f } = useLocale() + + const [email, setEmail] = useState<string>('') + const [phone, setPhone] = useState<string>('') + const [isVisible, setIsVisible] = useState(false) + + return ( + <FormGroup + title={f(publishing.headings.communications)} + intro={f(publishing.general.communicationIntro)} + > + <ChannelList + onEditChannel={(email, phone) => { + if (email) setEmail(email) + if (phone) setPhone(phone ?? '') + setIsVisible(true) + }} + applicationId={application.id} + /> + <AddChannel + defaultEmail={email} + defaultPhone={phone} + defaultVisible={isVisible} + applicationId={application.id} + /> + </FormGroup> + ) +} diff --git a/libs/application/templates/official-journal-of-iceland/src/fields/Message.tsx b/libs/application/templates/official-journal-of-iceland/src/fields/Message.tsx new file mode 100644 index 000000000000..f7815955eb2e --- /dev/null +++ b/libs/application/templates/official-journal-of-iceland/src/fields/Message.tsx @@ -0,0 +1,18 @@ +import { FormGroup } from '../components/form/FormGroup' +import { OJOIInputController } from '../components/input/OJOIInputController' +import { publishing } from '../lib/messages' +import { InputFields, OJOIFieldBaseProps } from '../lib/types' + +export const Message = ({ application }: OJOIFieldBaseProps) => { + return ( + <FormGroup title={publishing.headings.messages}> + <OJOIInputController + name={InputFields.advert.message} + label={publishing.inputs.messages.label} + placeholder={publishing.inputs.messages.placeholder} + applicationId={application.id} + textarea + /> + </FormGroup> + ) +} diff --git a/libs/application/templates/official-journal-of-iceland/src/fields/Original.tsx b/libs/application/templates/official-journal-of-iceland/src/fields/Original.tsx index f2e12858f5e2..a3d8a9ac7dd9 100644 --- a/libs/application/templates/official-journal-of-iceland/src/fields/Original.tsx +++ b/libs/application/templates/official-journal-of-iceland/src/fields/Original.tsx @@ -1,29 +1,33 @@ -import { FileUploadController } from '@island.is/application/ui-components' -import { FormGroup } from '../components/form/FormGroup' -import { InputFields, OJOIFieldBaseProps } from '../lib/types' -import { Application } from '@island.is/application/types' -import { FILE_SIZE_LIMIT, UPLOAD_ACCEPT } from '../lib/constants' -import { original } from '../lib/messages' +import { OJOIFieldBaseProps } from '../lib/types' +import { ALLOWED_FILE_TYPES, ApplicationAttachmentType } from '../lib/constants' +import { attachments } from '../lib/messages' import { useLocale } from '@island.is/localization' -import { getErrorViaPath } from '@island.is/application/core' +import { InputFileUpload, Box } from '@island.is/island-ui/core' -export const Original = (props: OJOIFieldBaseProps) => { +import { useFileUpload } from '../hooks/useFileUpload' + +export const Original = ({ application }: OJOIFieldBaseProps) => { const { formatMessage: f } = useLocale() + const { files, onChange, onRemove } = useFileUpload({ + applicationId: application.id, + attachmentType: ApplicationAttachmentType.ORIGINAL, + }) return ( - <FormGroup> - <FileUploadController - application={props.application as unknown as Application} - id={InputFields.original.files} - accept={UPLOAD_ACCEPT} - maxSize={FILE_SIZE_LIMIT} - header={f(original.fileUpload.header)} - description={f(original.fileUpload.description)} - buttonLabel={f(original.fileUpload.buttonLabel)} - error={ - props.errors && - getErrorViaPath(props.errors, InputFields.original.files) - } + <Box> + <InputFileUpload + header={f(attachments.inputs.fileUpload.header)} + description={f(attachments.inputs.fileUpload.description)} + buttonLabel={f(attachments.inputs.fileUpload.buttonLabel)} + fileList={files} + accept={ALLOWED_FILE_TYPES} + onChange={onChange} + onRemove={onRemove} + defaultFileBackgroundColor={{ + background: 'blue100', + border: 'blue200', + icon: 'blue200', + }} /> - </FormGroup> + </Box> ) } diff --git a/libs/application/templates/official-journal-of-iceland/src/fields/Preview.tsx b/libs/application/templates/official-journal-of-iceland/src/fields/Preview.tsx index c018afad1301..7385687ab936 100644 --- a/libs/application/templates/official-journal-of-iceland/src/fields/Preview.tsx +++ b/libs/application/templates/official-journal-of-iceland/src/fields/Preview.tsx @@ -1,45 +1,36 @@ -import { Box, Button, SkeletonLoader } from '@island.is/island-ui/core' +import { + AlertMessage, + Box, + Bullet, + BulletList, + SkeletonLoader, + Stack, + Text, +} from '@island.is/island-ui/core' import { HTMLEditor } from '../components/htmlEditor/HTMLEditor' import { signatureConfig } from '../components/htmlEditor/config/signatureConfig' -import { advertisementTemplate } from '../components/htmlEditor/templates/content' -import { - regularSignatureTemplate, - committeeSignatureTemplate, -} from '../components/htmlEditor/templates/signatures' -import { preview } from '../lib/messages' -import { - OJOIFieldBaseProps, - OfficialJournalOfIcelandGraphqlResponse, -} from '../lib/types' +import { OJOIFieldBaseProps } from '../lib/types' import { useLocale } from '@island.is/localization' -import { useQuery } from '@apollo/client' -import { PDF_QUERY, PDF_URL_QUERY, TYPE_QUERY } from '../graphql/queries' - -export const Preview = (props: OJOIFieldBaseProps) => { - const { formatMessage: f } = useLocale() - const { answers, id } = props.application - const { advert, signature } = answers +import { HTMLText } from '@island.is/regulations-tools/types' +import { getAdvertMarkup, getSignatureMarkup } from '../lib/utils' +import { SignatureTypes } from '../lib/constants' +import { useApplication } from '../hooks/useUpdateApplication' +import { advert, error, preview } from '../lib/messages' +import { useType } from '../hooks/useType' - const { data, loading } = useQuery(TYPE_QUERY, { - variables: { - params: { - id: advert?.type, - }, - }, +export const Preview = ({ application }: OJOIFieldBaseProps) => { + const { application: currentApplication } = useApplication({ + applicationId: application.id, }) - const type = data?.officialJournalOfIcelandType?.type?.title - - const { data: pdfUrlData } = useQuery(PDF_URL_QUERY, { - variables: { - id: id, - }, - }) + const { formatMessage: f } = useLocale() - const { data: pdfData } = useQuery(PDF_QUERY, { - variables: { - id: id, - }, + const { + type, + loading, + error: typeError, + } = useType({ + typeId: currentApplication.answers.advert?.typeId, }) if (loading) { @@ -48,75 +39,63 @@ export const Preview = (props: OJOIFieldBaseProps) => { ) } - const onCopyPreviewLink = () => { - if (!pdfData) { - return - } + const signatureMarkup = getSignatureMarkup({ + signatures: currentApplication.answers.signatures, + type: currentApplication.answers.misc?.signatureType as SignatureTypes, + }) - const url = pdfData.officialJournalOfIcelandApplicationGetPdfUrl.url + const advertMarkup = getAdvertMarkup({ + type: type?.title, + title: currentApplication.answers.advert?.title, + html: currentApplication.answers.advert?.html, + }) - navigator.clipboard.writeText(url) - } + const hasMarkup = + !!currentApplication.answers.advert?.html || + type?.title || + currentApplication.answers.advert?.title - const onOpenPdfPreview = () => { - if (!pdfData) { - return - } - - window.open( - `data:application/pdf,${pdfData.officialJournalOfIcelandApplicationGetPdf.pdf}`, - '_blank', - ) - } + const combinedHtml = hasMarkup + ? (`${advertMarkup}<br />${signatureMarkup}` as HTMLText) + : (`${signatureMarkup}` as HTMLText) return ( - <> - <Box display="flex" columnGap={2}> - {!!pdfUrlData && ( - <Button - onClick={onOpenPdfPreview} - variant="utility" - icon="download" - iconType="outline" - > - {f(preview.buttons.fetchPdf)} - </Button> + <Stack space={4}> + <Stack space={2}> + {typeError && ( + <AlertMessage + type="error" + message={f(error.fetchFailedMessage)} + title={f(error.fetchFailedTitle)} + /> )} - {!!pdfData && ( - <Button - onClick={onCopyPreviewLink} - variant="utility" - icon="link" - iconType="outline" - > - {f(preview.buttons.copyPreviewLink)} - </Button> + {!hasMarkup && ( + <AlertMessage + type="warning" + title={f(preview.errors.noContent)} + message={ + <Stack space={1}> + <Text>{f(error.missingHtmlMessage)}</Text> + <BulletList space={1} color="black"> + <Bullet>{f(advert.inputs.department.label)}</Bullet> + <Bullet>{f(advert.inputs.type.label)}</Bullet> + <Bullet>{f(advert.inputs.title.label)}</Bullet> + <Bullet>{f(advert.inputs.editor.label)}</Bullet> + </BulletList> + </Stack> + } + /> )} - </Box> + </Stack> <Box border="standard" borderRadius="large"> <HTMLEditor name="preview.document" config={signatureConfig} readOnly={true} hideWarnings={true} - value={advertisementTemplate({ - category: type, - content: advert?.document, - title: advert?.title, - signature: - signature?.type === 'regular' - ? regularSignatureTemplate({ - signatureGroups: signature?.regular, - additionalSignature: signature?.additional, - }) - : committeeSignatureTemplate({ - signature: signature?.committee, - additionalSignature: signature?.additional, - }), - readonly: true, - })} + value={combinedHtml} /> </Box> - </> + </Stack> ) } diff --git a/libs/application/templates/official-journal-of-iceland/src/fields/Publishing.tsx b/libs/application/templates/official-journal-of-iceland/src/fields/Publishing.tsx index 5f8ae9da5521..859364996ebf 100644 --- a/libs/application/templates/official-journal-of-iceland/src/fields/Publishing.tsx +++ b/libs/application/templates/official-journal-of-iceland/src/fields/Publishing.tsx @@ -1,296 +1,136 @@ import { useLocale } from '@island.is/localization' import { FormGroup } from '../components/form/FormGroup' +import { InputFields, OJOIFieldBaseProps } from '../lib/types' +import { error, publishing } from '../lib/messages' +import { OJOIDateController } from '../components/input/OJOIDateController' +import { useApplication } from '../hooks/useUpdateApplication' import { - InputFields, - OfficialJournalOfIcelandGraphqlResponse, - OJOIFieldBaseProps, - Override, -} from '../lib/types' -import { publishing } from '../lib/messages' -import { - DEBOUNCE_INPUT_TIMER, - MINIMUM_WEEKDAYS, - INITIAL_ANSWERS, -} from '../lib/constants' -import { useCallback, useEffect, useState } from 'react' -import { - DatePickerController, - InputController, -} from '@island.is/shared/form-fields' -import { getErrorViaPath } from '@island.is/application/core' -import { Box, Icon, Select, Tag } from '@island.is/island-ui/core' + AlertMessage, + Box, + Select, + SkeletonLoader, + Tag, +} from '@island.is/island-ui/core' +import { useCategories } from '../hooks/useCategories' +import { MINIMUM_WEEKDAYS, OJOI_INPUT_HEIGHT } from '../lib/constants' +import set from 'lodash/set' import addYears from 'date-fns/addYears' import { addWeekdays, getWeekendDates } from '../lib/utils' -import { useMutation, useQuery } from '@apollo/client' -import { CATEGORIES_QUERY } from '../graphql/queries' -import { ChannelList } from '../components/communicationChannels/ChannelList' -import { AddChannel } from '../components/communicationChannels/AddChannel' -import { UPDATE_APPLICATION } from '@island.is/application/graphql' -import debounce from 'lodash/debounce' -import { useFormContext } from 'react-hook-form' - -type LocalState = Override< - typeof INITIAL_ANSWERS['publishing'], - { - contentCategories: CategoryOption[] - communicationChannels: Channel[] - } -> -type Channel = { - email: string - phone: string -} +export const Publishing = ({ application }: OJOIFieldBaseProps) => { + const { formatMessage: f } = useLocale() -type CategoryOption = { - label: string - value: string -} + const { application: currentApplication, updateApplication } = useApplication( + { + applicationId: application.id, + }, + ) -export const Publishing = (props: OJOIFieldBaseProps) => { - const { formatMessage: f, locale } = useLocale() - const { application } = props - const { answers } = application + const { + categories, + error: categoryError, + loading: categoryLoading, + } = useCategories() - const today = new Date() - const maxEndDate = addYears(today, 5) - const minDate = new Date() - if (minDate.getHours() >= 12) { - minDate.setDate(minDate.getDate() + 1) + if (categoryLoading) { + return <SkeletonLoader height={OJOI_INPUT_HEIGHT} repeat={2} /> } - const defaultDate = answers.publishing?.date - ? new Date(answers.publishing.date).toISOString().split('T')[0] - : addWeekdays(today, MINIMUM_WEEKDAYS).toISOString().split('T')[0] - - const { setValue, clearErrors } = useFormContext() - - const [channelState, setChannelState] = useState<Channel>({ - email: '', - phone: '', - }) + if (categoryError) { + return ( + <AlertMessage + type="error" + message={f(error.fetchFailedTitle)} + title={f(error.fetchFailedTitle)} + /> + ) + } - const [categories, setCategories] = useState<CategoryOption[]>([]) + const onCategoryChange = (value?: string) => { + if (!value) { + return + } - const [state, setState] = useState<LocalState>({ - date: answers.publishing?.date ?? '', - contentCategories: - answers.publishing?.contentCategories ?? ([] as CategoryOption[]), - communicationChannels: - answers.publishing?.communicationChannels ?? ([] as Channel[]), - message: answers.publishing?.message ?? '', - }) + const currentAnswers = structuredClone(currentApplication.answers) + const selectedCategories = currentAnswers.advert?.categories || [] - const [updateApplication] = useMutation(UPDATE_APPLICATION) + const newCategories = selectedCategories.includes(value) + ? selectedCategories.filter((c) => c !== value) + : [...selectedCategories, value] - useQuery<OfficialJournalOfIcelandGraphqlResponse<'categories'>>( - CATEGORIES_QUERY, - { - variables: { - params: { - pageSize: 1000, - }, - }, - onCompleted: (data) => { - setCategories( - data.officialJournalOfIcelandCategories.categories.map( - (category) => ({ - label: category.title, - value: category.id, - }), - ), - ) - }, - }, - ) - - const onSelect = (opt: CategoryOption) => { - if (!opt.value) return - - const shouldAdd = !state.contentCategories.some( - (category) => category.value === opt.value, + const updatedAnswers = set( + currentAnswers, + InputFields.advert.categories, + newCategories, ) - const updatedCategories = shouldAdd - ? [...state.contentCategories, { label: opt.label, value: opt.value }] - : state.contentCategories.filter( - (category) => category.value !== opt.value, - ) - - setState({ ...state, contentCategories: updatedCategories }) - setValue(InputFields.publishing.contentCategories, updatedCategories) - } - const onEditChannel = (channel: Channel) => { - onRemoveChannel(channel) - setChannelState(channel) + updateApplication(updatedAnswers) } - const onRemoveChannel = (channel: Channel) => { - setState({ - ...state, - communicationChannels: state.communicationChannels.filter( - (c) => c.email !== channel.email, - ), - }) + const defaultCategory = { + label: f(publishing.inputs.contentCategories.placeholder), + value: '', } - const onAddChannel = () => { - if (!channelState.email) return - setState({ - ...state, - communicationChannels: [ - ...state.communicationChannels, - { - email: channelState.email, - phone: channelState.phone, - }, - ], - }) - setChannelState({ email: '', phone: '' }) - } - - const updateHandler = useCallback(async () => { - await updateApplication({ - variables: { - locale, - input: { - skipValidation: true, - id: application.id, - answers: { - ...application.answers, - publishing: state, - }, - }, - }, - }) + const mappedCategories = categories?.map((c) => ({ + label: c.title, + value: c.id, + })) - setValue(InputFields.publishing.date, state.date) - setValue(InputFields.publishing.contentCategories, state.contentCategories) - setValue( - InputFields.publishing.communicationChannels, - state.communicationChannels, - ) - setValue(InputFields.publishing.message, state.message) - }, [ - application.answers, - application.id, - locale, - setValue, - state, - updateApplication, - ]) + const selectedCategories = categories?.filter((c) => + currentApplication.answers.advert?.categories?.includes(c.id), + ) - const updateState = useCallback((newState: typeof state) => { - setState((prev) => ({ ...prev, ...newState })) - }, []) + const today = new Date() + const maxEndDate = addYears(today, 5) + const minDate = new Date() + if (minDate.getHours() >= 12) { + minDate.setDate(minDate.getDate() + 1) + } - useEffect(() => { - updateHandler() - }, [updateHandler]) - const debouncedStateUpdate = debounce(updateState, DEBOUNCE_INPUT_TIMER) + const defaultDate = currentApplication.answers.advert?.requestedDate + ? new Date(currentApplication.answers.advert.requestedDate) + .toISOString() + .split('T')[0] + : addWeekdays(today, MINIMUM_WEEKDAYS).toISOString().split('T')[0] return ( - <> - <FormGroup title={f(publishing.headings.date)}> - <Box width="half"> - <DatePickerController - id={InputFields.publishing.date} - label={f(publishing.inputs.datepicker.label)} - placeholder={f(publishing.inputs.datepicker.placeholder)} - key={defaultDate} - backgroundColor="blue" - size="sm" - locale="is" - defaultValue={defaultDate} - minDate={minDate} - maxDate={maxEndDate} - excludeDates={getWeekendDates(today, maxEndDate)} - onChange={(date) => setState({ ...state, date })} - error={ - props.errors && - getErrorViaPath(props.errors, InputFields.publishing.date) - } - /> - </Box> - <Box width="half"> - <Select - size="sm" - backgroundColor="blue" - id={InputFields.publishing.contentCategories} - name={InputFields.publishing.contentCategories} - label={f(publishing.inputs.contentCategories.label)} - options={categories} - hasError={ - props.errors && - !!getErrorViaPath( - props.errors, - InputFields.publishing.contentCategories, - ) - } - errorMessage={ - props.errors && - getErrorViaPath( - props.errors, - InputFields.publishing.contentCategories, - ) - } - onChange={(opt) => { - if (!opt) return - clearErrors(InputFields.publishing.contentCategories) - onSelect({ label: opt.label, value: opt.value }) - }} - /> - <Box - marginTop={1} - display="flex" - flexWrap="wrap" - rowGap={1} - columnGap={1} - > - {state.contentCategories.map((c, i) => ( - <Tag key={i} outlined onClick={() => onSelect(c)}> - <Box - display="flex" - justifyContent="spaceBetween" - alignItems="center" - columnGap={1} - > - {c.label} - <Icon icon="close" size="small" /> - </Box> - </Tag> - ))} - </Box> - </Box> - </FormGroup> - <FormGroup - title={f(publishing.headings.communications)} - intro={f(publishing.general.communicationIntro)} - > - <ChannelList - channels={state.communicationChannels} - onEditChannel={onEditChannel} - onRemoveChannel={onRemoveChannel} + <FormGroup title={f(publishing.headings.date)}> + <Box width="half"> + <OJOIDateController + name={InputFields.advert.requestedDate} + label={f(publishing.inputs.datepicker.label)} + placeholder={f(publishing.inputs.datepicker.placeholder)} + applicationId={application.id} + excludeDates={getWeekendDates(today, maxEndDate)} + minDate={minDate} + maxDate={maxEndDate} + defaultValue={defaultDate} /> - <AddChannel - state={channelState} - setState={setChannelState} - onAdd={onAddChannel} + </Box> + <Box width="half"> + <Select + size="sm" + label={f(publishing.inputs.contentCategories.label)} + backgroundColor="blue" + defaultValue={defaultCategory} + options={mappedCategories} + onChange={(opt) => onCategoryChange(opt?.value)} /> - </FormGroup> - <FormGroup title={f(publishing.headings.messages)}> - <InputController - label={f(publishing.inputs.messages.label)} - placeholder={f(publishing.inputs.messages.placeholder)} - id={InputFields.publishing.message} - defaultValue={state.message} - onChange={(e) => - debouncedStateUpdate({ ...state, message: e.target.value }) - } - textarea - rows={4} - /> - </FormGroup> - </> + <Box + marginTop={1} + display="flex" + rowGap={1} + columnGap={1} + flexWrap="wrap" + > + {selectedCategories?.map((c) => ( + <Tag onClick={() => onCategoryChange(c.id)} outlined key={c.id}> + {c.title} + </Tag> + ))} + </Box> + </Box> + </FormGroup> ) } diff --git a/libs/application/templates/official-journal-of-iceland/src/fields/Signatures.tsx b/libs/application/templates/official-journal-of-iceland/src/fields/Signatures.tsx index 2174c65700a8..881ad65f48ef 100644 --- a/libs/application/templates/official-journal-of-iceland/src/fields/Signatures.tsx +++ b/libs/application/templates/official-journal-of-iceland/src/fields/Signatures.tsx @@ -2,173 +2,57 @@ import { useLocale } from '@island.is/localization' import { FormGroup } from '../components/form/FormGroup' import { InputFields, OJOIFieldBaseProps } from '../lib/types' import { signatures } from '../lib/messages/signatures' +import { useState } from 'react' +import { SignatureType, SignatureTypes } from '../lib/constants' import { Tabs } from '@island.is/island-ui/core' import { CommitteeSignature } from '../components/signatures/Committee' import { RegularSignature } from '../components/signatures/Regular' -import { useCallback, useEffect, useState } from 'react' -import { useMutation } from '@apollo/client' -import { UPDATE_APPLICATION } from '@island.is/application/graphql' -import { DEBOUNCE_INPUT_TIMER, INITIAL_ANSWERS } from '../lib/constants' -import debounce from 'lodash/debounce' +import { useApplication } from '../hooks/useUpdateApplication' +import set from 'lodash/set' import { HTMLEditor } from '../components/htmlEditor/HTMLEditor' -import { - committeeSignatureTemplate, - regularSignatureTemplate, -} from '../components/htmlEditor/templates/signatures' -import { signatureConfig } from '../components/htmlEditor/config/signatureConfig' -import { useFormContext } from 'react-hook-form' -import { AdditionalSignature } from '../components/signatures/Additional' +import { getSignatureMarkup } from '../lib/utils' -type LocalState = typeof INITIAL_ANSWERS['signature'] - -export const Signatures = ({ application, errors }: OJOIFieldBaseProps) => { - const { formatMessage: f, locale } = useLocale() - - const { answers } = application - - const { setValue } = useFormContext() - - const [updateApplication] = useMutation(UPDATE_APPLICATION) - - const [selectedTab, setSelectedTab] = useState<string>( - answers?.signature?.type ?? 'regular', - ) - - const [state, setState] = useState<LocalState>({ - type: answers?.signature?.type ?? 'regular', - signature: answers?.signature?.signature ?? '', - regular: answers?.signature?.regular ?? [ - { - institution: '', - date: '', - members: [ - { - name: '', - above: '', - after: '', - below: '', - }, - ], - }, - ], - committee: answers?.signature?.committee ?? { - institution: '', - date: '', - chairman: { - name: '', - above: '', - after: '', - below: '', - }, - members: [ - { - below: '', - name: '', - }, - ], +export const Signatures = ({ application }: OJOIFieldBaseProps) => { + const { formatMessage: f } = useLocale() + const { updateApplication, application: currentApplication } = useApplication( + { + applicationId: application.id, }, - additional: answers?.signature?.additional ?? '', - }) - - setValue('signature', state) - - const updateHandler = useCallback(async () => { - await updateApplication({ - variables: { - locale, - input: { - skipValidation: true, - id: application.id, - answers: { - ...application.answers, - signature: { - type: state.type, - signature: state.signature, - regular: state.regular, - committee: state.committee, - additional: state.additional, - }, - }, - }, - }, - }) - }, [application.answers, application.id, locale, state, updateApplication]) - - const updateAdditionalSignature = useCallback((newSignature: string) => { - setState((prev) => { - return { - ...prev, - additional: newSignature, - } - }) - }, []) - - const debouncedAdditionalSignatureUpdate = debounce( - updateAdditionalSignature, - DEBOUNCE_INPUT_TIMER, ) - const updateState = useCallback((newState: typeof state) => { - setState((prev) => { - return { - ...prev, - ...newState, - signature: - newState.type === 'regular' - ? regularSignatureTemplate({ - signatureGroups: newState.regular, - additionalSignature: newState.additional, - }) - : committeeSignatureTemplate({ - signature: newState.committee, - additionalSignature: newState.additional, - }), - } - }) - }, []) - - const debouncedStateUpdate = debounce(updateState, DEBOUNCE_INPUT_TIMER) - - const preview = - selectedTab === 'regular' - ? regularSignatureTemplate({ - signatureGroups: state.regular, - additionalSignature: state.additional, - }) - : committeeSignatureTemplate({ - signature: state.committee, - additionalSignature: state.additional, - }) - - useEffect(() => { - updateHandler() - }, [updateHandler]) + const [selectedTab, setSelectedTab] = useState<SignatureType>( + (application.answers?.misc?.signatureType as SignatureType) ?? + SignatureTypes.REGULAR, + ) const tabs = [ { - id: 'regular', + id: SignatureTypes.REGULAR, label: f(signatures.tabs.regular), - content: ( - <RegularSignature - state={state} - errors={errors} - setState={debouncedStateUpdate} - /> - ), + content: <RegularSignature applicationId={application.id} />, }, { - id: 'committee', + id: SignatureTypes.COMMITTEE, label: f(signatures.tabs.committee), - content: ( - <CommitteeSignature - errors={errors} - state={state} - setState={debouncedStateUpdate} - /> - ), + content: <CommitteeSignature applicationId={application.id} />, }, ] + const onTabChangeHandler = (tabId: string) => { + if (Object.values(SignatureTypes).includes(tabId as SignatureTypes)) { + setSelectedTab(tabId as SignatureType) + + const currentAnswers = structuredClone(application.answers) + const newAnswers = set( + currentAnswers, + InputFields.misc.signatureType, + tabId, + ) + + updateApplication(newAnswers) + } + } + return ( <> <FormGroup @@ -177,28 +61,21 @@ export const Signatures = ({ application, errors }: OJOIFieldBaseProps) => { > <Tabs selected={selectedTab} - onChange={(id) => { - updateState({ ...state, type: id }) - setValue(InputFields.signature.type, id) - setSelectedTab(id) - }} tabs={tabs} label={f(signatures.general.title)} - /> - <AdditionalSignature - application={application} - errors={errors} - setSignature={debouncedAdditionalSignatureUpdate} - signature={state.additional} + contentBackground="white" + onChange={onTabChangeHandler} /> </FormGroup> <FormGroup title={f(signatures.headings.preview)}> <HTMLEditor + name="signaturePreview" key={selectedTab} - value={preview} - config={signatureConfig} + value={getSignatureMarkup({ + signatures: currentApplication.answers.signatures, + type: selectedTab as SignatureTypes, + })} readOnly={true} - name={InputFields.signature.contents} /> </FormGroup> </> diff --git a/libs/application/templates/official-journal-of-iceland/src/fields/Summary.tsx b/libs/application/templates/official-journal-of-iceland/src/fields/Summary.tsx index 8cd186a319f5..975ddc0f87e6 100644 --- a/libs/application/templates/official-journal-of-iceland/src/fields/Summary.tsx +++ b/libs/application/templates/official-journal-of-iceland/src/fields/Summary.tsx @@ -1,81 +1,213 @@ import { useUserInfo } from '@island.is/auth/react' -import { Stack } from '@island.is/island-ui/core' -import { useQuery } from '@apollo/client' -import { Property } from '../components/property/Property' import { - DEPARTMENT_QUERY, - GET_PRICE_QUERY, - TYPE_QUERY, -} from '../graphql/queries' -import { summary } from '../lib/messages' + AlertMessage, + Box, + Bullet, + BulletList, + Stack, + Text, +} from '@island.is/island-ui/core' +import { Property } from '../components/property/Property' +import { advert, error, publishing, summary } from '../lib/messages' import { OJOIFieldBaseProps } from '../lib/types' import { useLocale } from '@island.is/localization' import { MINIMUM_WEEKDAYS } from '../lib/constants' -import { addWeekdays } from '../lib/utils' +import { addWeekdays, parseZodIssue } from '../lib/utils' +import { useCategories } from '../hooks/useCategories' +import { + advertValidationSchema, + publishingValidationSchema, + signatureValidationSchema, +} from '../lib/dataSchema' +import { useApplication } from '../hooks/useUpdateApplication' +import { ZodCustomIssue } from 'zod' +import { useType } from '../hooks/useType' +import { useDepartment } from '../hooks/useDepartment' +import { usePrice } from '../hooks/usePrice' +import { useEffect } from 'react' +import { signatures } from '../lib/messages/signatures' -export const Summary = ({ application }: OJOIFieldBaseProps) => { - const { formatMessage: f, formatDate } = useLocale() +export const Summary = ({ + application, + setSubmitButtonDisabled, +}: OJOIFieldBaseProps) => { + const { formatMessage: f, formatDate, formatNumber } = useLocale() + const { application: currentApplication } = useApplication({ + applicationId: application.id, + }) const user = useUserInfo() - const { answers } = application - - const { data, loading } = useQuery(TYPE_QUERY, { - variables: { - params: { - id: application?.answers?.advert?.type, - }, - }, + const { type, loading: loadingType } = useType({ + typeId: currentApplication.answers.advert?.typeId, }) - const { data: priceData } = useQuery(GET_PRICE_QUERY, { - variables: { id: application.id }, + const { price, loading: loadingPrice } = usePrice({ + applicationId: application.id, }) - const price = - priceData?.officialJournalOfIcelandApplicationGetPrice?.price ?? 0 + const { department, loading: loadingDepartment } = useDepartment({ + departmentId: currentApplication.answers.advert?.departmentId, + }) - const type = data?.officialJournalOfIcelandType?.type?.title + const { categories, loading: loadingCategories } = useCategories() - const { data: department } = useQuery(DEPARTMENT_QUERY, { - variables: { - params: { - id: answers?.advert?.department, - }, - }, - }) + const selectedCategories = categories?.filter((c) => + currentApplication.answers?.advert?.categories?.includes(c.id), + ) const today = new Date() const estimatedDate = addWeekdays(today, MINIMUM_WEEKDAYS) + const advertValidationCheck = advertValidationSchema.safeParse( + currentApplication.answers, + ) + + const signatureValidationCheck = signatureValidationSchema.safeParse({ + signatures: currentApplication.answers.signatures, + misc: currentApplication.answers.misc, + }) + + const publishingCheck = publishingValidationSchema.safeParse( + currentApplication.answers.advert, + ) + + useEffect(() => { + if ( + advertValidationCheck.success && + signatureValidationCheck.success && + publishingCheck.success + ) { + setSubmitButtonDisabled && setSubmitButtonDisabled(false) + } else { + setSubmitButtonDisabled && setSubmitButtonDisabled(true) + } + }, [ + advertValidationCheck, + signatureValidationCheck, + publishingCheck, + setSubmitButtonDisabled, + ]) + return ( - <Stack space={0} dividers> - <Property name={f(summary.properties.sender)} value={user.profile.name} /> - <Property name={f(summary.properties.type)} value={type} /> - <Property - name={f(summary.properties.title)} - value={answers?.advert?.title} - /> - <Property - name={f(summary.properties.department)} - value={department?.officialJournalOfIcelandDepartment.department.title} - /> - <Property - name={f(summary.properties.submissionDate)} - value={new Date().toLocaleDateString()} - /> - <Property - name={f(summary.properties.estimatedDate)} - value={formatDate(estimatedDate)} - /> - <Property name={f(summary.properties.estimatedPrice)} value={price} /> - <Property - name={f(summary.properties.classification)} - value={answers?.publishing?.contentCategories - .map((c) => c.label) - .join(', ')} - /> - </Stack> + <> + <Box + hidden={ + advertValidationCheck.success && + signatureValidationCheck.success && + publishingCheck.success + } + > + <Stack space={2}> + {!advertValidationCheck.success && ( + <AlertMessage + type="warning" + title={f(error.missingFieldsTitle, { + x: f(advert.general.section), + })} + message={ + <BulletList color="black"> + {advertValidationCheck.error.issues.map((issue) => { + const parsedIssue = parseZodIssue(issue as ZodCustomIssue) + return ( + <Bullet key={issue.path.join('.')}> + {f(parsedIssue.message)} + </Bullet> + ) + })} + </BulletList> + } + /> + )} + {!signatureValidationCheck.success && ( + <AlertMessage + type="warning" + title={f(error.missingFieldsTitle, { + x: f(signatures.general.section, { + abbreviation: 'a', + }), + })} + message={ + <Stack space={1}> + <Text> + {f(error.missingSignatureFieldsMessage, { + x: <strong>{f(advert.general.section)}</strong>, + })} + </Text> + <BulletList color="black"> + {signatureValidationCheck.error.issues.map((issue) => { + const parsedIssue = parseZodIssue(issue as ZodCustomIssue) + return ( + <Bullet key={issue.path.join('.')}> + {f(parsedIssue.message)} + </Bullet> + ) + })} + </BulletList> + </Stack> + } + /> + )} + {!publishingCheck.success && ( + <AlertMessage + type="warning" + title={f(error.missingFieldsTitle)} + message={ + <BulletList color="black"> + {publishingCheck.error.issues.map((issue) => { + const parsedIssue = parseZodIssue(issue as ZodCustomIssue) + return ( + <Bullet key={issue.path.join('.')}> + {f(parsedIssue.message)} + </Bullet> + ) + })} + </BulletList> + } + /> + )} + </Stack> + </Box> + + <Stack space={0} dividers> + <Property + name={f(summary.properties.sender)} + value={user.profile.name} + /> + <Property + loading={loadingType} + name={f(summary.properties.type)} + value={type?.title} + /> + <Property + name={f(summary.properties.title)} + value={currentApplication.answers?.advert?.title} + /> + <Property + loading={loadingDepartment} + name={f(summary.properties.department)} + value={department?.title} + /> + <Property + name={f(summary.properties.submissionDate)} + value={new Date().toLocaleDateString()} + /> + <Property + name={f(summary.properties.estimatedDate)} + value={formatDate(estimatedDate)} + /> + <Property + loading={loadingPrice} + name={f(summary.properties.estimatedPrice)} + value={`${formatNumber(price)}. kr`} + /> + <Property + loading={loadingCategories} + name={f(summary.properties.classification)} + value={selectedCategories?.map((c) => c.title).join(', ')} + /> + </Stack> + </> ) } diff --git a/libs/application/templates/official-journal-of-iceland/src/graphql/queries.ts b/libs/application/templates/official-journal-of-iceland/src/graphql/queries.ts index 92555efea10d..49fb20dcc52b 100644 --- a/libs/application/templates/official-journal-of-iceland/src/graphql/queries.ts +++ b/libs/application/templates/official-journal-of-iceland/src/graphql/queries.ts @@ -242,10 +242,83 @@ export const PDF_URL_QUERY = gql` } ` -export const PDF_QUERY = gql` - query PdfDocument($id: String!) { - officialJournalOfIcelandApplicationGetPdf(id: $id) { - pdf +export const GET_PRESIGNED_URL_MUTATION = gql` + mutation GetPresignedUrl( + $input: OfficialJournalOfIcelandApplicationGetPresignedUrlInput! + ) { + officialJournalOfIcelandApplicationGetPresignedUrl(input: $input) { + url + } + } +` + +export const ADD_APPLICATION_ATTACHMENT_MUTATION = gql` + mutation AddApplicationAttachment( + $input: OfficialJournalOfIcelandApplicationAddApplicationAttachmentInput! + ) { + officialJournalOfIcelandApplicationAddAttachment(input: $input) { + success + } + } +` + +export const GET_APPLICATION_ATTACHMENTS_QUERY = gql` + query OfficialJournalOfIcelandApplicationGetAttachments( + $input: OfficialJournalOfIcelandApplicationGetApplicationAttachmentInput! + ) { + officialJournalOfIcelandApplicationGetAttachments(input: $input) { + attachments { + id + originalFileName + fileName + fileFormat + fileExtension + fileLocation + fileSize + } + } + } +` + +export const DELETE_APPLICATION_ATTACHMENT_MUTATION = gql` + mutation DeleteApplicationAttachment( + $input: OfficialJournalOfIcelandApplicationDeleteApplicationAttachmentInput! + ) { + officialJournalOfIcelandApplicationDeleteAttachment(input: $input) { + success + } + } +` + +export const GET_COMMENTS_QUERY = gql` + query GetComments( + $input: OfficialJournalOfIcelandApplicationGetCommentsInput! + ) { + officialJournalOfIcelandApplicationGetComments(input: $input) { + comments { + id + createdAt + internal + type + caseStatus + state + task { + from + to + title + comment + } + } + } + } +` + +export const POST_COMMENT_MUTATION = gql` + mutation AddComment( + $input: OfficialJournalOfIcelandApplicationPostCommentInput! + ) { + officialJournalOfIcelandApplicationPostComment(input: $input) { + success } } ` diff --git a/libs/application/templates/official-journal-of-iceland/src/hooks/useAdvert.ts b/libs/application/templates/official-journal-of-iceland/src/hooks/useAdvert.ts new file mode 100644 index 000000000000..a2882dab7a46 --- /dev/null +++ b/libs/application/templates/official-journal-of-iceland/src/hooks/useAdvert.ts @@ -0,0 +1,37 @@ +import { useQuery } from '@apollo/client' +import { ADVERT_QUERY } from '../graphql/queries' +import { + OfficialJournalOfIcelandAdvert, + OfficialJournalOfIcelandAdvertResponse, +} from '@island.is/api/schema' + +type AdvertResponse = { + officialJournalOfIcelandAdvert: OfficialJournalOfIcelandAdvertResponse +} + +type Props = { + advertId: string | undefined | null + onCompleted?: (data: OfficialJournalOfIcelandAdvert) => void +} + +export const useAdvert = ({ advertId, onCompleted }: Props) => { + const { data, error, loading } = useQuery<AdvertResponse>(ADVERT_QUERY, { + skip: !advertId, + variables: { + params: { + id: advertId, + }, + }, + onCompleted: (data) => { + if (onCompleted) { + onCompleted(data.officialJournalOfIcelandAdvert.advert) + } + }, + }) + + return { + advert: data?.officialJournalOfIcelandAdvert, + error, + loading, + } +} diff --git a/libs/application/templates/official-journal-of-iceland/src/hooks/useAdverts.ts b/libs/application/templates/official-journal-of-iceland/src/hooks/useAdverts.ts new file mode 100644 index 000000000000..561491768aaf --- /dev/null +++ b/libs/application/templates/official-journal-of-iceland/src/hooks/useAdverts.ts @@ -0,0 +1,67 @@ +import { useQuery } from '@apollo/client' +import { DEFAULT_PAGE_SIZE, DEFAULT_PAGE } from '../lib/constants' +import { ADVERTS_QUERY } from '../graphql/queries' +import { OfficialJournalOfIcelandAdvertsResponse } from '@island.is/api/schema' + +/** + * Fetches adverts from the API + * @param page - The page number + * @param pageSize - The number of items per page + * @param search - The search query + * @param department - The slug of the deparments to filter by + * @param type - The slug of the types to filter by + * @param category - The slug of the categories to filter by + * @param involvedParty - The slug of the involved parties to filter by + * @param dateFrom - The date to filter from + * @param dateTo - The date to filter to + */ +type Props = { + search?: string + page?: number + pageSize?: number + department?: string[] + type?: string[] + category?: string[] + involvedParty?: string[] + dateFrom?: Date + dateTo?: Date +} + +type AdvertsResponse = { + officialJournalOfIcelandAdverts: OfficialJournalOfIcelandAdvertsResponse +} + +export const useAdverts = ({ + page = DEFAULT_PAGE, + pageSize = DEFAULT_PAGE_SIZE, + search, + department, + type, + category, + involvedParty, + dateFrom, + dateTo, +}: Props) => { + const { data, loading, error } = useQuery<AdvertsResponse>(ADVERTS_QUERY, { + variables: { + input: { + page, + search, + pageSize, + department, + type, + category, + involvedParty, + dateFrom, + dateTo, + }, + }, + }) + + return { + adverts: data?.officialJournalOfIcelandAdverts.adverts, + paging: data?.officialJournalOfIcelandAdverts.paging, + loading, + error, + } +} diff --git a/libs/application/templates/official-journal-of-iceland/src/hooks/useCategories.ts b/libs/application/templates/official-journal-of-iceland/src/hooks/useCategories.ts new file mode 100644 index 000000000000..bb454d63eb5d --- /dev/null +++ b/libs/application/templates/official-journal-of-iceland/src/hooks/useCategories.ts @@ -0,0 +1,26 @@ +import { useQuery } from '@apollo/client' +import { CATEGORIES_QUERY } from '../graphql/queries' +import { OfficialJournalOfIcelandAdvertsCategoryResponse } from '@island.is/api/schema' + +type CategoriesResponse = { + officialJournalOfIcelandCategories: OfficialJournalOfIcelandAdvertsCategoryResponse +} + +export const useCategories = () => { + const { data, loading, error } = useQuery<CategoriesResponse>( + CATEGORIES_QUERY, + { + variables: { + params: { + pageSize: 1000, + }, + }, + }, + ) + + return { + categories: data?.officialJournalOfIcelandCategories.categories, + loading, + error, + } +} diff --git a/libs/application/templates/official-journal-of-iceland/src/hooks/useComments.ts b/libs/application/templates/official-journal-of-iceland/src/hooks/useComments.ts index d3d3485748c4..76f99f5c6889 100644 --- a/libs/application/templates/official-journal-of-iceland/src/hooks/useComments.ts +++ b/libs/application/templates/official-journal-of-iceland/src/hooks/useComments.ts @@ -1,21 +1,69 @@ -import { useQuery } from '@apollo/client' -import { GET_APPLICATION_COMMENTS_QUERY } from '../graphql/queries' +import { useMutation, useQuery } from '@apollo/client' +import { OfficialJournalOfIcelandApplicationGetCommentsResponse } from '@island.is/api/schema' +import { POST_COMMENT_MUTATION, GET_COMMENTS_QUERY } from '../graphql/queries' type Props = { applicationId: string } + +type CommentsResponse = { + officialJournalOfIcelandApplicationGetComments: OfficialJournalOfIcelandApplicationGetCommentsResponse +} + +type AddCommentVariables = { + comment: string +} + +type PostCommentResponse = { + officialJournalOfIcelandApplicationPostComment: { + success: boolean + } +} + export const useComments = ({ applicationId }: Props) => { - const { data, loading, error } = useQuery(GET_APPLICATION_COMMENTS_QUERY, { - variables: { - input: { - id: applicationId, + const { data, loading, error, refetch } = useQuery<CommentsResponse>( + GET_COMMENTS_QUERY, + { + variables: { + input: { + id: applicationId, + }, }, }, + ) + + const [ + addCommentMutation, + { + data: addCommentSuccess, + loading: addCommentLoading, + error: addCommentError, + }, + ] = useMutation<PostCommentResponse>(POST_COMMENT_MUTATION, { + onCompleted: () => { + refetch() + }, }) + const addComment = (variables: AddCommentVariables) => { + addCommentMutation({ + variables: { + input: { + id: applicationId, + comment: variables.comment, + }, + }, + }) + } + return { comments: data?.officialJournalOfIcelandApplicationGetComments.comments, loading, error, + addComment, + addCommentLoading, + addCommentError, + addCommentSuccess: + addCommentSuccess?.officialJournalOfIcelandApplicationPostComment.success, } } diff --git a/libs/application/templates/official-journal-of-iceland/src/hooks/useDepartment.ts b/libs/application/templates/official-journal-of-iceland/src/hooks/useDepartment.ts new file mode 100644 index 000000000000..ee9b89e91220 --- /dev/null +++ b/libs/application/templates/official-journal-of-iceland/src/hooks/useDepartment.ts @@ -0,0 +1,30 @@ +import { useQuery } from '@apollo/client' +import { OfficialJournalOfIcelandAdvertEntity } from '@island.is/api/schema' +import { DEPARTMENT_QUERY } from '../graphql/queries' + +type Props = { + departmentId?: string +} + +type DepartmentResponse = { + department: OfficialJournalOfIcelandAdvertEntity +} + +export const useDepartment = ({ departmentId }: Props) => { + const { data, loading, error } = useQuery<{ + officialJournalOfIcelandDepartment: DepartmentResponse + }>(DEPARTMENT_QUERY, { + skip: !departmentId, + variables: { + params: { + id: departmentId, + }, + }, + }) + + return { + department: data?.officialJournalOfIcelandDepartment?.department, + loading, + error, + } +} diff --git a/libs/application/templates/official-journal-of-iceland/src/hooks/useDepartments.ts b/libs/application/templates/official-journal-of-iceland/src/hooks/useDepartments.ts new file mode 100644 index 000000000000..dee972356a9b --- /dev/null +++ b/libs/application/templates/official-journal-of-iceland/src/hooks/useDepartments.ts @@ -0,0 +1,26 @@ +import { useQuery } from '@apollo/client' +import { DEPARTMENTS_QUERY } from '../graphql/queries' +import { OfficialJournalOfIcelandAdvertsDepartmentsResponse } from '@island.is/api/schema' + +type DepartmentsResponse = { + officialJournalOfIcelandDepartments: OfficialJournalOfIcelandAdvertsDepartmentsResponse +} + +export const useDepartments = () => { + const { data, loading, error } = useQuery<DepartmentsResponse>( + DEPARTMENTS_QUERY, + { + variables: { + params: { + page: 1, + }, + }, + }, + ) + + return { + departments: data?.officialJournalOfIcelandDepartments.departments, + loading, + error, + } +} diff --git a/libs/application/templates/official-journal-of-iceland/src/hooks/useFileUpload.ts b/libs/application/templates/official-journal-of-iceland/src/hooks/useFileUpload.ts new file mode 100644 index 000000000000..836ca2ada877 --- /dev/null +++ b/libs/application/templates/official-journal-of-iceland/src/hooks/useFileUpload.ts @@ -0,0 +1,227 @@ +import { UploadFile } from '@island.is/island-ui/core' +import { + ADD_APPLICATION_ATTACHMENT_MUTATION, + DELETE_APPLICATION_ATTACHMENT_MUTATION, + GET_APPLICATION_ATTACHMENTS_QUERY, + GET_PRESIGNED_URL_MUTATION, +} from '../graphql/queries' +import { useMutation, useQuery } from '@apollo/client' +import { useState } from 'react' +import { ApplicationAttachmentType } from '../lib/constants' + +/** + * + * @param applicationId id of the application + * @param attachmentType type of the attachment used for constructing the presigned URL key + */ +type UseFileUploadProps = { + applicationId: string + attachmentType: ApplicationAttachmentType +} + +type GetPresignedUrlResponse = { + url: string +} + +type AddAttachmentResponse = { + success: boolean +} + +type ApplicationAttachment = { + id: string + fileName: string + originalFileName: string + fileFormat: string + fileExtension: string + fileLocation: string + fileSize: number +} + +type GetAttachmentsResponse = { + attachments: ApplicationAttachment[] +} + +/** + * Hook for uploading files to S3 + * @param props UseFileUploadProps + * @param props.applicationId id of the application + * @param props.attachmentType type of the attachment used for constructing the presigned URL key + */ +export const useFileUpload = ({ + applicationId, + attachmentType, +}: UseFileUploadProps) => { + const [files, setFiles] = useState<UploadFile[]>([]) + + const [getPresignedUrlMutation] = useMutation<{ + officialJournalOfIcelandApplicationGetPresignedUrl: GetPresignedUrlResponse + }>(GET_PRESIGNED_URL_MUTATION) + + const [addApplicationMutation] = useMutation<{ + officialJournalOfIcelandApplicationAddAttachment: AddAttachmentResponse + }>(ADD_APPLICATION_ATTACHMENT_MUTATION, { + onCompleted() { + refetch() + }, + }) + + const { refetch } = useQuery<{ + officialJournalOfIcelandApplicationGetAttachments: GetAttachmentsResponse + }>(GET_APPLICATION_ATTACHMENTS_QUERY, { + variables: { + input: { + applicationId: applicationId, + attachmentType: attachmentType, + }, + }, + fetchPolicy: 'no-cache', + onCompleted(data) { + const currentFiles = + data.officialJournalOfIcelandApplicationGetAttachments.attachments.map( + (attachment) => + ({ + name: attachment.originalFileName, + size: attachment.fileSize, + type: attachment.fileFormat, + key: attachment.fileLocation, + status: 'done', + } as UploadFile), + ) + setFiles((prevFiles) => [...prevFiles, ...currentFiles]) + }, + onError() { + setFiles([]) + }, + }) + + const [deleteApplicationAttachmentMutation] = useMutation<{ + officialJournalOfIcelandApplicationDeleteAttachment: AddAttachmentResponse + }>(DELETE_APPLICATION_ATTACHMENT_MUTATION, { + onCompleted() { + refetch() + }, + }) + + /** + * + * @param newFiles comes from the onChange function on the fileInput component + */ + const onChange = (newFiles: UploadFile[]) => { + newFiles.forEach(async (file) => { + const type = file?.type?.split('/')[1] + const name = file?.name?.split('.').slice(0, -1).join('.') + + if (!type || !name) { + return + } + + const url = await getPresignedUrl(name, type) + + if (!url) { + file.status = 'error' + return + } + + const loc = new URL(url).pathname + + uploadToS3(url, file as File) + addApplicationAttachments(loc, file as File) + + file.key = loc + + setFiles((prevFiles) => [...prevFiles, file]) + }) + } + + /** + * Deletes the file from the database and S3 + */ + const onRemove = async (file: UploadFile) => { + deleteApplicationAttachmentMutation({ + variables: { + input: { + applicationId: applicationId, + key: file.key, + }, + }, + }) + + setFiles(files.filter((f) => f.key !== file.key)) + } + + /** + * Gets a presigned URL for a file + * @param name name of the file ex. myFile + * @param type type of the file ex. pdf, doc, docx... + * @returns + */ + const getPresignedUrl = async (name: string, type: string) => { + const { data } = await getPresignedUrlMutation({ + variables: { + input: { + attachmentType: attachmentType, + applicationId: applicationId, + fileName: name, + fileType: type, + }, + }, + }) + + return data?.officialJournalOfIcelandApplicationGetPresignedUrl.url + } + + /** + * Uploads a file to S3 using a presigned URL + * Used when a presigned URL has been successfully retrieved + * @param preSignedUrl presigned URL + * @param file file to upload + * @param onSuccess callback function to run on success + */ + const uploadToS3 = async (preSignedUrl: string, file: File) => { + await fetch(preSignedUrl, { + headers: { + 'Content-Type': file.type, + 'Content-Length': file.size.toString(), + }, + method: 'PUT', + body: file, + }) + } + + /** + * Adds a record in the database for the uploaded file with the presigned URL. + * Used after the file has been successfully uploaded to S3 + * @param url presigned URL + * @param file file to upload + */ + const addApplicationAttachments = (url: string, file: UploadFile) => { + const type = file?.type?.split('/')[1] + const name = file?.name?.split('.').slice(0, -1).join('.') + if (!type || !name) { + return + } + + addApplicationMutation({ + variables: { + input: { + applicationId: applicationId, + attachmentType: attachmentType, + fileName: name, + originalFileName: file.name, + fileFormat: type, + fileExtension: type, + fileLocation: url, + fileSize: file.size, + }, + }, + onCompleted() { + file.status = 'done' + }, + onError() { + file.status = 'error' + }, + }) + } + + return { files, onChange, onRemove } +} diff --git a/libs/application/templates/official-journal-of-iceland/src/hooks/usePrice.ts b/libs/application/templates/official-journal-of-iceland/src/hooks/usePrice.ts new file mode 100644 index 000000000000..03551902f72e --- /dev/null +++ b/libs/application/templates/official-journal-of-iceland/src/hooks/usePrice.ts @@ -0,0 +1,24 @@ +import { useQuery } from '@apollo/client' +import { GET_PRICE_QUERY } from '../graphql/queries' +import { OfficialJournalOfIcelandApplicationGetPriceResponse } from '@island.is/api/schema' + +type Props = { + applicationId: string +} + +export const usePrice = ({ applicationId }: Props) => { + const { data, loading, error } = useQuery<{ + officialJournalOfIcelandApplicationGetPrice: OfficialJournalOfIcelandApplicationGetPriceResponse + }>(GET_PRICE_QUERY, { + skip: !applicationId, + variables: { + id: applicationId, + }, + }) + + return { + price: data?.officialJournalOfIcelandApplicationGetPrice.price ?? 0, + loading, + error, + } +} diff --git a/libs/application/templates/official-journal-of-iceland/src/hooks/useType.ts b/libs/application/templates/official-journal-of-iceland/src/hooks/useType.ts new file mode 100644 index 000000000000..40930127c1c7 --- /dev/null +++ b/libs/application/templates/official-journal-of-iceland/src/hooks/useType.ts @@ -0,0 +1,30 @@ +import { useQuery } from '@apollo/client' +import { TYPE_QUERY } from '../graphql/queries' +import { OfficialJournalOfIcelandAdvertType } from '@island.is/api/schema' + +type Props = { + typeId?: string +} + +type TypeResponse = { + type: OfficialJournalOfIcelandAdvertType +} + +export const useType = ({ typeId }: Props) => { + const { data, loading, error } = useQuery<{ + officialJournalOfIcelandType: TypeResponse + }>(TYPE_QUERY, { + skip: !typeId, + variables: { + params: { + id: typeId, + }, + }, + }) + + return { + type: data?.officialJournalOfIcelandType?.type, + loading, + error, + } +} diff --git a/libs/application/templates/official-journal-of-iceland/src/hooks/useTypes.ts b/libs/application/templates/official-journal-of-iceland/src/hooks/useTypes.ts new file mode 100644 index 000000000000..61d05bb9a6c7 --- /dev/null +++ b/libs/application/templates/official-journal-of-iceland/src/hooks/useTypes.ts @@ -0,0 +1,45 @@ +import { NetworkStatus, useQuery } from '@apollo/client' +import { OfficialJournalOfIcelandAdvertsTypesResponse } from '@island.is/api/schema' + +import { TYPES_QUERY } from '../graphql/queries' + +type UseTypesParams = { + initalDepartmentId?: string +} + +type TypesResponse = { + officialJournalOfIcelandTypes: OfficialJournalOfIcelandAdvertsTypesResponse +} + +type TypesVariables = { + params: { + department: string + page?: number + pageSize?: number + } +} + +export const useTypes = ({ + initalDepartmentId: departmentId, +}: UseTypesParams) => { + const { data, loading, error, refetch, networkStatus } = useQuery< + TypesResponse, + TypesVariables + >(TYPES_QUERY, { + variables: { + params: { + department: departmentId ?? '', + page: 1, + pageSize: 1000, + }, + }, + notifyOnNetworkStatusChange: true, + }) + + return { + useLazyTypes: refetch, + types: data?.officialJournalOfIcelandTypes.types, + loading: loading || networkStatus === NetworkStatus.refetch, + error, + } +} diff --git a/libs/application/templates/official-journal-of-iceland/src/hooks/useUpdateApplication.ts b/libs/application/templates/official-journal-of-iceland/src/hooks/useUpdateApplication.ts new file mode 100644 index 000000000000..473c12aac10b --- /dev/null +++ b/libs/application/templates/official-journal-of-iceland/src/hooks/useUpdateApplication.ts @@ -0,0 +1,79 @@ +import { useMutation, useQuery } from '@apollo/client' +import { + UPDATE_APPLICATION, + APPLICATION_APPLICATION, +} from '@island.is/application/graphql' +import { useLocale } from '@island.is/localization' +import { partialSchema } from '../lib/dataSchema' +import { OJOIApplication } from '../lib/types' +import debounce from 'lodash/debounce' +import { DEBOUNCE_INPUT_TIMER } from '../lib/constants' + +type OJOIUseApplicationParams = { + applicationId?: string +} + +export const useApplication = ({ applicationId }: OJOIUseApplicationParams) => { + const { locale } = useLocale() + + const { + data: application, + loading: applicationLoading, + error: applicationError, + refetch: refetchApplication, + } = useQuery(APPLICATION_APPLICATION, { + variables: { + locale: locale, + input: { + id: applicationId, + }, + }, + }) + + const [ + mutation, + { data: updateData, loading: updateLoading, error: updateError }, + ] = useMutation(UPDATE_APPLICATION) + + const updateApplication = async (input: partialSchema, cb?: () => void) => { + await mutation({ + variables: { + locale, + input: { + id: applicationId, + answers: { + ...input, + }, + }, + }, + }) + + cb && cb() + } + + const debouncedUpdateApplication = debounce( + updateApplication, + DEBOUNCE_INPUT_TIMER, + ) + + const debouncedOnUpdateApplicationHandler = ( + input: partialSchema, + cb?: () => void, + ) => { + debouncedUpdateApplication.cancel() + debouncedUpdateApplication(input, cb) + } + + return { + application: application?.applicationApplication as OJOIApplication, + applicationLoading, + applicationError, + updateData, + updateLoading, + updateError, + isLoading: applicationLoading || updateLoading, + debouncedOnUpdateApplicationHandler, + updateApplication, + refetchApplication, + } +} diff --git a/libs/application/templates/official-journal-of-iceland/src/lib/OJOIApplication.ts b/libs/application/templates/official-journal-of-iceland/src/lib/OJOIApplication.ts index f68c75a2a6e1..0c479ddd9e1f 100644 --- a/libs/application/templates/official-journal-of-iceland/src/lib/OJOIApplication.ts +++ b/libs/application/templates/official-journal-of-iceland/src/lib/OJOIApplication.ts @@ -11,7 +11,7 @@ import { InstitutionNationalIds, defineTemplateApi, } from '@island.is/application/types' -import { dataSchema } from './dataSchema' +import { partialSchema } from './dataSchema' import { general } from './messages' import { TemplateApiActions } from './types' import { Features } from '@island.is/feature-flags' @@ -47,7 +47,7 @@ const OJOITemplate: ApplicationTemplate< translationNamespaces: [ ApplicationConfigurations.OfficialJournalOfIceland.translation, ], - dataSchema: dataSchema, + dataSchema: partialSchema, allowMultipleApplicationsInDraft: true, stateMachineOptions: { actions: { @@ -99,18 +99,6 @@ const OJOITemplate: ApplicationTemplate< status: 'inprogress', progress: 0.66, lifecycle: pruneAfterDays(90), - onEntry: [ - defineTemplateApi({ - action: TemplateApiActions.departments, - externalDataId: 'departments', - order: 1, - }), - defineTemplateApi({ - action: TemplateApiActions.types, - externalDataId: 'types', - order: 2, - }), - ], roles: [ { id: Roles.APPLICANT, diff --git a/libs/application/templates/official-journal-of-iceland/src/lib/constants.ts b/libs/application/templates/official-journal-of-iceland/src/lib/constants.ts index a6ae4f0e79c2..4ee1ecaaf486 100644 --- a/libs/application/templates/official-journal-of-iceland/src/lib/constants.ts +++ b/libs/application/templates/official-journal-of-iceland/src/lib/constants.ts @@ -1,6 +1,6 @@ export const emailRegex = /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i -export const UPLOAD_ACCEPT = '.pdf' +export const ALLOWED_FILE_TYPES = ['.pdf', '.doc', '.docx'] export const FILE_SIZE_LIMIT = 10000000 @@ -12,12 +12,19 @@ export enum AnswerOption { NO = 'no', } +export enum ApplicationAttachmentType { + ORIGINAL = 'frumrit', + ADDITIONS = 'fylgiskjol', +} + +export const DEFAULT_PAGE = 1 +export const DEFAULT_PAGE_SIZE = 10 + export const MINIMUM_WEEKDAYS = 10 export enum Routes { - TEST = 'test', - COMMENTS = 'comments', REQUIREMENTS = 'requirements', + COMMENTS = 'comments', ADVERT = 'advert', SIGNATURE = 'signature', ATTACHMENTS = 'attachments', @@ -26,6 +33,7 @@ export enum Routes { PUBLISHING = 'publishing', SUMMARY = 'summary', COMPLETE = 'complete', + MISC = 'misc', } // this will be replaced with correct values once the api is ready @@ -38,7 +46,7 @@ export enum TypeIds { } export const MEMBER_INDEX = '{memberIndex}' -export const INSTITUTION_INDEX = '{institutionIndex}' +export const SIGNATURE_INDEX = '{institutionIndex}' export const INTERVAL_TIMER = 3000 export const DEBOUNCE_INPUT_TIMER = 333 @@ -48,66 +56,21 @@ export enum FileNames { ADDITIONS = 'additions', } -export const INITIAL_ANSWERS = { - [Routes.TEST]: { - name: '', - department: '', - job: '', - }, - [Routes.REQUIREMENTS]: { - approveExternalData: false, - }, - [Routes.ADVERT]: { - department: '', - type: '', - subType: '', - title: '', - template: '', - document: '', - }, - [Routes.SIGNATURE]: { - type: 'regular', - signature: '', - regular: [ - { - institution: '', - date: '', - members: [ - { - above: '', - name: '', - below: '', - after: '', - }, - ], - }, - ], - committee: { - institution: '', - date: '', - chairman: { - above: '', - name: '', - after: '', - below: '', - }, - members: [ - { - name: '', - below: '', - }, - ], - }, - additional: '', - }, - [Routes.ATTACHMENTS]: { - files: [], - fileNames: [], - }, - [Routes.PUBLISHING]: { - date: '', - contentCategories: [], - communicationChannels: [], - message: '', - }, +export const OJOI_INPUT_HEIGHT = 64 + +export type SignatureType = 'regular' | 'committee' +export enum SignatureTypes { + REGULAR = 'regular', + COMMITTEE = 'committee', } + +export const ONE = 1 +export const MINIMUM_REGULAR_SIGNATURE_MEMBER_COUNT = 1 +export const DEFAULT_REGULAR_SIGNATURE_MEMBER_COUNT = 1 +export const MAXIMUM_REGULAR_SIGNATURE_MEMBER_COUNT = 10 +export const MINIMUM_REGULAR_SIGNATURE_COUNT = 1 +export const DEFAULT_REGULAR_SIGNATURE_COUNT = 1 +export const MAXIMUM_REGULAR_SIGNATURE_COUNT = 3 +export const MINIMUM_COMMITTEE_SIGNATURE_MEMBER_COUNT = 2 +export const DEFAULT_COMMITTEE_SIGNATURE_MEMBER_COUNT = 2 +export const MAXIMUM_COMMITTEE_SIGNATURE_MEMBER_COUNT = 10 diff --git a/libs/application/templates/official-journal-of-iceland/src/lib/dataSchema.ts b/libs/application/templates/official-journal-of-iceland/src/lib/dataSchema.ts index 92cfcb487db0..b4d6f02e29ad 100644 --- a/libs/application/templates/official-journal-of-iceland/src/lib/dataSchema.ts +++ b/libs/application/templates/official-journal-of-iceland/src/lib/dataSchema.ts @@ -1,197 +1,330 @@ import { z } from 'zod' import { error } from './messages' -import { InputFields } from './types' -import { - TypeIds, - INSTITUTION_INDEX, - MEMBER_INDEX, - FileNames, - AnswerOption, -} from './constants' - -const FileSchema = z.object({ - name: z.string(), - key: z.string(), - url: z.string().optional(), -}) +import { AnswerOption, SignatureTypes } from './constants' +import { institution } from '../components/signatures/Signatures.css' +import { MessageDescriptor } from 'react-intl' + +export const memberItemSchema = z + .object({ + name: z.string().optional(), + before: z.string().optional(), + below: z.string().optional(), + above: z.string().optional(), + after: z.string().optional(), + }) + .partial() + +export const membersSchema = z.array(memberItemSchema).optional() + +export const regularSignatureItemSchema = z + .object({ + date: z.string().optional(), + institution: z.string().optional(), + members: membersSchema.optional(), + html: z.string().optional(), + }) + .partial() + +export const regularSignatureSchema = z + .array(regularSignatureItemSchema) + .optional() + +export const signatureInstitutionSchema = z.enum(['institution', 'date']) + +export const committeeSignatureSchema = regularSignatureItemSchema + .extend({ + chairman: memberItemSchema.optional(), + }) + .partial() + +export const channelSchema = z + .object({ + email: z.string(), + phone: z.string(), + }) + .partial() + +const advertSchema = z + .object({ + departmentId: z.string().optional(), + typeId: z.string().optional(), + title: z.string().optional(), + html: z.string().optional(), + requestedDate: z.string().optional(), + categories: z.array(z.string()).optional(), + channels: z.array(channelSchema).optional(), + message: z.string().optional(), + }) + .partial() -const getPath = (path: string) => path.split('.').slice(1) +const miscSchema = z + .object({ + signatureType: z.string().optional(), + selectedTemplate: z.string().optional(), + }) + .partial() -export const dataSchema = z.object({ +export const partialSchema = z.object({ requirements: z .object({ approveExternalData: z.string(), }) .refine((schema) => schema.approveExternalData === AnswerOption.YES, { params: error.dataGathering, - path: getPath(InputFields.requirements.approveExternalData), + path: ['approveExternalData'], }), - advert: z + advert: advertSchema.optional(), + signatures: z .object({ - department: z.string().optional(), - type: z.string().optional(), - title: z.string().optional(), - document: z.string().optional(), - template: z.string().optional(), - subType: z.string().optional(), + additionalSignature: z.object({ + committee: z.string().optional(), + regular: z.string().optional(), + }), + regular: z.array(regularSignatureItemSchema).optional(), + committee: committeeSignatureSchema.optional(), }) - .superRefine((advert, ctx) => { - if (advert.type === TypeIds.REGLUGERDIR) { - if (!advert.subType) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - params: error.emptyFieldError, - path: getPath(InputFields.advert.subType), - }) - } - } + .partial() + .optional(), + misc: miscSchema.optional(), +}) + +// We make properties optional to throw custom error messages +export const advertValidationSchema = z.object({ + advert: z.object({ + departmentId: z + .string() + .optional() + .refine((value) => value && value.length > 0, { + params: error.missingDepartment, + }), + typeId: z + .string() + .optional() + .refine((value) => value && value.length > 0, { + params: error.missingType, + }), + title: z + .string() + .optional() + .refine((value) => value && value.length > 0, { + params: error.missingTitle, + }), + html: z + .string() + .optional() + .refine((value) => value && value.length > 0, { + params: error.missingHtml, + }), + }), +}) + +export const publishingValidationSchema = z.object({ + requestedDate: z + .string() + .optional() + .refine((value) => value && value.length > 0, { + // TODO: Add date validation + params: error.missingRequestedDate, }), - signature: z - .object({ - type: z.string().optional(), - signature: z.string().optional(), - regular: z - .array( - z.object({ - institution: z.string(), - date: z.string(), - members: z.array( - z.object({ - above: z.string(), - name: z.string(), - below: z.string(), - after: z.string(), - }), - ), - }), - ) - .optional(), - committee: z + categories: z + .array(z.string()) + .optional() + .refine((value) => Array.isArray(value) && value.length > 0, { + params: error.noCategorySelected, + }), +}) + +export const signatureValidationSchema = z + .object({ + signatures: z.object({ + additionalSignature: z .object({ - institution: z.string(), - date: z.string(), - chairman: z.object({ - above: z.string(), - name: z.string(), - below: z.string(), - after: z.string(), - }), - members: z.array( - z.object({ - name: z.string(), - below: z.string(), - }), - ), + committee: z.string().optional(), + regular: z.string().optional(), }) .optional(), - additional: z.string().optional(), + regular: z.array(regularSignatureItemSchema).optional(), + committee: committeeSignatureSchema.optional(), + }), + misc: miscSchema.optional(), + }) + .superRefine((schema, context) => { + const signatureType = schema.misc?.signatureType + + if (!signatureType) { + context.addIssue({ + code: z.ZodIssueCode.custom, + params: error.missingSignatureType, + path: ['misc', 'signatureType'], + }) + } + + let hasRegularIssues = false + let hasCommitteeIssues = false + + if (signatureType === SignatureTypes.REGULAR) { + hasRegularIssues = validateRegularSignature( + schema.signatures.regular, + context, + ) + } + + if (signatureType === SignatureTypes.COMMITTEE) { + hasCommitteeIssues = validateCommitteeSignature( + schema.signatures.committee as z.infer<typeof committeeSignatureSchema>, + context, + ) + } + + if (!hasRegularIssues && !hasCommitteeIssues) { + return false + } + + return true + }) + +const validateMember = ( + schema: z.infer<typeof memberItemSchema>, + context: z.RefinementCtx, + params?: MessageDescriptor, +) => { + if (!schema || !schema.name) { + context.addIssue({ + code: z.ZodIssueCode.custom, + params: params ? params : error.missingSignatureMember, + }) + + return false + } + + return true +} + +const validateInstitutionAndDate = ( + institution: string | undefined, + date: string | undefined, + context: z.RefinementCtx, +) => { + if (!institution) { + context.addIssue({ + code: z.ZodIssueCode.custom, + params: error.missingSignatureInstitution, }) - .superRefine((signature, ctx) => { - switch (signature.type) { - case 'regular': - signature.regular?.forEach((institution, index) => { - // required fields are institution, date, member.name - - if (!institution.institution) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - params: error.emptyFieldError, - path: InputFields.signature.regular.institution - .replace(INSTITUTION_INDEX, `${index}`) - .split('.') - .slice(1), - }) - } - - if (!institution.date) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - params: error.emptyFieldError, - path: InputFields.signature.regular.date - .replace(INSTITUTION_INDEX, `${index}`) - .split('.') - .slice(1), - }) - } - - institution.members?.forEach((member, memberIndex) => { - if (!member.name) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - params: error.emptyFieldError, - path: InputFields.signature.regular.members.name - .replace(INSTITUTION_INDEX, `${index}`) - .replace(MEMBER_INDEX, `${memberIndex}`) - .split('.') - .slice(1), - }) - } - }) - }) - - break - case 'committee': - // required fields are institution, date, chairman.name, members.name - - if (!signature.committee?.institution) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - params: error.emptyFieldError, - path: getPath(InputFields.signature.committee.institution), - }) - } - - if (!signature.committee?.date) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - params: error.emptyFieldError, - path: getPath(InputFields.signature.committee.date), - }) - } - - if (!signature.committee?.chairman.name) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - params: error.emptyFieldError, - path: getPath(InputFields.signature.committee.chairman.name), - }) - } - - signature.committee?.members?.forEach((member, index) => { - if (!member.name) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - params: error.emptyFieldError, - path: InputFields.signature.committee.members.name - .replace(MEMBER_INDEX, `${index}`) - .split('.') - .slice(1), - }) - } - }) - break + + return false + } + + if (!date) { + context.addIssue({ + code: z.ZodIssueCode.custom, + params: error.missingSignatureDate, + }) + + return false + } + + return true +} + +const validateRegularSignature = ( + schema: z.infer<typeof regularSignatureSchema>, + context: z.RefinementCtx, +) => { + if (!schema || (Array.isArray(schema) && schema.length === 0)) { + context.addIssue({ + code: z.ZodIssueCode.custom, + params: error.signaturesValidationError, + }) + + return false + } + + const validSignatures = schema + ?.map((signature) => { + // institution and date are required + let hasValidInstitutionAndDate = true + let hasValidMembers = true + + hasValidInstitutionAndDate = validateInstitutionAndDate( + signature.institution, + signature.date, + context, + ) + + if (!signature.members && !Array.isArray(signature.members)) { + context.addIssue({ + code: z.ZodIssueCode.custom, + params: error.noSignatureMembers, + }) } - }), - attachments: z.object({ - files: z.array(FileSchema), - fileNames: z.enum([FileNames.ADDITIONS, FileNames.DOCUMENT]), - }), - publishing: z.object({ - date: z.string().optional(), - contentCategories: z.array( - z.object({ - label: z.string(), - value: z.string(), - }), - ), - communicationChannels: z.array( - z.object({ - email: z.string(), - phone: z.string(), - }), - ), - message: z.string().optional(), - }), -}) -export type answerSchemas = z.infer<typeof dataSchema> + hasValidMembers = + signature.members + ?.map((member) => validateMember(member, context)) + .every((isValid) => isValid) ?? false + + return hasValidInstitutionAndDate && hasValidMembers + }) + .every((isValid) => isValid) + + return validSignatures +} + +const validateCommitteeSignature = ( + schema: z.infer<typeof committeeSignatureSchema>, + context: z.RefinementCtx, +) => { + if (!schema) { + context.addIssue({ + code: z.ZodIssueCode.custom, + params: error.signaturesValidationError, + }) + } + + let hasValidInstitutionAndDate = true + let hasValidChairman = true + let hasValidMembers = true + + hasValidInstitutionAndDate = validateInstitutionAndDate( + schema.institution, + schema.date, + context, + ) + + hasValidChairman = validateMember( + schema.chairman as z.infer<typeof memberItemSchema>, + context, + error.missingChairmanName, + ) + + hasValidMembers = + schema.members + ?.map((member) => + validateMember(member, context, error.missingCommitteeMemberName), + ) + .every((isValid) => isValid) ?? false + + return hasValidInstitutionAndDate && hasValidChairman && hasValidMembers +} + +type Flatten<T> = T extends any[] ? T[number] : T + +type MapProps<T> = { + [K in keyof T]: T[K] +} + +export type partialSchema = z.infer<typeof partialSchema> + +export type partialRegularSignatureSchema = Flatten< + z.infer<typeof regularSignatureItemSchema> +> + +export type partialCommitteeSignatureSchema = MapProps< + z.infer<typeof committeeSignatureSchema> +> + +export type validationSchema = z.infer<typeof advertValidationSchema> + +export const signatureProperties = committeeSignatureSchema.keyof() + +export const sharedSignatureProperties = signatureProperties diff --git a/libs/application/templates/official-journal-of-iceland/src/lib/messages/advert.ts b/libs/application/templates/official-journal-of-iceland/src/lib/messages/advert.ts index ac24eaca9956..fcbcd5e3be03 100644 --- a/libs/application/templates/official-journal-of-iceland/src/lib/messages/advert.ts +++ b/libs/application/templates/official-journal-of-iceland/src/lib/messages/advert.ts @@ -10,7 +10,7 @@ export const advert = { intro: { id: 'ojoi.application:advert.general.intro', defaultMessage: - 'Veldu deild og tegund birtingar í fellilistanum hér að neðan og skráðu heiti auglýsingar í viðeigandi reit. Tegundarheitið birtist sjálfkrafa í hástöfum í fyrirsögn og titillinn í næstu línu. Efni auglýsinga er sett í ritilinn hér að neðan og skal vanda alla uppsetningu, setja inn töluliði, töflur o.þ.h. Til einföldunar við vinnslu meginmáls getur þú valið sniðmát og aðlagað það að þinni auglýsingu eða sótt eldri auglýsingu og breytt henni.', + 'Veldu deild og tegund birtingar í fellilistanum hér að neðan og skráðu heiti innsendingar í viðeigandi reit. Tegundarheitið birtist sjálfkrafa í hástöfum í fyrirsögn og titillinn í næstu línu. Efni innsendingar er sett í ritilinn hér að neðan og skal vanda alla uppsetningu, setja inn töluliði, töflur o.þ.h. Til einföldunar við vinnslu meginmáls getur þú valið sniðmát og aðlagað það að þinni innsendingu eða sótt eldri innsendingar og breytt henni.', description: 'Intro of the advert form', }, section: { @@ -73,12 +73,12 @@ export const advert = { title: defineMessages({ label: { id: 'ojoi.application:advert.inputs.title.label', - defaultMessage: 'Heiti auglýsingar', + defaultMessage: 'Titill innsendingar', description: 'Label for the title input', }, placeholder: { id: 'ojoi.application:advert.inputs.title.placeholder', - defaultMessage: 'Skráðu heiti auglýsinga', + defaultMessage: 'Skráðu heiti innsendingar', description: 'Placeholder for the title input', }, }), @@ -90,7 +90,7 @@ export const advert = { }, placeholder: { id: 'ojoi.application:advert.inputs.template.placeholder', - defaultMessage: 'Fyrirmynd auglýsinga', + defaultMessage: 'Fyrirmynd innsendingar', description: 'Placeholder for the template input', }, }), diff --git a/libs/application/templates/official-journal-of-iceland/src/lib/messages/comments.ts b/libs/application/templates/official-journal-of-iceland/src/lib/messages/comments.ts index 80697969e702..c204aad33321 100644 --- a/libs/application/templates/official-journal-of-iceland/src/lib/messages/comments.ts +++ b/libs/application/templates/official-journal-of-iceland/src/lib/messages/comments.ts @@ -19,26 +19,26 @@ export const comments = { description: 'Title of comments section', }, }), - errors: defineMessages({ - fetchComments: { - id: 'ojoi.application:comments.errors.fetchComments', - defaultMessage: 'Villa kom upp við að sækja athugasemdir', - description: 'Error fetching comments', + warnings: defineMessages({ + noCommentsTitle: { + id: 'ojoi.application:comments.warnings.noComments', + defaultMessage: 'Engar athugasemdir', + description: 'No comments', }, - fetchCommentsMessage: { - id: 'ojoi.application:comments.errors.fetchCommentsMessage', - defaultMessage: 'Ekki tókst að sækja athugasemdir, reynið aftur síðar', - description: 'Error fetching comments message', + noCommentsMessage: { + id: 'ojoi.application:comments.warnings.noCommentsMessage', + defaultMessage: 'Engar athugasemdir eru skráðar á þessa innsendingu.', + description: 'No comments message', }, - addComment: { - id: 'ojoi.application:comments.errors.addComment', + postCommentFailedTitle: { + id: 'ojoi.application:comments.warnings.postCommentFailedTitle', defaultMessage: 'Ekki tókst að vista athugasemd', - description: 'Error adding comment', + description: 'Post comment failed title', }, - emptyComments: { - id: 'ojoi.application:comments.errors.emptyComments', - defaultMessage: 'Engar athugasemdir eru á þessari umsókn', - description: 'No comments on this application', + postCommentFailedMessage: { + id: 'ojoi.application:comments.warnings.postCommentFailedMessage', + defaultMessage: 'Ekki tókst að vista athugasemd, reyndu aftur síðar.', + description: 'Post comment failed message', }, }), dates: defineMessages({ diff --git a/libs/application/templates/official-journal-of-iceland/src/lib/messages/error.ts b/libs/application/templates/official-journal-of-iceland/src/lib/messages/error.ts index 567d8088de0c..69b527a4b5e1 100644 --- a/libs/application/templates/official-journal-of-iceland/src/lib/messages/error.ts +++ b/libs/application/templates/official-journal-of-iceland/src/lib/messages/error.ts @@ -1,11 +1,152 @@ import { defineMessages } from 'react-intl' export const error = defineMessages({ + fetchCommentsFailedTitle: { + id: 'ojoi.application:error.fetchCommentsFailedTitle', + defaultMessage: 'Ekki tókst að sækja athugasemdir', + description: 'Error message when fetching comments fails', + }, + fetchCommentsFailedMessage: { + id: 'ojoi.application:error.fetchCommentsFailedMessage', + defaultMessage: + 'Villa kom upp við að sækja athugasemdir, reyndu aftur síðar', + description: 'Error message when fetching comments fails', + }, + fetchAdvertFailed: { + id: 'ojoi.application:error.fetchAdvertFailed', + defaultMessage: 'Ekki tókst að sækja auglýsingu', + description: 'Error message when fetching advert fails', + }, + fetchAdvertFailedMessage: { + id: 'ojoi.application:error.fetchAdvertFailedMessage', + defaultMessage: 'Villa kom upp við að sækja auglýsingu, reyndu aftur síðar', + description: 'Error message when fetching advert fails', + }, + fetchApplicationFailedTitle: { + id: 'ojoi.application:error.fetchApplicationFailedTitle', + defaultMessage: 'Ekki tókst að sækja umsókn', + description: 'Error message when fetching application fails', + }, + fetchApplicationFailedMessage: { + id: 'ojoi.application:error.fetchApplicationFailedMessage', + defaultMessage: 'Villa kom upp við að sækja umsókn, reyndu aftur síðar', + description: 'Error message when fetching application fails', + }, + missingChairmanName: { + id: 'ojoi.application:error.missingChairmanName', + defaultMessage: 'Nafn formanns vantar', + description: 'Error message when chairman name is missing', + }, + missingCommitteeMemberName: { + id: 'ojoi.application:error.missingCommitteeMemberName', + defaultMessage: 'Nafn nefndarmanns vantar', + description: 'Error message when committee member name is missing', + }, + missingSignatureInstitution: { + id: 'ojoi.application:error.missingSignatureInstitution', + defaultMessage: 'Nafn stofnunar vantar', + description: 'Error message when signature institution is missing', + }, + missingSignatureDate: { + id: 'ojoi.application:error.missingSignatureDate', + defaultMessage: 'Dagsetning undirskriftar vantar', + description: 'Error message when signature date is missing', + }, + missingSignatureType: { + id: 'ojoi.application:error.missingSignatureType', + defaultMessage: 'Tegund undirskriftar vantar', + description: 'Error message when signature type is missing', + }, + missingFieldsTitle: { + id: 'ojoi.application:error.missingFieldsTitle', + defaultMessage: 'Fylla þarf út eftirfarandi reiti í {x}', + description: 'Error message when fields are missing', + }, + missingSignatureFieldsMessage: { + id: 'ojoi.application:error.missingSignatureFieldsMessage', + defaultMessage: 'Undirritunarkafli er undir {x}', + description: 'Error message when signature fields are missing', + }, + noSignatureMembers: { + id: 'ojoi.application:error.noSignatureMembers', + defaultMessage: 'Engin undirskriftarmenn valdir', + description: 'Error message when no signature members are selected', + }, + missingSignatureMember: { + id: 'ojoi.application:error.missingSignatureMember', + defaultMessage: 'Nafn undirskriftar meðlims vantar', + description: 'Error message when signature member is missing', + }, + noCategorySelected: { + id: 'ojoi.application:error.noCategorySelected', + defaultMessage: 'Enginn efnisflokkur valinn, vinsamlegast veldu efnisflokk', + description: 'Error message when no category is selected', + }, + missingType: { + id: 'ojoi.application:error.missingType', + defaultMessage: 'Velja þarf tegund innsendingar', + description: 'Error message when type is missing', + }, + missingDepartment: { + id: 'ojoi.application:error.missingDepartment', + defaultMessage: 'Velja þarf deild innsendingar', + description: 'Error message when department is missing', + }, + missingTitle: { + id: 'ojoi.application:error.missingTitle', + defaultMessage: 'Fylla þarf út titill innsendingar', + description: 'Error message when title is missing', + }, + missingHtml: { + id: 'ojoi.application:error.missingHtml', + defaultMessage: 'Innihald innsendingar má ekki vera autt', + description: 'Error message when html is missing', + }, + missingHtmlMessage: { + id: 'ojoi.application:error.missingHtmlMessage', + defaultMessage: 'Innsending samanstendur af eftirfarandi reitum', + description: 'Error message when html is missing', + }, + missingRequestedDate: { + id: 'ojoi.application:error.missingRequestedDate', + defaultMessage: 'Útgáfudagsetning má ekki vera tóm', + description: 'Error message when requested date is missing', + }, + applicationValidationError: { + id: 'ojoi.application:error.applicationValidationError', + defaultMessage: 'Umsókn er ekki rétt útfyllt', + description: 'Error message when application is not valid', + }, + signaturesValidationError: { + id: 'ojoi.application:error.signaturesValidationError', + defaultMessage: 'Undirskriftir eru ekki réttar', + description: 'Error message when signatures are not valid', + }, dataSubmissionErrorTitle: { id: 'ojoi.application:error.dataSubmissionErrorTitle', defaultMessage: 'Villa kom upp við vistun gagna', description: 'Error message when data is not submitted', }, + fetchXFailedTitle: { + id: 'ojoi.application:error.fetchXFailedTitle', + defaultMessage: 'Ekki tókst að sækja {x}', + description: 'Error message when fetching x fails', + }, + fetchXFailedMessage: { + id: 'ojoi.application:error.fetchXFailedMessage', + defaultMessage: 'Villa kom upp við að sækja {x}', + description: 'Error message when fetching x fails', + }, + fetchFailedTitle: { + id: 'ojoi.application:error.fetchFailedTitle', + defaultMessage: 'Ekki tókst að sækja gögn', + description: 'Error message when fetching fails', + }, + fetchFailedMessage: { + id: 'ojoi.application:error.fetchFailedMessage', + defaultMessage: 'Villa kom upp við að sækja gögn', + description: 'Error message when fetching fails', + }, xIsNotValid: { id: 'ojoi.application:error.xIsNotValid', defaultMessage: '{x} er ekki gilt', diff --git a/libs/application/templates/official-journal-of-iceland/src/lib/messages/index.ts b/libs/application/templates/official-journal-of-iceland/src/lib/messages/index.ts index a61e3a9cdf36..125c0744b8c7 100644 --- a/libs/application/templates/official-journal-of-iceland/src/lib/messages/index.ts +++ b/libs/application/templates/official-journal-of-iceland/src/lib/messages/index.ts @@ -9,3 +9,5 @@ export * from './requirements' export * from './preview' export * from './publishing' export * from './summary' +export * from './signatures' +export * from './comments' diff --git a/libs/application/templates/official-journal-of-iceland/src/lib/messages/preview.ts b/libs/application/templates/official-journal-of-iceland/src/lib/messages/preview.ts index d0c399b4025a..7f12cc6c3987 100644 --- a/libs/application/templates/official-journal-of-iceland/src/lib/messages/preview.ts +++ b/libs/application/templates/official-journal-of-iceland/src/lib/messages/preview.ts @@ -19,6 +19,13 @@ export const preview = { description: 'Title of the preview section', }, }), + errors: defineMessages({ + noContent: { + id: 'ojoi.application:preview.errors.noContent', + defaultMessage: 'Innihald innsendingar er ekki útfyllt', + description: 'Error message when content is missing', + }, + }), buttons: defineMessages({ fetchPdf: { id: 'ojoi.application:preview.buttons.fetchPdf', diff --git a/libs/application/templates/official-journal-of-iceland/src/lib/messages/publishing.ts b/libs/application/templates/official-journal-of-iceland/src/lib/messages/publishing.ts index f52271e8c72a..5e4ccbb90bc1 100644 --- a/libs/application/templates/official-journal-of-iceland/src/lib/messages/publishing.ts +++ b/libs/application/templates/official-journal-of-iceland/src/lib/messages/publishing.ts @@ -68,6 +68,11 @@ export const publishing = { defaultMessage: 'Efnisflokkar', description: 'Label of the content categories input', }, + placeholder: { + id: 'ojoi.application:publishing.inputs.contentCategories.placeholder', + defaultMessage: 'Veldu efnisflokka', + description: 'Placeholder of the content categories input', + }, }), messages: defineMessages({ label: { diff --git a/libs/application/templates/official-journal-of-iceland/src/lib/messages/signatures.ts b/libs/application/templates/official-journal-of-iceland/src/lib/messages/signatures.ts index 037c9a484637..3c82160a966c 100644 --- a/libs/application/templates/official-journal-of-iceland/src/lib/messages/signatures.ts +++ b/libs/application/templates/official-journal-of-iceland/src/lib/messages/signatures.ts @@ -13,6 +13,11 @@ export const signatures = { 'Hér má velja þá uppsetningu undirrritana sem best á við. Mikilvægt er að tryggja samræmi við frumtexta, til dæmis varðandi stað og dagsetningu.', description: 'Intro of the signatures section', }, + section: { + id: 'ojoi.application:signatures.general.section', + defaultMessage: 'Undirritunarkafl{abbreviation}', + description: 'Title of the signatures section', + }, }), headings: defineMessages({ signedBy: { @@ -84,7 +89,7 @@ export const signatures = { }, placeholder: { id: 'ojoi.application:signatures.inputs.institution.placeholder', - defaultMessage: 'Veldu stofnun', + defaultMessage: 'Nafn stofnunar eða staðsetning', description: 'Placeholder for the institution input', }, }), diff --git a/libs/application/templates/official-journal-of-iceland/src/lib/messages/summary.ts b/libs/application/templates/official-journal-of-iceland/src/lib/messages/summary.ts index 83240b61b3b3..33e74bc4896c 100644 --- a/libs/application/templates/official-journal-of-iceland/src/lib/messages/summary.ts +++ b/libs/application/templates/official-journal-of-iceland/src/lib/messages/summary.ts @@ -32,7 +32,7 @@ export const summary = { }, title: { id: 'ojoi.application:summary.properties.title', - defaultMessage: 'Heiti auglýsingar', + defaultMessage: 'Heiti innsendingar', description: 'Title of the advertisement', }, department: { diff --git a/libs/application/templates/official-journal-of-iceland/src/lib/types.ts b/libs/application/templates/official-journal-of-iceland/src/lib/types.ts index d118cb9707ed..ab0c6d2a68a3 100644 --- a/libs/application/templates/official-journal-of-iceland/src/lib/types.ts +++ b/libs/application/templates/official-journal-of-iceland/src/lib/types.ts @@ -1,98 +1,50 @@ import { Application, FieldBaseProps } from '@island.is/application/types' -import { type answerSchemas } from './dataSchema' -import { INSTITUTION_INDEX, MEMBER_INDEX, Routes } from './constants' +import { Routes } from './constants' import { - OfficialJournalOfIcelandAdvert, OfficialJournalOfIcelandAdvertEntity, OfficialJournalOfIcelandPaging, } from '@island.is/api/schema' +import { partialSchema } from './dataSchema' export const InputFields = { - [Routes.TEST]: { - name: 'test.name', - department: 'test.department', - job: 'test.job', - }, [Routes.REQUIREMENTS]: { approveExternalData: 'requirements.approveExternalData', }, [Routes.ADVERT]: { - department: 'advert.department', - type: 'advert.type', - subType: 'advert.subType', + departmentId: 'advert.departmentId', + typeId: 'advert.typeId', title: 'advert.title', - template: 'advert.template', - document: 'advert.document', + html: 'advert.html', + requestedDate: 'advert.requestedDate', + categories: 'advert.categories', + channels: 'advert.channels', + message: 'advert.message', }, [Routes.SIGNATURE]: { - type: 'signature.type', - contents: 'signature.contents', - regular: { - institution: `signature.regular-${INSTITUTION_INDEX}.institution`, - date: `signature.regular-${INSTITUTION_INDEX}.date`, - members: { - above: `signature.regular-${INSTITUTION_INDEX}.members-${MEMBER_INDEX}.above`, - name: `signature.regular-${INSTITUTION_INDEX}.members-${MEMBER_INDEX}.name`, - below: `signature.regular-${INSTITUTION_INDEX}.members-${MEMBER_INDEX}.below`, - after: `signature.regular-${INSTITUTION_INDEX}.members-${MEMBER_INDEX}.after`, - }, - }, - committee: { - institution: 'signature.committee.institution', - date: 'signature.committee.date', - chairman: { - above: 'signature.committee.chairman.above', - name: 'signature.committee.chairman.name', - after: 'signature.committee.chairman.after', - below: 'signature.committee.chairman.below', - }, - members: { - name: `signature.committee.members-${MEMBER_INDEX}.name`, - below: `signature.committee.members-${MEMBER_INDEX}.below`, - }, + regular: 'signatures.regular', + committee: 'signatures.committee', + additionalSignature: { + regular: 'signatures.additionalSignature.regular', + committee: 'signatures.additionalSignature.committee', }, - additonalSignature: 'signature.additonalSignature', - }, - [Routes.ATTACHMENTS]: { - files: 'additionsAndDocuments.files', - fileNames: 'additionsAndDocuments.fileNames', }, - [Routes.ORIGINAL]: { - files: 'original.files', + [Routes.MISC]: { + signatureType: 'misc.signatureType', + selectedTemplate: 'misc.selectedTemplate', }, - [Routes.PUBLISHING]: { - date: 'publishing.date', - fastTrack: 'publishing.fastTrack', - contentCategories: 'publishing.contentCategories', - communicationChannels: 'publishing.communicationChannels', - message: 'publishing.message', - }, -} - -export type LocalError = { - type: string - message: string } -type Option = { - id: string - title: string - slug: string -} - -export type AdvertOption<Key extends string> = { - [key in Key]: Array<Option> +export const RequiredInputFieldsNames = { + [Routes.ADVERT]: { + departmentId: 'Deild', + typeId: 'Tegund', + title: 'Titill', + html: 'Auglýsing', + requestedDate: 'Útgáfudagur', + categories: 'Efnisflokkar', + }, } -export type SignatureType = 'regular' | 'committee' - -export type RegularSignatureState = NonNullable< - answerSchemas['signature']['regular'] -> -export type CommitteeSignatureState = NonNullable< - answerSchemas['signature']['committee'] -> - export enum TemplateApiActions { departments = 'getDepartments', types = 'getAdvertTypes', @@ -107,34 +59,12 @@ export type NestedType<T> = { export type Override<T1, T2> = Omit<T1, keyof T2> & T2 -type StatusProvider = 'success' | 'failure' - -export type ErrorSchema = NestedType<answerSchemas> - -export interface ExternalData { - departments: { - data: AdvertOption<'departments'> - date: string - status: StatusProvider - } - - types: { - data: AdvertOption<'types'> - date: string - status: StatusProvider - } - submitApplication: { - data: { application: OfficialJournalOfIcelandAdvert } - date: string - status: StatusProvider - } -} +export type ErrorSchema = NestedType<partialSchema> export type OJOIApplication = Override< Application, { - answers: Partial<answerSchemas> - externalData: ExternalData + answers: partialSchema } > diff --git a/libs/application/templates/official-journal-of-iceland/src/lib/utils.ts b/libs/application/templates/official-journal-of-iceland/src/lib/utils.ts index ad959b9fb41a..f588b640eeb8 100644 --- a/libs/application/templates/official-journal-of-iceland/src/lib/utils.ts +++ b/libs/application/templates/official-journal-of-iceland/src/lib/utils.ts @@ -1,10 +1,19 @@ import addDays from 'date-fns/addDays' import addYears from 'date-fns/addYears' -import { parsePhoneNumberFromString } from 'libphonenumber-js' -import { Routes, emailRegex } from './constants' -import { RecordObject } from '@island.is/application/types' -import { LocalError } from './types' -import get from 'lodash/get' +import { z } from 'zod' +import { + committeeSignatureSchema, + memberItemSchema, + partialSchema, + regularSignatureSchema, +} from './dataSchema' +import { getValueViaPath } from '@island.is/application/core' +import { InputFields, OJOIApplication, RequiredInputFieldsNames } from './types' +import { HTMLText } from '@island.is/regulations-tools/types' +import format from 'date-fns/format' +import is from 'date-fns/locale/is' +import { SignatureTypes } from './constants' +import { MessageDescriptor } from 'react-intl' export const countDaysAgo = (date: Date) => { const now = new Date() @@ -44,21 +53,6 @@ export const addWeekdays = (date: Date, days: number) => { return result } -export const getWeekdayDates = ( - startDate = new Date(), - endDate = addYears(new Date(), 1), -) => { - const weekends = [] - let currentDay = startDate - while (currentDay <= endDate) { - if (isWeekday(currentDay)) { - weekends.push(currentDay) - } - currentDay = addDays(currentDay, 1) - } - return weekends -} - export const getNextAvailableDate = (date: Date): Date => { if (isWeekday(date)) { return date @@ -66,109 +60,254 @@ export const getNextAvailableDate = (date: Date): Date => { return getNextAvailableDate(addDays(date, 1)) } -export const isValidEmail = (email: string) => { - return emailRegex.test(email) -} +export const getEmptyMember = () => ({ + name: '', + above: '', + after: '', + before: '', + below: '', +}) + +export const getRegularSignature = ( + signatureCount: number, + memberCount: number, +) => + Array.from({ length: signatureCount }).map(() => ({ + institution: '', + date: '', + members: Array.from({ length: memberCount }).map(() => getEmptyMember()), + html: '', + })) + +export const getCommitteeSignature = ( + memberCount: number, +): z.infer<typeof committeeSignatureSchema> => ({ + institution: '', + date: '', + chairman: getEmptyMember(), + members: Array.from({ length: memberCount }).map(() => getEmptyMember()), + html: '', +}) + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const getSignatureDefaultValues = (signature: any, index?: number) => { + if (signature === undefined) { + return { institution: '', date: '' } + } + + const isRegularSignature = regularSignatureSchema.safeParse(signature) + + if (isRegularSignature.success) { + if (index === undefined) { + return { institution: '', date: '' } + } + + const { data } = isRegularSignature + + if (data === undefined) { + return { institution: '', date: '' } + } -export const isValidPhone = (phone: string) => { - return parsePhoneNumberFromString(phone, 'IS')?.isValid() ?? false + return { + institution: data[index].institution, + date: data[index].date, + } + } + + return { institution: signature.institution, date: signature.date } } -export const mapDepartmentToId = (department?: string) => { - if (!department) return '' - const s = department.toLowerCase() - switch (s) { - case 'a-deild': - return '0' - case 'b-deild': - return '1' - case 'c-deild': - return '2' - default: - return '' +export const isRegularSignature = ( + any: unknown, +): any is z.infer<typeof regularSignatureSchema> => + regularSignatureSchema.safeParse(any).success + +export const isCommitteeSignature = ( + any: unknown, +): any is z.infer<typeof committeeSignatureSchema> => + committeeSignatureSchema.safeParse(any).success + +export const getCommitteeAnswers = (answers: OJOIApplication['answers']) => { + const currentAnswers = structuredClone(answers) + const signature = getValueViaPath( + currentAnswers, + InputFields.signature.committee, + ) + + if (isCommitteeSignature(signature)) { + return { + currentAnswers, + signature, + } + } + + return { + currentAnswers, + signature: null, } } -export const mapDepartmentEnumToDepartment = (department?: string) => { - if (!department) return '' - switch (department) { - case 'ADeild': - return 'A deild' - case 'BDeild': - return 'B deild' - case 'CDeild': - return 'C deild' - default: - return '' +export const getRegularAnswers = (answers: OJOIApplication['answers']) => { + const currentAnswers = structuredClone(answers) + const signature = getValueViaPath( + currentAnswers, + InputFields.signature.regular, + ) + + if (isRegularSignature(signature)) { + return { + currentAnswers, + signature, + } + } + + return { + currentAnswers, + signature: null, } } -export const mapStatusEnumToStatus = (status?: string) => { - if (!status) return '' - switch (status) { - case 'Active': - return 'Virk' - case 'Revoked': - return 'Afturkölluð' - case 'Draft': - return 'Drög' - case 'Old': - return 'Eldri auglýsing' - case 'Rejected': - return 'Hafnað' - case 'Waiting': - return 'Í bið' - case 'InProgress': - return 'Í vinnslu' - case 'Submitted': - return 'Innsend' - case 'ReadyForPublication': - return 'Tilbúin til útgáfu' - case 'Published': - return 'Útgefin' - default: - return '' +const getMembersMarkup = (member: z.infer<typeof memberItemSchema>) => { + if (!member.name) return '' + + const styleObject = { + marginBottom: member.below ? '0' : '1.5em', } + + const aboveMarkup = member.above + ? `<p style="margin-bottom: ${styleObject.marginBottom}" margins align="center">${member.above}</p>` + : '' + const afterMarkup = member.after ? ` ${member.after}` : '' + const belowMarkup = member.below + ? `<p align="center">${member.below}</p>` + : '' + + return ` + <div class="signature__member"> + ${aboveMarkup} + <p style="margin-bottom: ${styleObject.marginBottom}" align="center"><strong>${member.name}</strong>${afterMarkup}</p> + ${belowMarkup} + </div> + ` } -export const dotToObj = (dotString: string, obj = {}, value = '') => { - const keys = dotString.split('.').filter(Boolean) - const lastIndex = keys.length - 1 - const result: Record<string, any> = { ...obj } - - keys.reduce((acc, key, index) => { - if (index === lastIndex) { - acc[key] = value - } else { - if (!acc[key]) { - acc[key] = {} +const signatureTemplate = ( + signatures: z.infer<typeof regularSignatureSchema>, + additionalSignature?: string, + chairman?: z.infer<typeof memberItemSchema>, +) => { + const markup = signatures + ?.map((signature) => { + const membersCount = Math.min(signature.members?.length ?? 1, 3) + const styleObject = { + display: membersCount > 1 ? 'grid' : 'block', + gridTemplateColumns: + membersCount === 1 + ? '1fr' + : membersCount === 2 || membersCount === 4 + ? '1fr 1fr' + : '1fr 1fr 1fr', } - } - return acc[key] - }, result) - return result + const date = signature.date + ? format(new Date(signature.date), 'dd. MMM yyyy.', { locale: is }) + : '' + + const chairmanMarkup = chairman + ? `<div style="margin-bottom: 1.5em;">${getMembersMarkup( + chairman, + )}</div>` + : '' + + const membersMarkup = signature.members + ?.map((member) => getMembersMarkup(member)) + .join('') + + return ` + <div class="signature"> + <p align="center"><em>${signature.institution} ${date}</em></p> + ${chairmanMarkup} + <div style="margin-bottom: 1.5em; display: ${styleObject.display}; grid-template-columns: ${styleObject.gridTemplateColumns};" class="signature__content"> + ${membersMarkup} + </div> + </div> + ` + }) + .join('') + + const additionalMarkup = additionalSignature + ? `<p style="font-size: 16px;" align="right"><em>${additionalSignature}</em></p>` + : '' + + return `${markup}${additionalMarkup}` as HTMLText } -export const getLocalError = (obj: RecordObject, path: string) => { - return get(obj, path) as LocalError | string | undefined +export const getSignatureMarkup = ({ + signatures, + type, +}: { + signatures: z.infer<typeof partialSchema>['signatures'] + type: SignatureTypes +}): HTMLText => { + if (signatures === undefined) { + return '' + } + + if ( + type === SignatureTypes.REGULAR && + isRegularSignature(signatures.regular) + ) { + return signatureTemplate( + signatures.regular, + signatures.additionalSignature?.regular, + ) + } + + if ( + type === SignatureTypes.COMMITTEE && + isCommitteeSignature(signatures.committee) + ) { + return signatureTemplate( + [signatures.committee], + signatures.additionalSignature?.committee, + signatures.committee.chairman, + ) + } + + return '' +} + +export const getAdvertMarkup = ({ + type, + title, + html, +}: { + type?: string + title?: string + html?: string +}) => { + const typeMarkup = type + ? `<p align="center" style="margin-bottom: 0;">${type.toUpperCase()}</p>` + : '' + const titleMarkup = title + ? `<p align="center"><strong>${title}</strong></p>` + : '' + + const htmlMarkup = html ? html : '' + + return ` + <div class="advert"> + ${typeMarkup} + ${titleMarkup} + ${htmlMarkup} + </div> + ` as HTMLText } -export const keyMapper = (key: string) => { - switch (key) { - case Routes.ADVERT: - return Routes.ADVERT - case Routes.SIGNATURE: - return Routes.SIGNATURE - case Routes.ATTACHMENTS: - return Routes.ATTACHMENTS - case Routes.PUBLISHING: - return Routes.PUBLISHING - case Routes.REQUIREMENTS: - return Routes.REQUIREMENTS - case Routes.TEST: - return Routes.TEST - default: - return null +export const parseZodIssue = (issue: z.ZodCustomIssue) => { + const path = issue.path.join('.') + return { + name: getValueViaPath(RequiredInputFieldsNames, path) as string, + message: issue?.params as MessageDescriptor, } } diff --git a/libs/application/templates/official-journal-of-iceland/src/screens/AdvertScreen.tsx b/libs/application/templates/official-journal-of-iceland/src/screens/AdvertScreen.tsx index 080a7144bc9a..faf9108c8961 100644 --- a/libs/application/templates/official-journal-of-iceland/src/screens/AdvertScreen.tsx +++ b/libs/application/templates/official-journal-of-iceland/src/screens/AdvertScreen.tsx @@ -10,9 +10,14 @@ import { AdvertModal } from '../fields/AdvertModal' export const AdvertScreen = (props: OJOIFieldBaseProps) => { const { formatMessage: f } = useLocale() + const [modalVisible, setModalVisability] = useState(false) - const [modalToggle, setModalToggle] = useState(false) - const [selectedAdvertId, setSelectedAdvertId] = useState<string | null>(null) + const generateTimestamp = () => new Date().toISOString() + + /** + * This state here is for force rerendering of the HTML editor when a value is received from the modal + */ + const [timestamp, setTimestamp] = useState(generateTimestamp()) return ( <FormScreen @@ -23,18 +28,19 @@ export const AdvertScreen = (props: OJOIFieldBaseProps) => { variant="utility" iconType="outline" icon="copy" - onClick={() => setModalToggle((prev) => !prev)} + onClick={() => setModalVisability((prev) => !prev)} > {f(advert.buttons.copyOldAdvert)} </Button> } > - <Advert {...props} selectedAdvertId={selectedAdvertId} /> + <Advert {...props} timeStamp={timestamp} /> <Signatures {...props} /> <AdvertModal - setSelectedAdvertId={setSelectedAdvertId} - visible={modalToggle} - setVisibility={setModalToggle} + applicationId={props.application.id} + visible={modalVisible} + setVisible={setModalVisability} + onConfirmChange={() => setTimestamp(generateTimestamp())} /> </FormScreen> ) diff --git a/libs/application/templates/official-journal-of-iceland/src/screens/PublishingScreen.tsx b/libs/application/templates/official-journal-of-iceland/src/screens/PublishingScreen.tsx index 01cb6f778de0..68e23c201a59 100644 --- a/libs/application/templates/official-journal-of-iceland/src/screens/PublishingScreen.tsx +++ b/libs/application/templates/official-journal-of-iceland/src/screens/PublishingScreen.tsx @@ -3,6 +3,8 @@ import { FormScreen } from '../components/form/FormScreen' import { OJOIFieldBaseProps } from '../lib/types' import { publishing } from '../lib/messages' import { Publishing } from '../fields/Publishing' +import { CommunicationChannels } from '../fields/CommunicationChannels' +import { Message } from '../fields/Message' export const PublishingScreen = (props: OJOIFieldBaseProps) => { const { formatMessage: f } = useLocale() @@ -12,6 +14,8 @@ export const PublishingScreen = (props: OJOIFieldBaseProps) => { intro={f(publishing.general.intro)} > <Publishing {...props} /> + <CommunicationChannels {...props} /> + <Message {...props} /> </FormScreen> ) } diff --git a/libs/application/templates/official-journal-of-iceland/src/screens/RequirementsScreen.tsx b/libs/application/templates/official-journal-of-iceland/src/screens/RequirementsScreen.tsx index 8071eee9ae08..d8dfe7783b50 100644 --- a/libs/application/templates/official-journal-of-iceland/src/screens/RequirementsScreen.tsx +++ b/libs/application/templates/official-journal-of-iceland/src/screens/RequirementsScreen.tsx @@ -1,17 +1,99 @@ import { useLocale } from '@island.is/localization' import { FormScreen } from '../components/form/FormScreen' import { InputFields, OJOIFieldBaseProps } from '../lib/types' -import { Checkbox } from '@island.is/island-ui/core' -import { requirements } from '../lib/messages' -import { Controller } from 'react-hook-form' +import { + AlertMessage, + Checkbox, + SkeletonLoader, +} from '@island.is/island-ui/core' +import { error, requirements } from '../lib/messages' +import { Controller, useFormContext } from 'react-hook-form' import { getErrorViaPath } from '@island.is/application/core' -import { AnswerOption } from '../lib/constants' +import { + AnswerOption, + DEFAULT_COMMITTEE_SIGNATURE_MEMBER_COUNT, + DEFAULT_REGULAR_SIGNATURE_COUNT, + DEFAULT_REGULAR_SIGNATURE_MEMBER_COUNT, + OJOI_INPUT_HEIGHT as OJOI_INPUT_HEIGHT, + SignatureTypes, +} from '../lib/constants' +import { useApplication } from '../hooks/useUpdateApplication' +import { getRegularSignature, getCommitteeSignature } from '../lib/utils' +import set from 'lodash/set' +import { useEffect } from 'react' export const RequirementsScreen = ({ application, errors, + setSubmitButtonDisabled, }: OJOIFieldBaseProps) => { const { formatMessage: f } = useLocale() + const { setValue } = useFormContext() + const { + updateApplication, + applicationLoading, + applicationError, + updateLoading, + } = useApplication({ + applicationId: application.id, + }) + + /** + * Set default values for the application + */ + useEffect(() => { + let currentAnswers = structuredClone(application.answers) + currentAnswers = set(currentAnswers, InputFields.signature.regular, [ + ...getRegularSignature( + DEFAULT_REGULAR_SIGNATURE_COUNT, + DEFAULT_REGULAR_SIGNATURE_MEMBER_COUNT, + ), + ]) + + currentAnswers = set(currentAnswers, InputFields.signature.committee, { + ...getCommitteeSignature(DEFAULT_COMMITTEE_SIGNATURE_MEMBER_COUNT), + }) + + currentAnswers = set( + currentAnswers, + InputFields.misc.signatureType, + SignatureTypes.REGULAR, + ) + + setValue(InputFields.signature.regular, currentAnswers.signatures?.regular) + setValue( + InputFields.signature.committee, + currentAnswers.signatures?.committee, + ) + setValue(InputFields.misc.signatureType, currentAnswers.misc?.signatureType) + + updateApplication(currentAnswers) + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + if (applicationLoading || updateLoading) { + return ( + <SkeletonLoader + repeat={3} + height={OJOI_INPUT_HEIGHT} + space={2} + borderRadius="standard" + /> + ) + } + + if (applicationError) { + setSubmitButtonDisabled && setSubmitButtonDisabled(true) + + return ( + <AlertMessage + type="error" + title={f(error.fetchApplicationFailedTitle)} + message={f(error.fetchApplicationFailedMessage)} + /> + ) + } return ( <FormScreen @@ -58,7 +140,7 @@ export const RequirementsScreen = ({ /> ) }} - />{' '} + /> </FormScreen> ) } diff --git a/libs/application/templates/university/src/shared/types.ts b/libs/application/templates/university/src/shared/types.ts index 3afa07a83d31..2d5eb6aba94b 100644 --- a/libs/application/templates/university/src/shared/types.ts +++ b/libs/application/templates/university/src/shared/types.ts @@ -20,3 +20,39 @@ export type CurrentApplication = { id: string nationalId: string } + +export type UniversityGatewayProgram = { + active: boolean + applicationEndDate: string + applicationInUniversityGateway: boolean + applicationPeriodOpen: boolean + applicationStartDate: string + costPerYear?: number + credits: number + degreeAbbreviation: string + degreeType: string + departmentNameEn: string + departmentNameIs: string + descriptionEn: string + descriptionIs: string + durationInYears: number + externalId: string + id: string + iscedCode: string + modeOfDelivery: Array<ModeOfDelivery> + nameEn: string + nameIs: string + schoolAnswerDate?: string + specializationExternalId?: string + specializationNameEn?: string + specializationNameIs?: string + startingSemesterSeason: string + startingSemesterYear: number + studentAnswerDate?: string + universityContentfulKey: string + universityId: string +} + +type ModeOfDelivery = { + modeOfDelivery: string +} diff --git a/libs/application/types/src/lib/Fields.ts b/libs/application/types/src/lib/Fields.ts index 14a5ccc3d75a..a3631b76dc20 100644 --- a/libs/application/types/src/lib/Fields.ts +++ b/libs/application/types/src/lib/Fields.ts @@ -534,15 +534,31 @@ export interface NationalIdWithNameField extends BaseField { minAgePerson?: number } +type Modify<T, R> = Omit<T, keyof R> & R + export type ActionCardListField = BaseField & { readonly type: FieldTypes.ACTION_CARD_LIST component: FieldComponents.ACTION_CARD_LIST - items: (application: Application) => ActionCardProps[] + items: (application: Application) => ApplicationActionCardProps[] space?: BoxProps['paddingTop'] marginBottom?: BoxProps['marginBottom'] marginTop?: BoxProps['marginTop'] } +export type ApplicationActionCardProps = Modify< + ActionCardProps, + { + heading?: FormText + text?: FormText + tag?: Modify<ActionCardProps['tag'], { label: FormText }> + cta?: Modify<ActionCardProps['cta'], { label: FormText }> + unavailable?: Modify< + ActionCardProps['unavailable'], + { label?: FormText; message?: FormText } + > + } +> + export type TableRepeaterField = BaseField & { readonly type: FieldTypes.TABLE_REPEATER component: FieldComponents.TABLE_REPEATER diff --git a/libs/application/types/src/lib/template-api/shared-api/shared-api-definitions/national-registry-user.ts b/libs/application/types/src/lib/template-api/shared-api/shared-api-definitions/national-registry-user.ts index c1f33d57daf2..824f9cd43e43 100644 --- a/libs/application/types/src/lib/template-api/shared-api/shared-api-definitions/national-registry-user.ts +++ b/libs/application/types/src/lib/template-api/shared-api/shared-api-definitions/national-registry-user.ts @@ -6,6 +6,7 @@ export interface NationalRegistryParameters { legalDomicileIceland?: boolean ageToValidateError?: ProviderErrorReason icelandicCitizenship?: boolean + allowIfChildHasCitizenship?: boolean validateAlreadyHasIcelandicCitizenship?: boolean allowPassOnChild?: boolean citizenshipWithinEES?: boolean diff --git a/libs/application/ui-fields/src/lib/ActionCardListFormField/ActionCardListFormField.tsx b/libs/application/ui-fields/src/lib/ActionCardListFormField/ActionCardListFormField.tsx index acaa73b1cd10..b2f63f1f93c1 100644 --- a/libs/application/ui-fields/src/lib/ActionCardListFormField/ActionCardListFormField.tsx +++ b/libs/application/ui-fields/src/lib/ActionCardListFormField/ActionCardListFormField.tsx @@ -4,6 +4,9 @@ import { } from '@island.is/application/types' import { ActionCard, Stack, Box } from '@island.is/island-ui/core' import { FC } from 'react' +import { useLocale } from '@island.is/localization' +import { formatText } from '@island.is/application/core' +import { ActionCardProps } from '@island.is/island-ui/core/types' interface Props extends FieldBaseProps { field: ActionCardListField @@ -11,13 +14,42 @@ interface Props extends FieldBaseProps { export const ActionCardListFormField: FC<Props> = ({ application, field }) => { const { items, marginBottom = 4, marginTop = 4, space = 2 } = field - + const { formatMessage } = useLocale() return ( <Box marginBottom={marginBottom} marginTop={marginTop}> <Stack space={space}> - {items(application).map((item, index) => ( - <ActionCard key={index} {...item} /> - ))} + {items(application).map((item, index) => { + const itemWithTranslatedTexts: ActionCardProps = { + ...item, + heading: + item.heading && + formatText(item.heading, application, formatMessage), + text: + item.text && formatText(item.text, application, formatMessage), + tag: item.tag && { + ...item.tag, + label: formatText(item.tag?.label, application, formatMessage), + }, + cta: item.cta && { + ...item.cta, + label: formatText(item.cta?.label, application, formatMessage), + }, + unavailable: { + ...item.unavailable, + label: + item.unavailable?.label && + formatText(item.unavailable.label, application, formatMessage), + message: + item.unavailable?.message && + formatText( + item.unavailable.message, + application, + formatMessage, + ), + }, + } + return <ActionCard key={index} {...itemWithTranslatedTexts} /> + })} </Stack> </Box> ) diff --git a/libs/clients/health-directorate/src/index.ts b/libs/clients/health-directorate/src/index.ts index 2ec0bfdbc768..0ce3077b2353 100644 --- a/libs/clients/health-directorate/src/index.ts +++ b/libs/clients/health-directorate/src/index.ts @@ -12,4 +12,5 @@ export { Locale, organLocale, VaccinationDto, + DiseaseVaccinationDtoVaccinationStatusEnum, } from './lib/clients' diff --git a/libs/clients/health-directorate/src/lib/clients/organ-donation/clientConfig.json b/libs/clients/health-directorate/src/lib/clients/organ-donation/clientConfig.json index b95e62cdb6a8..3f2b4e84bf09 100644 --- a/libs/clients/health-directorate/src/lib/clients/organ-donation/clientConfig.json +++ b/libs/clients/health-directorate/src/lib/clients/organ-donation/clientConfig.json @@ -1,11 +1,26 @@ { "openapi": "3.0.0", "paths": { - "/v1/me/donor-status": { + "/v1/me/organ-donor-status": { "get": { "operationId": "MeDonorStatusController_getOrganDonorStatus", "description": "Get user's donation-exception donor status", - "parameters": [], + "parameters": [ + { + "name": "ip", + "required": false, + "in": "query", + "description": "The IP address of the user", + "schema": { "type": "string" } + }, + { + "name": "locale", + "required": false, + "in": "query", + "description": "The locale to use for the response", + "schema": { "$ref": "#/components/schemas/Locale" } + } + ], "responses": { "200": { "description": "", @@ -48,12 +63,20 @@ } } }, - "tags": ["me/donor-status"] + "tags": ["me/organ-donor-status"] }, "post": { "operationId": "MeDonorStatusController_updateOrganDonorStatus", "description": "Update user's donation-exception donor status", - "parameters": [], + "parameters": [ + { + "name": "ip", + "required": false, + "in": "query", + "description": "The IP address of the user", + "schema": { "type": "string" } + } + ], "requestBody": { "required": true, "content": { @@ -97,7 +120,7 @@ } } }, - "tags": ["me/donor-status"] + "tags": ["me/organ-donor-status"] } }, "/v1/donation-exceptions": { @@ -164,7 +187,7 @@ }, "info": { "title": "Organ donor API", - "description": "The api provides access to information about individuals' donation-exception donation status.", + "description": "The api provides access to information about individuals' donation status.", "version": "1.0", "contact": {} }, @@ -180,33 +203,40 @@ "tokenUrl": "https://identity-server.dev01.devland.is/connect/token", "scopes": { "openid": "openid", - "@landlaeknir.is/organ-donations": "Get and update donation-exception donation status for a user" + "@landlaeknir.is/organ-donations": "Get and update donation status for a user" } } } } }, "schemas": { + "Locale": { "type": "string", "enum": ["en", "is"] }, + "OrganDto": { + "type": "object", + "properties": { + "id": { "type": "string" }, + "name": { "type": "string" } + }, + "required": ["id", "name"] + }, "OrganDonorDto": { "type": "object", "properties": { "isDonor": { "type": "boolean" }, - "exceptions": { "type": "array", "items": { "type": "string" } }, + "exceptions": { + "type": "array", + "items": { "$ref": "#/components/schemas/OrganDto" } + }, "exceptionComment": { "type": "string" }, "registrationDate": { "format": "date-time", "type": "string" } }, - "required": [ - "isDonor", - "exceptions", - "exceptionComment", - "registrationDate" - ] + "required": ["isDonor", "exceptions"] }, "HttpProblemResponse": { "type": "object", "properties": { "type": { - "type": "string", + "type": "object", "description": "A URI reference that identifies the problem type" }, "title": { @@ -230,18 +260,9 @@ "properties": { "isDonor": { "type": "boolean" }, "exceptions": { "type": "array", "items": { "type": "string" } }, - "exceptionComment": { "type": "string" } - }, - "required": ["isDonor", "exceptions", "exceptionComment"] - }, - "Locale": { "type": "string", "enum": ["en", "is"] }, - "OrganDto": { - "type": "object", - "properties": { - "id": { "type": "string" }, - "name": { "type": "string" } + "exceptionComment": { "type": "object" } }, - "required": ["id", "name"] + "required": ["isDonor", "exceptions"] } } } diff --git a/libs/clients/health-directorate/src/lib/clients/organ-donation/organDonation.config.ts b/libs/clients/health-directorate/src/lib/clients/organ-donation/organDonation.config.ts index 6d6e400e89d1..bfc0d54aa840 100644 --- a/libs/clients/health-directorate/src/lib/clients/organ-donation/organDonation.config.ts +++ b/libs/clients/health-directorate/src/lib/clients/organ-donation/organDonation.config.ts @@ -14,10 +14,10 @@ export const HealthDirectorateOrganDonationClientConfig = defineConfig< load(env) { return { xroadPath: env.required( - 'XROAD_HEALTH_DIRECTORATE_PATH', - 'IS-DEV/GOV/10015/EmbaettiLandlaeknis-Protected/landlaeknir', + 'XROAD_HEALTH_DIRECTORATE_ORGAN_DONATION_PATH', + 'IS-DEV/GOV/10015/EmbaettiLandlaeknis-Protected/organ-donation-v1', ), - scope: [], + scope: ['@landlaeknir.is/organ-donations'], } }, }) diff --git a/libs/clients/health-directorate/src/lib/clients/organ-donation/organDonation.provider.ts b/libs/clients/health-directorate/src/lib/clients/organ-donation/organDonation.provider.ts index 28a50d3c1657..23c07598c250 100644 --- a/libs/clients/health-directorate/src/lib/clients/organ-donation/organDonation.provider.ts +++ b/libs/clients/health-directorate/src/lib/clients/organ-donation/organDonation.provider.ts @@ -8,19 +8,19 @@ import { import { HealthDirectorateOrganDonationClientConfig } from './organDonation.config' import { Configuration, - MeDonorStatusApi, + MeOrganDonorStatusApi, DonationExceptionsApi, } from './gen/fetch' export const OrganDonorApiProvider = { - provide: MeDonorStatusApi, + provide: MeOrganDonorStatusApi, scope: LazyDuringDevScope, useFactory: ( xRoadConfig: ConfigType<typeof XRoadConfig>, config: ConfigType<typeof HealthDirectorateOrganDonationClientConfig>, idsClientConfig: ConfigType<typeof IdsClientConfig>, ) => { - return new MeDonorStatusApi( + return new MeOrganDonorStatusApi( new Configuration({ fetchApi: createEnhancedFetch({ name: 'clients-health-directorate-organ-donation', diff --git a/libs/clients/health-directorate/src/lib/clients/organ-donation/organDonation.service.ts b/libs/clients/health-directorate/src/lib/clients/organ-donation/organDonation.service.ts index b794307b59db..37ceeb3da061 100644 --- a/libs/clients/health-directorate/src/lib/clients/organ-donation/organDonation.service.ts +++ b/libs/clients/health-directorate/src/lib/clients/organ-donation/organDonation.service.ts @@ -4,9 +4,10 @@ import { Inject, Injectable } from '@nestjs/common' import { DonationExceptionsApi, Locale, - MeDonorStatusApi, + MeOrganDonorStatusApi, OrganDonorDto, OrganDto, + UpdateOrganDonorDto, } from './gen/fetch' import { LOGGER_PROVIDER } from '@island.is/logging' import type { Logger } from '@island.is/logging' @@ -16,7 +17,7 @@ const LOG_CATEGORY = 'health-directorate-organ-donation-api' export class HealthDirectorateOrganDonationService { constructor( @Inject(LOGGER_PROVIDER) private readonly logger: Logger, - private readonly organDonationApi: MeDonorStatusApi, + private readonly organDonationApi: MeOrganDonorStatusApi, private readonly donationExceptionsApi: DonationExceptionsApi, ) {} @@ -24,9 +25,12 @@ export class HealthDirectorateOrganDonationService { return this.organDonationApi.withMiddleware(new AuthMiddleware(auth)) } - public async getOrganDonation(auth: Auth): Promise<OrganDonorDto | null> { + public async getOrganDonation( + auth: Auth, + input: Locale, + ): Promise<OrganDonorDto | null> { const organDonation = await this.organDonationApiWithAuth(auth) - .meDonorStatusControllerGetOrganDonorStatus() + .meDonorStatusControllerGetOrganDonorStatus({ locale: input }) .catch(handle404) if (!organDonation) { @@ -41,17 +45,13 @@ export class HealthDirectorateOrganDonationService { public async updateOrganDonation( auth: Auth, - input: OrganDonorDto, + input: UpdateOrganDonorDto, ): Promise<void> { - await this.organDonationApiWithAuth(auth) - .meDonorStatusControllerUpdateOrganDonorStatus({ - updateOrganDonorDto: input, - }) - .catch((error) => { - throw new Error( - `health-directorate-organ-donation-client: upload organ donation status failed ${error.type}`, - ) - }) + await this.organDonationApiWithAuth( + auth, + ).meDonorStatusControllerUpdateOrganDonorStatus({ + updateOrganDonorDto: input, + }) } public async getDonationExceptions( diff --git a/libs/clients/health-directorate/src/lib/clients/vaccinations/clientConfig.json b/libs/clients/health-directorate/src/lib/clients/vaccinations/clientConfig.json index 797800fb2f17..28fcdb7b9ec0 100644 --- a/libs/clients/health-directorate/src/lib/clients/vaccinations/clientConfig.json +++ b/libs/clients/health-directorate/src/lib/clients/vaccinations/clientConfig.json @@ -58,14 +58,7 @@ "get": { "operationId": "MeVaccinationController_getVaccinationsForDiseases", "description": "Get overview of vaccinations for a user", - "parameters": [ - { - "name": "locale", - "required": true, - "in": "query", - "schema": { "type": "string" } - } - ], + "parameters": [], "responses": { "200": { "description": "", @@ -127,12 +120,6 @@ "in": "path", "description": "id of the disease", "schema": { "type": "string" } - }, - { - "name": "locale", - "required": true, - "in": "query", - "schema": { "type": "string" } } ], "responses": { @@ -585,6 +572,29 @@ "tags": ["diseases"] } }, + "/v1/vaccines": { + "get": { + "operationId": "VaccineController_getVaccines", + "parameters": [], + "responses": { "200": { "description": "" } }, + "tags": ["vaccines"] + } + }, + "/v1/vaccines/{vaccineId}": { + "get": { + "operationId": "VaccineController_getVaccineById", + "parameters": [ + { + "name": "vaccineId", + "required": true, + "in": "path", + "schema": { "type": "number" } + } + ], + "responses": { "200": { "description": "" } }, + "tags": ["vaccines"] + } + }, "/v1/visualizer/diseases/{diseaseId}": { "get": { "operationId": "VisualizerController_getStatus", @@ -649,8 +659,14 @@ } } } - }, - "tags": ["visualizer"] + } + } + }, + "/*": { + "get": { + "operationId": "VisualizerController_getVisualizer", + "parameters": [], + "responses": { "200": { "description": "" } } } } }, @@ -673,7 +689,9 @@ "scopes": { "openid": "openid", "vaccination": "vaccination", - "@landlaeknir.is/vaccinations:admin": "Get vaccination data for a user" + "@landlaeknir.is/vaccinations:admin": "Manage disease- and vaccine data", + "@landlaeknir.is/vaccinations": "Get vaccination data for a user", + "@landlaeknir.is/vaccinations:read": "Get disease- and vaccine data" } } } @@ -685,10 +703,11 @@ "properties": { "id": { "type": "number" }, "nationalId": { "type": "string", "example": "1234567890" }, - "code": { "type": "string", "example": "J07BJ51" }, - "codingSystem": { "type": "string", "example": "ATC" }, - "codeDescription": { "type": "string", "example": "ATC" }, - "vaccineName": { "type": "string", "example": "ATC" }, + "vaccineCode": { "type": "string", "example": "J07BJ51" }, + "vaccineCodingSystem": { "type": "string", "example": "ATC" }, + "vaccineCodeDescription": { "type": "string" }, + "vaccineCodeDescriptionShort": { "type": "string" }, + "vaccineUrl": { "type": "string" }, "vaccinationDate": { "format": "date-time", "type": "string", @@ -698,16 +717,17 @@ "type": "object", "example": { "years": 1, "months": 2 } }, - "generalComment": { "type": "object", "example": "ATC" }, + "generalComment": { "type": "string" }, "rejected": { "type": "boolean", "example": false } }, "required": [ "id", "nationalId", - "code", - "codingSystem", - "codeDescription", - "vaccineName", + "vaccineCode", + "vaccineCodingSystem", + "vaccineCodeDescription", + "vaccineCodeDescriptionShort", + "vaccineUrl", "vaccinationDate", "vaccinationAge", "generalComment", @@ -718,7 +738,7 @@ "type": "object", "properties": { "type": { - "type": "string", + "type": "object", "description": "A URI reference that identifies the problem type" }, "title": { @@ -737,12 +757,20 @@ }, "required": ["type", "title"] }, + "VaccinationStatusColor": { + "type": "string", + "enum": ["green", "yellow", "red"] + }, "DiseaseVaccinationDto": { "type": "object", "properties": { "diseaseId": { "type": "string", "example": "tetanus" }, "diseaseName": { "type": "string", "example": "Stífkrampi" }, "diseaseDescription": { "type": "string" }, + "isFeatured": { + "type": "boolean", + "description": "Is the vaccination scheduled as in \"almenn bólusetning\"" + }, "vaccinationStatus": { "type": "string", "example": "vaccinated", @@ -758,6 +786,9 @@ ] }, "vaccinationStatusName": { "type": "string", "example": "Í gildi" }, + "vaccinationStatusColor": { + "$ref": "#/components/schemas/VaccinationStatusColor" + }, "lastVaccinationDate": { "format": "date-time", "type": "string" }, "vaccinations": { "type": "array", @@ -768,10 +799,7 @@ "required": [ "diseaseId", "diseaseName", - "diseaseDescription", - "vaccinationStatus", - "vaccinationStatusName", - "lastVaccinationDate", + "isFeatured", "vaccinations", "comments" ] @@ -779,12 +807,21 @@ "VaccineDto": { "type": "object", "properties": { + "id": { "type": "number" }, "code": { "type": "string" }, "codingSystem": { "type": "string", "example": "ATC" }, "name": { "type": "string" }, - "description": { "type": "string" } + "description": { "type": "string" }, + "diseases": { "type": "array", "items": { "type": "string" } } }, - "required": ["code", "codingSystem", "name", "description"] + "required": [ + "id", + "code", + "codingSystem", + "name", + "description", + "diseases" + ] }, "Locale": { "type": "string", "enum": ["en", "is"] }, "DiseaseRuleTranslationDto": { @@ -801,7 +838,7 @@ "id": { "type": "number" }, "diseaseId": { "type": "string" }, "order": { "type": "number" }, - "type": { "type": "string" }, + "type": { "type": "object" }, "cond1Type": { "type": "string" }, "cond1Min": { "type": "number" }, "cond1Max": { "type": "number" }, @@ -865,7 +902,7 @@ "items": { "type": "number" } }, "isFeatured": { "type": "boolean" }, - "isScheduled": { "type": "boolean" }, + "isVisible": { "type": "boolean" }, "vaccines": { "type": "array", "items": { "$ref": "#/components/schemas/VaccineDto" } @@ -885,7 +922,7 @@ "description", "doseSchedule", "isFeatured", - "isScheduled", + "isVisible", "vaccines", "rules", "translations" @@ -904,7 +941,7 @@ "items": { "type": "number" } }, "isFeatured": { "type": "boolean" }, - "isScheduled": { "type": "boolean" }, + "isVisible": { "type": "boolean" }, "rules": { "type": "array", "items": { "$ref": "#/components/schemas/DiseaseRuleDto" } @@ -919,7 +956,7 @@ "type": "object", "properties": { "order": { "type": "number" }, - "type": { "type": "string" }, + "type": { "type": "object" }, "cond1Type": { "type": "string" }, "cond1Min": { "type": "number" }, "cond1Max": { "type": "number" }, @@ -965,7 +1002,7 @@ "id": { "type": "number" }, "diseaseId": { "type": "string" }, "order": { "type": "number" }, - "type": { "type": "string" }, + "type": { "type": "object" }, "cond1Type": { "type": "string" }, "cond1Min": { "type": "number" }, "cond1Max": { "type": "number" }, diff --git a/libs/clients/health-directorate/src/lib/clients/vaccinations/vaccinations.config.ts b/libs/clients/health-directorate/src/lib/clients/vaccinations/vaccinations.config.ts index 98b99dca5f3f..c82aa75c2dd0 100644 --- a/libs/clients/health-directorate/src/lib/clients/vaccinations/vaccinations.config.ts +++ b/libs/clients/health-directorate/src/lib/clients/vaccinations/vaccinations.config.ts @@ -14,10 +14,10 @@ export const HealthDirectorateVaccinationsClientConfig = defineConfig< load(env) { return { xroadPath: env.required( - 'XROAD_HEALTH_DIRECTORATE_PATH', - 'IS-DEV/GOV/10015/EmbaettiLandlaeknis-Protected/landlaeknir', + 'XROAD_HEALTH_DIRECTORATE_VACCINATION_PATH', + 'IS-DEV/GOV/10015/EmbaettiLandlaeknis-Protected/vaccination-v1', ), - scope: [], + scope: ['@landlaeknir.is/vaccinations'], } }, }) diff --git a/libs/clients/health-directorate/src/lib/clients/vaccinations/vaccinations.service.ts b/libs/clients/health-directorate/src/lib/clients/vaccinations/vaccinations.service.ts index e494367fbff1..84acba873821 100644 --- a/libs/clients/health-directorate/src/lib/clients/vaccinations/vaccinations.service.ts +++ b/libs/clients/health-directorate/src/lib/clients/vaccinations/vaccinations.service.ts @@ -3,7 +3,6 @@ import { handle404 } from '@island.is/clients/middlewares' import { Inject, Injectable } from '@nestjs/common' import { DiseaseVaccinationDto, - MeVaccinationControllerGetVaccinationsForDiseasesRequest, MeVaccinationsApi, VaccinationDto, } from './gen/fetch' @@ -41,10 +40,9 @@ export class HealthDirectorateVaccinationsService { public async getVaccinationDiseaseDetail( auth: Auth, - input: MeVaccinationControllerGetVaccinationsForDiseasesRequest, ): Promise<Array<DiseaseVaccinationDto> | null> { const disease = await this.vaccinationsApiWithAuth(auth) - .meVaccinationControllerGetVaccinationsForDiseases(input) + .meVaccinationControllerGetVaccinationsForDiseases() .catch(handle404) if (!disease) { diff --git a/libs/clients/official-journal-of-iceland/application/src/clientConfig.json b/libs/clients/official-journal-of-iceland/application/src/clientConfig.json index 60799421cc3b..86aa18f66b74 100644 --- a/libs/clients/official-journal-of-iceland/application/src/clientConfig.json +++ b/libs/clients/official-journal-of-iceland/application/src/clientConfig.json @@ -4,19 +4,16 @@ "/api/v1/applications/{id}/price": { "get": { "operationId": "getPrice", - "summary": "Get price of application by ID.", "parameters": [ { "name": "id", "required": true, "in": "path", - "description": "Id of the application to get price.", - "allowEmptyValue": false, "schema": { "type": "string" } } ], "responses": { - "200": { + "default": { "description": "", "content": { "application/json": { @@ -27,38 +24,58 @@ } } }, + "/api/v1/applications/{id}": { + "get": { + "operationId": "getApplication", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { "type": "string" } + } + ], + "responses": { + "default": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetApplicationResponse" + } + } + } + } + } + } + }, "/api/v1/applications/{id}/post": { "post": { "operationId": "postApplication", - "summary": "Post application.", "parameters": [ { "name": "id", "required": true, "in": "path", - "description": "Id of the application to post.", - "allowEmptyValue": false, "schema": { "type": "string" } } ], - "responses": { "204": { "description": "" } } + "responses": { "201": { "description": "" } } } }, "/api/v1/applications/{id}/comments": { "get": { "operationId": "getComments", - "summary": "Get comments by application ID.", "parameters": [ { "name": "id", "required": true, "in": "path", - "description": "Id of the application to get comments.", "schema": { "type": "string" } } ], "responses": { - "200": { + "default": { "description": "", "content": { "application/json": { @@ -72,23 +89,162 @@ }, "post": { "operationId": "postComment", - "summary": "Add comment to application.", "parameters": [ { "name": "id", "required": true, "in": "path", - "description": "Id of the application to post comment.", "schema": { "type": "string" } } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PostApplicationComment" + } + } + } + }, + "responses": { "201": { "description": "" } } + } + }, + "/api/v1/applications/{id}/upload": { + "post": { + "operationId": "uploadApplicationAttachment", + "summary": "", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { "type": "string" } + } + ], + "requestBody": { + "required": true, + "description": "Handles uploading attachments for an application.", + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "properties": { + "files": { + "description": "The attachments", + "type": "array", + "items": { "type": "string", "format": "binary" } + } + } + } + } + } + }, + "responses": { + "200": { + "description": "The attachments were uploaded successfully.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/S3UploadFilesResponse" + } + } + } + } + } + } + }, + "/api/v1/applications/{id}/presigned-url/{type}": { + "post": { + "operationId": "getPresignedUrl", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { "type": "string" } + }, + { + "name": "type", + "required": true, + "in": "path", + "schema": { "enum": ["frumrit", "fylgiskjol"], "type": "string" } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/GetPresignedUrlBody" } + } + } + }, + "responses": { + "default": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PresignedUrlResponse" + } + } + } + } + } + } + }, + "/api/v1/applications/{id}/attachments/{type}": { + "post": { + "operationId": "addApplicationAttachment", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { "type": "string" } + }, + { + "name": "type", + "required": true, + "in": "path", + "schema": { "enum": ["frumrit", "fylgiskjol"], "type": "string" } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PostApplicationAttachmentBody" + } + } + } + }, + "responses": { "201": { "description": "" } } + }, + "get": { + "operationId": "getApplicationAttachments", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { "type": "string" } + }, + { + "name": "type", + "required": true, + "in": "path", + "schema": { "enum": ["frumrit", "fylgiskjol"], "type": "string" } + } + ], "responses": { - "201": { + "default": { "description": "", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PostCaseCommentResponse" + "$ref": "#/components/schemas/GetApplicationAttachmentsResponse" } } } @@ -96,6 +252,26 @@ } } }, + "/api/v1/applications/{id}/attachments": { + "delete": { + "operationId": "deleteApplicationAttachment", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { "type": "string" } + }, + { + "name": "key", + "required": true, + "in": "query", + "schema": { "type": "string" } + } + ], + "responses": { "204": { "description": "" } } + } + }, "/api/v1/health": { "get": { "operationId": "HealthController_", @@ -213,6 +389,446 @@ }, "required": ["price"] }, + "ApplicationRequirements": { + "type": "object", + "properties": { + "approveExternalData": { + "type": "string", + "enum": ["yes", "no"], + "example": "yes", + "description": "Has the applicant approved the requirements" + } + }, + "required": ["approveExternalData"] + }, + "ApplicationAdvert": { + "type": "object", + "properties": { + "department": { + "type": "string", + "example": "b783c4d5-6e78-9f01-2g34-h56i7j8k9l0m", + "description": "Id of the selected department for the application advert" + }, + "type": { + "type": "string", + "example": "a71ka2b3-4c56-7d89-0e12-3f45g6h7i8j9", + "description": "Id of the selected type for the application advert" + }, + "title": { + "type": "string", + "example": "GJALDSKRÁ fyrir hundahald í Reykjavík", + "description": "Title of the application advert" + }, + "document": { + "type": "string", + "description": "HTML string of the application advert" + }, + "template": { + "type": "string", + "description": "Selected template for the application advert" + }, + "subType": { + "type": "string", + "example": "b781ks2-3c45-6d78-9e01-2f34g5h6i7j8", + "description": "Id of the selected subType for the application advert, only when type is \"Reglugerð\"" + } + }, + "required": [ + "department", + "type", + "title", + "document", + "template", + "subType" + ] + }, + "ApplicationPreview": { + "type": "object", + "properties": { + "document": { + "type": "string", + "example": "<html><p>GJALDSKRÁ</p></html>", + "description": "HTML string of the advert with signature" + } + }, + "required": ["document"] + }, + "ApplicationSignatureMember": { + "type": "object", + "properties": { + "above": { + "type": "string", + "example": "F.h.r", + "description": "Text above the name of the signature" + }, + "name": { + "type": "string", + "example": "Jón Jónsson", + "description": "Name of the signature" + }, + "after": { + "type": "string", + "example": "ráðherra", + "description": "Text after the name of the signature" + }, + "below": { + "type": "string", + "example": "Text below the name of the signature", + "description": "borgarstjóri" + } + }, + "required": ["above", "name", "after", "below"] + }, + "ApplicationRegularSignature": { + "type": "object", + "properties": { + "institution": { + "type": "string", + "example": "Dómsmálaráðuneytið", + "description": "Institution/place of the signature" + }, + "date": { + "type": "string", + "example": "2021-04-01T00:00:00.000Z", + "description": "Date of the signature" + }, + "members": { + "description": "Member of the signature", + "type": "array", + "items": { + "$ref": "#/components/schemas/ApplicationSignatureMember" + } + } + }, + "required": ["institution", "date", "members"] + }, + "ApplicationCommitteeSignature": { + "type": "object", + "properties": { + "institution": { + "type": "string", + "example": "Dómsmálaráðuneytið", + "description": "Institution/place of the signature" + }, + "date": { + "type": "string", + "example": "2021-04-01T00:00:00.000Z", + "description": "Date of the signature" + }, + "members": { + "description": "Member of the signature", + "type": "array", + "items": { + "$ref": "#/components/schemas/ApplicationSignatureMember" + } + } + }, + "required": ["institution", "date", "members"] + }, + "ApplicationSignature": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "committee", + "description": "Signature type of the application" + }, + "signature": { + "type": "string", + "example": "<p>Jón Jónsson</p>", + "description": "HTML string of the signature" + }, + "additional": { + "type": "string", + "example": "Dagur B. Eggertsson", + "description": "Additional name of the signature" + }, + "regular": { + "description": "Regular signature of the application", + "type": "array", + "items": { + "$ref": "#/components/schemas/ApplicationRegularSignature" + } + }, + "committee": { + "description": "Committee signature of the application", + "allOf": [ + { "$ref": "#/components/schemas/ApplicationCommitteeSignature" } + ] + } + }, + "required": ["type", "signature", "additional", "regular", "committee"] + }, + "ApplicationAttachmentsFileSchema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "example": "filename.doc", + "description": "Name of the attachment" + }, + "key": { + "type": "string", + "example": "key", + "description": "Key of the attachment" + } + }, + "required": ["name", "key"] + }, + "ApplicationAttachments": { + "type": "object", + "properties": { + "files": { + "description": "List of attachments", + "type": "array", + "items": { + "$ref": "#/components/schemas/ApplicationAttachmentsFileSchema" + } + }, + "fileNames": { + "type": "string", + "example": "document", + "description": "Selected department for the application attachment, should be \"document\" or \"additions\"" + } + }, + "required": ["files", "fileNames"] + }, + "ApplicationContentCategories": { + "type": "object", + "properties": { + "label": { + "type": "string", + "example": "Gæludýr", + "description": "Label of the category" + }, + "value": { + "type": "string", + "example": "b619j2k3-4l56-7m89-0n12-3o45p6q7r8s9", + "description": "Id of the selected category" + } + }, + "required": ["label", "value"] + }, + "ApplicationCommunicationChannels": { + "type": "object", + "properties": { + "email": { + "type": "string", + "example": "email", + "description": "Selected communication channel" + }, + "phone": { + "type": "string", + "example": "5555555", + "description": "Phone number of the communication channel" + } + }, + "required": ["email", "phone"] + }, + "ApplicationPublishing": { + "type": "object", + "properties": { + "date": { + "type": "string", + "example": "2021-04-01T00:00:00.000Z", + "description": "Requested publishing date" + }, + "fastTrack": { + "type": "string", + "enum": ["yes", "no"], + "example": "yes", + "description": "Request fast track for the advert" + }, + "contentCategories": { + "description": "List of selected categories", + "type": "array", + "items": { + "$ref": "#/components/schemas/ApplicationContentCategories" + } + }, + "communicationChannels": { + "description": "Selected communication channels", + "type": "array", + "items": { + "$ref": "#/components/schemas/ApplicationCommunicationChannels" + } + }, + "message": { + "type": "string", + "example": "Some message..", + "description": "Message for the publisher" + } + }, + "required": [ + "date", + "fastTrack", + "contentCategories", + "communicationChannels", + "message" + ] + }, + "ApplicationOriginalFiles": { + "type": "object", + "properties": { + "files": { + "description": "List of original files", + "type": "array", + "items": { + "$ref": "#/components/schemas/ApplicationAttachmentsFileSchema" + } + } + }, + "required": ["files"] + }, + "ApplicationAnswers": { + "type": "object", + "properties": { + "requirements": { + "example": "true", + "description": "Has the applicant approved the external data", + "allOf": [ + { "$ref": "#/components/schemas/ApplicationRequirements" } + ] + }, + "advert": { + "description": "Application advert", + "allOf": [{ "$ref": "#/components/schemas/ApplicationAdvert" }] + }, + "preview": { + "description": "Contents of the full document", + "allOf": [{ "$ref": "#/components/schemas/ApplicationPreview" }] + }, + "signature": { + "description": "Application signature", + "allOf": [{ "$ref": "#/components/schemas/ApplicationSignature" }] + }, + "additionsAndDocuments": { + "description": "Application attachments", + "allOf": [{ "$ref": "#/components/schemas/ApplicationAttachments" }] + }, + "publishing": { + "description": "Application publishing", + "allOf": [{ "$ref": "#/components/schemas/ApplicationPublishing" }] + }, + "original": { + "description": "Application original files", + "allOf": [ + { "$ref": "#/components/schemas/ApplicationOriginalFiles" } + ] + } + }, + "required": [ + "requirements", + "advert", + "preview", + "signature", + "additionsAndDocuments", + "publishing", + "original" + ] + }, + "Application": { + "type": "object", + "properties": { + "id": { + "type": "string", + "example": "a12c3d4e-5f67-8h90-1i23-j45k6l7m8n9o0", + "description": "Guid of the application" + }, + "applicant": { + "type": "string", + "example": "0101015050", + "description": "National id of the applicant" + }, + "assignees": { + "example": ["0101015050"], + "description": "List of assignees", + "type": "array", + "items": { "type": "string" } + }, + "attachments": { "type": "object", "description": "Attachments" }, + "state": { + "type": "string", + "example": "draft", + "description": "State of the application" + }, + "status": { + "type": "string", + "example": "inprogress", + "description": "Status of the application" + }, + "typeId": { + "type": "string", + "example": "OfficialJournalOfIceland", + "description": "Type of the application" + }, + "created": { + "type": "string", + "example": "2021-04-01T00:00:00.000Z", + "description": "Application creation date" + }, + "modified": { + "type": "string", + "example": "2021-04-01T00:00:00.000Z", + "description": "Application last modified date" + }, + "name": { + "type": "string", + "example": "Stjórnartíðindi", + "description": "Name of the application" + }, + "applicantActors": { + "description": "List of applicant actors", + "type": "array", + "items": { "type": "string" } + }, + "answers": { + "description": "Application answers", + "allOf": [{ "$ref": "#/components/schemas/ApplicationAnswers" }] + }, + "externalData": { "type": "object" }, + "listed": { + "type": "boolean", + "example": true, + "description": "Is the application listed" + }, + "prunedAt": { + "type": "string", + "example": "2021-04-01T00:00:00.000Z", + "description": "Prune date of the application" + }, + "pruned": { + "type": "boolean", + "example": "2021-04-01T00:00:00.000Z", + "description": "Date of the application" + } + }, + "required": [ + "id", + "applicant", + "assignees", + "attachments", + "state", + "status", + "typeId", + "created", + "modified", + "name", + "applicantActors", + "answers", + "externalData", + "listed", + "prunedAt", + "pruned" + ] + }, + "GetApplicationResponse": { + "type": "object", + "properties": { + "application": { "$ref": "#/components/schemas/Application" } + }, + "required": ["application"] + }, "CaseCommentTask": { "type": "object", "properties": { @@ -232,8 +848,8 @@ "type": "string", "enum": [ "Innsent af:", - "merkir sér málið.", "færir mál á", + "merkir sér málið.", "færir mál í stöðuna:", "gerir athugasemd.", "skráir skilaboð" @@ -331,16 +947,147 @@ }, "required": ["comments"] }, - "PostCaseCommentResponse": { + "PostApplicationComment": { "type": "object", "properties": { "comment": { - "description": "The created case comment", - "allOf": [{ "$ref": "#/components/schemas/CaseComment" }] + "type": "string", + "description": "The case comment itself" } }, "required": ["comment"] }, + "S3UploadFileResponse": { + "type": "object", + "properties": { + "url": { + "type": "string", + "description": "The URL of the uploaded file." + }, + "filename": { + "type": "string", + "description": "Filename of the uploaded file." + }, + "size": { + "type": "number", + "description": "The size of the uploaded file." + } + }, + "required": ["url", "filename", "size"] + }, + "S3UploadFilesResponse": { + "type": "object", + "properties": { + "files": { + "description": "The uploaded files.", + "type": "array", + "items": { "$ref": "#/components/schemas/S3UploadFileResponse" } + } + }, + "required": ["files"] + }, + "GetPresignedUrlBody": { + "type": "object", + "properties": { + "fileName": { "type": "string" }, + "fileType": { "type": "string" } + }, + "required": ["fileName", "fileType"] + }, + "PresignedUrlResponse": { + "type": "object", + "properties": { "url": { "type": "string" } }, + "required": ["url"] + }, + "PostApplicationAttachmentBody": { + "type": "object", + "properties": { + "fileName": { "type": "string" }, + "originalFileName": { "type": "string" }, + "fileFormat": { "type": "string" }, + "fileExtension": { "type": "string" }, + "fileLocation": { "type": "string" }, + "fileSize": { "type": "number" } + }, + "required": [ + "fileName", + "originalFileName", + "fileFormat", + "fileExtension", + "fileLocation", + "fileSize" + ] + }, + "ApplicationAttachmentType": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Id of the attachment type" + }, + "title": { + "type": "string", + "description": "Title of the attachment type" + }, + "slug": { + "type": "string", + "description": "Slug of the attachment type" + } + }, + "required": ["id", "title", "slug"] + }, + "ApplicationAttachment": { + "type": "object", + "properties": { + "id": { "type": "string", "description": "Id of the attachment" }, + "applicationId": { + "type": "string", + "description": "Id of the application" + }, + "originalFileName": { + "type": "string", + "description": "Original file name" + }, + "fileName": { "type": "string", "description": "File name" }, + "fileFormat": { "type": "string", "description": "File format" }, + "fileExtension": { + "type": "string", + "description": "File extension" + }, + "fileSize": { "type": "number", "description": "File size" }, + "fileLocation": { "type": "string", "description": "File location" }, + "type": { + "description": "Attachment type", + "allOf": [ + { "$ref": "#/components/schemas/ApplicationAttachmentType" } + ] + }, + "deleted": { "type": "boolean", "description": "Deleted" } + }, + "required": [ + "id", + "applicationId", + "originalFileName", + "fileName", + "fileFormat", + "fileExtension", + "fileSize", + "fileLocation", + "type", + "deleted" + ] + }, + "GetApplicationAttachmentsResponse": { + "type": "object", + "properties": { + "attachments": { + "description": "Array of attachments tied to the application", + "type": "array", + "items": { "$ref": "#/components/schemas/ApplicationAttachment" } + } + }, + "required": ["attachments"] + }, "StreamableFile": { "type": "object", "properties": {} }, "GetPdfUrlResponse": { "type": "object", diff --git a/libs/clients/official-journal-of-iceland/application/src/lib/ojoiApplicationClient.service.ts b/libs/clients/official-journal-of-iceland/application/src/lib/ojoiApplicationClient.service.ts index e2d9c3c5f9a3..c897634300ae 100644 --- a/libs/clients/official-journal-of-iceland/application/src/lib/ojoiApplicationClient.service.ts +++ b/libs/clients/official-journal-of-iceland/application/src/lib/ojoiApplicationClient.service.ts @@ -10,6 +10,11 @@ import { GetPdfUrlResponse, GetPdfUrlByApplicationIdRequest, GetPdfByApplicationIdRequest, + GetPresignedUrlRequest, + PresignedUrlResponse, + AddApplicationAttachmentRequest, + GetApplicationAttachmentsRequest, + DeleteApplicationAttachmentRequest, } from '../../gen/fetch' import { LOGGER_PROVIDER } from '@island.is/logging' import type { Logger } from '@island.is/logging' @@ -30,8 +35,18 @@ export class OfficialJournalOfIcelandApplicationClientService { return await this.ojoiApplicationApi.getComments(params) } - async postComment(params: PostCommentRequest): Promise<void> { - await this.ojoiApplicationApi.postComment(params) + async postComment(params: PostCommentRequest): Promise<boolean> { + try { + await this.ojoiApplicationApi.postComment(params) + return true + } catch (error) { + this.logger.error('Failed to post comment', { + error, + applicationId: params.id, + category: LOG_CATEGORY, + }) + return false + } } async postApplication(params: PostApplicationRequest): Promise<boolean> { @@ -39,6 +54,11 @@ export class OfficialJournalOfIcelandApplicationClientService { await this.ojoiApplicationApi.postApplication(params) return Promise.resolve(true) } catch (error) { + this.logger.error('Failed to post application', { + error, + applicationId: params.id, + category: LOG_CATEGORY, + }) return Promise.reject(false) } } @@ -79,7 +99,8 @@ export class OfficialJournalOfIcelandApplicationClientService { try { return await this.ojoiApplicationApi.getPrice(params) } catch (error) { - this.logger.warn('Failed to get price', { + this.logger.error('Failed to get price', { + applicationId: params.id, error, category: LOG_CATEGORY, }) @@ -88,4 +109,25 @@ export class OfficialJournalOfIcelandApplicationClientService { } } } + async getPresignedUrl( + params: GetPresignedUrlRequest, + ): Promise<PresignedUrlResponse> { + return await this.ojoiApplicationApi.getPresignedUrl(params) + } + + async addApplicationAttachment( + params: AddApplicationAttachmentRequest, + ): Promise<void> { + await this.ojoiApplicationApi.addApplicationAttachment(params) + } + + async getApplicationAttachments(params: GetApplicationAttachmentsRequest) { + return this.ojoiApplicationApi.getApplicationAttachments(params) + } + + async deleteApplicationAttachment( + params: DeleteApplicationAttachmentRequest, + ) { + await this.ojoiApplicationApi.deleteApplicationAttachment(params) + } } diff --git a/libs/clients/passports/src/clientConfig.json b/libs/clients/passports/src/clientConfig.json index c8a5e526d197..07c0faf3af0e 100644 --- a/libs/clients/passports/src/clientConfig.json +++ b/libs/clients/passports/src/clientConfig.json @@ -375,9 +375,21 @@ "type": "array", "nullable": true, "items": { "$ref": "#/components/schemas/IdentityDocumentResponse" } + }, + "rikisfang": { + "nullable": true, + "oneOf": [{ "$ref": "#/components/schemas/Rikisfang" }] } } }, + "Rikisfang": { + "type": "object", + "additionalProperties": false, + "properties": { + "kodi": { "type": "string", "nullable": true }, + "land": { "type": "string", "nullable": true } + } + }, "Preregistration": { "type": "object", "additionalProperties": false, diff --git a/libs/clients/passports/src/lib/passportsApi.service.ts b/libs/clients/passports/src/lib/passportsApi.service.ts index fb4577797a98..4d7a6472b8f2 100644 --- a/libs/clients/passports/src/lib/passportsApi.service.ts +++ b/libs/clients/passports/src/lib/passportsApi.service.ts @@ -152,6 +152,7 @@ export class PassportsService { passports: child.identityDocumentResponses ? this.resolvePassports(child.identityDocumentResponses) : undefined, + citizenship: child.rikisfang, } }) diff --git a/libs/clients/passports/src/lib/passportsApi.types.ts b/libs/clients/passports/src/lib/passportsApi.types.ts index 62274ecb6299..7cd2fa0f3857 100644 --- a/libs/clients/passports/src/lib/passportsApi.types.ts +++ b/libs/clients/passports/src/lib/passportsApi.types.ts @@ -23,6 +23,12 @@ export interface IdentityDocumentChild { secondParentName?: string | null childName?: string | null passports?: IdentityDocument[] + citizenship?: Citizenship | null +} + +export interface Citizenship { + kodi?: string | null + land?: string | null } export interface ContactInfo { diff --git a/libs/island-ui/core/src/lib/Filter/Filter.tsx b/libs/island-ui/core/src/lib/Filter/Filter.tsx index ac7db7fba7bd..692b369ebabd 100644 --- a/libs/island-ui/core/src/lib/Filter/Filter.tsx +++ b/libs/island-ui/core/src/lib/Filter/Filter.tsx @@ -6,6 +6,8 @@ import { Button } from '../Button/Button' import { Inline } from '../Inline/Inline' import { Stack } from '../Stack/Stack' import { Text } from '../Text/Text' +import { usePreventBodyScroll } from './usePreventBodyScroll' + import * as styles from './Filter.css' export interface FilterProps { @@ -52,7 +54,7 @@ export interface FilterProps { /** * Datatype to use for Filter context. * Provides the Filter's childs access to shared values, - * like the `isDialog` state with out bloating the childs props. + * like the `isDialog` state without bloating the childs props. */ interface FilterContextValue { variant?: FilterProps['variant'] @@ -78,7 +80,7 @@ export const Filter: FC<React.PropsWithChildren<FilterProps>> = ({ children, popoverFlip = true, }) => { - const dialog = useDialogState() + const dialog = useDialogState({ modal: true }) const popover = usePopoverState({ placement: 'bottom-start', unstable_flip: popoverFlip, @@ -87,6 +89,8 @@ export const Filter: FC<React.PropsWithChildren<FilterProps>> = ({ const hasFilterInput = !!filterInput + usePreventBodyScroll(dialog.visible && variant === 'dialog') + return ( <FilterContext.Provider value={{ variant }}> {variant === 'popover' && ( @@ -171,7 +175,7 @@ export const Filter: FC<React.PropsWithChildren<FilterProps>> = ({ /> </Box> </DialogDisclosure> - <Dialog {...dialog}> + <Dialog {...dialog} preventBodyScroll={false}> <Box background="white" position="fixed" diff --git a/libs/island-ui/core/src/lib/Filter/usePreventBodyScroll.ts b/libs/island-ui/core/src/lib/Filter/usePreventBodyScroll.ts new file mode 100644 index 000000000000..ba31348e1040 --- /dev/null +++ b/libs/island-ui/core/src/lib/Filter/usePreventBodyScroll.ts @@ -0,0 +1,37 @@ +import { useEffect, useRef } from 'react' + +export const usePreventBodyScroll = (preventBodyScroll: boolean) => { + const initialBodyPosition = useRef<string | null>(null) + const initialScrollPosition = useRef<number | null>(null) + + useEffect(() => { + const isBrowser = typeof window !== 'undefined' + if (!isBrowser || !preventBodyScroll) { + return + } + + if (initialBodyPosition.current === null) { + initialBodyPosition.current = + window.document.body.style.position || 'static' + } + if (initialScrollPosition.current === null) { + initialScrollPosition.current = window.scrollY + } + + // Prevent scrolling on the body element + window.document.body.style.position = 'fixed' + + return () => { + if (initialBodyPosition.current !== null) { + window.document.body.style.position = initialBodyPosition.current + initialBodyPosition.current = null + } + if (initialScrollPosition.current !== null) { + // When setting the body position to fixed, the scroll position resets to 0 + // Here we are restoring the scroll position + window.scrollTo(0, initialScrollPosition.current) + initialScrollPosition.current = null + } + } + }, [preventBodyScroll]) +} diff --git a/libs/judicial-system/types/src/index.ts b/libs/judicial-system/types/src/index.ts index 27dcae8b586d..772c1463302e 100644 --- a/libs/judicial-system/types/src/index.ts +++ b/libs/judicial-system/types/src/index.ts @@ -28,10 +28,12 @@ export { isCourtOfAppealsUser, prisonSystemRoles, isPrisonSystemUser, + isPrisonStaffUser, defenceRoles, isDefenceUser, isAdminUser, isCoreUser, + isPrisonAdminUser, isPublicProsecutor, } from './lib/user' export type { User } from './lib/user' @@ -80,7 +82,7 @@ export { courtSessionTypeNames, } from './lib/case' -export { getIndictmentVerdictAppealDeadline } from './lib/indictmentCase' +export { getIndictmentVerdictAppealDeadlineStatus } from './lib/indictmentCase' export type { CrimeScene, diff --git a/libs/judicial-system/types/src/lib/file.ts b/libs/judicial-system/types/src/lib/file.ts index 44f65118a9a1..ae687d066896 100644 --- a/libs/judicial-system/types/src/lib/file.ts +++ b/libs/judicial-system/types/src/lib/file.ts @@ -15,16 +15,16 @@ export enum CaseFileCategory { CASE_FILE_RECORD = 'CASE_FILE_RECORD', PROSECUTOR_CASE_FILE = 'PROSECUTOR_CASE_FILE', DEFENDANT_CASE_FILE = 'DEFENDANT_CASE_FILE', - PROSECUTOR_APPEAL_BRIEF = 'PROSECUTOR_APPEAL_BRIEF', - DEFENDANT_APPEAL_BRIEF = 'DEFENDANT_APPEAL_BRIEF', - PROSECUTOR_APPEAL_BRIEF_CASE_FILE = 'PROSECUTOR_APPEAL_BRIEF_CASE_FILE', - DEFENDANT_APPEAL_BRIEF_CASE_FILE = 'DEFENDANT_APPEAL_BRIEF_CASE_FILE', - PROSECUTOR_APPEAL_STATEMENT = 'PROSECUTOR_APPEAL_STATEMENT', - DEFENDANT_APPEAL_STATEMENT = 'DEFENDANT_APPEAL_STATEMENT', - PROSECUTOR_APPEAL_STATEMENT_CASE_FILE = 'PROSECUTOR_APPEAL_STATEMENT_CASE_FILE', - DEFENDANT_APPEAL_STATEMENT_CASE_FILE = 'DEFENDANT_APPEAL_STATEMENT_CASE_FILE', - PROSECUTOR_APPEAL_CASE_FILE = 'PROSECUTOR_APPEAL_CASE_FILE', - DEFENDANT_APPEAL_CASE_FILE = 'DEFENDANT_APPEAL_CASE_FILE', + PROSECUTOR_APPEAL_BRIEF = 'PROSECUTOR_APPEAL_BRIEF', // Sækjandi: Kæruskjal til Landsréttar + DEFENDANT_APPEAL_BRIEF = 'DEFENDANT_APPEAL_BRIEF', // Verjandi: Kæruskjal til Landsréttar + PROSECUTOR_APPEAL_BRIEF_CASE_FILE = 'PROSECUTOR_APPEAL_BRIEF_CASE_FILE', // Sækjandi: Fylgigögn kæruskjals til Landsréttar + DEFENDANT_APPEAL_BRIEF_CASE_FILE = 'DEFENDANT_APPEAL_BRIEF_CASE_FILE', // Verjandi: Fylgigögn kæruskjals til Landsréttar + PROSECUTOR_APPEAL_STATEMENT = 'PROSECUTOR_APPEAL_STATEMENT', // Sækjandi: Greinargerð + DEFENDANT_APPEAL_STATEMENT = 'DEFENDANT_APPEAL_STATEMENT', // Verjandi: Greinargerð + PROSECUTOR_APPEAL_STATEMENT_CASE_FILE = 'PROSECUTOR_APPEAL_STATEMENT_CASE_FILE', // Sækjandi: Fylgigögn greinargerðar + DEFENDANT_APPEAL_STATEMENT_CASE_FILE = 'DEFENDANT_APPEAL_STATEMENT_CASE_FILE', // Verjandi: Fylgigögn greinargerðar + PROSECUTOR_APPEAL_CASE_FILE = 'PROSECUTOR_APPEAL_CASE_FILE', // Sækjandi: Viðbótargögn við kæru til Landsréttar + DEFENDANT_APPEAL_CASE_FILE = 'DEFENDANT_APPEAL_CASE_FILE', // Verjandi: Viðbótargögn við kæru til Landsréttar APPEAL_COURT_RECORD = 'APPEAL_COURT_RECORD', APPEAL_RULING = 'APPEAL_RULING', } diff --git a/libs/judicial-system/types/src/lib/indictmentCase.spec.ts b/libs/judicial-system/types/src/lib/indictmentCase.spec.ts index f6391960f18f..d38410bac426 100644 --- a/libs/judicial-system/types/src/lib/indictmentCase.spec.ts +++ b/libs/judicial-system/types/src/lib/indictmentCase.spec.ts @@ -1,41 +1,39 @@ -import { getIndictmentVerdictAppealDeadline } from './indictmentCase' +import { getIndictmentVerdictAppealDeadlineStatus } from './indictmentCase' -describe('getIndictmentVerdictAppealDeadline', () => { - it('should return undefined if no dates are provided', () => { - const result = getIndictmentVerdictAppealDeadline([]) - expect(result).toBeUndefined() +describe('getIndictmentVerdictAppealDeadlineStatus', () => { + it('should return [true, true] if no dates are provided', () => { + const result = getIndictmentVerdictAppealDeadlineStatus([]) + expect(result).toEqual([true, true]) }) - it('should return undefined if any dates are undefined', () => { - const result1 = getIndictmentVerdictAppealDeadline([ - undefined, - new Date('2024-06-10T00:00:00Z'), + it('should return [false, false] if any dates are undefined', () => { + const result1 = getIndictmentVerdictAppealDeadlineStatus([ + [true, undefined], + [true, new Date('2024-06-10T00:00:00Z')], ]) - const result2 = getIndictmentVerdictAppealDeadline([ - new Date('2024-06-10T00:00:00Z'), - undefined, + const result2 = getIndictmentVerdictAppealDeadlineStatus([ + [true, new Date('2024-06-10T00:00:00Z')], + [true, undefined], ]) - expect(result1).toBeUndefined() - expect(result2).toBeUndefined() + expect(result1).toEqual([false, false]) + expect(result2).toEqual([false, false]) }) - it('should return the correct deadline', () => { - const dates = [ - new Date('2024-06-01T00:00:00Z'), - new Date('2024-06-01T00:00:00Z'), - new Date('2024-06-01T00:00:00Z'), - ] - const result = getIndictmentVerdictAppealDeadline(dates) - const expectedDeadline = new Date('2024-06-29T00:00:00Z') - - expect(result).toStrictEqual(expectedDeadline) + it('should return [true, false] if some deadline is not passed', () => { + const result = getIndictmentVerdictAppealDeadlineStatus([ + [true, new Date('2024-06-01T00:00:00Z')], + [true, new Date()], + [true, new Date('2024-06-01T00:00:00Z')], + ]) + expect(result).toStrictEqual([true, false]) }) - it('should handle a single valid date', () => { - const dates = [new Date('2024-07-15T00:00:00Z')] - const result = getIndictmentVerdictAppealDeadline(dates) - const expectedDeadline = new Date('2024-08-12T00:00:00Z') - - expect(result).toStrictEqual(expectedDeadline) + it('should return [true, true] if all deadlines have passed', () => { + const result = getIndictmentVerdictAppealDeadlineStatus([ + [true, new Date('2024-06-01T00:00:00Z')], + [true, new Date('2024-06-01T00:00:00Z')], + [true, new Date('2024-06-01T00:00:00Z')], + ]) + expect(result).toStrictEqual([true, true]) }) }) diff --git a/libs/judicial-system/types/src/lib/indictmentCase.ts b/libs/judicial-system/types/src/lib/indictmentCase.ts index d4aea465da52..a7298c89bfc2 100644 --- a/libs/judicial-system/types/src/lib/indictmentCase.ts +++ b/libs/judicial-system/types/src/lib/indictmentCase.ts @@ -1,20 +1,36 @@ const MILLISECONDS_TO_EXPIRY = 28 * 24 * 60 * 60 * 1000 -export const getIndictmentVerdictAppealDeadline = ( - verdictViewDates?: (Date | undefined)[], -): Date | undefined => { +/* + This function takes an array of verdict info tuples: + - The first element of the tuple is a boolean indicating whether the defendant can appeal the verdict. + - The second element of the tuple is a Date object indicating when the defendant viewed the verdict. Undefined if the defendant has not viewed the verdict. + The function returns a tuple of two booleans: + - The first boolean indicates whether all defendants who need to see the verdict have seen it. + - The second boolean indicates whether all defenant appeal deadlines have expired. +*/ +export const getIndictmentVerdictAppealDeadlineStatus = ( + verdictInfo?: [boolean, Date | undefined][], +): [boolean, boolean] => { if ( - !verdictViewDates || - verdictViewDates.length === 0 || - verdictViewDates.some((date) => !date) + !verdictInfo || + verdictInfo.length === 0 || + verdictInfo.every(([canBeAppealed]) => !canBeAppealed) ) { - return undefined + return [true, true] } - const newestViewDate = verdictViewDates.reduce( - (newest: Date, current) => (current && current > newest ? current : newest), + if ( + verdictInfo.some( + ([canBeAppealed, viewedDate]) => canBeAppealed && !viewedDate, + ) + ) { + return [false, false] + } + + const newestViewDate = verdictInfo.reduce( + (newest, [_, current]) => (current && current > newest ? current : newest), new Date(0), ) - return new Date(newestViewDate.getTime() + MILLISECONDS_TO_EXPIRY) + return [true, Date.now() > newestViewDate.getTime() + MILLISECONDS_TO_EXPIRY] } diff --git a/libs/judicial-system/types/src/lib/user.ts b/libs/judicial-system/types/src/lib/user.ts index ba7a5a6ec417..8c0dcb478e20 100644 --- a/libs/judicial-system/types/src/lib/user.ts +++ b/libs/judicial-system/types/src/lib/user.ts @@ -114,6 +114,20 @@ export const isPrisonSystemUser = (user?: InstitutionUser): boolean => { ) } +export const isPrisonAdminUser = (user: InstitutionUser): boolean => + Boolean( + user.role && + prisonSystemRoles.includes(user.role) && + user.institution?.type === InstitutionType.PRISON_ADMIN, + ) + +export const isPrisonStaffUser = (user: InstitutionUser): boolean => + Boolean( + user.role && + prisonSystemRoles.includes(user.role) && + user.institution?.type === InstitutionType.PRISON, + ) + export const defenceRoles: string[] = [UserRole.DEFENDER] export const isDefenceUser = (user?: InstitutionUser): boolean => { diff --git a/libs/portals/admin/application-system/src/components/ApplicationDetails/ApplicationDetails.tsx b/libs/portals/admin/application-system/src/components/ApplicationDetails/ApplicationDetails.tsx index 266b82f86dee..e68600af3121 100644 --- a/libs/portals/admin/application-system/src/components/ApplicationDetails/ApplicationDetails.tsx +++ b/libs/portals/admin/application-system/src/components/ApplicationDetails/ApplicationDetails.tsx @@ -101,9 +101,6 @@ export const ApplicationDetails = ({ </Box> <Box padding={4} background="purple100" borderRadius="large"> <GridRow rowGap={3}> - <GridColumn span={['2/2', '2/2', '1/2']}> - <ValueLine title={formatMessage(m.name)}>{actor}</ValueLine> - </GridColumn> <GridColumn span={['2/2', '2/2', '1/2']}> <ValueLine title={formatMessage(m.nationalId)}> {actor} diff --git a/libs/portals/admin/regulations-admin/src/utils/formatAmendingRegulation.ts b/libs/portals/admin/regulations-admin/src/utils/formatAmendingRegulation.ts index d4cccd26ac03..ac3fca2aef41 100644 --- a/libs/portals/admin/regulations-admin/src/utils/formatAmendingRegulation.ts +++ b/libs/portals/admin/regulations-admin/src/utils/formatAmendingRegulation.ts @@ -1,6 +1,10 @@ import { asDiv, HTMLText } from '@island.is/regulations' import { GroupedDraftImpactForms, RegDraftForm } from '../state/types' +import format from 'date-fns/format' +import is from 'date-fns/locale/is' +import compact from 'lodash/compact' import flatten from 'lodash/flatten' +import uniq from 'lodash/uniq' import { groupElementsByArticleTitleFromDiv } from './groupByArticleTitle' import { getDeletionOrAddition } from './getDeletionOrAddition' @@ -16,17 +20,107 @@ const removeRegPrefix = (title: string) => { return title } -const moveMatchingStringsToEnd = (arr: Array<string>) => { - const isGildisTaka = (str: string) => { - return /(öðlast|tekur).*gildi|sett.*með.*(?:heimild|stoð)/.test( - (str || '').toLowerCase(), - ) +const isGildisTaka = (str: string) => { + return /(öðlast|tekur).*gildi|sett.*með.*(?:heimild|stoð)/.test( + (str || '').toLowerCase(), + ) +} + +const formatAffectedAndPlaceAffectedAtEnd = ( + groups: { + formattedRegBody: HTMLText[] + date?: Date | undefined + }[], +) => { + function formatArray(arr: string[]): string { + if (arr.length === 1) { + return arr[0] + } else if (arr.length === 2) { + return arr.join(' og ') + } else if (arr.length > 2) { + const lastItem = arr.pop() + const joinedItems = arr.join(', ') + const cleanString = `${joinedItems} og ${lastItem}`.replace( + / +(?= )/g, + '', + ) + + return cleanString + } + + return '' } - const matchingStrings = arr.filter((str) => isGildisTaka(str)) - const nonMatchingStrings = arr.filter((str) => !isGildisTaka(str)) + const formatDate = (date: Date) => { + const newDate = new Date(date) + if (newDate) { + const formattedDate = format(new Date(date), 'dd. MMMM yyyy', { + locale: is, + }) + return formattedDate.replace(/^0+/, '') // Remove leading zeros + } else { + return '' + } + } + + const extractArticleNumber = (str: string): number | null => { + const match = str.match(/(\d+)\. gr/) + return match ? parseInt(match[1], 10) : null + } + + let articleNumber = 0 + const gildsTakaKeepArray: HTMLText[] = [] + const articleKeepArray: { text: HTMLText; originalIndex: number }[] = [] + const impactAffectArray: HTMLText[] = [] + + groups.forEach((item) => { + const affectedImpacts: HTMLText[] = [] + item.formattedRegBody.forEach((body) => { + if (isGildisTaka(body)) { + gildsTakaKeepArray.push(body) + } else { + articleKeepArray.push({ text: body, originalIndex: articleNumber }) + affectedImpacts.push(`${articleNumber + 1}. gr.` as HTMLText) + articleNumber++ + } + }) + const impactString = formatArray(affectedImpacts) + const impactAffectedString = `Ákvæði ${impactString} reglugerðarinnar ${ + item.date ? 'öðlast gildi ' + formatDate(item.date) : 'öðlast þegar gildi' + }` + impactAffectArray.push(impactAffectedString as HTMLText) + }) + + // Sort the articleKeepArray based on extracted article numbers + articleKeepArray.sort((a, b) => { + const numA = extractArticleNumber(a.text as string) + const numB = extractArticleNumber(b.text as string) + return (numA || 0) - (numB || 0) + }) - return [...nonMatchingStrings, ...matchingStrings] + // Reassign the article numbers in affectedImpacts based on the new order + const updatedImpactAffectArray = impactAffectArray.map((impact, index) => { + const match = impact.match(/(\d+)\. gr\./g) + if (match) { + match.forEach((m, i) => { + const oldNumber = parseInt(m.match(/(\d+)/)![1], 10) + const newNumber = + articleKeepArray.findIndex( + (item) => item.originalIndex === oldNumber - 1, + ) + 1 + impact = impact.replace(m, `${newNumber}. gr.`) as HTMLText + }) + } + return impact as HTMLText + }) + + const uniqueGildistaka = uniq(gildsTakaKeepArray) + const joinedAffected = updatedImpactAffectArray.join('. ') + const gildistakaReturn = flatten([...uniqueGildistaka, joinedAffected]).join( + '', + ) as HTMLText + + return [...articleKeepArray.map((item) => item.text), gildistakaReturn] } const removeRegNamePrefix = (name: string) => { @@ -45,11 +139,15 @@ export const formatAmendingRegTitle = (draft: RegDraftForm) => { const amendingArray = titleArray.filter((item) => item.type === 'amend') const repealArray = titleArray.filter((item) => item.type === 'repeal') - const amendingTitles = amendingArray.map( - (item, i) => - `${i === 0 ? `${PREFIX_AMENDING}` : ''}${removeRegNamePrefix( - item.name, - )} ${removeRegPrefix(item.regTitle)}`, + const amendingTitles = uniq( + amendingArray.map( + (item) => + `${removeRegNamePrefix(item.name)} ${removeRegPrefix(item.regTitle)}`, + ), + ) + + const prefixedAmendingTitles = amendingTitles.map( + (title, i) => `${i === 0 ? `${PREFIX_AMENDING}` : ''}${title}`, ) const repealTitles = repealArray.map( @@ -61,7 +159,9 @@ export const formatAmendingRegTitle = (draft: RegDraftForm) => { return ( PREFIX + - [...amendingTitles, ...repealTitles].join(' og ').replace(/ +(?= )/g, '') + [...prefixedAmendingTitles, ...repealTitles] + .join(' og ') + .replace(/ +(?= )/g, '') ) } @@ -100,6 +200,11 @@ export const formatAmendingRegBody = ( let paragraph = 0 const groupedArticles = groupElementsByArticleTitleFromDiv(diffDiv) + const regNameDisplay = + regName && regName !== 'self' + ? `reglugerðar nr. ${regName}`.replace(/\.$/, '') + : 'reglugerðarinnar' + groupedArticles.forEach((group, i) => { // Get grouped article index to get name of previous grein for addition text. let articleTitle = '' @@ -117,11 +222,6 @@ export const formatAmendingRegBody = ( isAddition: undefined, } - const regNameDisplay = - regName && regName !== 'self' - ? `reglugerðar nr. ${regName}`.replace(/\.$/, '') - : 'reglugerðarinnar' - group.forEach((element) => { let pushHtml = '' as HTMLText @@ -162,10 +262,7 @@ export const formatAmendingRegBody = ( const hasInsert = !!element.querySelector('ins') const isGildistokuGrein = - isParagraph && - /(öðlast|tekur).*gildi|sett.*með.*(?:heimild|stoð)/.test( - (element.textContent || '').toLowerCase(), - ) + isParagraph && isGildisTaka(element.textContent || '') const elementType = isLetterList || isNumberList @@ -335,22 +432,26 @@ export const formatAmendingBodyWithArticlePrefix = ( const impactAdditionArray = Object.entries(impactsArray).map( ([key, impacts]) => { - const impactArray = impacts.map((item, i) => - formatAmendingRegBody( - item.type === 'repeal' || draftImpactLength > 1 ? item.name : '', - item.type === 'repeal', - item.type === 'amend' ? item.diff?.value : undefined, - item.regTitle, - ), - ) - const flatArray = flatten(impactArray) - return flatArray + const impactArray = impacts.map((item, i) => { + return { + formattedRegBody: formatAmendingRegBody( + item.type === 'repeal' || draftImpactLength > 1 ? item.name : '', + item.type === 'repeal', + item.type === 'amend' ? item.diff?.value : undefined, + item.regTitle, + ), + date: item.date.value, + } + }) + return flatten(impactArray) }, ) const additions = flatten(impactAdditionArray) - const returnArray = moveMatchingStringsToEnd(additions) + const htmlForEditor = formatAffectedAndPlaceAffectedAtEnd(additions) + + const returnArray = compact(htmlForEditor) const prependString = returnArray.map( (item, i) => diff --git a/libs/service-portal/core/src/components/Modal/Modal.css.ts b/libs/service-portal/core/src/components/Modal/Modal.css.ts index 95d03a4cef5a..a471a7f93337 100644 --- a/libs/service-portal/core/src/components/Modal/Modal.css.ts +++ b/libs/service-portal/core/src/components/Modal/Modal.css.ts @@ -27,3 +27,13 @@ export const closeButton = style({ right: theme.spacing['1'], zIndex: 2, }) + +export const image = style({ + display: 'none', + ...themeUtils.responsiveStyle({ + lg: { + marginRight: `-${theme.spacing['2']}px`, + display: 'initial', + }, + }), +}) diff --git a/libs/service-portal/core/src/components/Modal/Modal.tsx b/libs/service-portal/core/src/components/Modal/Modal.tsx index 51720105b1bb..d668ac6f0409 100644 --- a/libs/service-portal/core/src/components/Modal/Modal.tsx +++ b/libs/service-portal/core/src/components/Modal/Modal.tsx @@ -1,6 +1,14 @@ -import React, { FC, ReactElement } from 'react' +import React, { FC, ReactElement, useEffect, useState } from 'react' import * as styles from './Modal.css' -import { Box, ModalBase, Button } from '@island.is/island-ui/core' +import { + Box, + Text, + ModalBase, + Button, + ButtonProps, + Inline, +} from '@island.is/island-ui/core' +import { useDebounce } from 'react-use' interface Props { id: string @@ -10,6 +18,17 @@ interface Props { initialVisibility?: boolean disclosure?: ReactElement label?: string + title?: string + text?: string + buttons?: Array<{ + id: ButtonProps['id'] + type?: 'ghost' | 'primary' | 'utility' + onClick?: () => void + text?: string + loading?: boolean + }> + iconSrc?: string + iconAlt?: string /** * No styling. All callbacks available. */ @@ -24,12 +43,39 @@ export const Modal: FC<React.PropsWithChildren<Props>> = ({ disclosure, isVisible, label, + title, + text, + buttons, initialVisibility = true, skeleton, + iconAlt, + iconSrc, }) => { + const [closing, setClosing] = useState(false) + const [startClosing, setStartClosing] = useState(false) + + useEffect(() => { + if (closing) { + onCloseModal && onCloseModal() + setClosing(false) + setStartClosing(false) + } + }, [closing, onCloseModal]) + + useDebounce( + () => { + if (startClosing) { + setClosing(startClosing) + } + }, + 500, + [startClosing], + ) + const handleOnVisibilityChange = (isVisible: boolean) => { - !isVisible && onCloseModal && onCloseModal() + !isVisible && onCloseModal && setStartClosing(true) } + return ( <ModalBase baseId={id} @@ -47,6 +93,10 @@ export const Modal: FC<React.PropsWithChildren<Props>> = ({ ) : ( <Box background="white" + display="flex" + flexDirection="row" + alignItems="center" + rowGap={2} paddingY={[3, 6, 12]} paddingX={[3, 6, 12, 15]} > @@ -61,7 +111,37 @@ export const Modal: FC<React.PropsWithChildren<Props>> = ({ size="large" /> </Box> + <Box> + <Box marginBottom={6}> + {title && ( + <Text variant="h3" marginBottom={'auto'}> + {title} + </Text> + )} + {text && <Text>{text}</Text>} + </Box> + {buttons && ( + <Inline space={2}> + {buttons.map((b) => ( + <Button + key={b.id} + variant={b.type ?? 'primary'} + size="small" + onClick={b.onClick} + loading={b.loading} + > + {b.text} + </Button> + ))} + </Inline> + )} + </Box> {children} + {iconSrc && ( + <Box marginLeft={6} className={styles.image}> + <img src={iconSrc} alt={iconAlt} /> + </Box> + )} </Box> ) } diff --git a/libs/service-portal/core/src/index.ts b/libs/service-portal/core/src/index.ts index be2a0f215437..9f528d7f1c1f 100644 --- a/libs/service-portal/core/src/index.ts +++ b/libs/service-portal/core/src/index.ts @@ -29,6 +29,7 @@ export * from './components/ToolTip/ToolTip' export * from './components/LinkButton/LinkButton' export * from './components/GoBack/GoBack' export * from './components/TabNavigation/TabNavigation' +export * from './components/TabNavigation/TabBar' export * from './components/ProgressBar/ProgressBar' export * from './components/ScrollableMiddleTable/ScrollableMiddleTable' export * from './components/Gallery/Gallery' diff --git a/libs/service-portal/health/src/components/RegisterModal/RegisterModal.css.ts b/libs/service-portal/health/src/components/RegisterModal/RegisterModal.css.ts deleted file mode 100644 index 72713fc6fe52..000000000000 --- a/libs/service-portal/health/src/components/RegisterModal/RegisterModal.css.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { style } from '@vanilla-extract/css' - -export const modalBaseStyle = style({ - maxWidth: '55.5rem', - position: 'absolute', - width: 'calc(100% - 2rem)', - inset: '1rem', - top: '50%', - left: '50%', - transform: 'translate(-50%, -50%)', -}) - -export const modalGridStyle = style({ - display: 'grid', - gridTemplateColumns: '1fr 4fr 3fr', -}) - -export const modalGridContentStyle = style({ - gridColumnStart: 2, -}) - -export const modalGridImageStyle = style({ - placeSelf: 'center', -}) - -export const closeModalButtonStyle = style({ - position: 'absolute', - top: '1rem', - right: '1rem', - cursor: 'pointer', -}) - -export const modalGridButtonGroup = style({ - display: 'flex', - flexDirection: 'row', - gap: '1.5rem', -}) diff --git a/libs/service-portal/health/src/components/RegisterModal/RegisterModal.tsx b/libs/service-portal/health/src/components/RegisterModal/RegisterModal.tsx deleted file mode 100644 index 23b5f589dcbe..000000000000 --- a/libs/service-portal/health/src/components/RegisterModal/RegisterModal.tsx +++ /dev/null @@ -1,99 +0,0 @@ -import { - ModalBase, - Icon, - Box, - Button, - Text, - Select, -} from '@island.is/island-ui/core' -import { messages } from '../../lib/messages' -import * as styles from './RegisterModal.css' -import { useLocale } from '@island.is/localization' -import { useState } from 'react' -import { HealthCenterDoctorOption } from '../../screens/HealthCenterRegistration/HealthCenterRegistration' - -type RegisterModalProps = { - onClose: () => void - onAccept: (doctorId?: number) => void - id: string - title: string - description: string - buttonLoading?: boolean - isVisible?: boolean - healthCenterDoctors?: HealthCenterDoctorOption[] -} - -export const RegisterModal = ({ - id, - onAccept, - onClose, - title, - description, - buttonLoading = false, - isVisible = false, - healthCenterDoctors, -}: RegisterModalProps) => { - const { formatMessage } = useLocale() - const [doctorId, setDoctorId] = useState<number>() - - return ( - <ModalBase - isVisible={isVisible} - baseId={id} - className={styles.modalBaseStyle} - > - <Box paddingTop={10} paddingBottom={9} paddingX={3} background="white"> - <Box className={styles.closeModalButtonStyle}> - <button - aria-label={formatMessage(messages.closeModal)} - onClick={onClose} - > - <Icon icon="close" size="large" /> - </button> - </Box> - <Box className={styles.modalGridStyle}> - <Box className={styles.modalGridContentStyle}> - <Text variant="h2">{title}</Text> - {description ? ( - <Text marginTop={2} marginBottom={3}> - {description} - </Text> - ) : ( - // Temp fix - will refactor and use core model component - <Box marginY={15}></Box> - )} - {healthCenterDoctors?.length ? ( - <Box marginBottom={3}> - <Select - isClearable - options={healthCenterDoctors} - label={formatMessage(messages.chooseDoctorLabel)} - placeholder={formatMessage(messages.chooseDoctorPlaceholder)} - onChange={(val) => { - setDoctorId(val?.value) - }} - /> - </Box> - ) : null} - <Box className={styles.modalGridButtonGroup}> - <Button size="small" variant="primary" onClick={onClose}> - {formatMessage(messages.healthRegisterModalDecline)} - </Button> - <Button - size="small" - variant="ghost" - onClick={() => onAccept(doctorId)} - loading={buttonLoading} - > - {formatMessage(messages.healthRegisterModalAccept)} - </Button> - </Box> - </Box> - <Box className={styles.modalGridImageStyle}> - <img src="./assets/images/hourglass.svg" alt="" /> - </Box> - </Box> - </Box> - </ModalBase> - ) -} diff --git a/libs/service-portal/health/src/components/RegisterModal/index.ts b/libs/service-portal/health/src/components/RegisterModal/index.ts deleted file mode 100644 index 11dab747046f..000000000000 --- a/libs/service-portal/health/src/components/RegisterModal/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { RegisterModal } from './RegisterModal' diff --git a/libs/service-portal/health/src/lib/messages.ts b/libs/service-portal/health/src/lib/messages.ts index 2c58f91532bf..6ea14565057b 100644 --- a/libs/service-portal/health/src/lib/messages.ts +++ b/libs/service-portal/health/src/lib/messages.ts @@ -998,10 +998,26 @@ export const messages = defineMessages({ id: 'sp.health:i-am-organ-donor', defaultMessage: 'Ég er líffæragjafi.', }, + iAmOrganDonorText: { + id: 'sp.health:i-am-organ-donor-text', + defaultMessage: 'Öll líffærin mín má nota til ígræðslu.', + }, + iAmOrganDonorWithExceptions: { + id: 'sp.health:i-am-organ-donor-w-exceptions', + defaultMessage: 'Ég heimila líffæragjöf, með takmörkunum.', + }, + iAmOrganDonorWithExceptionsText: { + id: 'sp.health:i-am-organ-donor-w-exceptions-text', + defaultMessage: 'Öll líffæri má nota til ígræðslu fyrir utan:', + }, iAmNotOrganDonor: { id: 'sp.health:i-am-not-organ-donor', defaultMessage: 'Ég banna líffæragjöf.', }, + iAmNotOrganDonorText: { + id: 'sp.health:i-am-not-organ-donor-text', + defaultMessage: 'Engin líffæri má nota til ígræðslu', + }, organDonationRegistrationOptIn: { id: 'sp.health:organ-donation-registration-opt-in', defaultMessage: 'Við andlát mitt má nota líffæri mín til líffæragjafa.', @@ -1107,4 +1123,16 @@ export const messages = defineMessages({ defaultMessage: 'Ekki tókst að sækja gögn frá Embætti Landlæknis. Vinsamlegast reynið aftur síðar', }, + medicinePrescriptions: { + id: 'sp.health:medicine-prescriptions', + defaultMessage: 'Lyfjaávísanir', + }, + medicineDelegation: { + id: 'sp.health:medicine-delegation', + defaultMessage: 'Lyfjaumboð', + }, + medicinePaymentParticipation: { + id: 'sp.health:medicine-payment-participation', + defaultMessage: 'Greiðsluþátttaka', + }, }) diff --git a/libs/service-portal/health/src/module.tsx b/libs/service-portal/health/src/module.tsx index a69c0c4efcda..3889342cefed 100644 --- a/libs/service-portal/health/src/module.tsx +++ b/libs/service-portal/health/src/module.tsx @@ -62,12 +62,8 @@ const OrganDonationRegistration = lazy(() => import('./screens/OrganDonationRegistration/RegistrationForm'), ) -const VaccinationsGeneral = lazy(() => - import('./screens/Vaccinations/VaccinationsGeneral'), -) - -const VaccinationsOther = lazy(() => - import('./screens/Vaccinations/VaccinationsOther'), +const Vaccinations = lazy(() => + import('./screens/Vaccinations/VaccinationsWrapper'), ) export const healthModule: PortalModule = { @@ -224,21 +220,7 @@ export const healthModule: PortalModule = { path: HealthPaths.HealthVaccinations, key: 'HealthVaccinations', enabled: userInfo.scopes.includes(ApiScope.healthVaccinations), - element: <VaccinationsGeneral />, - }, - { - name: hm.generalVaccinations, - path: HealthPaths.HealthVaccinationsGeneral, - key: 'HealthVaccinations', - enabled: userInfo.scopes.includes(ApiScope.healthVaccinations), - element: <VaccinationsGeneral />, - }, - { - name: hm.otherVaccinations, - path: HealthPaths.HealthVaccinationsOther, - key: 'HealthVaccinations', - enabled: userInfo.scopes.includes(ApiScope.healthVaccinations), - element: <VaccinationsOther />, + element: <Vaccinations />, }, ], } diff --git a/libs/service-portal/health/src/screens/DentistRegistration/DentistRegistration.tsx b/libs/service-portal/health/src/screens/DentistRegistration/DentistRegistration.tsx index 8fc188331fd3..bcb7e79f7ed5 100644 --- a/libs/service-portal/health/src/screens/DentistRegistration/DentistRegistration.tsx +++ b/libs/service-portal/health/src/screens/DentistRegistration/DentistRegistration.tsx @@ -13,7 +13,7 @@ import { useGetPaginatedDentistsQuery, useRegisterDentistMutation, } from './DentistRegistration.generated' -import { m } from '@island.is/service-portal/core' +import { Modal, m } from '@island.is/service-portal/core' import { IntroHeader } from '@island.is/portals/core' import { useLocale, useNamespaces } from '@island.is/localization' import { messages } from '../../lib/messages' @@ -22,7 +22,6 @@ import { useDebounce } from 'react-use' import { useNavigate } from 'react-router-dom' import { HealthPaths } from '../../lib/paths' import { RightsPortalDentist } from '@island.is/api/schema' -import { RegisterModal } from '../../components/RegisterModal' import * as styles from './DentistRegistration.css' import { Problem } from '@island.is/react-spa/shared' @@ -40,6 +39,7 @@ export const DentistRegistration = () => { const [activeSearch, setActiveSearch] = useState('') const [selectedDentist, setSelectedDentist] = useState<SelectedDentist | null>(null) + const [modalVisible, setModalVisible] = useState<boolean>(false) const [hoverId, setHoverId] = useState(0) const [errorTransfering, setErrorTransfering] = useState(false) const errorBoxRef = useRef<HTMLDivElement>(null) @@ -158,30 +158,6 @@ export const DentistRegistration = () => { backgroundColor="blue" /> </Box> - - <RegisterModal - onClose={() => setSelectedDentist(null)} - onAccept={() => { - setErrorTransfering(false) - if (selectedDentist && selectedDentist.id) { - registerDentist({ - variables: { - input: { - id: `${selectedDentist.id}`, - }, - }, - }) - } - }} - id={'dentistRegisterModal'} - title={`${formatMessage(messages.dentistModalTitle)} ${ - selectedDentist?.name - }`} - description="" - isVisible={!!selectedDentist} - buttonLoading={loadingTranser} - /> - {loading ? ( <SkeletonLoader repeat={3} space={2} height={40} /> ) : ( @@ -219,19 +195,69 @@ export const DentistRegistration = () => { visible: dentist.id === hoverId, })} > - <Button - size="small" - variant="text" - icon="pencil" - onClick={() => { - setSelectedDentist({ - id: dentist.id, - name: dentist.name, - }) + <Modal + id={'dentistRegisterModal'} + initialVisibility={false} + iconSrc="./assets/images/coffee.svg" + iconAlt="coffee" + toggleClose={!modalVisible} + onCloseModal={() => { + setSelectedDentist(null) }} - > - {formatMessage(messages.healthRegistrationSave)} - </Button> + title={`${formatMessage( + messages.dentistModalTitle, + )} ${selectedDentist?.name}`} + buttons={[ + { + id: 'RegisterModalAccept', + type: 'primary' as const, + text: formatMessage( + messages.healthRegisterModalAccept, + ), + onClick: () => { + setErrorTransfering(false) + setModalVisible(false) + if (selectedDentist && selectedDentist.id) { + registerDentist({ + variables: { + input: { + id: `${selectedDentist.id}`, + }, + }, + }) + } + }, + }, + { + id: 'RegisterModalDecline', + type: 'ghost' as const, + text: formatMessage( + messages.healthRegisterModalDecline, + ), + onClick: () => { + setModalVisible(false) + }, + }, + ]} + disclosure={ + <Button + size="small" + variant="text" + icon="pencil" + onClick={() => { + setModalVisible(true) + setSelectedDentist({ + id: dentist.id, + name: dentist.name, + }) + }} + > + {formatMessage( + messages.healthRegistrationSave, + )} + </Button> + } + /> </Box> ) : undefined} </T.Data> diff --git a/libs/service-portal/health/src/screens/HealthCenterRegistration/HealthCenterRegistration.tsx b/libs/service-portal/health/src/screens/HealthCenterRegistration/HealthCenterRegistration.tsx index 7e84491a1e2d..432eddfdccf4 100644 --- a/libs/service-portal/health/src/screens/HealthCenterRegistration/HealthCenterRegistration.tsx +++ b/libs/service-portal/health/src/screens/HealthCenterRegistration/HealthCenterRegistration.tsx @@ -16,6 +16,7 @@ import { EmptyState, ErrorScreen, ExcludesFalse, + Modal, } from '@island.is/service-portal/core' import { messages } from '../../lib/messages' import * as styles from './HealthRegistration.css' @@ -26,7 +27,6 @@ import { RightsPortalHealthCenter } from '@island.is/api/schema' import { useNavigate } from 'react-router-dom' import { HealthPaths } from '../../lib/paths' import { formatHealthCenterName } from '../../utils/format' -import { RegisterModal } from '../../components/RegisterModal' import { useGetHealthCenterDoctorsLazyQuery, useGetHealthCenterQuery, @@ -237,22 +237,6 @@ const HealthCenterRegistration = () => { </Box> )} - <RegisterModal - id="healthCenterDialog" - title={formatMessage(messages.healthCenterRegistrationModalTitle, { - healthCenter: selectedHealthCenter?.name, - })} - description={formatMessage(messages.healthCenterRegistrationModalInfo)} - onClose={() => { - setSelectedHealthCenter(null) - setHealthCenterDoctors([]) - }} - onAccept={handleHealthCenterTransfer} - isVisible={!!selectedHealthCenter} - buttonLoading={loadingTransfer} - healthCenterDoctors={healthCenterDoctors} - /> - <Box className={styles.filterWrapperStyle} marginBottom={3}> <FilterInput onChange={(val) => setFilter(val)} @@ -306,21 +290,61 @@ const HealthCenterRegistration = () => { visible: healthCenter.id === hoverId, })} > - <Button - size="small" - variant="text" - icon="pencil" - onClick={() => { - setSelectedHealthCenter({ - id: healthCenter.id, - name: healthCenter.name, - }) + <Modal + id={'healthCenterRegisterModal'} + initialVisibility={false} + iconSrc="./assets/images/coffee.svg" + iconAlt="coffee" + toggleClose={!selectedHealthCenter} + onCloseModal={() => { + setSelectedHealthCenter(null) + setHealthCenterDoctors([]) }} - > - {formatMessage( - messages.healthRegistrationSave, + title={formatMessage( + messages.healthCenterRegistrationModalTitle, + { + healthCenter: selectedHealthCenter?.name, + }, )} - </Button> + buttons={[ + { + id: 'RegisterHealthCenterModalAccept', + type: 'primary' as const, + text: formatMessage( + messages.healthRegisterModalAccept, + ), + onClick: handleHealthCenterTransfer, + }, + { + id: 'RegisterHealthCenterModalDecline', + type: 'ghost' as const, + text: formatMessage( + messages.healthRegisterModalDecline, + ), + onClick: () => { + setSelectedHealthCenter(null) + setHealthCenterDoctors([]) + }, + }, + ]} + disclosure={ + <Button + size="small" + variant="text" + icon="pencil" + onClick={() => { + setSelectedHealthCenter({ + id: healthCenter.id, + name: healthCenter.name, + }) + }} + > + {formatMessage( + messages.healthRegistrationSave, + )} + </Button> + } + /> </Box> </T.Data> </tr> diff --git a/libs/service-portal/health/src/screens/OrganDonation/OrganDonation.graphql b/libs/service-portal/health/src/screens/OrganDonation/OrganDonation.graphql index 17dd93a23a84..e445153ee094 100644 --- a/libs/service-portal/health/src/screens/OrganDonation/OrganDonation.graphql +++ b/libs/service-portal/health/src/screens/OrganDonation/OrganDonation.graphql @@ -1,23 +1,38 @@ -query getDonorStatus { - HealthDirectorateOrganDonationGetDonorStatus { - isDonor - exceptions - exceptionComment - registrationDate +query getDonorStatus($locale: String) { + healthDirectorateOrganDonation(locale: $locale) { + donor { + isDonor + limitations { + hasLimitations + limitedOrgansList { + id + name + } + comment + } + } } } -query getOrganDonationExceptions($locale: String) { - HealthDirectorateOrganDonationGetDonationExceptions(locale: $locale) { - values { +query getOrgansList($locale: String) { + healthDirectorateOrganDonation(locale: $locale) { + donor { + isDonor + limitations { + hasLimitations + limitedOrgansList { + id + name + } + } + } + organList { id name } } } -mutation updateOrganDonationInfo( - $input: HealthDirectorateOrganDonorStatusInput! -) { - HealthDirectorateOrganDonationUpdateDonorStatus(input: $input) +mutation updateOrganDonationInfo($input: HealthDirectorateOrganDonorInput!) { + healthDirectorateOrganDonationUpdateDonorStatus(input: $input) } diff --git a/libs/service-portal/health/src/screens/OrganDonation/OrganDonation.tsx b/libs/service-portal/health/src/screens/OrganDonation/OrganDonation.tsx index 58cf1d6ac945..275e6b0fd25b 100644 --- a/libs/service-portal/health/src/screens/OrganDonation/OrganDonation.tsx +++ b/libs/service-portal/health/src/screens/OrganDonation/OrganDonation.tsx @@ -1,6 +1,7 @@ import { useLocale, useNamespaces } from '@island.is/localization' import { ActionCard, + CardLoader, IntroHeader, LinkResolver, } from '@island.is/service-portal/core' @@ -9,68 +10,75 @@ import { Button, Box, Text } from '@island.is/island-ui/core' import { HealthPaths } from '../../lib/paths' import { Problem } from '@island.is/react-spa/shared' import { useGetDonorStatusQuery } from './OrganDonation.generated' + const OrganDonation = () => { useNamespaces('sp.health') const { formatMessage } = useLocale() - const { data, loading, error } = useGetDonorStatusQuery() - const donorStatus = data?.HealthDirectorateOrganDonationGetDonorStatus - - const exceptionText: string = - donorStatus?.exceptions?.length && donorStatus.exceptions.length > 0 + const { data, loading, error } = useGetDonorStatusQuery({ + fetchPolicy: 'no-cache', + }) + const donorStatus = data?.healthDirectorateOrganDonation.donor + const cardText: string = donorStatus?.isDonor + ? donorStatus?.limitations?.hasLimitations ? [ - donorStatus?.exceptionComment, + formatMessage(m.iAmOrganDonorWithExceptionsText), + donorStatus?.limitations.limitedOrgansList + ?.map((organ) => organ.name) + .join(', '), + ].join(' ') + '.' ?? '' + : formatMessage(m.iAmOrganDonorText) + : formatMessage(m.iAmNotOrganDonorText) - donorStatus?.exceptions?.join(', '), - ].join(':') ?? '' - : donorStatus?.exceptionComment ?? '' return ( <Box> <IntroHeader title={formatMessage(m.organDonation)} intro={formatMessage(m.organDonationDescription)} /> + <Box> + <LinkResolver + href={formatMessage(m.organDonationLink)} + key="organ-donation" + > + <Button variant="utility" size="small" icon="open" iconType="outline"> + {formatMessage(m.readAboutOrganDonation)} + </Button> + </LinkResolver> + </Box> + {loading && ( + <Box marginY={4}> + <CardLoader /> + </Box> + )} {!error && !loading && donorStatus !== null && ( - <> - <Box> - <LinkResolver - href={formatMessage(m.organDonationLink)} - key="organ-donation" - > - <Button - variant="utility" - size="small" - icon="open" - iconType="outline" - > - {formatMessage(m.readAboutOrganDonation)} - </Button> - </LinkResolver> - </Box> - <Box> - <Text - variant="eyebrow" - color="purple400" - marginTop={5} - marginBottom={1} - > - {formatMessage(m.takeOnOrganDonation)} - </Text> - <ActionCard - heading={ - donorStatus?.isDonor - ? formatMessage(m.iAmOrganDonor) - : formatMessage(m.iAmNotOrganDonor) - } - text={exceptionText} - cta={{ - url: HealthPaths.HealthOrganDonationRegistration, - label: formatMessage(m.changeTake), - centered: true, - }} - /> - </Box> - </> + <Box> + <Text + variant="eyebrow" + color="purple400" + marginTop={5} + marginBottom={1} + > + {formatMessage(m.takeOnOrganDonation)} + </Text> + <ActionCard + heading={ + donorStatus?.isDonor + ? donorStatus.limitations?.hasLimitations + ? formatMessage(m.iAmOrganDonorWithExceptions) + : formatMessage(m.iAmOrganDonor) + : formatMessage(m.iAmNotOrganDonor) + } + text={cardText} + cta={{ + url: HealthPaths.HealthOrganDonationRegistration, + label: formatMessage(m.changeTake), + centered: true, + variant: 'text', + }} + loading={loading} + /> + </Box> )} {error && !loading && <Problem error={error} noBorder={false} />} {!error && !loading && data === null && ( diff --git a/libs/service-portal/health/src/screens/OrganDonationRegistration/Limitations.tsx b/libs/service-portal/health/src/screens/OrganDonationRegistration/Limitations.tsx index 72d9566b915d..e625cdf7a3a4 100644 --- a/libs/service-portal/health/src/screens/OrganDonationRegistration/Limitations.tsx +++ b/libs/service-portal/health/src/screens/OrganDonationRegistration/Limitations.tsx @@ -1,28 +1,16 @@ -import { - Box, - Checkbox, - Divider, - GridColumn, - GridContainer, - GridRow, - Input, - Stack, -} from '@island.is/island-ui/core' +import { Box, Checkbox, Divider, Stack } from '@island.is/island-ui/core' import React, { useState } from 'react' -import { OptionsLimitations } from '../../utils/OrganDonationMock' -import { messages } from '../..' -import { useLocale, useNamespaces } from '@island.is/localization' -import { HealthDirectorateOrganDonationExceptionObject } from '@island.is/api/schema' +import { useNamespaces } from '@island.is/localization' +import { HealthDirectorateOrganDonationOrgan } from '@island.is/api/schema' interface LimitationsProps { - data: HealthDirectorateOrganDonationExceptionObject[] + data: HealthDirectorateOrganDonationOrgan[] + selected?: string[] | null } -const Limitations = ({ data }: LimitationsProps) => { +const Limitations = ({ data, selected }: LimitationsProps) => { useNamespaces('sp.health') - const { formatMessage } = useLocale() - const [checked, setChecked] = useState<Array<string>>([]) - + const [checked, setChecked] = useState<Array<string>>(selected ?? []) const handleCheckboxChange = (id: string, isChecked: boolean) => { setChecked((prevState) => isChecked ? [...prevState, id] : prevState.filter((item) => item !== id), @@ -39,22 +27,21 @@ const Limitations = ({ data }: LimitationsProps) => { {data?.map( (y, yi) => ( // y.type === 'checkbox' && ( + <Box key={`organ-donation-limitation-${yi}`} width="half" marginY="smallGutter" > <Checkbox - id={`organ-registration-form-${y.name?.toLowerCase() ?? ''}`} - name={`selected-limitations-${y.name?.toLowerCase() ?? ''}`} + id={y.id?.toString()} + name={`selected-limitations-${y.id}`} label={y.name} - value={y.name?.toLowerCase() ?? ''} + value={y.id ?? ''} onChange={(e) => - handleCheckboxChange( - y.name?.toLowerCase() ?? '', - e.target.checked, - ) + handleCheckboxChange(y.id ?? '', e.target.checked) } + checked={checked.includes(y.id ?? '')} /> </Box> ), diff --git a/libs/service-portal/health/src/screens/OrganDonationRegistration/Loader.tsx b/libs/service-portal/health/src/screens/OrganDonationRegistration/Loader.tsx new file mode 100644 index 000000000000..fb45c474eb98 --- /dev/null +++ b/libs/service-portal/health/src/screens/OrganDonationRegistration/Loader.tsx @@ -0,0 +1,32 @@ +import React from 'react' +import { Box, SkeletonLoader, Stack } from '@island.is/island-ui/core' +import { useIsMobile } from '@island.is/service-portal/core' + +interface Props { + amount?: number +} +export const Loader = ({ amount = 3 }: Props) => { + const { isMobile } = useIsMobile() + const length = isMobile ? 200 : 500 + const loaderItem = ( + <Box + display="flex" + alignItems="center" + paddingY={[4, 3, 4]} + paddingX={[2, 3, 4]} + border="standard" + borderRadius="large" + > + <div> + <SkeletonLoader display="block" width={length} height={24} /> + </div> + </Box> + ) + return ( + <Stack space={2}> + {Array.from({ length: amount }).map((_, i) => ( + <React.Fragment key={i}>{loaderItem}</React.Fragment> + ))} + </Stack> + ) +} diff --git a/libs/service-portal/health/src/screens/OrganDonationRegistration/RegistrationForm.tsx b/libs/service-portal/health/src/screens/OrganDonationRegistration/RegistrationForm.tsx index 92eeedf3f847..090f933a10b6 100644 --- a/libs/service-portal/health/src/screens/OrganDonationRegistration/RegistrationForm.tsx +++ b/libs/service-portal/health/src/screens/OrganDonationRegistration/RegistrationForm.tsx @@ -5,6 +5,7 @@ import { Text, Button, toast, + LoadingDots, } from '@island.is/island-ui/core' import { useLocale, useNamespaces } from '@island.is/localization' import { @@ -20,24 +21,39 @@ import * as styles from './OrganDonationRegistration.css' import Limitations from './Limitations' import { useNavigate } from 'react-router-dom' import { - useGetDonorStatusQuery, - useGetOrganDonationExceptionsQuery, + useGetOrgansListQuery, useUpdateOrganDonationInfoMutation, } from '../OrganDonation/OrganDonation.generated' +import { Loader } from './Loader' + +const OPT_IN = 'opt-in' +const OPT_IN_EXCEPTIONS = 'opt-in-exceptions' +const OPT_OUT = 'opt-out' export const Form2 = () => { useNamespaces('sp.health') const { formatMessage, lang } = useLocale() const navigate = useNavigate() - const OPT_IN = 'opt-in' - const OPT_IN_EXCEPTIONS = 'opt-in-exceptions' - const OPT_OUT = 'opt-out' - - const { data, loading } = useGetOrganDonationExceptionsQuery({ + const { data, loading } = useGetOrgansListQuery({ variables: { locale: lang }, }) + const isDonor = data?.healthDirectorateOrganDonation.donor?.isDonor + const hasLimitations = + data?.healthDirectorateOrganDonation.donor?.limitations?.hasLimitations + const allLimitations = data?.healthDirectorateOrganDonation.organList + const selectedLimitations = + data?.healthDirectorateOrganDonation.donor?.limitations?.limitedOrgansList?.map( + (item) => item.id, + ) + const donorStatus = isDonor + ? hasLimitations + ? OPT_IN_EXCEPTIONS + : OPT_IN + : OPT_OUT + const [radioValue, setRadioValue] = useState<string | undefined>(donorStatus) + const [updateDonorStatus] = useUpdateOrganDonationInfoMutation({ onCompleted: () => { toast.success(formatMessage(messages.registrationComplete)) @@ -47,39 +63,28 @@ export const Form2 = () => { toast.error(formatMessage(messages.registrationFailed)) }, }) - const { data: status } = useGetDonorStatusQuery() - - const exceptions = - data?.HealthDirectorateOrganDonationGetDonationExceptions.values - const [radioValue, setRadioValue] = useState<string | undefined>() useEffect(() => { - if (radioValue === undefined) { - setRadioValue( - status?.HealthDirectorateOrganDonationGetDonorStatus.isDonor - ? OPT_IN - : (status?.HealthDirectorateOrganDonationGetDonorStatus - ?.exceptionComment?.length ?? 0) > 0 - ? OPT_IN_EXCEPTIONS - : OPT_OUT, - ) + if (radioValue !== donorStatus) { + setRadioValue(donorStatus) } - }, [status]) + }, [donorStatus]) const onSubmit = async (e: React.FormEvent<HTMLFormElement>) => { e.preventDefault() const formData = new FormData(e.currentTarget) const data = Object.fromEntries(formData.entries()) - const idKey = 'organ-donation-limitation-' + const idKey = 'selected-limitations-' const limitations = Object.keys(data) .filter((key) => key.includes(idKey)) .map((key) => key.replace(idKey, '').toLowerCase()) + await updateDonorStatus({ variables: { input: { isDonor: radioValue === OPT_IN || radioValue === OPT_IN_EXCEPTIONS, - exceptions: radioValue === OPT_IN_EXCEPTIONS ? limitations : [], + organLimitations: radioValue === OPT_IN_EXCEPTIONS ? limitations : [], }, }, }) @@ -94,84 +99,91 @@ export const Form2 = () => { <Text variant="eyebrow" color="purple400" marginBottom={1}> {formatMessage(messages.changeTake)} </Text> - - <form onSubmit={onSubmit}> - <Stack space={2}> + {loading && <Loader />} + {!loading && ( + <form onSubmit={onSubmit}> + <Stack space={2}> + <Box + background="blue100" + borderRadius="large" + border="standard" + borderColor="blue200" + padding={3} + > + <RadioButton + id={`organ-donation-0`} + name="organ-registration-value" + label={formatMessage(messages.organDonationRegistrationOptIn)} + value={OPT_IN} + checked={radioValue === OPT_IN} + onChange={() => setRadioValue(OPT_IN)} + /> + </Box> + <Box + background="blue100" + borderRadius="large" + border="standard" + borderColor="blue200" + padding={3} + > + <RadioButton + id={`organ-donation-1`} + name="organ-registration-value" + label={formatMessage( + messages.organDonationRegistrationException, + )} + value={OPT_IN_EXCEPTIONS} + checked={radioValue === OPT_IN_EXCEPTIONS} + onChange={() => setRadioValue(OPT_IN_EXCEPTIONS)} + /> + {allLimitations && + allLimitations.length > 0 && + radioValue === OPT_IN_EXCEPTIONS && ( + <Limitations + data={allLimitations} + selected={selectedLimitations ?? []} + /> + )} + </Box> + <Box + background="blue100" + borderRadius="large" + border="standard" + borderColor="blue200" + padding={3} + > + <RadioButton + id={`organ-donation-2`} + name="organ-registration-value" + label={formatMessage(messages.organDonationRegistrationOptOut)} + value={OPT_OUT} + checked={radioValue === OPT_OUT} + onChange={() => setRadioValue(OPT_OUT)} + /> + </Box> + </Stack> <Box - background="blue100" - borderRadius="large" - border="standard" - borderColor="blue200" - padding={3} + display="flex" + justifyContent="flexEnd" + marginTop={3} + className={styles.buttonContainer} > - <RadioButton - id={`organ-donation-0`} - name="organ-registration-value" - label={formatMessage(messages.organDonationRegistrationOptIn)} - value={OPT_IN} - checked={radioValue === OPT_IN} - onChange={() => setRadioValue(OPT_IN)} - /> - </Box> - <Box - background="blue100" - borderRadius="large" - border="standard" - borderColor="blue200" - padding={3} - > - <RadioButton - id={`organ-donation-1`} - name="organ-registration-value" - label={formatMessage(messages.organDonationRegistrationException)} - value={OPT_IN_EXCEPTIONS} - checked={radioValue === OPT_IN_EXCEPTIONS} - onChange={() => setRadioValue(OPT_IN_EXCEPTIONS)} - /> - {exceptions && - exceptions.length > 0 && - radioValue === OPT_IN_EXCEPTIONS && ( - <Limitations data={exceptions} /> - )} - </Box> - <Box - background="blue100" - borderRadius="large" - border="standard" - borderColor="blue200" - padding={3} - > - <RadioButton - id={`organ-donation-2`} - name="organ-registration-value" - label={formatMessage(messages.organDonationRegistrationOptOut)} - value={OPT_OUT} - checked={radioValue === OPT_OUT} - onChange={() => setRadioValue(OPT_OUT)} - /> - </Box> - </Stack> - <Box - display="flex" - justifyContent="flexEnd" - marginTop={3} - className={styles.buttonContainer} - > - <LinkResolver href={HealthPaths.HealthOrganDonation}> - <Button size="small" variant="ghost"> - {formatMessage(coreMessages.buttonCancel)} + <LinkResolver href={HealthPaths.HealthOrganDonation}> + <Button size="small" variant="ghost"> + {formatMessage(coreMessages.buttonCancel)} + </Button> + </LinkResolver> + <Button + size="small" + type="submit" + loading={loading} + disabled={radioValue === undefined} + > + {formatMessage(coreMessages.codeConfirmation)} </Button> - </LinkResolver> - <Button - size="small" - type="submit" - loading={loading} - disabled={radioValue === undefined} - > - {formatMessage(coreMessages.codeConfirmation)} - </Button> - </Box> - </form> + </Box> + </form> + )} </Box> ) } diff --git a/libs/service-portal/health/src/screens/Vaccinations/Vaccinations.graphql b/libs/service-portal/health/src/screens/Vaccinations/Vaccinations.graphql index 9949bf0df212..18d2890814fb 100644 --- a/libs/service-portal/health/src/screens/Vaccinations/Vaccinations.graphql +++ b/libs/service-portal/health/src/screens/Vaccinations/Vaccinations.graphql @@ -1,23 +1,27 @@ query getVaccinations { - HealthDirectorateVaccinationsGetVaccinations { - diseaseId - diseaseName - diseaseDescription - vaccinationStatus - vaccinationsStatusName - lastVaccinationDate + healthDirectorateVaccinations { vaccinations { id - nationalId - code - vaccinationDate - vaccinationsAge { - years - months + name + description + isFeatured + status + statusName + statusColor + lastVaccinationDate + vaccinationsInfo { + id + name + date + age { + years + months + } + url + comment + rejected } - generalComment - rejected + comments } - comments } } diff --git a/libs/service-portal/health/src/screens/Vaccinations/VaccinationsGeneral.tsx b/libs/service-portal/health/src/screens/Vaccinations/VaccinationsGeneral.tsx deleted file mode 100644 index e5c8f7e8c4b8..000000000000 --- a/libs/service-portal/health/src/screens/Vaccinations/VaccinationsGeneral.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { Box, SkeletonLoader } from '@island.is/island-ui/core' -import { HealthPaths } from '../../lib/paths' -import { VaccinationsWrapper } from './wrapper/VaccinationsWrapper' -import { SortedVaccinationsTable } from './tables/SortedVaccinationsTable' -import { useGetVaccinationsQuery } from './Vaccinations.generated' -import { EmptyTable } from '@island.is/service-portal/core' -import { useLocale } from '@island.is/localization' -import { messages as m } from '../../lib/messages' -import { Problem } from '@island.is/react-spa/shared' - -export const VaccinationsGeneral = () => { - const { formatMessage } = useLocale() - - const { data, loading, error } = useGetVaccinationsQuery() - - const vaccinations = data?.HealthDirectorateVaccinationsGetVaccinations - return ( - <VaccinationsWrapper pathname={HealthPaths.HealthVaccinationsGeneral}> - <Box> - {loading && ( - <SkeletonLoader - repeat={3} - space={2} - height={24} - borderRadius="standard" - /> - )} - {!error && vaccinations?.length === 0 && ( - <EmptyTable message={formatMessage(m.noVaccinesRegistered)} /> - )} - {!error && !loading && vaccinations !== undefined && ( - <SortedVaccinationsTable data={vaccinations} /> - )} - {!loading && error && <Problem error={error} noBorder={false} />} - </Box> - </VaccinationsWrapper> - ) -} - -export default VaccinationsGeneral diff --git a/libs/service-portal/health/src/screens/Vaccinations/VaccinationsOther.tsx b/libs/service-portal/health/src/screens/Vaccinations/VaccinationsOther.tsx deleted file mode 100644 index 4ef397b65f64..000000000000 --- a/libs/service-portal/health/src/screens/Vaccinations/VaccinationsOther.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { Box, SkeletonLoader } from '@island.is/island-ui/core' -import { HealthPaths } from '../../lib/paths' -import { VaccinationsWrapper } from './wrapper/VaccinationsWrapper' -import { SortedVaccinationsTable } from './tables/SortedVaccinationsTable' -import { useGetVaccinationsQuery } from './Vaccinations.generated' -import { EmptyTable } from '@island.is/service-portal/core' -import { useLocale } from '@island.is/localization' -import { messages as m } from '../../lib/messages' -import { Problem } from '@island.is/react-spa/shared' - -export const VaccinationsOther = () => { - const { formatMessage } = useLocale() - const { data, loading, error } = useGetVaccinationsQuery() - const vaccinations = data?.HealthDirectorateVaccinationsGetVaccinations - return ( - <VaccinationsWrapper pathname={HealthPaths.HealthVaccinationsOther}> - <Box> - {loading && ( - <SkeletonLoader - repeat={3} - space={2} - height={24} - borderRadius="standard" - /> - )} - {!error && vaccinations?.length === 0 && ( - <EmptyTable message={formatMessage(m.noVaccinesRegistered)} /> - )} - {!error && !loading && vaccinations !== undefined && ( - <SortedVaccinationsTable data={vaccinations} /> - )} - {!loading && error && <Problem error={error} noBorder={false} />} - </Box> - </VaccinationsWrapper> - ) -} - -export default VaccinationsOther diff --git a/libs/service-portal/health/src/screens/Vaccinations/VaccinationsWrapper.tsx b/libs/service-portal/health/src/screens/Vaccinations/VaccinationsWrapper.tsx new file mode 100644 index 000000000000..4cea2521d6c6 --- /dev/null +++ b/libs/service-portal/health/src/screens/Vaccinations/VaccinationsWrapper.tsx @@ -0,0 +1,93 @@ +import { useLocale, useNamespaces } from '@island.is/localization' +import { Box, SkeletonLoader, Tabs } from '@island.is/island-ui/core' +import { + HEALTH_DIRECTORATE_SLUG, + IntroHeader, + LinkButton, + EmptyTable, +} from '@island.is/service-portal/core' +import { messages as m } from '../../lib/messages' +import { SECTION_GAP } from '../../utils/constants' +import { useGetVaccinationsQuery } from './Vaccinations.generated' +import { SortedVaccinationsTable } from './tables/SortedVaccinationsTable' +import { isDefined } from '@island.is/shared/utils' +import { Problem } from '@island.is/react-spa/shared' + +export const VaccinationsWrapper = () => { + useNamespaces('sp.health') + const { formatMessage } = useLocale() + const { data, loading, error } = useGetVaccinationsQuery() + + const vaccinations = data?.healthDirectorateVaccinations.vaccinations + + const general = vaccinations?.filter((x) => x.isFeatured) + const other = vaccinations?.filter((x) => !x.isFeatured) + + const tabs = [ + { + label: formatMessage(m.generalVaccinations), + content: <SortedVaccinationsTable data={general} />, + }, + { + label: formatMessage(m.otherVaccinations), + content: <SortedVaccinationsTable data={other} />, + }, + ].filter(isDefined) + + return ( + <Box> + <IntroHeader + title={formatMessage(m.vaccinations)} + intro={formatMessage(m.vaccinationsIntro)} + serviceProviderSlug={HEALTH_DIRECTORATE_SLUG} + serviceProviderTooltip={formatMessage(m.landlaeknirVaccinationsTooltip)} + /> + {/* Buttons */} + <Box printHidden display="flex" flexDirection="row" marginBottom={6}> + <LinkButton + to={formatMessage(m.readAboutVaccinationsLink)} + icon="open" + variant="utility" + text={formatMessage(m.readAboutVaccinations)} + /> + <Box marginLeft={1}> + <LinkButton + to={formatMessage(m.makeVaccinationAppointmentLink)} + icon="open" + variant="utility" + text={formatMessage(m.makeVaccinationAppointment)} + /> + </Box> + </Box> + + <Box> + {loading && ( + <SkeletonLoader + repeat={3} + space={2} + height={24} + borderRadius="standard" + /> + )} + {!error && vaccinations?.length === 0 && ( + <EmptyTable message={formatMessage(m.noVaccinesRegistered)} /> + )} + + {!loading && error && <Problem error={error} noBorder={false} />} + </Box> + {/* Tabs content */} + {!loading && !error && ( + <Box paddingY={SECTION_GAP}> + <Tabs + label={''} + tabs={tabs} + contentBackground="transparent" + selected="0" + size="xs" + /> + </Box> + )} + </Box> + ) +} +export default VaccinationsWrapper diff --git a/libs/service-portal/health/src/screens/Vaccinations/tables/SortedVaccinationsTable.tsx b/libs/service-portal/health/src/screens/Vaccinations/tables/SortedVaccinationsTable.tsx index babe324efcbf..39cf4ea001e8 100644 --- a/libs/service-portal/health/src/screens/Vaccinations/tables/SortedVaccinationsTable.tsx +++ b/libs/service-portal/health/src/screens/Vaccinations/tables/SortedVaccinationsTable.tsx @@ -1,95 +1,106 @@ import { useLocale, useNamespaces } from '@island.is/localization' -import { SortableTable, formatDate } from '@island.is/service-portal/core' +import { + EmptyTable, + SortableTable, + formatDate, +} from '@island.is/service-portal/core' import { messages } from '../../../lib/messages' -import { Vaccine } from '../dataStructure' import { tagSelector } from '../../../utils/tagSelector' import { VaccinationsDetailTable } from './VaccinationsDetailTable' import { DetailHeader, DetailRow } from '../../../utils/types' -import { ATC_URL_BASE } from '../../../utils/constants' -import { HealthDirectorateVaccinations } from '@island.is/api/schema' +import { HealthDirectorateVaccination } from '@island.is/api/schema' +import { Box } from '@island.is/island-ui/core' interface Props { - data: Array<HealthDirectorateVaccinations> + data?: Array<HealthDirectorateVaccination> } export const SortedVaccinationsTable = ({ data }: Props) => { useNamespaces('sp.health') const { formatMessage } = useLocale() const headerDataDetail: Array<DetailHeader> = [ { - value: 'Nr.', + value: formatMessage(messages.vaccinesTableHeaderNr), }, { - value: 'Dags.', + value: formatMessage(messages.vaccinesTableHeaderDate), }, { - value: 'Aldur', + value: formatMessage(messages.vaccinesTableHeaderAge), }, { - value: 'Bóluefni', + value: formatMessage(messages.vaccinesTableHeaderVaccine), }, { - value: 'Staður', + value: formatMessage(messages.vaccinesTableHeaderLocation), }, ] + if (data?.length === 0) + return <EmptyTable message={formatMessage(messages.noVaccinesRegistered)} /> + return ( - <SortableTable - title="" - labels={{ - vaccine: formatMessage(messages.vaccinatedFor), - date: formatMessage(messages.vaccinatedLast), - status: formatMessage(messages.status), - }} - tagOutlined - expandable - defaultSortByKey="vaccine" - items={ - data?.map((item, i) => ({ - id: item?.diseaseId ?? `${i}`, - name: item?.diseaseName ?? '', - vaccine: item?.diseaseName ?? '', - date: formatDate(item?.lastVaccinationDate) ?? '', + <Box paddingY={4}> + <SortableTable + title="" + labels={{ + vaccine: formatMessage(messages.vaccinatedFor), + date: formatMessage(messages.vaccinatedLast), + status: formatMessage(messages.status), + }} + tagOutlined + expandable + defaultSortByKey="vaccine" + items={ + data?.map((item, i) => ({ + id: item?.id ?? `${i}`, + name: item?.name ?? '', + vaccine: item?.name ?? '', + date: formatDate(item?.lastVaccinationDate) ?? '', - children: ( - <VaccinationsDetailTable - headerData={headerDataDetail} - rowData={item.vaccinations?.map( - (vaccination, i): Array<DetailRow> => { - return [ - { - value: (i + 1).toString(), - }, - { - value: - vaccination.vaccinationDate.toLocaleDateString('is-IS'), - }, - { - value: [ - vaccination.vaccinationsAge?.years, - formatMessage(messages.years), - vaccination.vaccinationsAge?.months, - formatMessage(messages.months), - ] - .filter(Boolean) - .join(' '), - }, - { - value: vaccination.code ?? '', - type: 'link', - url: ATC_URL_BASE + vaccination.code, - }, - { - value: vaccination.generalComment, - }, - ] - }, - )} - footerText={item.comments ?? []} - /> - ), - status: item?.vaccinationsStatusName ?? '', - tag: tagSelector(item?.vaccinationStatus ?? ''), - })) ?? [] - } - /> + children: ( + <VaccinationsDetailTable + headerData={headerDataDetail} + rowData={item.vaccinationsInfo?.map( + (vaccination, i): Array<DetailRow> => { + return [ + { + value: (i + 1).toString(), + }, + { + value: new Date(vaccination.date).toLocaleDateString( + 'is-IS', + ), + }, + { + value: [ + vaccination.age?.years, + vaccination.age?.years + ? formatMessage(messages.years) + : undefined, + vaccination.age?.months, + formatMessage(messages.months), + ] + .filter(Boolean) + .join(' '), + }, + { + value: vaccination.name ?? '', + type: 'link', + url: vaccination.url ?? '', + }, + { + value: vaccination.comment ?? '', + }, + ] + }, + )} + footerText={item.comments ?? []} + /> + ), + status: item?.statusName ?? '', + tag: tagSelector(item?.status), + })) ?? [] + } + /> + </Box> ) } diff --git a/libs/service-portal/health/src/screens/Vaccinations/tables/VaccinationsDetailTable.tsx b/libs/service-portal/health/src/screens/Vaccinations/tables/VaccinationsDetailTable.tsx index 5120ed6382eb..31f8b999cfb5 100644 --- a/libs/service-portal/health/src/screens/Vaccinations/tables/VaccinationsDetailTable.tsx +++ b/libs/service-portal/health/src/screens/Vaccinations/tables/VaccinationsDetailTable.tsx @@ -49,7 +49,11 @@ export const VaccinationsDetailTable = ({ </T.Body> )} </T.Table> - {rowData === undefined && <EmptyTable message={m.noVaccinesRegistered} />} + {(rowData === undefined || rowData.length === 0) && ( + <Box width="full" background="white"> + <EmptyTable message={m.noVaccinesRegistered} /> + </Box> + )} <Box marginTop={2} paddingLeft={2}> <ul color="black"> {footerText.map((item, i) => ( diff --git a/libs/service-portal/health/src/screens/Vaccinations/wrapper/VaccinationsWrapper.tsx b/libs/service-portal/health/src/screens/Vaccinations/wrapper/VaccinationsWrapper.tsx deleted file mode 100644 index bed64757288f..000000000000 --- a/libs/service-portal/health/src/screens/Vaccinations/wrapper/VaccinationsWrapper.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import { useLocale, useNamespaces } from '@island.is/localization' -import { Box, Hidden } from '@island.is/island-ui/core' -import { - HEALTH_DIRECTORATE_SLUG, - IntroHeader, - LinkButton, - TabNavigation, -} from '@island.is/service-portal/core' -import { messages as m } from '../../../lib/messages' -import { healthNavigation } from '../../../lib/navigation' -import { SECTION_GAP } from '../../../utils/constants' - -export const VaccinationsWrapper = ({ - children, - pathname, -}: { - children: React.ReactNode - pathname?: string -}) => { - useNamespaces('sp.health') - const { formatMessage } = useLocale() - - return ( - <Box> - <IntroHeader - title={formatMessage(m.vaccinations)} - intro={formatMessage(m.vaccinationsIntro)} - serviceProviderSlug={HEALTH_DIRECTORATE_SLUG} - serviceProviderTooltip={formatMessage(m.landlaeknirVaccinationsTooltip)} - /> - {/* Buttons */} - <Box printHidden display="flex" flexDirection="row" marginBottom={6}> - <LinkButton - to={formatMessage(m.readAboutVaccinationsLink)} - icon="open" - variant="utility" - text={formatMessage(m.readAboutVaccinations)} - /> - <Box marginLeft={1}> - <LinkButton - to={formatMessage(m.makeVaccinationAppointmentLink)} - icon="open" - variant="utility" - text={formatMessage(m.makeVaccinationAppointment)} - /> - </Box> - </Box> - {/* Tabs content */} - <Hidden print={true}> - <TabNavigation - label={formatMessage(m.vaccinations)} - pathname={pathname} - items={ - healthNavigation.children?.find( - (itm) => itm.name === m.vaccinations, - )?.children ?? [] - } - /> - </Hidden> - <Box paddingY={SECTION_GAP}>{children}</Box> - </Box> - ) -} diff --git a/libs/service-portal/health/src/utils/tagSelector.ts b/libs/service-portal/health/src/utils/tagSelector.ts index e4a913841f7a..475ae4ec5f6c 100644 --- a/libs/service-portal/health/src/utils/tagSelector.ts +++ b/libs/service-portal/health/src/utils/tagSelector.ts @@ -1,10 +1,24 @@ +import { isDefined } from 'class-validator' + // Tag selector for expandable, sorting table in vaccinations -export const tagSelector = (str: string) => { +export const tagSelector = (str?: string | null) => { + if (!isDefined(str)) return 'blue' + const obj = { expired: 'blue', - vaccinated: 'mint', unvaccinated: 'red', + undetermined: 'purple', + valid: 'mint', } - return (obj as any)?.[str] || 'blue' + return (str && (obj as any)?.[str]) || 'blue' } + +// Valid = 'valid', +// Expired = 'expired', +// Complete = 'complete', +// Incomplete = 'incomplete', +// Undocumented = 'undocumented', +// Unvaccinated = 'unvaccinated', +// Rejected = 'rejected', +// Undetermined = 'undetermined' diff --git a/libs/service-portal/social-insurance-maintenance/src/screens/IncomePlanDetail/IncomePlanDetail.graphql b/libs/service-portal/social-insurance-maintenance/src/screens/IncomePlanDetail/IncomePlanDetail.graphql index ec6c8750b52d..c53181ae2bbe 100644 --- a/libs/service-portal/social-insurance-maintenance/src/screens/IncomePlanDetail/IncomePlanDetail.graphql +++ b/libs/service-portal/social-insurance-maintenance/src/screens/IncomePlanDetail/IncomePlanDetail.graphql @@ -5,7 +5,7 @@ query getIncomePlanDetail { reason } incomeCategories { - name + typeName annualSum currency } diff --git a/libs/service-portal/social-insurance-maintenance/src/screens/IncomePlanDetail/IncomePlanDetail.tsx b/libs/service-portal/social-insurance-maintenance/src/screens/IncomePlanDetail/IncomePlanDetail.tsx index 815d0ca2a838..1b74aa01978f 100644 --- a/libs/service-portal/social-insurance-maintenance/src/screens/IncomePlanDetail/IncomePlanDetail.tsx +++ b/libs/service-portal/social-insurance-maintenance/src/screens/IncomePlanDetail/IncomePlanDetail.tsx @@ -101,7 +101,7 @@ const IncomePlanDetail = () => { data.socialInsuranceIncomePlan.incomeCategories.map( (category, index) => ( <T.Row key={index}> - <T.Data>{category.name}</T.Data> + <T.Data>{category.typeName}</T.Data> <T.Data>{amountFormat(category.annualSum)}</T.Data> <T.Data>{category.currency}</T.Data> </T.Row> diff --git a/libs/shared/constants/src/lib/chargeItemCode.ts b/libs/shared/constants/src/lib/chargeItemCode.ts index 6fce9ba5c15f..816c9cd52e95 100644 --- a/libs/shared/constants/src/lib/chargeItemCode.ts +++ b/libs/shared/constants/src/lib/chargeItemCode.ts @@ -31,19 +31,7 @@ export enum ChargeItemCode { HEALTHCARE_LICENSE_CERTIFICATE = 'L6102', ID_CARD_REGULAR = 'AY155', //Nafnskírteini almennt gjald ID_CARD_EXPRESS = 'AY156', //Nafnskírteini skyndiútgáfa - ID_CARD_TRAVEL_REGULAR = 'AY157', //Nafnskírteini ferðaskilríki - ID_CARD_TRAVEL_EXPRESS = 'AY158', //Nafnskírteini ferðaskilríki skyndiútgáfa - ID_CARD_DISABILITY_REGULAR = 'AY159', //Nafnskírteini örykjar - ID_CARD_DISABILITY_EXPRESS = 'AY160', //Nafnskírteini örykjar skyndiútgáfa - ID_CARD_DISABILITY_TRAVEL_REGULAR = 'AY161', //Nafnskírteini öyrkjar ferðaskilríki - ID_CARD_DISABILITY_TRAVEL_EXPRESS = 'AY162', //Nafnskírteini örykjar ferðask. skyndiútgáfa - ID_CARD_CHILDREN_REGULAR = 'AY163', //Nafnskírteini börn - ID_CARD_CHILDREN_EXPRESS = 'AY164', //Nafnskírteini börn skyndiútgáfa - ID_CARD_CHILDREN_TRAVEL_REGULAR = 'AY165', //Nafnskírteini börn ferðaskilríki - ID_CARD_CHILDREN_TRAVEL_EXPRESS = 'AY166', //Nafnskírteini börn ferðaskilríki skyndiútg. - ID_CARD_OLDER_REGULAR = 'AY167', //Nafnskírteini 67 ára og eldri - ID_CARD_OLDER_EXPRESS = 'AY168', //Nafnskírteini 67 ára og eldri skyndiútgáfa - ID_CARD_OLDER_TRAVEL_REGULAR = 'AY169', //Nafnskírteini 67 ára og eldri ferðaskilríki - ID_CARD_OLDER_TRAVEL_EXPRESS = 'AY170', //Nafnskírteini 67 ára og eldri ferðask skyndiútgáfa + ID_CARD_OTHERS_REGULAR = 'AY157', //Nafnskírteini aðrir gjald + ID_CARD_OTHERS_EXPRESS = 'AY158', //Nafnskírteini aðrir skyndiútgáfa HEALTHCARE_WORK_PERMIT = 'L6101', } diff --git a/libs/shared/utils/src/lib/videoEmbed.ts b/libs/shared/utils/src/lib/videoEmbed.ts index c2cbd67b6df5..128752bcdc09 100644 --- a/libs/shared/utils/src/lib/videoEmbed.ts +++ b/libs/shared/utils/src/lib/videoEmbed.ts @@ -34,7 +34,14 @@ export const getVideoEmbedProperties = ( if (match) { let id = match[7] if (id.startsWith('/')) id = id.slice(1) - if (id.length === 11) youtubeId = id + if (id.length === 11) { + youtubeId = id + } else { + const v = item.searchParams.get('v') + if (v && v.length === 11) { + youtubeId = v + } + } } if (youtubeId) { diff --git a/mocks/README.md b/mocks/README.md index dc1c442dbcf8..89602163d283 100644 --- a/mocks/README.md +++ b/mocks/README.md @@ -1,29 +1,45 @@ -# Mocking national registry XROAD endpoints +# Mocking REST endpoints with recorded data -## Prerequisites +It can be incredibly useful to be able to alter responses from a REST endpoint or even entirely replace an unavailable one. -Since the mock will be listening on port `8081`, the port forwarding for xroad needs to be listening on port `8082` as that is where the mock server will forward requests it does not have mock responses for. +This readme covers how to do that with Mockoon + +## Proxying XROAD + +Since the requests from the services we are running locally default to making their calls on port `8081`so the mock will be listening on port `8081`. This means the port forwarding for xroad needs to be listening on port `8082` (or some other port) and then we will set the mock server will forward requests it does not have mock responses for to that port. To set the port forwarding to listen on port `8082` you can pass a port argument to the proxies script like so `yarn proxies xroad --p 8082`. Alternatively if you use kubectl and socat just replace `8081:80` with `8082:80`. -## How to +## Mockoon-CLI -First you'll want to install [mockoon-cli](https://github.com/mockoon/mockoon/tree/main/packages/cli#installation), then you just call `mockoon-cli start --data <path to capture file>`. The capture file can be one you made yourself (see below) or one that has been checked in such as `national-registryv2.json` Mockoon will now start listening on port `8081` and proxying non-mocked traffic to port `8082`. +If you only want to proxy and mock using existing mock data, you'll want to install [mockoon-cli](https://github.com/mockoon/mockoon/tree/main/packages/cli#installation). Then you just call `mockoon-cli start --data <path to capture file>`. The capture file can be one you made yourself (see below) or one that has been checked in such as `national-registryv2.json` Mockoon will now start listening on port `8081` and proxying non-mocked traffic to port `8082`. For more in-depth instructions, you can check out the [mockoon site](https://mockoon.com/cli/). ## Current mocks -Currently, only a capture file for the accident notification form for Gervimaður Færeyjar on national registry V2 is included. +### Applications + +If mockdata is available for an application it should be in the mockData directory in the application in question (see above under how to). If you create mock data for an application that doesn't have any, consider adding it under the appropriate directory. + +## Q&A -## What if I need to call an endpoint that isn't mocked +### What if I need to call an endpoint that isn't mocked No problem, mockoon will transparently proxy whatever requests it does not have mocks for. -## My calls aren't being mocked +### What if I want to get an actual response from an endpoint being mocked + +Find the endpoint in question in the `Routes` panel, click on the three little dots in the upper right corner of the route entry and select `Toggle`. This will cause any incoming requests to be proxied rather than mocked. + +### What if I want to update the mocked data for an endpoint + +The simplest way is to delete the existing endpoint by finding it in the routes list as above but selecting `Delete` instead of `Toggle`, turning on the recording function by clicking the little dot in the `Logs` tab above the request list and then performing a call to the underlying endpoint. You can also toggle the endpoint mock off as described above, do a call to the endpoint, find the log for that call in the logs tab and simply copy over the returned data. + +### My calls aren't being mocked -The mocks are currently set up for the Gervimaður Færeyjar fake person. If you need to mock other fake persons, you can download the [mockoon app](https://mockoon.com/download/) and either open the `national-registry.json` collection or start your own with [automocking](https://mockoon.com/docs/latest/logging-and-recording/auto-mocking-and-recording/). +The mocks are currently set up for the Gervimaður Færeyjar fake person. If you need to mock other fake persons, you can download the [mockoon app](https://mockoon.com/download/) and either open the applicable collection or start your own with [automocking](https://mockoon.com/docs/latest/logging-and-recording/auto-mocking-and-recording/). -## Does the mocking proxy only respond with mocks when the proxied service is down? +### Does the mocking proxy only respond with mocks when the proxied service is down? -No, one of the benefits of mocking locally is a significantly shorter response time, and to achieve that, it's necessary to use mocks even if the underlying service is operational. +No, one of the benefits of mocking locally is a significantly shorter response time, and to achieve that, it's necessary to use mocks even if the underlying service is operational. If you want to send calls to the proxied endpoint you can toggle the mock off in the `Routes` tab.