Share one modal shell and inline confirm across the app
Modal owns the overlay, header, close button and footer for the balance, planned-day and QR modals, and closes on Escape (held off while the QR fetch runs). InlineConfirm replaces the hand-built delete and remove-member confirmations. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,187 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { InlineConfirm } from './InlineConfirm'
|
||||
|
||||
describe('InlineConfirm', () => {
|
||||
it('renders the prompt and both buttons', () => {
|
||||
render(
|
||||
<InlineConfirm
|
||||
prompt="Are you sure?"
|
||||
confirmLabel="Yes"
|
||||
cancelLabel="No"
|
||||
onConfirm={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText('Are you sure?')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Yes' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'No' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('defaults to cancel before confirm', () => {
|
||||
render(
|
||||
<InlineConfirm
|
||||
prompt="Are you sure?"
|
||||
confirmLabel="Yes"
|
||||
cancelLabel="No"
|
||||
onConfirm={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
const buttons = screen.getAllByRole('button')
|
||||
expect(buttons.map((b) => b.textContent)).toEqual(['No', 'Yes'])
|
||||
})
|
||||
|
||||
it('shows confirm before cancel when confirmFirst is set', () => {
|
||||
render(
|
||||
<InlineConfirm
|
||||
prompt="Are you sure?"
|
||||
confirmLabel="Yes"
|
||||
cancelLabel="No"
|
||||
onConfirm={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
confirmFirst
|
||||
/>,
|
||||
)
|
||||
const buttons = screen.getAllByRole('button')
|
||||
expect(buttons.map((b) => b.textContent)).toEqual(['Yes', 'No'])
|
||||
})
|
||||
|
||||
it('calls onConfirm when the confirm button is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onConfirm = vi.fn()
|
||||
render(
|
||||
<InlineConfirm
|
||||
prompt="Are you sure?"
|
||||
confirmLabel="Yes"
|
||||
cancelLabel="No"
|
||||
onConfirm={onConfirm}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
await user.click(screen.getByRole('button', { name: 'Yes' }))
|
||||
expect(onConfirm).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('calls onCancel when the cancel button is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onCancel = vi.fn()
|
||||
render(
|
||||
<InlineConfirm
|
||||
prompt="Are you sure?"
|
||||
confirmLabel="Yes"
|
||||
cancelLabel="No"
|
||||
onConfirm={vi.fn()}
|
||||
onCancel={onCancel}
|
||||
/>,
|
||||
)
|
||||
await user.click(screen.getByRole('button', { name: 'No' }))
|
||||
expect(onCancel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('disables both buttons while busy', () => {
|
||||
render(
|
||||
<InlineConfirm
|
||||
prompt="Are you sure?"
|
||||
confirmLabel="Yes"
|
||||
cancelLabel="No"
|
||||
onConfirm={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
isBusy
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByRole('button', { name: 'Yes' })).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: 'No' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('leaves both buttons enabled when not busy', () => {
|
||||
render(
|
||||
<InlineConfirm
|
||||
prompt="Are you sure?"
|
||||
confirmLabel="Yes"
|
||||
cancelLabel="No"
|
||||
onConfirm={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByRole('button', { name: 'Yes' })).not.toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: 'No' })).not.toBeDisabled()
|
||||
})
|
||||
|
||||
it('appends btn--sm to both buttons when small is set', () => {
|
||||
render(
|
||||
<InlineConfirm
|
||||
prompt="Are you sure?"
|
||||
confirmLabel="Yes"
|
||||
cancelLabel="No"
|
||||
onConfirm={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
small
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByRole('button', { name: 'Yes' })).toHaveClass('btn--sm')
|
||||
expect(screen.getByRole('button', { name: 'No' })).toHaveClass('btn--sm')
|
||||
})
|
||||
|
||||
it('does not append btn--sm by default', () => {
|
||||
render(
|
||||
<InlineConfirm
|
||||
prompt="Are you sure?"
|
||||
confirmLabel="Yes"
|
||||
cancelLabel="No"
|
||||
onConfirm={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByRole('button', { name: 'Yes' })).not.toHaveClass('btn--sm')
|
||||
expect(screen.getByRole('button', { name: 'No' })).not.toHaveClass('btn--sm')
|
||||
})
|
||||
|
||||
it('wires up confirmTestId and cancelTestId', () => {
|
||||
render(
|
||||
<InlineConfirm
|
||||
prompt="Are you sure?"
|
||||
confirmLabel="Yes"
|
||||
cancelLabel="No"
|
||||
onConfirm={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
confirmTestId="confirm-test"
|
||||
cancelTestId="cancel-test"
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByTestId('confirm-test')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('cancel-test')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('wires up the wrapper testId and className', () => {
|
||||
const { container } = render(
|
||||
<InlineConfirm
|
||||
prompt="Are you sure?"
|
||||
confirmLabel="Yes"
|
||||
cancelLabel="No"
|
||||
onConfirm={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
testId="wrapper-test"
|
||||
className="my-wrapper"
|
||||
/>,
|
||||
)
|
||||
const wrapper = screen.getByTestId('wrapper-test')
|
||||
expect(wrapper).toHaveClass('my-wrapper')
|
||||
expect(container.firstChild).toBe(wrapper)
|
||||
})
|
||||
|
||||
it('wires up promptClassName on the prompt span', () => {
|
||||
render(
|
||||
<InlineConfirm
|
||||
prompt="Are you sure?"
|
||||
confirmLabel="Yes"
|
||||
cancelLabel="No"
|
||||
onConfirm={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
promptClassName="prompt-class"
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText('Are you sure?')).toHaveClass('prompt-class')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,97 @@
|
||||
import { ReactNode } from 'react'
|
||||
|
||||
export interface InlineConfirmProps {
|
||||
prompt: ReactNode
|
||||
confirmLabel: ReactNode
|
||||
cancelLabel: ReactNode
|
||||
onConfirm: () => void
|
||||
onCancel: () => void
|
||||
isBusy?: boolean
|
||||
confirmTestId?: string
|
||||
cancelTestId?: string
|
||||
/** Class for the outer wrapper — the two callers use different elements
|
||||
* for this (`.modal__delete-confirm`, `.settings-panel__remove-confirm`),
|
||||
* so the wrapper's look stays theirs rather than being unified here. */
|
||||
className?: string
|
||||
/** Class for the span wrapping `prompt` — only `PlannedDayModal` needs
|
||||
* one (`.modal__delete-confirm-text`, which pushes the buttons right via
|
||||
* `margin-right: auto`); `Settings`' row has no such class. */
|
||||
promptClassName?: string
|
||||
/** `Settings`' remove-member row shows the danger confirm before cancel;
|
||||
* `PlannedDayModal`'s delete row shows cancel first. Defaults to cancel
|
||||
* first. */
|
||||
confirmFirst?: boolean
|
||||
/** Appends `btn--sm` to both buttons — `Settings`' compact row uses it,
|
||||
* `PlannedDayModal`'s footer-sized row doesn't. */
|
||||
small?: boolean
|
||||
/** `data-testid` on the outer wrapper — `PlannedDayModal`'s row has one
|
||||
* (`delete-confirm`); `Settings`' row doesn't. */
|
||||
testId?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The shared "are you sure" row — a prompt plus a danger confirm and a
|
||||
* secondary cancel, both disabled while busy. Replaces the hand-copied
|
||||
* versions in `PlannedDayModal` (delete confirm) and `Settings` (remove-
|
||||
* member confirm), which differ only in wrapper class, prompt wrapping,
|
||||
* button order and button size — all exposed as props here so neither
|
||||
* caller's DOM has to change.
|
||||
*/
|
||||
export function InlineConfirm({
|
||||
prompt,
|
||||
confirmLabel,
|
||||
cancelLabel,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
isBusy = false,
|
||||
confirmTestId,
|
||||
cancelTestId,
|
||||
className,
|
||||
promptClassName,
|
||||
confirmFirst = false,
|
||||
small = false,
|
||||
testId,
|
||||
}: InlineConfirmProps): ReactNode {
|
||||
const sizeClass = small ? ' btn--sm' : ''
|
||||
|
||||
const confirmButton = (
|
||||
<button
|
||||
type="button"
|
||||
className={`btn btn--danger${sizeClass}`}
|
||||
onClick={onConfirm}
|
||||
disabled={isBusy}
|
||||
data-testid={confirmTestId}
|
||||
>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
)
|
||||
|
||||
const cancelButton = (
|
||||
<button
|
||||
type="button"
|
||||
className={`btn btn--secondary${sizeClass}`}
|
||||
onClick={onCancel}
|
||||
disabled={isBusy}
|
||||
data-testid={cancelTestId}
|
||||
>
|
||||
{cancelLabel}
|
||||
</button>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className={className} data-testid={testId}>
|
||||
<span className={promptClassName}>{prompt}</span>
|
||||
{confirmFirst ? (
|
||||
<>
|
||||
{confirmButton}
|
||||
{cancelButton}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{cancelButton}
|
||||
{confirmButton}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { Modal } from './Modal'
|
||||
|
||||
describe('Modal', () => {
|
||||
it('renders the title, close button and children', () => {
|
||||
render(
|
||||
<Modal onClose={vi.fn()} title="Hello" closeAriaLabel="Close">
|
||||
<p>Body content</p>
|
||||
</Modal>,
|
||||
)
|
||||
expect(screen.getByRole('heading', { name: 'Hello' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument()
|
||||
expect(screen.getByText('Body content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('defaults to the md content size', () => {
|
||||
const { container } = render(
|
||||
<Modal onClose={vi.fn()} title="Hello" closeAriaLabel="Close">
|
||||
<p>Body</p>
|
||||
</Modal>,
|
||||
)
|
||||
expect(container.querySelector('.modal__content--md')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('applies the sm content size when requested', () => {
|
||||
const { container } = render(
|
||||
<Modal onClose={vi.fn()} title="Hello" size="sm" closeAriaLabel="Close">
|
||||
<p>Body</p>
|
||||
</Modal>,
|
||||
)
|
||||
expect(container.querySelector('.modal__content--sm')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('calls onClose when the close button is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onClose = vi.fn()
|
||||
render(
|
||||
<Modal onClose={onClose} title="Hello" closeAriaLabel="Close">
|
||||
<p>Body</p>
|
||||
</Modal>,
|
||||
)
|
||||
await user.click(screen.getByRole('button', { name: 'Close' }))
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('disables the close button when closeDisabled is set', () => {
|
||||
render(
|
||||
<Modal onClose={vi.fn()} title="Hello" closeAriaLabel="Close" closeDisabled>
|
||||
<p>Body</p>
|
||||
</Modal>,
|
||||
)
|
||||
expect(screen.getByRole('button', { name: 'Close' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('closes on overlay click by default', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onClose = vi.fn()
|
||||
const { container } = render(
|
||||
<Modal onClose={onClose} title="Hello" closeAriaLabel="Close">
|
||||
<p>Body</p>
|
||||
</Modal>,
|
||||
)
|
||||
await user.click(container.querySelector('.modal__overlay') as Element)
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not close on overlay click when closeOnOverlayClick is false', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onClose = vi.fn()
|
||||
const { container } = render(
|
||||
<Modal onClose={onClose} title="Hello" closeAriaLabel="Close" closeOnOverlayClick={false}>
|
||||
<p>Body</p>
|
||||
</Modal>,
|
||||
)
|
||||
await user.click(container.querySelector('.modal__overlay') as Element)
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not close when clicking inside the content box', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onClose = vi.fn()
|
||||
render(
|
||||
<Modal onClose={onClose} title="Hello" closeAriaLabel="Close">
|
||||
<p>Body content</p>
|
||||
</Modal>,
|
||||
)
|
||||
await user.click(screen.getByText('Body content'))
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('calls onClose on Escape', () => {
|
||||
const onClose = vi.fn()
|
||||
render(
|
||||
<Modal onClose={onClose} title="Hello" closeAriaLabel="Close">
|
||||
<p>Body</p>
|
||||
</Modal>,
|
||||
)
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('ignores other keys', () => {
|
||||
const onClose = vi.fn()
|
||||
render(
|
||||
<Modal onClose={onClose} title="Hello" closeAriaLabel="Close">
|
||||
<p>Body</p>
|
||||
</Modal>,
|
||||
)
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' }))
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not call onClose on Escape when closeDisabled is set', () => {
|
||||
const onClose = vi.fn()
|
||||
render(
|
||||
<Modal onClose={onClose} title="Hello" closeAriaLabel="Close" closeDisabled>
|
||||
<p>Body</p>
|
||||
</Modal>,
|
||||
)
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('removes the Escape listener on unmount', () => {
|
||||
const onClose = vi.fn()
|
||||
const { unmount } = render(
|
||||
<Modal onClose={onClose} title="Hello" closeAriaLabel="Close">
|
||||
<p>Body</p>
|
||||
</Modal>,
|
||||
)
|
||||
unmount()
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('renders a footer when provided', () => {
|
||||
render(
|
||||
<Modal onClose={vi.fn()} title="Hello" closeAriaLabel="Close" footer={<button>Save</button>}>
|
||||
<p>Body</p>
|
||||
</Modal>,
|
||||
)
|
||||
expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders no footer when omitted', () => {
|
||||
const { container } = render(
|
||||
<Modal onClose={vi.fn()} title="Hello" closeAriaLabel="Close">
|
||||
<p>Body</p>
|
||||
</Modal>,
|
||||
)
|
||||
expect(container.querySelector('.modal__footer')).toBeNull()
|
||||
})
|
||||
|
||||
it('wires up testId, contentTestId, titleTestId, titleId and role', () => {
|
||||
render(
|
||||
<Modal
|
||||
onClose={vi.fn()}
|
||||
title="Hello"
|
||||
closeAriaLabel="Close"
|
||||
testId="overlay-test"
|
||||
contentTestId="content-test"
|
||||
titleTestId="title-test"
|
||||
titleId="hello-title"
|
||||
role="dialog"
|
||||
>
|
||||
<p>Body</p>
|
||||
</Modal>,
|
||||
)
|
||||
expect(screen.getByTestId('overlay-test')).toHaveAttribute('role', 'dialog')
|
||||
expect(screen.getByTestId('content-test')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('title-test')).toHaveAttribute('id', 'hello-title')
|
||||
})
|
||||
|
||||
it('omits role when not provided', () => {
|
||||
const { container } = render(
|
||||
<Modal onClose={vi.fn()} title="Hello" closeAriaLabel="Close">
|
||||
<p>Body</p>
|
||||
</Modal>,
|
||||
)
|
||||
expect(container.querySelector('.modal__overlay')).not.toHaveAttribute('role')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,96 @@
|
||||
import { ReactNode, useEffect } from 'react'
|
||||
import { Icon } from './Icon'
|
||||
|
||||
export interface ModalProps {
|
||||
onClose: () => void
|
||||
title: ReactNode
|
||||
/** Maps to `.modal__content--{size}` in `src/styles/primitives.css`. */
|
||||
size?: 'sm' | 'md'
|
||||
/** Disables the close button and stops Escape from closing — mirrors
|
||||
* `QRCodeModal`'s "can't close while the scrape is running" rule. */
|
||||
closeDisabled?: boolean
|
||||
/** Whether clicking the overlay (outside the content box) closes the
|
||||
* modal. Defaults to true; `QRCodeModal` sets it false so a stray click
|
||||
* can't drop a running fetch. */
|
||||
closeOnOverlayClick?: boolean
|
||||
footer?: ReactNode
|
||||
/** `data-testid` on the overlay div. */
|
||||
testId?: string
|
||||
/** `data-testid` on the content box. */
|
||||
contentTestId?: string
|
||||
/** `data-testid` on the title `<h2>`. */
|
||||
titleTestId?: string
|
||||
/** `id` on the title `<h2>`, for a caller that wants to wire up
|
||||
* `aria-labelledby` on the overlay itself. */
|
||||
titleId?: string
|
||||
/** Set to `'dialog'` when the caller wants that role on the overlay
|
||||
* (`PlannedDayModal` does; the others don't add one). */
|
||||
role?: 'dialog'
|
||||
closeAriaLabel: string
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* The shared `.modal__overlay > .modal__content > .modal__header (h2 +
|
||||
* close button) > .modal__body > .modal__footer` shell — hand-copied across
|
||||
* `ParentBalanceForm`, `PlannedDayModal` and `QRCodeModal` before this.
|
||||
* Standardises the close button on `<Icon name="close">`; the caller still
|
||||
* supplies its own `aria-label` text. No focus trap — out of scope here.
|
||||
*/
|
||||
export function Modal({
|
||||
onClose,
|
||||
title,
|
||||
size = 'md',
|
||||
closeDisabled = false,
|
||||
closeOnOverlayClick = true,
|
||||
footer,
|
||||
testId,
|
||||
contentTestId,
|
||||
titleTestId,
|
||||
titleId,
|
||||
role,
|
||||
closeAriaLabel,
|
||||
children,
|
||||
}: ModalProps): ReactNode {
|
||||
useEffect(() => {
|
||||
if (closeDisabled) return
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
}
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||
}, [closeDisabled, onClose])
|
||||
|
||||
const handleOverlayClick = () => {
|
||||
if (closeOnOverlayClick) onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="modal__overlay" onClick={handleOverlayClick} data-testid={testId} role={role}>
|
||||
<div
|
||||
className={`modal__content modal__content--${size}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
data-testid={contentTestId}
|
||||
>
|
||||
<div className="modal__header">
|
||||
<h2 id={titleId} data-testid={titleTestId}>
|
||||
{title}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--icon"
|
||||
onClick={onClose}
|
||||
disabled={closeDisabled}
|
||||
aria-label={closeAriaLabel}
|
||||
>
|
||||
<Icon name="close" size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="modal__body">{children}</div>
|
||||
|
||||
{footer && <div className="modal__footer">{footer}</div>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { useTranslation } from '@/i18n'
|
||||
import { isApiError, type ApiRequestError } from '@/services/errors'
|
||||
import { FormField } from '@/components/common/FormField'
|
||||
import { Alert } from '@/components/common/Alert'
|
||||
import { Modal } from '@/components/common/Modal'
|
||||
import './parent-balance-form.css'
|
||||
|
||||
export interface ParentBalanceInitial {
|
||||
@@ -206,149 +207,131 @@ export function ParentBalanceForm({
|
||||
}
|
||||
}
|
||||
|
||||
const footer = (
|
||||
<>
|
||||
<button type="button" className="btn btn--secondary" onClick={onClose} disabled={isSaving}>
|
||||
{t('parentBalance.cancelButton')}
|
||||
</button>
|
||||
<button type="button" className="btn btn--primary" onClick={() => void handleSave()} disabled={isSaving}>
|
||||
{isSaving ? t('parentBalance.savingButton') : t('parentBalance.saveButton')}
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="modal__overlay" onClick={onClose}>
|
||||
<div className="modal__content modal__content--md" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal__header">
|
||||
<h2>{isSelf ? t('parentBalance.titleSelf') : t('parentBalance.title', { name: parentName })}</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--icon"
|
||||
onClick={onClose}
|
||||
aria-label={t('parentBalance.closeAria')}
|
||||
<Modal
|
||||
onClose={onClose}
|
||||
title={isSelf ? t('parentBalance.titleSelf') : t('parentBalance.title', { name: parentName })}
|
||||
size="md"
|
||||
closeAriaLabel={t('parentBalance.closeAria')}
|
||||
footer={footer}
|
||||
>
|
||||
<p className="parent-balance-form__description">{t('parentBalance.description')}</p>
|
||||
|
||||
{(error || saveError) && <Alert variant="error">{error || saveError}</Alert>}
|
||||
|
||||
<div className="stack">
|
||||
<FormField id="sjukpenning-left" label={t('parentBalance.sjukpenningLeftLabel')} error={sjukpenningError}>
|
||||
<input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={0}
|
||||
max={390}
|
||||
value={sjukpenningLeft ?? ''}
|
||||
onChange={(e) => {
|
||||
setSjukpenningLeft(parseField(e.target.value))
|
||||
clearBackendFieldError('sjukpenning')
|
||||
}}
|
||||
disabled={isSaving}
|
||||
className="field__control"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField id="lagsta-left" label={t('parentBalance.lagstaLeftLabel')} error={lagstaError}>
|
||||
<input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={0}
|
||||
max={90}
|
||||
value={lagstaNivaLeft ?? ''}
|
||||
onChange={(e) => {
|
||||
setLagstaNivaLeft(parseField(e.target.value))
|
||||
clearBackendFieldError('lagsta')
|
||||
}}
|
||||
disabled={isSaving}
|
||||
className="field__control"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
id="dubbeldag-left"
|
||||
label={t('parentBalance.dubbeldagarLeftLabel')}
|
||||
error={dubbeldagarError}
|
||||
helperText={t('parentBalance.dubbeldagarHelper')}
|
||||
>
|
||||
<input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={0}
|
||||
max={60}
|
||||
value={dubbeldagarLeft ?? ''}
|
||||
onChange={(e) => {
|
||||
setDubbeldagarLeft(parseField(e.target.value))
|
||||
clearBackendFieldError('dubbeldagar')
|
||||
}}
|
||||
disabled={isSaving}
|
||||
className="field__control"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--secondary btn--sm"
|
||||
onClick={applyPreset}
|
||||
disabled={isSaving}
|
||||
>
|
||||
{t('parentBalance.presetButton')}
|
||||
</button>
|
||||
|
||||
<p className="parent-balance-form__summary">
|
||||
{t('parentBalance.summary', { left: daysLeft, total: totalDays })}
|
||||
<br />
|
||||
{t('parentBalance.summaryDubbeldagar', { count: dubbeldagarLeft ?? 0 })}
|
||||
</p>
|
||||
{totalError && <p className="field__error">{totalError}</p>}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--link"
|
||||
aria-expanded={showAdvanced}
|
||||
onClick={() => setShowAdvanced((v) => !v)}
|
||||
>
|
||||
{t('parentBalance.advancedToggle')}
|
||||
</button>
|
||||
|
||||
{showAdvanced && (
|
||||
<FormField
|
||||
id="reserved-left"
|
||||
label={t('parentBalance.reservedLeftLabel')}
|
||||
error={reservedError}
|
||||
helperText={t('parentBalance.reservedLeftHelper')}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="modal__body">
|
||||
<p className="parent-balance-form__description">{t('parentBalance.description')}</p>
|
||||
|
||||
{(error || saveError) && <Alert variant="error">{error || saveError}</Alert>}
|
||||
|
||||
<div className="stack">
|
||||
<FormField id="sjukpenning-left" label={t('parentBalance.sjukpenningLeftLabel')} error={sjukpenningError}>
|
||||
<input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={0}
|
||||
max={390}
|
||||
value={sjukpenningLeft ?? ''}
|
||||
onChange={(e) => {
|
||||
setSjukpenningLeft(parseField(e.target.value))
|
||||
clearBackendFieldError('sjukpenning')
|
||||
}}
|
||||
disabled={isSaving}
|
||||
className="field__control"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField id="lagsta-left" label={t('parentBalance.lagstaLeftLabel')} error={lagstaError}>
|
||||
<input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={0}
|
||||
max={90}
|
||||
value={lagstaNivaLeft ?? ''}
|
||||
onChange={(e) => {
|
||||
setLagstaNivaLeft(parseField(e.target.value))
|
||||
clearBackendFieldError('lagsta')
|
||||
}}
|
||||
disabled={isSaving}
|
||||
className="field__control"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
id="dubbeldag-left"
|
||||
label={t('parentBalance.dubbeldagarLeftLabel')}
|
||||
error={dubbeldagarError}
|
||||
helperText={t('parentBalance.dubbeldagarHelper')}
|
||||
>
|
||||
<input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={0}
|
||||
max={60}
|
||||
value={dubbeldagarLeft ?? ''}
|
||||
onChange={(e) => {
|
||||
setDubbeldagarLeft(parseField(e.target.value))
|
||||
clearBackendFieldError('dubbeldagar')
|
||||
}}
|
||||
disabled={isSaving}
|
||||
className="field__control"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--secondary btn--sm"
|
||||
onClick={applyPreset}
|
||||
<input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={0}
|
||||
max={90}
|
||||
value={reservedDaysLeft ?? ''}
|
||||
onChange={(e) => {
|
||||
setReservedDaysLeft(parseField(e.target.value))
|
||||
clearBackendFieldError('reserved')
|
||||
}}
|
||||
disabled={isSaving}
|
||||
>
|
||||
{t('parentBalance.presetButton')}
|
||||
</button>
|
||||
|
||||
<p className="parent-balance-form__summary">
|
||||
{t('parentBalance.summary', { left: daysLeft, total: totalDays })}
|
||||
<br />
|
||||
{t('parentBalance.summaryDubbeldagar', { count: dubbeldagarLeft ?? 0 })}
|
||||
</p>
|
||||
{totalError && <p className="field__error">{totalError}</p>}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--link"
|
||||
aria-expanded={showAdvanced}
|
||||
onClick={() => setShowAdvanced((v) => !v)}
|
||||
>
|
||||
{t('parentBalance.advancedToggle')}
|
||||
</button>
|
||||
|
||||
{showAdvanced && (
|
||||
<FormField
|
||||
id="reserved-left"
|
||||
label={t('parentBalance.reservedLeftLabel')}
|
||||
error={reservedError}
|
||||
helperText={t('parentBalance.reservedLeftHelper')}
|
||||
>
|
||||
<input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={0}
|
||||
max={90}
|
||||
value={reservedDaysLeft ?? ''}
|
||||
onChange={(e) => {
|
||||
setReservedDaysLeft(parseField(e.target.value))
|
||||
clearBackendFieldError('reserved')
|
||||
}}
|
||||
disabled={isSaving}
|
||||
className="field__control"
|
||||
/>
|
||||
</FormField>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="modal__footer">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--secondary"
|
||||
onClick={onClose}
|
||||
disabled={isSaving}
|
||||
>
|
||||
{t('parentBalance.cancelButton')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--primary"
|
||||
onClick={() => void handleSave()}
|
||||
disabled={isSaving}
|
||||
>
|
||||
{isSaving ? t('parentBalance.savingButton') : t('parentBalance.saveButton')}
|
||||
</button>
|
||||
</div>
|
||||
className="field__control"
|
||||
/>
|
||||
</FormField>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import { useTranslation } from '@/i18n'
|
||||
import { Icon } from '@/components/common/Icon'
|
||||
import { FormField } from '@/components/common/FormField'
|
||||
import { Alert } from '@/components/common/Alert'
|
||||
import { Modal } from '@/components/common/Modal'
|
||||
import { InlineConfirm } from '@/components/common/InlineConfirm'
|
||||
import './planned-day-modal.css'
|
||||
|
||||
/** The default path — todo.md #10/#11: the planner decides which day type
|
||||
@@ -268,59 +270,205 @@ export function PlannedDayModal({
|
||||
|
||||
const parentModeSelectValue = parentMode === 'specific' ? specificParentId : parentMode
|
||||
|
||||
return (
|
||||
<div className="modal__overlay" onClick={onClose} data-testid="planned-day-modal__overlay" role="dialog">
|
||||
<div
|
||||
className="modal__content modal__content--sm"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
data-testid="planned-day-modal__content"
|
||||
>
|
||||
<div className="modal__header">
|
||||
<h2 data-testid="modal-title">{modalTitle}</h2>
|
||||
<button type="button" className="btn btn--icon" onClick={onClose} aria-label={t('plannedDayModal.closeAria')}>
|
||||
<Icon name="close" size={20} />
|
||||
const footer = (
|
||||
<>
|
||||
{!isRangeMode && existingDay && onDelete && isConfirmingDelete && (
|
||||
<InlineConfirm
|
||||
testId="delete-confirm"
|
||||
className="modal__delete-confirm"
|
||||
promptClassName="modal__delete-confirm-text"
|
||||
prompt={t('plannedDayModal.confirmDelete')}
|
||||
cancelLabel={t('plannedDayModal.cancelButton')}
|
||||
confirmLabel={isSaving ? t('plannedDayModal.deletingButton') : t('plannedDayModal.deleteConfirmButton')}
|
||||
onCancel={handleCancelDelete}
|
||||
onConfirm={() => void handleConfirmDelete()}
|
||||
isBusy={isSaving}
|
||||
cancelTestId="delete-cancel-button"
|
||||
confirmTestId="delete-confirm-button"
|
||||
/>
|
||||
)}
|
||||
{!isConfirmingDelete && (
|
||||
<>
|
||||
{!isRangeMode && existingDay && onDelete && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--danger btn--start"
|
||||
onClick={handleDeleteClick}
|
||||
disabled={isSaving}
|
||||
data-testid="delete-button"
|
||||
>
|
||||
{isSaving ? t('plannedDayModal.deletingButton') : t('plannedDayModal.deleteButton')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--secondary"
|
||||
onClick={onClose}
|
||||
disabled={isSaving}
|
||||
data-testid="cancel-button"
|
||||
>
|
||||
{t('plannedDayModal.cancelButton')}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--primary"
|
||||
onClick={() => void handleSave()}
|
||||
disabled={isSaving}
|
||||
data-testid="save-button"
|
||||
>
|
||||
{isSaving ? t('plannedDayModal.savingButton') : t('plannedDayModal.saveButton')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
<div className="modal__body">
|
||||
{!isRangeMode && <p className="planned-day-modal__date">{displayDate}</p>}
|
||||
return (
|
||||
<Modal
|
||||
onClose={onClose}
|
||||
title={modalTitle}
|
||||
size="sm"
|
||||
closeAriaLabel={t('plannedDayModal.closeAria')}
|
||||
testId="planned-day-modal__overlay"
|
||||
contentTestId="planned-day-modal__content"
|
||||
titleTestId="modal-title"
|
||||
role="dialog"
|
||||
footer={footer}
|
||||
>
|
||||
{!isRangeMode && <p className="planned-day-modal__date">{displayDate}</p>}
|
||||
|
||||
{(error || localError) && <Alert variant="error">{error || localError}</Alert>}
|
||||
{(error || localError) && <Alert variant="error">{error || localError}</Alert>}
|
||||
|
||||
{!isRangeMode &&
|
||||
excludedEntries.map((entry) => (
|
||||
<div
|
||||
key={entry.id}
|
||||
className="planned-day-modal__excluded-notice"
|
||||
data-testid="excluded-notice"
|
||||
{!isRangeMode &&
|
||||
excludedEntries.map((entry) => (
|
||||
<div
|
||||
key={entry.id}
|
||||
className="planned-day-modal__excluded-notice"
|
||||
data-testid="excluded-notice"
|
||||
>
|
||||
<span>{t('plannedDayModal.excludedNotice', { parent: entry.parentName })}</span>
|
||||
{onRestoreExcluded && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--secondary btn--sm"
|
||||
onClick={() => void onRestoreExcluded(entry.id)}
|
||||
disabled={isSaving}
|
||||
data-testid={`restore-excluded-${entry.id}`}
|
||||
>
|
||||
<span>{t('plannedDayModal.excludedNotice', { parent: entry.parentName })}</span>
|
||||
{onRestoreExcluded && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--secondary btn--sm"
|
||||
onClick={() => void onRestoreExcluded(entry.id)}
|
||||
disabled={isSaving}
|
||||
data-testid={`restore-excluded-${entry.id}`}
|
||||
>
|
||||
{t('plannedDayModal.restoreExcludedButton')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{t('plannedDayModal.restoreExcludedButton')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="stack">
|
||||
{!forcedAdvanced && hasMultipleParents && (
|
||||
<FormField id="parent-mode-select" label={t('plannedDayModal.parentModeLabel')}>
|
||||
<div className="stack">
|
||||
{!forcedAdvanced && hasMultipleParents && (
|
||||
<FormField id="parent-mode-select" label={t('plannedDayModal.parentModeLabel')}>
|
||||
<select
|
||||
value={parentModeSelectValue}
|
||||
onChange={(e) => handleParentModeChange(e.target.value)}
|
||||
className="field__control"
|
||||
disabled={isSaving}
|
||||
data-testid="parent-mode-select"
|
||||
>
|
||||
<option value="either">{t('wishEditor.eitherParent')}</option>
|
||||
<option value="both">{t('wishEditor.bothParents')}</option>
|
||||
{parents.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</FormField>
|
||||
)}
|
||||
|
||||
{!forcedAdvanced && hasMultipleParents && (
|
||||
<div className="field">
|
||||
<label className="planned-day-modal__checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={wantsDubbeldag}
|
||||
onChange={(e) => setWantsDubbeldag(e.target.checked)}
|
||||
disabled={isSaving || parentMode !== 'both'}
|
||||
data-testid="wants-dubbeldag-checkbox"
|
||||
/>
|
||||
{' '}{t('plannedDayModal.wantsDubbeldagLabel')}
|
||||
</label>
|
||||
<p className="planned-day-modal__hint">
|
||||
{parentMode === 'both'
|
||||
? t('plannedDayModal.wantsDubbeldagHint')
|
||||
: t('plannedDayModal.wantsDubbeldagRequiresBothHint')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* The default path's own copy of the "will stay off" notice —
|
||||
* `showPinnedControls` is false here (Advanced isn't open), so
|
||||
* the copy inside that section below never renders for this
|
||||
* case. Gated on `!showPinnedControls` rather than re-deriving
|
||||
* "default path" so the two copies can never both render. */}
|
||||
{!forcedAdvanced && hasMultipleParents && !showPinnedControls && freeDaysWillStayOff && (
|
||||
<p className="planned-day-modal__hint" data-testid="free-days-stay-off-notice">
|
||||
{t('plannedDayModal.freeDaysStayOffNotice')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!forcedAdvanced && hasMultipleParents && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--link planned-day-modal__advanced-toggle"
|
||||
onClick={() => setIsAdvancedOpen((open) => !open)}
|
||||
disabled={isSaving}
|
||||
data-testid="advanced-toggle"
|
||||
aria-expanded={isAdvancedOpen}
|
||||
>
|
||||
{isAdvancedOpen ? t('plannedDayModal.hideAdvanced') : t('plannedDayModal.showAdvanced')}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{showPinnedControls && (
|
||||
<div className="planned-day-modal__advanced" data-testid="advanced-section">
|
||||
<FormField id="day-type" label={t('plannedDayModal.leaveTypeLabel')}>
|
||||
<select
|
||||
value={dayType}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value
|
||||
const next: DayTypeChoice = isPinnedDayType(value) ? value : 'auto'
|
||||
setDayType(next)
|
||||
if (next === 'vacation' || next === 'auto') setIsDubbeldag(false)
|
||||
}}
|
||||
className="field__control"
|
||||
disabled={isSaving}
|
||||
data-testid="day-type-select"
|
||||
>
|
||||
{soloDirect && <option value="auto">{t('plannedDayModal.autoOption')}</option>}
|
||||
<option value="vacation">{t('plannedDayModal.vacationOption')}</option>
|
||||
<option value="sjukpenning">{t('plannedDayModal.sjukpenningOption')}</option>
|
||||
<option value="lagsta">{t('plannedDayModal.lagstaOption')}</option>
|
||||
</select>
|
||||
</FormField>
|
||||
|
||||
{freeDaysWillStayOff && (
|
||||
<p className="planned-day-modal__hint" data-testid="free-days-stay-off-notice">
|
||||
{t('plannedDayModal.freeDaysStayOffNotice')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{showPinExclusionNotice && (
|
||||
<p className="planned-day-modal__hint" data-testid="pin-exclusion-notice">
|
||||
{t('plannedDayModal.pinExcludesFromSyncNotice')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{hasMultipleParents && (
|
||||
<FormField id="parent-select" label={t('plannedDayModal.parentLabel')}>
|
||||
<select
|
||||
value={parentModeSelectValue}
|
||||
onChange={(e) => handleParentModeChange(e.target.value)}
|
||||
value={selectedParentId}
|
||||
onChange={(e) => handleAdvancedParentChange(e.target.value)}
|
||||
className="field__control"
|
||||
disabled={isSaving}
|
||||
data-testid="parent-mode-select"
|
||||
data-testid="parent-select"
|
||||
>
|
||||
<option value="either">{t('wishEditor.eitherParent')}</option>
|
||||
<option value="both">{t('wishEditor.bothParents')}</option>
|
||||
{parents.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
@@ -330,194 +478,35 @@ export function PlannedDayModal({
|
||||
</FormField>
|
||||
)}
|
||||
|
||||
{!forcedAdvanced && hasMultipleParents && (
|
||||
{dayType !== 'auto' && (
|
||||
<div className="field">
|
||||
<label className="planned-day-modal__checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={wantsDubbeldag}
|
||||
onChange={(e) => setWantsDubbeldag(e.target.checked)}
|
||||
disabled={isSaving || parentMode !== 'both'}
|
||||
data-testid="wants-dubbeldag-checkbox"
|
||||
checked={isDubbeldag}
|
||||
onChange={(e) => setIsDubbeldag(e.target.checked)}
|
||||
disabled={isSaving || dayType === 'vacation'}
|
||||
data-testid="dubbeldag-checkbox"
|
||||
/>
|
||||
{' '}{t('plannedDayModal.wantsDubbeldagLabel')}
|
||||
{' '}{t('plannedDayModal.doubleDayLabel')}
|
||||
</label>
|
||||
<p className="planned-day-modal__hint">
|
||||
{parentMode === 'both'
|
||||
? t('plannedDayModal.wantsDubbeldagHint')
|
||||
: t('plannedDayModal.wantsDubbeldagRequiresBothHint')}
|
||||
</p>
|
||||
{dayType === 'vacation' && (
|
||||
<p className="planned-day-modal__hint">
|
||||
{t('plannedDayModal.doubleDayHint')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* The default path's own copy of the "will stay off" notice —
|
||||
* `showPinnedControls` is false here (Advanced isn't open), so
|
||||
* the copy inside that section below never renders for this
|
||||
* case. Gated on `!showPinnedControls` rather than re-deriving
|
||||
* "default path" so the two copies can never both render. */}
|
||||
{!forcedAdvanced && hasMultipleParents && !showPinnedControls && freeDaysWillStayOff && (
|
||||
<p className="planned-day-modal__hint" data-testid="free-days-stay-off-notice">
|
||||
{t('plannedDayModal.freeDaysStayOffNotice')}
|
||||
{existingDay?.isPinned && (
|
||||
<p className="planned-day-modal__pinned-notice" data-testid="pinned-notice">
|
||||
<Icon name="pin" size={16} />
|
||||
<span>{t('plannedDayModal.pinnedNotice')}</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!forcedAdvanced && hasMultipleParents && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--link planned-day-modal__advanced-toggle"
|
||||
onClick={() => setIsAdvancedOpen((open) => !open)}
|
||||
disabled={isSaving}
|
||||
data-testid="advanced-toggle"
|
||||
aria-expanded={isAdvancedOpen}
|
||||
>
|
||||
{isAdvancedOpen ? t('plannedDayModal.hideAdvanced') : t('plannedDayModal.showAdvanced')}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{showPinnedControls && (
|
||||
<div className="planned-day-modal__advanced" data-testid="advanced-section">
|
||||
<FormField id="day-type" label={t('plannedDayModal.leaveTypeLabel')}>
|
||||
<select
|
||||
value={dayType}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value
|
||||
const next: DayTypeChoice = isPinnedDayType(value) ? value : 'auto'
|
||||
setDayType(next)
|
||||
if (next === 'vacation' || next === 'auto') setIsDubbeldag(false)
|
||||
}}
|
||||
className="field__control"
|
||||
disabled={isSaving}
|
||||
data-testid="day-type-select"
|
||||
>
|
||||
{soloDirect && <option value="auto">{t('plannedDayModal.autoOption')}</option>}
|
||||
<option value="vacation">{t('plannedDayModal.vacationOption')}</option>
|
||||
<option value="sjukpenning">{t('plannedDayModal.sjukpenningOption')}</option>
|
||||
<option value="lagsta">{t('plannedDayModal.lagstaOption')}</option>
|
||||
</select>
|
||||
</FormField>
|
||||
|
||||
{freeDaysWillStayOff && (
|
||||
<p className="planned-day-modal__hint" data-testid="free-days-stay-off-notice">
|
||||
{t('plannedDayModal.freeDaysStayOffNotice')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{showPinExclusionNotice && (
|
||||
<p className="planned-day-modal__hint" data-testid="pin-exclusion-notice">
|
||||
{t('plannedDayModal.pinExcludesFromSyncNotice')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{hasMultipleParents && (
|
||||
<FormField id="parent-select" label={t('plannedDayModal.parentLabel')}>
|
||||
<select
|
||||
value={selectedParentId}
|
||||
onChange={(e) => handleAdvancedParentChange(e.target.value)}
|
||||
className="field__control"
|
||||
disabled={isSaving}
|
||||
data-testid="parent-select"
|
||||
>
|
||||
{parents.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</FormField>
|
||||
)}
|
||||
|
||||
{dayType !== 'auto' && (
|
||||
<div className="field">
|
||||
<label className="planned-day-modal__checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isDubbeldag}
|
||||
onChange={(e) => setIsDubbeldag(e.target.checked)}
|
||||
disabled={isSaving || dayType === 'vacation'}
|
||||
data-testid="dubbeldag-checkbox"
|
||||
/>
|
||||
{' '}{t('plannedDayModal.doubleDayLabel')}
|
||||
</label>
|
||||
{dayType === 'vacation' && (
|
||||
<p className="planned-day-modal__hint">
|
||||
{t('plannedDayModal.doubleDayHint')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{existingDay?.isPinned && (
|
||||
<p className="planned-day-modal__pinned-notice" data-testid="pinned-notice">
|
||||
<Icon name="pin" size={16} />
|
||||
<span>{t('plannedDayModal.pinnedNotice')}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="modal__footer">
|
||||
{!isRangeMode && existingDay && onDelete && isConfirmingDelete && (
|
||||
<div className="modal__delete-confirm" data-testid="delete-confirm">
|
||||
<span className="modal__delete-confirm-text">
|
||||
{t('plannedDayModal.confirmDelete')}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--secondary"
|
||||
onClick={handleCancelDelete}
|
||||
disabled={isSaving}
|
||||
data-testid="delete-cancel-button"
|
||||
>
|
||||
{t('plannedDayModal.cancelButton')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--danger"
|
||||
onClick={() => void handleConfirmDelete()}
|
||||
disabled={isSaving}
|
||||
data-testid="delete-confirm-button"
|
||||
>
|
||||
{isSaving ? t('plannedDayModal.deletingButton') : t('plannedDayModal.deleteConfirmButton')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{!isConfirmingDelete && (
|
||||
<>
|
||||
{!isRangeMode && existingDay && onDelete && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--danger btn--start"
|
||||
onClick={handleDeleteClick}
|
||||
disabled={isSaving}
|
||||
data-testid="delete-button"
|
||||
>
|
||||
{isSaving ? t('plannedDayModal.deletingButton') : t('plannedDayModal.deleteButton')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--secondary"
|
||||
onClick={onClose}
|
||||
disabled={isSaving}
|
||||
data-testid="cancel-button"
|
||||
>
|
||||
{t('plannedDayModal.cancelButton')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--primary"
|
||||
onClick={() => void handleSave()}
|
||||
disabled={isSaving}
|
||||
data-testid="save-button"
|
||||
>
|
||||
{isSaving ? t('plannedDayModal.savingButton') : t('plannedDayModal.saveButton')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { ScraperStatus } from '@/hooks/useScraperFetch'
|
||||
import { useTranslation, type TranslationKey } from '@/i18n'
|
||||
import { Icon } from '@/components/common/Icon'
|
||||
import { Alert } from '@/components/common/Alert'
|
||||
import { Modal } from '@/components/common/Modal'
|
||||
import './qrcode-modal.css'
|
||||
|
||||
// Every `ScraperStatus` maps to a translated `balance.status.*` label — the
|
||||
@@ -47,95 +48,83 @@ export function QRCodeModal({
|
||||
const { t } = useTranslation()
|
||||
if (!isOpen) return null
|
||||
|
||||
const handleClose = () => {
|
||||
if (isRunning) return
|
||||
onClose()
|
||||
}
|
||||
const footer =
|
||||
status === 'done' || isRunning ? (
|
||||
<>
|
||||
{status === 'done' && (
|
||||
<button type="button" className="btn btn--primary" onClick={onDone}>
|
||||
{t('qrModal.closeButton')}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{isRunning && (
|
||||
<button type="button" className="btn btn--danger" onClick={() => void onCancel()}>
|
||||
{t('qrModal.cancelButton')}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
) : null
|
||||
|
||||
return (
|
||||
<div className="modal__overlay">
|
||||
<div className="modal__content modal__content--sm">
|
||||
<div className="modal__header">
|
||||
<h2>{t('qrModal.title')}</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--icon"
|
||||
onClick={handleClose}
|
||||
disabled={isRunning}
|
||||
aria-label={t('qrModal.closeAria')}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className="modal__body">
|
||||
{status && (
|
||||
<div className="qrcode-modal__status-container">
|
||||
<div className="qrcode-modal__status-row">
|
||||
<span className="qrcode-modal__status-label">{t('qrModal.statusLabel')}</span>
|
||||
<span
|
||||
className={`qrcode-modal__status-value qrcode-modal__status-value--${
|
||||
status === 'done' ? 'success' : status === 'error' ? 'error' : 'pending'
|
||||
}`}
|
||||
data-testid="qrcode-modal-status"
|
||||
>
|
||||
{t(STATUS_LABEL_KEYS[status])}
|
||||
</span>
|
||||
{message && <span className="qrcode-modal__status-message">— {message}</span>}
|
||||
</div>
|
||||
<Modal
|
||||
onClose={onClose}
|
||||
title={t('qrModal.title')}
|
||||
size="sm"
|
||||
closeAriaLabel={t('qrModal.closeAria')}
|
||||
closeDisabled={isRunning}
|
||||
closeOnOverlayClick={false}
|
||||
footer={footer}
|
||||
>
|
||||
{status && (
|
||||
<div className="qrcode-modal__status-container">
|
||||
<div className="qrcode-modal__status-row">
|
||||
<span className="qrcode-modal__status-label">{t('qrModal.statusLabel')}</span>
|
||||
<span
|
||||
className={`qrcode-modal__status-value qrcode-modal__status-value--${
|
||||
status === 'done' ? 'success' : status === 'error' ? 'error' : 'pending'
|
||||
}`}
|
||||
data-testid="qrcode-modal-status"
|
||||
>
|
||||
{t(STATUS_LABEL_KEYS[status])}
|
||||
</span>
|
||||
{message && <span className="qrcode-modal__status-message">— {message}</span>}
|
||||
</div>
|
||||
|
||||
{status === 'awaiting_scan' && qrData && (
|
||||
<div className="qrcode-modal__qr-section">
|
||||
<p className="qrcode-modal__qr-instruction">{t('qrModal.qrInstruction')}</p>
|
||||
<img
|
||||
src={`data:image/png;base64,${qrData}`}
|
||||
alt={t('qrModal.qrAlt')}
|
||||
className="qrcode-modal__qr-image"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(status === 'authenticating' || status === 'fetching') && (
|
||||
<p className="qrcode-modal__loading-message">
|
||||
<Icon name="clock" />{' '}
|
||||
{status === 'authenticating' ? t('qrModal.authenticating') : t('qrModal.fetching')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{status === 'done' && result && (
|
||||
<p className="qrcode-modal__success-message">
|
||||
<Icon name="check" /> {t('qrModal.success')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{status === 'cancelled' && (
|
||||
<p className="qrcode-modal__cancelled-message">{t('qrModal.cancelled')}</p>
|
||||
)}
|
||||
{status === 'awaiting_scan' && qrData && (
|
||||
<div className="qrcode-modal__qr-section">
|
||||
<p className="qrcode-modal__qr-instruction">{t('qrModal.qrInstruction')}</p>
|
||||
<img
|
||||
src={`data:image/png;base64,${qrData}`}
|
||||
alt={t('qrModal.qrAlt')}
|
||||
className="qrcode-modal__qr-image"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Alert variant="error">
|
||||
<strong>{t('qrModal.errorLabel')}</strong> {error}
|
||||
</Alert>
|
||||
{(status === 'authenticating' || status === 'fetching') && (
|
||||
<p className="qrcode-modal__loading-message">
|
||||
<Icon name="clock" />{' '}
|
||||
{status === 'authenticating' ? t('qrModal.authenticating') : t('qrModal.fetching')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{status === 'done' && result && (
|
||||
<p className="qrcode-modal__success-message">
|
||||
<Icon name="check" /> {t('qrModal.success')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{status === 'cancelled' && (
|
||||
<p className="qrcode-modal__cancelled-message">{t('qrModal.cancelled')}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(status === 'done' || isRunning) && (
|
||||
<div className="modal__footer">
|
||||
{status === 'done' && (
|
||||
<button type="button" className="btn btn--primary" onClick={onDone}>
|
||||
{t('qrModal.closeButton')}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{isRunning && (
|
||||
<button type="button" className="btn btn--danger" onClick={() => void onCancel()}>
|
||||
{t('qrModal.cancelButton')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{error && (
|
||||
<Alert variant="error">
|
||||
<strong>{t('qrModal.errorLabel')}</strong> {error}
|
||||
</Alert>
|
||||
)}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ import { CalendarExceptions } from '@/components/calendar/CalendarExceptions'
|
||||
import { BalanceFlow } from '@/components/balance/BalanceFlow'
|
||||
import { PartnerInvitePanel } from '@/components/settings/PartnerInvitePanel'
|
||||
import { Alert } from '@/components/common/Alert'
|
||||
import { InlineConfirm } from '@/components/common/InlineConfirm'
|
||||
import { orderSelfFirst } from '@/utils/members'
|
||||
import type { VacationDays } from '@/types'
|
||||
import './settings.css'
|
||||
@@ -780,25 +781,19 @@ export function Settings(): ReactNode {
|
||||
</button>
|
||||
{!isSelf &&
|
||||
(isConfirmingRemove ? (
|
||||
<span className="settings-panel__remove-confirm">
|
||||
{t('settings.removeConfirmPrompt', { name: member.name })}
|
||||
<button
|
||||
onClick={() => handleConfirmRemove(member.uuid)}
|
||||
className="btn btn--danger btn--sm"
|
||||
disabled={isSavingFamily}
|
||||
data-testid={`remove-confirm-${member.uuid}`}
|
||||
>
|
||||
{t('settings.removeConfirmButton')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCancelRemove}
|
||||
className="btn btn--secondary btn--sm"
|
||||
disabled={isSavingFamily}
|
||||
data-testid={`remove-cancel-${member.uuid}`}
|
||||
>
|
||||
{t('settings.cancelButton')}
|
||||
</button>
|
||||
</span>
|
||||
<InlineConfirm
|
||||
className="settings-panel__remove-confirm"
|
||||
prompt={t('settings.removeConfirmPrompt', { name: member.name })}
|
||||
confirmLabel={t('settings.removeConfirmButton')}
|
||||
cancelLabel={t('settings.cancelButton')}
|
||||
onConfirm={() => handleConfirmRemove(member.uuid)}
|
||||
onCancel={handleCancelRemove}
|
||||
isBusy={isSavingFamily}
|
||||
confirmTestId={`remove-confirm-${member.uuid}`}
|
||||
cancelTestId={`remove-cancel-${member.uuid}`}
|
||||
confirmFirst
|
||||
small
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => handleRequestRemove(member.uuid)}
|
||||
|
||||
Reference in New Issue
Block a user