Skip to content

Commit

Permalink
Replacement invoice processing UI and state transitions
Browse files Browse the repository at this point in the history
  • Loading branch information
akheron committed Nov 18, 2024
1 parent 6cef132 commit 6c017c2
Show file tree
Hide file tree
Showing 19 changed files with 523 additions and 35 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@
//
// SPDX-License-Identifier: LGPL-2.1-or-later

import { Page, Element } from '../../../utils/page'
import { InvoiceReplacementReason } from 'lib-common/generated/api-types/invoicing'

import { Page, Element, TextInput, Select } from '../../../utils/page'

export class InvoiceDetailsPage {
headOfFamilySection: InvoiceHeadOfFamilySection
Expand All @@ -11,6 +13,11 @@ export class InvoiceDetailsPage {
totalPrice: Element
previousTotalPrice: Element

replacementDraftForm: InvoiceReplacementDraftSection

// view
replacementInfo: InvoiceReplacementInfoSection

constructor(private page: Page) {
this.headOfFamilySection = new InvoiceHeadOfFamilySection(
page.findByDataQa('head-of-family')
Expand All @@ -22,6 +29,13 @@ export class InvoiceDetailsPage {
this.previousTotalPrice = this.page
.findByDataQa('total-sum')
.findByDataQa('previous-price')

this.replacementDraftForm = new InvoiceReplacementDraftSection(
page.findByDataQa('replacement-draft-form')
)
this.replacementInfo = new InvoiceReplacementInfoSection(
page.findByDataQa('replacement-info')
)
}

nthChild(index: number): InvoiceChildSection {
Expand Down Expand Up @@ -80,3 +94,20 @@ export class InvoiceRow extends Element {
unitPrice = this.findByDataQa('unit-price')
totalPrice = this.findByDataQa('total-price')
}

export class InvoiceReplacementDraftSection extends Element {
reason = new Select(this.findByDataQa('replacement-reason'))
notes = new TextInput(this.findByDataQa('replacement-notes'))
markSentButton = this.findByDataQa('mark-sent')

async selectReason(value: InvoiceReplacementReason) {
await this.reason.selectOption(value)
}
}

export class InvoiceReplacementInfoSection extends Element {
reason = this.findByDataQa('replacement-reason')
notes = this.findByDataQa('replacement-notes')
sentAt = this.findByDataQa('sent-at')
sentBy = this.findByDataQa('sent-by')
}
34 changes: 32 additions & 2 deletions frontend/src/e2e-test/specs/4_finance/invoices.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
//
// SPDX-License-Identifier: LGPL-2.1-or-later

import { DevPlacement } from 'e2e-test/generated/api-types'
import { DevEmployee, DevPlacement } from 'e2e-test/generated/api-types'
import FiniteDateRange from 'lib-common/finite-date-range'
import HelsinkiDateTime from 'lib-common/helsinki-date-time'
import LocalDate from 'lib-common/local-date'
Expand Down Expand Up @@ -43,6 +43,8 @@ const codebtor = Fixture.person({
ssn: '010177-1234'
}).data

let financeAdmin: DevEmployee

beforeEach(async () => {
await resetServiceState()
await Fixture.careArea(testCareArea).save()
Expand Down Expand Up @@ -79,7 +81,7 @@ beforeEach(async () => {
async function openInvoicesPage(): Promise<InvoicesPage> {
page = await Page.open({ acceptDownloads: true, mockedTime: now })

const financeAdmin = await Fixture.employee().financeAdmin().save()
financeAdmin = await Fixture.employee().financeAdmin().save()
await employeeLogin(page, financeAdmin)

await page.goto(config.employeeUrl)
Expand Down Expand Up @@ -339,5 +341,33 @@ describe('Invoices', () => {

await invoicesPage.navigateBackToInvoices()
})

test('Replacement invoice can be marked as sent', async () => {
// Add an absence => replacement invoice is generated
await Fixture.absence({
childId: testChild2.id,
date: today.subMonths(1).withDate(1),
absenceType: 'FORCE_MAJEURE',
absenceCategory: 'BILLABLE'
}).save()
await generateReplacementDraftInvoices()

await invoicesPage.filterByStatus('REPLACEMENT_DRAFT')

const invoicePage = await invoicesPage.openFirstInvoice()
const form = invoicePage.replacementDraftForm

await form.selectReason('ABSENCE')
await form.notes.fill('Unohtunut päiväkirjamerkintä')
await form.markSentButton.click()

const view = invoicePage.replacementInfo
await view.reason.assertTextEquals('Päiväkirjamerkintä')
await view.notes.assertTextEquals('Unohtunut päiväkirjamerkintä')
await view.sentAt.assertTextEquals(now.format())
await view.sentBy.assertTextEquals(
`${financeAdmin.lastName} ${financeAdmin.firstName}`
)
})
})
})
27 changes: 12 additions & 15 deletions frontend/src/employee-frontend/components/invoice/Actions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,24 +15,21 @@ type Props = {
invoiceResponse: InvoiceDetailedResponse
}

const Actions = React.memo(function Actions({ invoiceResponse }: Props) {
export const MarkSent = React.memo(function MarkSent({
invoiceResponse
}: Props) {
const { i18n } = useTranslation()
const { invoice, permittedActions } = invoiceResponse

return (
return permittedActions.includes('MARK_SENT') ? (
<FixedSpaceRow justifyContent="flex-end">
{permittedActions.includes('MARK_SENT') &&
invoice.status === 'WAITING_FOR_SENDING' ? (
<MutateButton
primary
text={i18n.invoice.form.buttons.markSent}
mutation={markInvoicesSentMutation}
onClick={() => ({ body: [invoice.id] })}
data-qa="invoice-actions-mark-sent"
/>
) : null}
<MutateButton
primary
text={i18n.invoice.form.buttons.markSent}
mutation={markInvoicesSentMutation}
onClick={() => ({ body: [invoice.id] })}
data-qa="invoice-actions-mark-sent"
/>
</FixedSpaceRow>
)
) : null
})

export default Actions
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,11 @@ import { TitleContext, TitleState } from '../../state/title'
import { renderResult } from '../async-rendering'
import { invoiceCodesQuery, invoiceDetailsQuery } from '../invoices/queries'

import Actions from './Actions'
import { MarkSent } from './Actions'
import InvoiceDetailsSection from './InvoiceDetailsSection'
import InvoiceHeadOfFamilySection from './InvoiceHeadOfFamilySection'
import InvoiceRowsSection from './InvoiceRowsSection'
import { ReplacementDraftForm, ReplacementInfo } from './ReplacementDraftInfo'
import Sum from './Sum'
import { formatInvoicePeriod } from './utils'

Expand Down Expand Up @@ -79,7 +80,13 @@ export default React.memo(function InvoiceDetailsPage() {
previousSum={response.replacedInvoice?.totalPrice}
data-qa="total-sum"
/>
<Actions invoiceResponse={response} />
{response.invoice.status === 'WAITING_FOR_SENDING' ? (
<MarkSent invoiceResponse={response} />
) : response.invoice.status === 'REPLACEMENT_DRAFT' ? (
<ReplacementDraftForm invoiceResponse={response} />
) : response.invoice.replacementReason !== null ? (
<ReplacementInfo invoiceResponse={response} />
) : null}
</ContentArea>
)
)}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
// SPDX-FileCopyrightText: 2017-2022 City of Espoo
//
// SPDX-License-Identifier: LGPL-2.1-or-later

import React from 'react'
import styled from 'styled-components'

import { string } from 'lib-common/form/fields'
import { object, oneOf, required } from 'lib-common/form/form'
import { useForm, useFormFields } from 'lib-common/form/hooks'
import {
InvoiceDetailedResponse,
InvoiceReplacementReason,
invoiceReplacementReasons
} from 'lib-common/generated/api-types/invoicing'
import { MutateButton } from 'lib-components/atoms/buttons/MutateButton'
import { SelectF } from 'lib-components/atoms/dropdowns/Select'
import { TextAreaF } from 'lib-components/atoms/form/TextArea'
import {
FixedSpaceColumn,
FixedSpaceRow
} from 'lib-components/layout/flex-helpers'
import { InfoBox } from 'lib-components/molecules/MessageBoxes'
import { H3, Label, P } from 'lib-components/typography'

import { useTranslation } from '../../state/i18n'
import { markReplacementDraftSentMutation } from '../invoices/queries'

const replacementDraftForm = object({
reason: required(oneOf<InvoiceReplacementReason>()),
notes: string()
})

export function ReplacementDraftForm({
invoiceResponse
}: {
invoiceResponse: InvoiceDetailedResponse
}) {
const { i18n } = useTranslation()

const form = useForm(
replacementDraftForm,
() => ({
reason: {
domValue: '',
options: invoiceReplacementReasons.map((r) => ({
domValue: r,
value: r,
label: i18n.invoice.form.replacement.reasons[r]
}))
},
notes: ''
}),
i18n.validationErrors
)
const { reason, notes } = useFormFields(form)

return (
<FixedSpaceColumn data-qa="replacement-draft-form">
<H3>{i18n.invoice.form.replacement.title}</H3>
<P>{i18n.invoice.form.replacement.info}</P>
<FixedSpaceRow>
<FixedSpaceColumn>
<Label>Oikaisun syy *</Label>
<SelectF
bind={reason}
placeholder={i18n.common.select}
hideErrorsBeforeTouched
data-qa="replacement-reason"
/>
</FixedSpaceColumn>
<FixedSpaceColumn>
<Label>Lisätiedot</Label>
<TextAreaWrapper>
<TextAreaF bind={notes} data-qa="replacement-notes" />
</TextAreaWrapper>
</FixedSpaceColumn>
</FixedSpaceRow>
<FixedSpaceRow justifyContent="flex-end">
<InfoBox message={i18n.invoice.form.replacement.sendInfo} />
</FixedSpaceRow>
<FixedSpaceRow justifyContent="flex-end">
<MutateButton
primary
mutation={markReplacementDraftSentMutation}
onClick={() => ({
invoiceId: invoiceResponse.invoice.id,
body: form.value()
})}
text={i18n.invoice.form.replacement.send}
disabled={!form.isValid()}
data-qa="mark-sent"
/>
</FixedSpaceRow>
</FixedSpaceColumn>
)
}

export function ReplacementInfo({
invoiceResponse
}: {
invoiceResponse: InvoiceDetailedResponse
}) {
const { i18n } = useTranslation()
const { invoice } = invoiceResponse

if (invoice.replacementReason === null) return null

return (
<FixedSpaceColumn data-qa="replacement-info">
<H3>{i18n.invoice.form.replacement.title}</H3>
<P>{i18n.invoice.form.replacement.info}</P>
<FixedSpaceRow spacing="L">
<FixedSpaceColumn>
<Label>Oikaisun syy</Label>
<div data-qa="replacement-reason">
{i18n.invoice.form.replacement.reasons[invoice.replacementReason]}
</div>
</FixedSpaceColumn>
<FixedSpaceColumn>
<Label>Lisätiedot</Label>
<NotesWrapper data-qa="replacement-notes">
{invoice.replacementNotes}
</NotesWrapper>
</FixedSpaceColumn>
</FixedSpaceRow>
<div>
<Label>Merkitty siirretyksi</Label>
<div>
<span data-qa="sent-at">{invoice.sentAt?.format()}</span>
{' ('}
<span data-qa="sent-by">{invoice.sentBy?.name}</span>)
</div>
</div>
</FixedSpaceColumn>
)
}

const TextAreaWrapper = styled.div`
min-width: 400px;
`

const NotesWrapper = styled.div`
white-space: pre-line;
`
6 changes: 6 additions & 0 deletions frontend/src/employee-frontend/components/invoices/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
sendInvoices,
sendInvoicesByDate
} from '../../generated/api-clients/invoicing'
import { markReplacementDraftSent } from '../../generated/api-clients/invoicing'
import { createQueryKeys } from '../../query'

const queryKeys = createQueryKeys('invoices', {
Expand Down Expand Up @@ -76,3 +77,8 @@ export const deleteDraftInvoicesMutation = mutation({
queryKeys.invoiceDetailsAll()
]
})

export const markReplacementDraftSentMutation = mutation({
api: markReplacementDraftSent,
invalidateQueryKeys: (arg) => [queryKeys.invoiceDetails(arg.invoiceId)]
})
19 changes: 19 additions & 0 deletions frontend/src/employee-frontend/generated/api-clients/invoicing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { InvoiceDetailedResponse } from 'lib-common/generated/api-types/invoicin
import { InvoicePayload } from 'lib-common/generated/api-types/invoicing'
import { JsonCompatible } from 'lib-common/json'
import { JsonOf } from 'lib-common/json'
import { MarkReplacementDraftSentRequest } from 'lib-common/generated/api-types/invoicing'
import { NoteUpdateBody } from 'lib-common/generated/api-types/invoicing'
import { PagedFeeDecisionSummaries } from 'lib-common/generated/api-types/invoicing'
import { PagedInvoiceSummaryResponses } from 'lib-common/generated/api-types/invoicing'
Expand Down Expand Up @@ -641,6 +642,24 @@ export async function markInvoicesSent(
}


/**
* Generated from fi.espoo.evaka.invoicing.controller.InvoiceController.markReplacementDraftSent
*/
export async function markReplacementDraftSent(
request: {
invoiceId: UUID,
body: MarkReplacementDraftSentRequest
}
): Promise<void> {
const { data: json } = await client.request<JsonOf<void>>({
url: uri`/employee/invoices/${request.invoiceId}/mark-replacement-draft-sent`.toString(),
method: 'POST',
data: request.body satisfies JsonCompatible<MarkReplacementDraftSentRequest>
})
return json
}


/**
* Generated from fi.espoo.evaka.invoicing.controller.InvoiceController.searchInvoices
*/
Expand Down
Loading

0 comments on commit 6c017c2

Please sign in to comment.