diff --git a/frontend/src/components/common/InlineConfirm.test.tsx b/frontend/src/components/common/InlineConfirm.test.tsx
new file mode 100644
index 0000000..f81f479
--- /dev/null
+++ b/frontend/src/components/common/InlineConfirm.test.tsx
@@ -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(
+ ,
+ )
+ 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(
+ ,
+ )
+ const buttons = screen.getAllByRole('button')
+ expect(buttons.map((b) => b.textContent)).toEqual(['No', 'Yes'])
+ })
+
+ it('shows confirm before cancel when confirmFirst is set', () => {
+ render(
+ ,
+ )
+ 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(
+ ,
+ )
+ 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(
+ ,
+ )
+ await user.click(screen.getByRole('button', { name: 'No' }))
+ expect(onCancel).toHaveBeenCalledTimes(1)
+ })
+
+ it('disables both buttons while busy', () => {
+ render(
+ ,
+ )
+ expect(screen.getByRole('button', { name: 'Yes' })).toBeDisabled()
+ expect(screen.getByRole('button', { name: 'No' })).toBeDisabled()
+ })
+
+ it('leaves both buttons enabled when not busy', () => {
+ render(
+ ,
+ )
+ 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(
+ ,
+ )
+ 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(
+ ,
+ )
+ 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(
+ ,
+ )
+ expect(screen.getByTestId('confirm-test')).toBeInTheDocument()
+ expect(screen.getByTestId('cancel-test')).toBeInTheDocument()
+ })
+
+ it('wires up the wrapper testId and className', () => {
+ const { container } = render(
+ ,
+ )
+ 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(
+ ,
+ )
+ expect(screen.getByText('Are you sure?')).toHaveClass('prompt-class')
+ })
+})
diff --git a/frontend/src/components/common/InlineConfirm.tsx b/frontend/src/components/common/InlineConfirm.tsx
new file mode 100644
index 0000000..31e1567
--- /dev/null
+++ b/frontend/src/components/common/InlineConfirm.tsx
@@ -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 = (
+
+ {confirmLabel}
+
+ )
+
+ const cancelButton = (
+
+ {cancelLabel}
+
+ )
+
+ return (
+
+ {prompt}
+ {confirmFirst ? (
+ <>
+ {confirmButton}
+ {cancelButton}
+ >
+ ) : (
+ <>
+ {cancelButton}
+ {confirmButton}
+ >
+ )}
+
+ )
+}
diff --git a/frontend/src/components/common/Modal.test.tsx b/frontend/src/components/common/Modal.test.tsx
new file mode 100644
index 0000000..6333b57
--- /dev/null
+++ b/frontend/src/components/common/Modal.test.tsx
@@ -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(
+
+ Body content
+ ,
+ )
+ 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(
+
+ Body
+ ,
+ )
+ expect(container.querySelector('.modal__content--md')).not.toBeNull()
+ })
+
+ it('applies the sm content size when requested', () => {
+ const { container } = render(
+
+ Body
+ ,
+ )
+ 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(
+
+ Body
+ ,
+ )
+ await user.click(screen.getByRole('button', { name: 'Close' }))
+ expect(onClose).toHaveBeenCalledTimes(1)
+ })
+
+ it('disables the close button when closeDisabled is set', () => {
+ render(
+
+ Body
+ ,
+ )
+ 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(
+
+ Body
+ ,
+ )
+ 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(
+
+ Body
+ ,
+ )
+ 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(
+
+ Body content
+ ,
+ )
+ await user.click(screen.getByText('Body content'))
+ expect(onClose).not.toHaveBeenCalled()
+ })
+
+ it('calls onClose on Escape', () => {
+ const onClose = vi.fn()
+ render(
+
+ Body
+ ,
+ )
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
+ expect(onClose).toHaveBeenCalledTimes(1)
+ })
+
+ it('ignores other keys', () => {
+ const onClose = vi.fn()
+ render(
+
+ Body
+ ,
+ )
+ 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(
+
+ Body
+ ,
+ )
+ 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(
+
+ Body
+ ,
+ )
+ unmount()
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
+ expect(onClose).not.toHaveBeenCalled()
+ })
+
+ it('renders a footer when provided', () => {
+ render(
+ Save}>
+ Body
+ ,
+ )
+ expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument()
+ })
+
+ it('renders no footer when omitted', () => {
+ const { container } = render(
+
+ Body
+ ,
+ )
+ expect(container.querySelector('.modal__footer')).toBeNull()
+ })
+
+ it('wires up testId, contentTestId, titleTestId, titleId and role', () => {
+ render(
+
+ Body
+ ,
+ )
+ 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(
+
+ Body
+ ,
+ )
+ expect(container.querySelector('.modal__overlay')).not.toHaveAttribute('role')
+ })
+})
diff --git a/frontend/src/components/common/Modal.tsx b/frontend/src/components/common/Modal.tsx
new file mode 100644
index 0000000..d878a36
--- /dev/null
+++ b/frontend/src/components/common/Modal.tsx
@@ -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 ``. */
+ titleTestId?: string
+ /** `id` on the title ``, 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 ``; 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 (
+
+
e.stopPropagation()}
+ data-testid={contentTestId}
+ >
+
+
+ {title}
+
+
+
+
+
+
+
{children}
+
+ {footer &&
{footer}
}
+
+
+ )
+}
diff --git a/frontend/src/components/modals/ParentBalanceForm.tsx b/frontend/src/components/modals/ParentBalanceForm.tsx
index df6ee38..4a7b7fa 100644
--- a/frontend/src/components/modals/ParentBalanceForm.tsx
+++ b/frontend/src/components/modals/ParentBalanceForm.tsx
@@ -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 = (
+ <>
+
+ {t('parentBalance.cancelButton')}
+
+ void handleSave()} disabled={isSaving}>
+ {isSaving ? t('parentBalance.savingButton') : t('parentBalance.saveButton')}
+
+ >
+ )
+
return (
-
-
e.stopPropagation()}>
-
-
{isSelf ? t('parentBalance.titleSelf') : t('parentBalance.title', { name: parentName })}
-
+ {t('parentBalance.description')}
+
+ {(error || saveError) && {error || saveError} }
+
+
+
+ {
+ setSjukpenningLeft(parseField(e.target.value))
+ clearBackendFieldError('sjukpenning')
+ }}
+ disabled={isSaving}
+ className="field__control"
+ />
+
+
+
+ {
+ setLagstaNivaLeft(parseField(e.target.value))
+ clearBackendFieldError('lagsta')
+ }}
+ disabled={isSaving}
+ className="field__control"
+ />
+
+
+
+ {
+ setDubbeldagarLeft(parseField(e.target.value))
+ clearBackendFieldError('dubbeldagar')
+ }}
+ disabled={isSaving}
+ className="field__control"
+ />
+
+
+
+ {t('parentBalance.presetButton')}
+
+
+
+ {t('parentBalance.summary', { left: daysLeft, total: totalDays })}
+
+ {t('parentBalance.summaryDubbeldagar', { count: dubbeldagarLeft ?? 0 })}
+
+ {totalError &&
{totalError}
}
+
+
setShowAdvanced((v) => !v)}
+ >
+ {t('parentBalance.advancedToggle')}
+
+
+ {showAdvanced && (
+
- ×
-
-
-
-
-
{t('parentBalance.description')}
-
- {(error || saveError) &&
{error || saveError} }
-
-
-
-
-
-
- {t('parentBalance.cancelButton')}
-
- void handleSave()}
- disabled={isSaving}
- >
- {isSaving ? t('parentBalance.savingButton') : t('parentBalance.saveButton')}
-
-
+ className="field__control"
+ />
+
+ )}
-
+
)
}
diff --git a/frontend/src/components/modals/PlannedDayModal.tsx b/frontend/src/components/modals/PlannedDayModal.tsx
index 5b658e3..c784e33 100644
--- a/frontend/src/components/modals/PlannedDayModal.tsx
+++ b/frontend/src/components/modals/PlannedDayModal.tsx
@@ -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 (
-
-
e.stopPropagation()}
- data-testid="planned-day-modal__content"
- >
-
-
{modalTitle}
-
-
+ const footer = (
+ <>
+ {!isRangeMode && existingDay && onDelete && isConfirmingDelete && (
+ void handleConfirmDelete()}
+ isBusy={isSaving}
+ cancelTestId="delete-cancel-button"
+ confirmTestId="delete-confirm-button"
+ />
+ )}
+ {!isConfirmingDelete && (
+ <>
+ {!isRangeMode && existingDay && onDelete && (
+
+ {isSaving ? t('plannedDayModal.deletingButton') : t('plannedDayModal.deleteButton')}
+
+ )}
+
+ {t('plannedDayModal.cancelButton')}
-
+
void handleSave()}
+ disabled={isSaving}
+ data-testid="save-button"
+ >
+ {isSaving ? t('plannedDayModal.savingButton') : t('plannedDayModal.saveButton')}
+
+ >
+ )}
+ >
+ )
-
- {!isRangeMode &&
{displayDate}
}
+ return (
+
+ {!isRangeMode && {displayDate}
}
- {(error || localError) && {error || localError} }
+ {(error || localError) && {error || localError} }
- {!isRangeMode &&
- excludedEntries.map((entry) => (
- (
+
+ {t('plannedDayModal.excludedNotice', { parent: entry.parentName })}
+ {onRestoreExcluded && (
+ void onRestoreExcluded(entry.id)}
+ disabled={isSaving}
+ data-testid={`restore-excluded-${entry.id}`}
>
- {t('plannedDayModal.excludedNotice', { parent: entry.parentName })}
- {onRestoreExcluded && (
- void onRestoreExcluded(entry.id)}
- disabled={isSaving}
- data-testid={`restore-excluded-${entry.id}`}
- >
- {t('plannedDayModal.restoreExcludedButton')}
-
- )}
-
- ))}
+ {t('plannedDayModal.restoreExcludedButton')}
+
+ )}
+
+ ))}
-
- {!forcedAdvanced && hasMultipleParents && (
-
+
+ {!forcedAdvanced && hasMultipleParents && (
+
+ handleParentModeChange(e.target.value)}
+ className="field__control"
+ disabled={isSaving}
+ data-testid="parent-mode-select"
+ >
+ {t('wishEditor.eitherParent')}
+ {t('wishEditor.bothParents')}
+ {parents.map((p) => (
+
+ {p.name}
+
+ ))}
+
+
+ )}
+
+ {!forcedAdvanced && hasMultipleParents && (
+
+
+ setWantsDubbeldag(e.target.checked)}
+ disabled={isSaving || parentMode !== 'both'}
+ data-testid="wants-dubbeldag-checkbox"
+ />
+ {' '}{t('plannedDayModal.wantsDubbeldagLabel')}
+
+
+ {parentMode === 'both'
+ ? t('plannedDayModal.wantsDubbeldagHint')
+ : t('plannedDayModal.wantsDubbeldagRequiresBothHint')}
+
+
+ )}
+
+ {/* 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 && (
+
+ {t('plannedDayModal.freeDaysStayOffNotice')}
+
+ )}
+
+ {!forcedAdvanced && hasMultipleParents && (
+
setIsAdvancedOpen((open) => !open)}
+ disabled={isSaving}
+ data-testid="advanced-toggle"
+ aria-expanded={isAdvancedOpen}
+ >
+ {isAdvancedOpen ? t('plannedDayModal.hideAdvanced') : t('plannedDayModal.showAdvanced')}
+
+ )}
+
+ {showPinnedControls && (
+
+
+ {
+ 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 && {t('plannedDayModal.autoOption')} }
+ {t('plannedDayModal.vacationOption')}
+ {t('plannedDayModal.sjukpenningOption')}
+ {t('plannedDayModal.lagstaOption')}
+
+
+
+ {freeDaysWillStayOff && (
+
+ {t('plannedDayModal.freeDaysStayOffNotice')}
+
+ )}
+
+ {showPinExclusionNotice && (
+
+ {t('plannedDayModal.pinExcludesFromSyncNotice')}
+
+ )}
+
+ {hasMultipleParents && (
+
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"
>
- {t('wishEditor.eitherParent')}
- {t('wishEditor.bothParents')}
{parents.map((p) => (
{p.name}
@@ -330,194 +478,35 @@ export function PlannedDayModal({
)}
- {!forcedAdvanced && hasMultipleParents && (
+ {dayType !== 'auto' && (
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')}
-
- {parentMode === 'both'
- ? t('plannedDayModal.wantsDubbeldagHint')
- : t('plannedDayModal.wantsDubbeldagRequiresBothHint')}
-
+ {dayType === 'vacation' && (
+
+ {t('plannedDayModal.doubleDayHint')}
+
+ )}
)}
- {/* 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 && (
-
- {t('plannedDayModal.freeDaysStayOffNotice')}
+ {existingDay?.isPinned && (
+
+
+ {t('plannedDayModal.pinnedNotice')}
)}
-
- {!forcedAdvanced && hasMultipleParents && (
-
setIsAdvancedOpen((open) => !open)}
- disabled={isSaving}
- data-testid="advanced-toggle"
- aria-expanded={isAdvancedOpen}
- >
- {isAdvancedOpen ? t('plannedDayModal.hideAdvanced') : t('plannedDayModal.showAdvanced')}
-
- )}
-
- {showPinnedControls && (
-
-
- {
- 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 && {t('plannedDayModal.autoOption')} }
- {t('plannedDayModal.vacationOption')}
- {t('plannedDayModal.sjukpenningOption')}
- {t('plannedDayModal.lagstaOption')}
-
-
-
- {freeDaysWillStayOff && (
-
- {t('plannedDayModal.freeDaysStayOffNotice')}
-
- )}
-
- {showPinExclusionNotice && (
-
- {t('plannedDayModal.pinExcludesFromSyncNotice')}
-
- )}
-
- {hasMultipleParents && (
-
- handleAdvancedParentChange(e.target.value)}
- className="field__control"
- disabled={isSaving}
- data-testid="parent-select"
- >
- {parents.map((p) => (
-
- {p.name}
-
- ))}
-
-
- )}
-
- {dayType !== 'auto' && (
-
-
- setIsDubbeldag(e.target.checked)}
- disabled={isSaving || dayType === 'vacation'}
- data-testid="dubbeldag-checkbox"
- />
- {' '}{t('plannedDayModal.doubleDayLabel')}
-
- {dayType === 'vacation' && (
-
- {t('plannedDayModal.doubleDayHint')}
-
- )}
-
- )}
-
- {existingDay?.isPinned && (
-
-
- {t('plannedDayModal.pinnedNotice')}
-
- )}
-
- )}
-
-
-
- {!isRangeMode && existingDay && onDelete && isConfirmingDelete && (
-
-
- {t('plannedDayModal.confirmDelete')}
-
-
- {t('plannedDayModal.cancelButton')}
-
- void handleConfirmDelete()}
- disabled={isSaving}
- data-testid="delete-confirm-button"
- >
- {isSaving ? t('plannedDayModal.deletingButton') : t('plannedDayModal.deleteConfirmButton')}
-
-
- )}
- {!isConfirmingDelete && (
- <>
- {!isRangeMode && existingDay && onDelete && (
-
- {isSaving ? t('plannedDayModal.deletingButton') : t('plannedDayModal.deleteButton')}
-
- )}
-
- {t('plannedDayModal.cancelButton')}
-
-
void handleSave()}
- disabled={isSaving}
- data-testid="save-button"
- >
- {isSaving ? t('plannedDayModal.savingButton') : t('plannedDayModal.saveButton')}
-
- >
- )}
-
+ )}
-
+
)
}
diff --git a/frontend/src/components/modals/QRCodeModal.tsx b/frontend/src/components/modals/QRCodeModal.tsx
index d08dfe2..6beafd9 100644
--- a/frontend/src/components/modals/QRCodeModal.tsx
+++ b/frontend/src/components/modals/QRCodeModal.tsx
@@ -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' && (
+
+ {t('qrModal.closeButton')}
+
+ )}
+
+ {isRunning && (
+
void onCancel()}>
+ {t('qrModal.cancelButton')}
+
+ )}
+ >
+ ) : null
return (
-
-
-
-
{t('qrModal.title')}
-
- ×
-
-
-
- {status && (
-
-
- {t('qrModal.statusLabel')}
-
- {t(STATUS_LABEL_KEYS[status])}
-
- {message && — {message} }
-
+
+ {status && (
+
+
+ {t('qrModal.statusLabel')}
+
+ {t(STATUS_LABEL_KEYS[status])}
+
+ {message && — {message} }
+
- {status === 'awaiting_scan' && qrData && (
-
-
{t('qrModal.qrInstruction')}
-
-
- )}
-
- {(status === 'authenticating' || status === 'fetching') && (
-
- {' '}
- {status === 'authenticating' ? t('qrModal.authenticating') : t('qrModal.fetching')}
-
- )}
-
- {status === 'done' && result && (
-
- {t('qrModal.success')}
-
- )}
-
- {status === 'cancelled' && (
-
{t('qrModal.cancelled')}
- )}
+ {status === 'awaiting_scan' && qrData && (
+
+
{t('qrModal.qrInstruction')}
+
)}
- {error && (
-
- {t('qrModal.errorLabel')} {error}
-
+ {(status === 'authenticating' || status === 'fetching') && (
+
+ {' '}
+ {status === 'authenticating' ? t('qrModal.authenticating') : t('qrModal.fetching')}
+
+ )}
+
+ {status === 'done' && result && (
+
+ {t('qrModal.success')}
+
+ )}
+
+ {status === 'cancelled' && (
+
{t('qrModal.cancelled')}
)}
+ )}
- {(status === 'done' || isRunning) && (
-
- {status === 'done' && (
-
- {t('qrModal.closeButton')}
-
- )}
-
- {isRunning && (
- void onCancel()}>
- {t('qrModal.cancelButton')}
-
- )}
-
- )}
-
-
+ {error && (
+
+ {t('qrModal.errorLabel')} {error}
+
+ )}
+
)
}
diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx
index 99460f1..07a0ac3 100644
--- a/frontend/src/pages/Settings.tsx
+++ b/frontend/src/pages/Settings.tsx
@@ -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 {
{!isSelf &&
(isConfirmingRemove ? (
-
- {t('settings.removeConfirmPrompt', { name: member.name })}
- handleConfirmRemove(member.uuid)}
- className="btn btn--danger btn--sm"
- disabled={isSavingFamily}
- data-testid={`remove-confirm-${member.uuid}`}
- >
- {t('settings.removeConfirmButton')}
-
-
- {t('settings.cancelButton')}
-
-
+
handleConfirmRemove(member.uuid)}
+ onCancel={handleCancelRemove}
+ isBusy={isSavingFamily}
+ confirmTestId={`remove-confirm-${member.uuid}`}
+ cancelTestId={`remove-cancel-${member.uuid}`}
+ confirmFirst
+ small
+ />
) : (
handleRequestRemove(member.uuid)}