Track preschool planning days, partner invites and real vacation forfeiture
Three strands of work that grew together across the same settings screen and
API client, so they land as one change.
Per-year preschool planning days ("studiedagar"): new table, repository
(SQLite + Postgres), service, router and migration 007, plus the hooks and
Settings UI to plan and record them per child and year. Allocation now
reserves the recorded figure for a year where one exists and only falls back
to the child-level estimate otherwise, marking that fallback as assumed.
Partner invitations: workspaces issue and list invitations, registration
accepts an invite token to join the inviter's workspace instead of creating a
personal one, and a /join/:token page plus the settings invite panel drive it.
Vacation forfeiture: tips and the dashboard now distinguish days a parent
genuinely loses from days that merely age, so the warning stops firing on
balances that were never at risk.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -206,6 +206,36 @@ describe('CalendarExceptions', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('controlled year disclosures (survive a background refresh)', () => {
|
||||
it('opens the current year by default and keeps other years closed', () => {
|
||||
render(<CalendarExceptions {...defaultProps} />)
|
||||
expect(screen.getByTestId('red-days-year-2026')).toHaveAttribute('open')
|
||||
expect(screen.getByTestId('red-days-year-2027')).not.toHaveAttribute('open')
|
||||
})
|
||||
|
||||
it('opening a non-default year persists across a re-render with fresh props (a background refresh)', () => {
|
||||
const { rerender } = render(<CalendarExceptions {...defaultProps} />);
|
||||
fireEvent.click(screen.getByTestId('red-days-year-2027').querySelector('summary')!)
|
||||
expect(screen.getByTestId('red-days-year-2027')).toHaveAttribute('open')
|
||||
|
||||
// Simulate the props a background refresh (e.g. from toggling a day
|
||||
// elsewhere) would produce — a new `days` array reference.
|
||||
rerender(<CalendarExceptions {...defaultProps} days={[...baseDays]} />)
|
||||
|
||||
expect(screen.getByTestId('red-days-year-2027')).toHaveAttribute('open')
|
||||
})
|
||||
|
||||
it('closing the default-open current year persists across a re-render', () => {
|
||||
const { rerender } = render(<CalendarExceptions {...defaultProps} />)
|
||||
fireEvent.click(screen.getByTestId('red-days-year-2026').querySelector('summary')!)
|
||||
expect(screen.getByTestId('red-days-year-2026')).not.toHaveAttribute('open')
|
||||
|
||||
rerender(<CalendarExceptions {...defaultProps} days={[...baseDays]} />)
|
||||
|
||||
expect(screen.getByTestId('red-days-year-2026')).not.toHaveAttribute('open')
|
||||
})
|
||||
})
|
||||
|
||||
describe('toggling a day (§Task8)', () => {
|
||||
it('clicking a statutory red day upserts an override making that exact date a working day', async () => {
|
||||
const onUpsertOverride = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
@@ -62,6 +62,19 @@ export function CalendarExceptions({
|
||||
const [pendingDate, setPendingDate] = useState<string | null>(null)
|
||||
const [toggleError, setToggleError] = useState<string | null>(null)
|
||||
|
||||
// Per-year open/closed state, explicit and controlled — a background
|
||||
// refresh (e.g. the toggle below, or a `planVersion` bump from an
|
||||
// override made elsewhere) re-renders this component with fresh `days`,
|
||||
// but must never reset which year(s) the user has open. Only a year the
|
||||
// user hasn't touched yet falls back to the "current year starts open"
|
||||
// default; any year they've explicitly opened or closed stays exactly as
|
||||
// they left it for the lifetime of this component instance.
|
||||
const [openYearOverrides, setOpenYearOverrides] = useState<Record<number, boolean>>({})
|
||||
const isYearOpen = (year: number) => openYearOverrides[year] ?? year === currentYear
|
||||
const handleToggleYear = (year: number, open: boolean) => {
|
||||
setOpenYearOverrides((prev) => (prev[year] === open ? prev : { ...prev, [year]: open }))
|
||||
}
|
||||
|
||||
// Form state (ported from `DayOverridesInput`).
|
||||
const [date, setDate] = useState('')
|
||||
const [isWorkingDay, setIsWorkingDay] = useState(true)
|
||||
@@ -184,7 +197,8 @@ export function CalendarExceptions({
|
||||
key={year}
|
||||
className="disclosure red-days-list__year"
|
||||
data-testid={`red-days-year-${year}`}
|
||||
open={year === currentYear}
|
||||
open={isYearOpen(year)}
|
||||
onToggle={(e) => handleToggleYear(year, e.currentTarget.open)}
|
||||
>
|
||||
<summary className="disclosure__summary red-days-list__year-summary">
|
||||
<span className="red-days-list__year-title">{year}</span>
|
||||
|
||||
@@ -6,11 +6,14 @@ import {
|
||||
makeParentYearBalance,
|
||||
makeParentalLeaveYearBalance,
|
||||
makePlannedLeaveDay,
|
||||
makePreschoolPlanningDays,
|
||||
makeShortfallRemedy,
|
||||
makeVacationYearBalance,
|
||||
makeWindowOutcome,
|
||||
} from '../../../tests/fixtures'
|
||||
|
||||
const CURRENT_YEAR = new Date().getFullYear()
|
||||
|
||||
const HORIZON = { startDate: '2025-06-01', endDate: '2029-06-01' }
|
||||
|
||||
function balancesFor(uuid: string, years: number[], overrides: Partial<ReturnType<typeof makeParentYearBalance>> = {}) {
|
||||
@@ -110,6 +113,74 @@ describe('YearRows', () => {
|
||||
expect(screen.queryByTestId('year-row-2026-expiring-pd-a')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('suppresses the expiring warning (summary and table) for a year already in the past', () => {
|
||||
renderRows()
|
||||
// sam's fixture has `expiring: 3` for every year including 2025, which
|
||||
// is before CURRENT_YEAR — already lost, no longer actionable, so it
|
||||
// must not appear as a warning anywhere for that year.
|
||||
expect(CURRENT_YEAR).toBeGreaterThan(2025)
|
||||
expect(screen.queryByTestId('year-row-2025-expiring-pd-b')).not.toBeInTheDocument()
|
||||
const table = screen.getByTestId('year-row-2025-table')
|
||||
const expiring = within(table).getByRole('row', { name: /^Förfaller/ })
|
||||
const cells = within(expiring).getAllByRole('cell')
|
||||
expect(cells[1]).not.toHaveClass('year-row__cell--danger')
|
||||
// The current year (2026) is unaffected — still warns.
|
||||
expect(screen.getByTestId('year-row-2026-expiring-pd-b')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('explains a comprehensible breakdown of why days expire (Jacob case: 15 left, 10 saved, 5 over the cap)', () => {
|
||||
const jacob: YearRowsParent = {
|
||||
personalDataUuid: 'pd-jacob',
|
||||
name: 'Jacob',
|
||||
yearBalances: [
|
||||
makeParentYearBalance({
|
||||
personalDataUuid: 'pd-jacob',
|
||||
year: 2027,
|
||||
vacation: makeVacationYearBalance({
|
||||
year: 2027,
|
||||
earned: 30,
|
||||
left: 15,
|
||||
carriedOut: 10,
|
||||
expiring: 5,
|
||||
expiringAge: 0,
|
||||
expiringOverCap: 5,
|
||||
}),
|
||||
}),
|
||||
],
|
||||
}
|
||||
renderRows({ parents: [jacob] })
|
||||
const table = screen.getByTestId('year-row-2027-table')
|
||||
const expiring = within(table).getByRole('row', { name: /^Förfaller/ })
|
||||
expect(expiring).toHaveTextContent('5')
|
||||
expect(expiring).toHaveTextContent('eftersom bara 10 av 15 kvarvarande dagar får sparas till nästa år')
|
||||
// The 5-year age-out cause is not shown since expiringAge is 0.
|
||||
expect(expiring).not.toHaveTextContent('5-årsgränsen')
|
||||
})
|
||||
})
|
||||
|
||||
describe('preschool planning days reserve', () => {
|
||||
it('shows the planned and taken figures for the year, with an assumed badge for a synthesized default', () => {
|
||||
renderRows({
|
||||
parents: [alex],
|
||||
preschoolPlanningDays: [makePreschoolPlanningDays({ year: 2026, plannedDays: 4, takenDays: 2, isDefault: true })],
|
||||
})
|
||||
const planned = screen.getByTestId('year-row-2026-preschool-planned')
|
||||
expect(planned).toHaveTextContent('4')
|
||||
expect(planned).toHaveTextContent('antaget')
|
||||
expect(screen.getByTestId('year-row-2026-preschool-taken')).toHaveTextContent('2')
|
||||
})
|
||||
|
||||
it('reads "not recorded" (not zero) when takenDays is null, and "–" when the year has no row', () => {
|
||||
renderRows({
|
||||
parents: [alex],
|
||||
preschoolPlanningDays: [makePreschoolPlanningDays({ year: 2026, plannedDays: 4, takenDays: null, isDefault: false })],
|
||||
})
|
||||
expect(screen.getByTestId('year-row-2026-preschool-taken')).toHaveTextContent('Ej registrerat')
|
||||
expect(screen.getByTestId('year-row-2027-preschool-planned')).toHaveTextContent('–')
|
||||
})
|
||||
})
|
||||
|
||||
describe('per-parent figures (couple), continued', () => {
|
||||
it('renders a dash for a year the parent has no balance entry for', () => {
|
||||
const partial: YearRowsParent = { ...alex, yearBalances: balancesFor('pd-a', [2025]) }
|
||||
renderRows({ parents: [partial, sam] })
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { MouseEvent, ReactNode } from 'react'
|
||||
import type { ParentYearBalance, PlannedLeaveDay, ShortfallRemedy, WindowOutcome } from '@/types'
|
||||
import type { ParentYearBalance, PlannedLeaveDay, PreschoolPlanningDays, ShortfallRemedy, WindowOutcome } from '@/types'
|
||||
import { useTranslation, toTranslationParams, type TranslationKey, type TranslationParams } from '@/i18n'
|
||||
import { getLabelForType, getTypeKey } from '@/utils/leaveTypes'
|
||||
import { formatDateForDisplay } from '@/utils/dates'
|
||||
@@ -27,6 +27,10 @@ export interface YearRowsProps {
|
||||
outcomes: WindowOutcome[]
|
||||
/** Called with a band's ISO-8601 start date when clicked. */
|
||||
onBandClick: (date: string) => void
|
||||
/** One row per (child, year) of preschool closure/planning days — a
|
||||
* household-level reserve, not per-parent. Absent or missing a year
|
||||
* renders that year's reserve as unknown ('–'), not zero. */
|
||||
preschoolPlanningDays?: PreschoolPlanningDays[]
|
||||
}
|
||||
|
||||
interface Band {
|
||||
@@ -171,8 +175,20 @@ function renderRemedyLine(remedy: ShortfallRemedy, t: Translate): string {
|
||||
* Opening a row shows the full per-parent balance table for that year plus
|
||||
* the plan proposal's wish outcomes.
|
||||
*/
|
||||
export function YearRows({ parents, plannedDays, startDate, endDate, outcomes, onBandClick }: YearRowsProps): ReactNode {
|
||||
export function YearRows({
|
||||
parents,
|
||||
plannedDays,
|
||||
startDate,
|
||||
endDate,
|
||||
outcomes,
|
||||
onBandClick,
|
||||
preschoolPlanningDays,
|
||||
}: YearRowsProps): ReactNode {
|
||||
const { t, locale } = useTranslation()
|
||||
// Years before this one are already closed out — a red "expiring" warning
|
||||
// there is noise the user can no longer act on, though the underlying
|
||||
// figure stays correct (later years' carriedIn depends on it).
|
||||
const currentYear = new Date().getFullYear()
|
||||
const startYear = toUtcDate(startDate).getUTCFullYear()
|
||||
const endYear = toUtcDate(endDate).getUTCFullYear()
|
||||
const years = startYear <= endYear ? Array.from({ length: endYear - startYear + 1 }, (_, i) => startYear + i) : []
|
||||
@@ -207,9 +223,11 @@ export function YearRows({ parents, plannedDays, startDate, endDate, outcomes, o
|
||||
|
||||
{years.map((year) => {
|
||||
const yearLength = daysInYear(year)
|
||||
const isPastYear = year < currentYear
|
||||
const outcomesForYear = outcomes
|
||||
.filter((o) => o.year === year)
|
||||
.sort((a, b) => a.startDate.localeCompare(b.startDate))
|
||||
const preschoolRow = (preschoolPlanningDays ?? []).find((p) => p.year === year)
|
||||
let bandIndex = 0
|
||||
return (
|
||||
<details key={year} className="disclosure year-row" data-testid={`year-row-${year}`}>
|
||||
@@ -283,7 +301,7 @@ export function YearRows({ parents, plannedDays, startDate, endDate, outcomes, o
|
||||
{t('yearRow.carriedOut', { days: balance.vacation.carriedOut })}
|
||||
</span>
|
||||
)}
|
||||
{balance && balance.vacation.expiring > 0 && (
|
||||
{balance && balance.vacation.expiring > 0 && year >= currentYear && (
|
||||
<span
|
||||
className="year-row__figure-danger tnum"
|
||||
data-testid={`year-row-${year}-expiring-${parent.personalDataUuid}`}
|
||||
@@ -373,8 +391,53 @@ export function YearRows({ parents, plannedDays, startDate, endDate, outcomes, o
|
||||
parents={parents}
|
||||
year={year}
|
||||
pick={(b) => b.vacation.expiring}
|
||||
danger={(b) => b.vacation.expiring > 0}
|
||||
danger={(b) => !isPastYear && b.vacation.expiring > 0}
|
||||
detail={(b) => {
|
||||
if (isPastYear || b.vacation.expiring === 0) return null
|
||||
const lines: string[] = []
|
||||
if (b.vacation.expiringOverCap > 0) {
|
||||
lines.push(
|
||||
t('yearRow.expiringOverCapDetail', {
|
||||
saved: b.vacation.carriedOut,
|
||||
left: b.vacation.left,
|
||||
}),
|
||||
)
|
||||
}
|
||||
if (b.vacation.expiringAge > 0) {
|
||||
lines.push(t('yearRow.expiringAgeDetail', { days: b.vacation.expiringAge }))
|
||||
}
|
||||
if (lines.length === 0) return null
|
||||
return <span className="year-row__cell-detail">{lines.join(' · ')}</span>
|
||||
}}
|
||||
/>
|
||||
<tr className="year-row__group">
|
||||
<th scope="rowgroup" colSpan={parents.length + 1}>
|
||||
{t('yearRow.preschoolTitle')}
|
||||
</th>
|
||||
</tr>
|
||||
<tr className="year-row__row" data-testid={`year-row-${year}-preschool-planned`}>
|
||||
<th scope="row" className="year-row__cell-label">
|
||||
{t('yearRow.preschoolPlanned')}
|
||||
</th>
|
||||
<td className="year-row__cell--num tnum" colSpan={parents.length}>
|
||||
{preschoolRow?.isDefault && (
|
||||
<span className="badge badge--muted year-row__badge">{t('yearRow.assumed')}</span>
|
||||
)}
|
||||
{preschoolRow ? preschoolRow.plannedDays : '–'}
|
||||
</td>
|
||||
</tr>
|
||||
<tr className="year-row__row" data-testid={`year-row-${year}-preschool-taken`}>
|
||||
<th scope="row" className="year-row__cell-label">
|
||||
{t('yearRow.preschoolTaken')}
|
||||
</th>
|
||||
<td className="year-row__cell--num tnum" colSpan={parents.length}>
|
||||
{preschoolRow
|
||||
? preschoolRow.takenDays === null
|
||||
? t('yearRow.preschoolNotRecorded')
|
||||
: preschoolRow.takenDays
|
||||
: '–'}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
@@ -436,9 +499,11 @@ interface BalanceRowProps {
|
||||
danger?: (balance: ParentYearBalance) => boolean
|
||||
/** A "what is left" row — heavier value weight. */
|
||||
emphasis?: boolean
|
||||
/** Optional muted explanatory line under the value (e.g. why days expire). */
|
||||
detail?: (balance: ParentYearBalance) => ReactNode | null
|
||||
}
|
||||
|
||||
function BalanceRow({ label, parents, year, pick, badge, danger, emphasis }: BalanceRowProps): ReactNode {
|
||||
function BalanceRow({ label, parents, year, pick, badge, danger, emphasis, detail }: BalanceRowProps): ReactNode {
|
||||
return (
|
||||
<tr className={emphasis ? 'year-row__row year-row__row--emphasis' : 'year-row__row'}>
|
||||
<th scope="row" className="year-row__cell-label">
|
||||
@@ -448,6 +513,7 @@ function BalanceRow({ label, parents, year, pick, badge, danger, emphasis }: Bal
|
||||
const balance = parent.yearBalances.find((b) => b.year === year)
|
||||
const badgeText = balance && badge ? badge(balance) : null
|
||||
const isDanger = balance && danger ? danger(balance) : false
|
||||
const detailNode = balance && detail ? detail(balance) : null
|
||||
return (
|
||||
<td
|
||||
key={parent.personalDataUuid}
|
||||
@@ -455,6 +521,7 @@ function BalanceRow({ label, parents, year, pick, badge, danger, emphasis }: Bal
|
||||
>
|
||||
{badgeText && <span className="badge badge--muted year-row__badge">{badgeText}</span>}
|
||||
{balance ? pick(balance) : '–'}
|
||||
{detailNode}
|
||||
</td>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||
import { PartnerInvitePanel } from './PartnerInvitePanel'
|
||||
import * as api from '@/services/api'
|
||||
import { makeInvitation } from '../../../tests/fixtures'
|
||||
|
||||
vi.mock('@/services/api')
|
||||
|
||||
const mockCreateInvitation = vi.mocked(api.createInvitation)
|
||||
const mockListInvitations = vi.mocked(api.listInvitations)
|
||||
const mockRevokeInvitation = vi.mocked(api.revokeInvitation)
|
||||
|
||||
describe('PartnerInvitePanel', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockListInvitations.mockResolvedValue([])
|
||||
Object.assign(navigator, { clipboard: { writeText: vi.fn().mockResolvedValue(undefined) } })
|
||||
})
|
||||
|
||||
it('lists pending invitations with their expiry', async () => {
|
||||
mockListInvitations.mockResolvedValue([
|
||||
makeInvitation({ id: 'inv-1', status: 'pending', expiresAt: '2025-01-08T00:00:00Z' }),
|
||||
makeInvitation({ id: 'inv-2', status: 'accepted' }),
|
||||
])
|
||||
render(<PartnerInvitePanel workspaceId="ws-1" />)
|
||||
|
||||
expect(await screen.findByTestId('invite-pending-list')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('invite-row-inv-1')).toBeInTheDocument()
|
||||
// Accepted invitations aren't shown in the pending list.
|
||||
expect(screen.queryByTestId('invite-row-inv-2')).not.toBeInTheDocument()
|
||||
expect(mockListInvitations).toHaveBeenCalledWith('ws-1')
|
||||
})
|
||||
|
||||
it('shows an empty state when there are no pending invitations', async () => {
|
||||
render(<PartnerInvitePanel workspaceId="ws-1" />)
|
||||
expect(await screen.findByText('Inga väntande inbjudningar just nu.')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('creates an invitation and shows the raw token as a copyable link, once', async () => {
|
||||
mockCreateInvitation.mockResolvedValue({ token: 'raw-token-xyz' })
|
||||
render(<PartnerInvitePanel workspaceId="ws-1" />)
|
||||
await waitFor(() => expect(mockListInvitations).toHaveBeenCalled())
|
||||
|
||||
fireEvent.click(screen.getByTestId('invite-generate-button'))
|
||||
|
||||
const input = await screen.findByTestId('invite-link-input')
|
||||
expect(input).toHaveValue(`${window.location.origin}/join/raw-token-xyz`)
|
||||
expect(screen.getByText(/visas bara här och nu/)).toBeInTheDocument()
|
||||
expect(mockCreateInvitation).toHaveBeenCalledWith('ws-1')
|
||||
})
|
||||
|
||||
it('copies the invite link to the clipboard', async () => {
|
||||
mockCreateInvitation.mockResolvedValue({ token: 'raw-token-xyz' })
|
||||
render(<PartnerInvitePanel workspaceId="ws-1" />)
|
||||
fireEvent.click(screen.getByTestId('invite-generate-button'))
|
||||
await screen.findByTestId('invite-link-input')
|
||||
|
||||
fireEvent.click(screen.getByTestId('invite-copy-button'))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(
|
||||
`${window.location.origin}/join/raw-token-xyz`,
|
||||
),
|
||||
)
|
||||
expect(await screen.findByText('Kopierad!')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('revokes a pending invitation and refreshes the list', async () => {
|
||||
mockListInvitations
|
||||
.mockResolvedValueOnce([makeInvitation({ id: 'inv-1', status: 'pending' })])
|
||||
.mockResolvedValueOnce([])
|
||||
render(<PartnerInvitePanel workspaceId="ws-1" />)
|
||||
|
||||
fireEvent.click(await screen.findByTestId('invite-revoke-inv-1'))
|
||||
|
||||
await waitFor(() => expect(mockRevokeInvitation).toHaveBeenCalledWith('inv-1'))
|
||||
await waitFor(() => expect(screen.getByText('Inga väntande inbjudningar just nu.')).toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('shows an error when creating the invitation fails', async () => {
|
||||
mockCreateInvitation.mockRejectedValue(new Error('boom'))
|
||||
render(<PartnerInvitePanel workspaceId="ws-1" />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('invite-generate-button'))
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('boom')
|
||||
})
|
||||
|
||||
it('shows an error when listing invitations fails', async () => {
|
||||
mockListInvitations.mockRejectedValue(new Error('list boom'))
|
||||
render(<PartnerInvitePanel workspaceId="ws-1" />)
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('list boom')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,194 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import * as api from '@/services/api'
|
||||
import { useTranslation } from '@/i18n'
|
||||
import type { Invitation } from '@/types'
|
||||
import './partner-invite-panel.css'
|
||||
|
||||
export interface PartnerInvitePanelProps {
|
||||
/** The workspace to invite a partner into. Invitation creation and listing
|
||||
* are scoped to it; revoking an invitation is not (the backend derives the
|
||||
* caller's own workspace from the session). */
|
||||
workspaceId: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a one-time partner invite link, shows it once for copying, and
|
||||
* lists/revokes pending invitations. Mounted by the Settings page inside its
|
||||
* partner section — this component owns no layout beyond its own root, so it
|
||||
* can drop into any surrounding section markup.
|
||||
*/
|
||||
export function PartnerInvitePanel({ workspaceId }: PartnerInvitePanelProps) {
|
||||
const { t, locale } = useTranslation()
|
||||
const [invitations, setInvitations] = useState<Invitation[]>([])
|
||||
const [isListLoading, setIsListLoading] = useState(true)
|
||||
const [listError, setListError] = useState<string | null>(null)
|
||||
const [isCreating, setIsCreating] = useState(false)
|
||||
const [createError, setCreateError] = useState<string | null>(null)
|
||||
const [newToken, setNewToken] = useState<string | null>(null)
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [revokingId, setRevokingId] = useState<string | null>(null)
|
||||
const [revokeError, setRevokeError] = useState<string | null>(null)
|
||||
|
||||
const loadInvitations = useCallback(async () => {
|
||||
setIsListLoading(true)
|
||||
setListError(null)
|
||||
try {
|
||||
const list = await api.listInvitations(workspaceId)
|
||||
setInvitations(list)
|
||||
} catch (err: unknown) {
|
||||
setListError(err instanceof Error ? err.message : t('invite.listError'))
|
||||
} finally {
|
||||
setIsListLoading(false)
|
||||
}
|
||||
}, [workspaceId, t])
|
||||
|
||||
useEffect(() => {
|
||||
void loadInvitations()
|
||||
}, [loadInvitations])
|
||||
|
||||
const handleCreate = async () => {
|
||||
setIsCreating(true)
|
||||
setCreateError(null)
|
||||
setCopied(false)
|
||||
try {
|
||||
const { token } = await api.createInvitation(workspaceId)
|
||||
setNewToken(token)
|
||||
await loadInvitations()
|
||||
} catch (err: unknown) {
|
||||
setCreateError(err instanceof Error ? err.message : t('invite.createError'))
|
||||
} finally {
|
||||
setIsCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!newToken) return
|
||||
const link = `${window.location.origin}/join/${newToken}`
|
||||
try {
|
||||
await navigator.clipboard.writeText(link)
|
||||
setCopied(true)
|
||||
} catch {
|
||||
// Clipboard API unavailable/denied — the link text is still visible
|
||||
// and selectable, so the person can copy it manually.
|
||||
setCopied(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRevoke = async (invitationId: string) => {
|
||||
setRevokingId(invitationId)
|
||||
setRevokeError(null)
|
||||
try {
|
||||
await api.revokeInvitation(invitationId)
|
||||
if (newToken) {
|
||||
// The just-created invitation may be the one being revoked; once
|
||||
// revoked its raw token is dead, so stop displaying it as live.
|
||||
setNewToken(null)
|
||||
}
|
||||
await loadInvitations()
|
||||
} catch (err: unknown) {
|
||||
setRevokeError(err instanceof Error ? err.message : t('invite.revokeError'))
|
||||
} finally {
|
||||
setRevokingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const pending = invitations.filter((inv) => inv.status === 'pending')
|
||||
const inviteLink = newToken ? `${window.location.origin}/join/${newToken}` : null
|
||||
|
||||
return (
|
||||
<div className="partner-invite-panel" data-testid="partner-invite-panel">
|
||||
<p className="partner-invite-panel__description">{t('invite.panelDescription')}</p>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--primary"
|
||||
onClick={() => void handleCreate()}
|
||||
disabled={isCreating}
|
||||
data-testid="invite-generate-button"
|
||||
>
|
||||
{isCreating ? t('invite.generatingButton') : t('invite.generateButton')}
|
||||
</button>
|
||||
|
||||
{createError && (
|
||||
<p role="alert" className="partner-invite-panel__error">
|
||||
{createError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{inviteLink && (
|
||||
<div className="partner-invite-panel__link-box" data-testid="invite-link-box">
|
||||
<label htmlFor="invite-link" className="field__label">
|
||||
{t('invite.linkLabel')}
|
||||
</label>
|
||||
<div className="partner-invite-panel__link-row">
|
||||
<input
|
||||
id="invite-link"
|
||||
className="field__control"
|
||||
type="text"
|
||||
readOnly
|
||||
value={inviteLink}
|
||||
data-testid="invite-link-input"
|
||||
onFocus={(e) => e.currentTarget.select()}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
onClick={() => void handleCopy()}
|
||||
data-testid="invite-copy-button"
|
||||
>
|
||||
{copied ? t('invite.copiedButton') : t('invite.copyButton')}
|
||||
</button>
|
||||
</div>
|
||||
<p className="partner-invite-panel__warning">{t('invite.onceWarning')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<h4 className="partner-invite-panel__pending-title">{t('invite.pendingTitle')}</h4>
|
||||
|
||||
{isListLoading ? (
|
||||
<p>{t('common.loading')}</p>
|
||||
) : listError ? (
|
||||
<p role="alert" className="partner-invite-panel__error">
|
||||
{listError}
|
||||
</p>
|
||||
) : pending.length === 0 ? (
|
||||
<p className="partner-invite-panel__empty">{t('invite.noPending')}</p>
|
||||
) : (
|
||||
<ul className="partner-invite-panel__list" data-testid="invite-pending-list">
|
||||
{pending.map((inv) => (
|
||||
<li key={inv.id} className="partner-invite-panel__row" data-testid={`invite-row-${inv.id}`}>
|
||||
<span className="partner-invite-panel__expiry">
|
||||
{t('invite.expiresLabel', { date: formatExpiry(inv.expiresAt, locale) })}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--danger"
|
||||
onClick={() => void handleRevoke(inv.id)}
|
||||
disabled={revokingId === inv.id}
|
||||
data-testid={`invite-revoke-${inv.id}`}
|
||||
>
|
||||
{revokingId === inv.id ? t('invite.revokingButton') : t('invite.revokeButton')}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{revokeError && (
|
||||
<p role="alert" className="partner-invite-panel__error">
|
||||
{revokeError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function formatExpiry(iso: string, locale: 'sv' | 'en'): string {
|
||||
const date = new Date(iso)
|
||||
if (Number.isNaN(date.getTime())) return iso
|
||||
return date.toLocaleDateString(locale === 'sv' ? 'sv-SE' : 'en-GB', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
/* PartnerInvitePanel — invite-link generation and pending-invite list.
|
||||
* Mounted inside Settings' partner section; owns no outer section chrome,
|
||||
* just its own internal spacing. */
|
||||
.partner-invite-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-3);
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.partner-invite-panel__description {
|
||||
margin: 0;
|
||||
color: var(--c-text-muted);
|
||||
}
|
||||
|
||||
.partner-invite-panel__link-box {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-2);
|
||||
padding: var(--sp-3);
|
||||
border: 1px solid var(--c-border);
|
||||
border-radius: 8px;
|
||||
background-color: var(--c-accent-weak);
|
||||
}
|
||||
|
||||
.partner-invite-panel__link-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.partner-invite-panel__link-row .field__control {
|
||||
flex: 1 1 240px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.partner-invite-panel__warning {
|
||||
margin: 0;
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--c-text-muted);
|
||||
}
|
||||
|
||||
.partner-invite-panel__pending-title {
|
||||
margin: var(--sp-2) 0 0 0;
|
||||
}
|
||||
|
||||
.partner-invite-panel__empty {
|
||||
margin: 0;
|
||||
color: var(--c-text-muted);
|
||||
}
|
||||
|
||||
.partner-invite-panel__list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.partner-invite-panel__row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--sp-2);
|
||||
padding: var(--sp-2) var(--sp-3);
|
||||
border: 1px solid var(--c-border);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.partner-invite-panel__expiry {
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--c-text-muted);
|
||||
}
|
||||
|
||||
.partner-invite-panel__error {
|
||||
margin: 0;
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--c-danger);
|
||||
}
|
||||
@@ -1,75 +1,191 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, fireEvent, screen } from '@testing-library/react'
|
||||
import { VacationDaysTable } from './VacationDaysTable'
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { render, fireEvent, screen, act } from '@testing-library/react'
|
||||
import { VacationDaysTable, type VacationDaysTableRow } from './VacationDaysTable'
|
||||
|
||||
const ROWS = [
|
||||
const ROWS: VacationDaysTableRow[] = [
|
||||
{ year: 2026, totalDays: 10, savedDaysIn: 2 },
|
||||
{ year: 2027, totalDays: 25, savedDaysIn: 0 },
|
||||
{ year: 2028, totalDays: 25, savedDaysIn: 0 },
|
||||
]
|
||||
|
||||
function renderTable(
|
||||
rows: VacationDaysTableRow[],
|
||||
onSave: (year: number, totalDays: number, savedDaysIn: number) => Promise<void>,
|
||||
) {
|
||||
return render(<VacationDaysTable rows={rows} onSave={onSave} personalDataUuid="pd-1" />)
|
||||
}
|
||||
|
||||
describe('VacationDaysTable', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('renders one row per year', () => {
|
||||
render(<VacationDaysTable rows={ROWS} onSave={() => {}} />)
|
||||
renderTable(ROWS, vi.fn().mockResolvedValue(undefined))
|
||||
expect(screen.getByTestId('vacation-year-row-2026')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('vacation-year-row-2027')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('vacation-year-row-2028')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the total days and saved days for each year', () => {
|
||||
render(<VacationDaysTable rows={ROWS} onSave={() => {}} />)
|
||||
renderTable(ROWS, vi.fn().mockResolvedValue(undefined))
|
||||
const totalInput = screen.getByTestId('vacation-total-days-2026') as HTMLInputElement
|
||||
const savedInput = screen.getByTestId('vacation-saved-days-2026') as HTMLInputElement
|
||||
expect(totalInput.value).toBe('10')
|
||||
expect(savedInput.value).toBe('2')
|
||||
})
|
||||
|
||||
it('calls onSave with the edited year and total days when Spara is clicked', () => {
|
||||
const onSave = vi.fn()
|
||||
render(<VacationDaysTable rows={ROWS} onSave={onSave} />)
|
||||
it('autosaves a total-days edit after the debounce delay, without a Save button', async () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined)
|
||||
renderTable(ROWS, onSave)
|
||||
|
||||
const totalInput = screen.getByTestId('vacation-total-days-2027')
|
||||
fireEvent.change(totalInput, { target: { value: '30' } })
|
||||
fireEvent.click(screen.getByTestId('vacation-save-2027'))
|
||||
fireEvent.change(screen.getByTestId('vacation-total-days-2027'), { target: { value: '30' } })
|
||||
expect(onSave).not.toHaveBeenCalled()
|
||||
expect(screen.queryByTestId('vacation-save-2027')).not.toBeInTheDocument()
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(800)
|
||||
})
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith(2027, 30, 0)
|
||||
})
|
||||
|
||||
it('calls onSave with edited saved-days-in value', () => {
|
||||
const onSave = vi.fn()
|
||||
render(<VacationDaysTable rows={ROWS} onSave={onSave} />)
|
||||
it('autosaves a saved-days-in edit', async () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined)
|
||||
renderTable(ROWS, onSave)
|
||||
|
||||
const savedInput = screen.getByTestId('vacation-saved-days-2026')
|
||||
fireEvent.change(savedInput, { target: { value: '5' } })
|
||||
fireEvent.click(screen.getByTestId('vacation-save-2026'))
|
||||
fireEvent.change(screen.getByTestId('vacation-saved-days-2026'), { target: { value: '5' } })
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(800)
|
||||
})
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith(2026, 10, 5)
|
||||
})
|
||||
|
||||
it('does not mix up edits between rows', () => {
|
||||
const onSave = vi.fn()
|
||||
render(<VacationDaysTable rows={ROWS} onSave={onSave} />)
|
||||
it('does not mix up edits between rows', async () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined)
|
||||
renderTable(ROWS, onSave)
|
||||
|
||||
fireEvent.change(screen.getByTestId('vacation-total-days-2026'), { target: { value: '15' } })
|
||||
fireEvent.click(screen.getByTestId('vacation-save-2027'))
|
||||
fireEvent.change(screen.getByTestId('vacation-total-days-2027'), { target: { value: '20' } })
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith(2027, 25, 0)
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(800)
|
||||
})
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith(2026, 15, 2)
|
||||
expect(onSave).toHaveBeenCalledWith(2027, 20, 0)
|
||||
expect(onSave).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('disables save buttons while saving', () => {
|
||||
render(<VacationDaysTable rows={ROWS} onSave={() => {}} isSaving />)
|
||||
expect(screen.getByTestId('vacation-save-2026')).toBeDisabled()
|
||||
it('collapses rapid successive edits to the same row into a single save', async () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined)
|
||||
renderTable(ROWS, onSave)
|
||||
|
||||
const input = screen.getByTestId('vacation-total-days-2027')
|
||||
fireEvent.change(input, { target: { value: '26' } })
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(400)
|
||||
})
|
||||
fireEvent.change(input, { target: { value: '27' } })
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(800)
|
||||
})
|
||||
|
||||
expect(onSave).toHaveBeenCalledTimes(1)
|
||||
expect(onSave).toHaveBeenCalledWith(2027, 27, 0)
|
||||
})
|
||||
|
||||
it('shows a saving then saved indicator around the autosave', async () => {
|
||||
let resolveSave: () => void = () => {}
|
||||
const onSave = vi.fn(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
resolveSave = resolve
|
||||
}),
|
||||
)
|
||||
renderTable(ROWS, onSave)
|
||||
|
||||
fireEvent.change(screen.getByTestId('vacation-total-days-2027'), { target: { value: '30' } })
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(800)
|
||||
})
|
||||
expect(screen.getByTestId('vacation-status-2027')).toHaveTextContent('Sparar')
|
||||
|
||||
await act(async () => {
|
||||
resolveSave()
|
||||
await Promise.resolve()
|
||||
})
|
||||
expect(screen.getByTestId('vacation-status-2027')).toHaveTextContent('Sparat')
|
||||
})
|
||||
|
||||
it('shows an error indicator when the autosave fails, without losing the typed value', async () => {
|
||||
const onSave = vi.fn().mockRejectedValue(new Error('network blew up'))
|
||||
renderTable(ROWS, onSave)
|
||||
|
||||
fireEvent.change(screen.getByTestId('vacation-total-days-2027'), { target: { value: '99' } })
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(800)
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('vacation-status-2027')).toHaveTextContent(/gick inte|kunde inte|error/i)
|
||||
expect((screen.getByTestId('vacation-total-days-2027') as HTMLInputElement).value).toBe('99')
|
||||
})
|
||||
|
||||
it('does not clobber an unsaved edit when the parent re-renders with fresh rows mid-debounce', async () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined)
|
||||
const { rerender } = render(<VacationDaysTable rows={ROWS} onSave={onSave} personalDataUuid="pd-1" />)
|
||||
|
||||
fireEvent.change(screen.getByTestId('vacation-total-days-2027'), { target: { value: '40' } })
|
||||
|
||||
// Simulate a background refetch (e.g. from another row's save bumping
|
||||
// `data`) landing mid-debounce, still carrying the OLD server value for
|
||||
// this row.
|
||||
rerender(<VacationDaysTable rows={ROWS} onSave={onSave} personalDataUuid="pd-1" />)
|
||||
|
||||
expect((screen.getByTestId('vacation-total-days-2027') as HTMLInputElement).value).toBe('40')
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(800)
|
||||
})
|
||||
expect(onSave).toHaveBeenCalledWith(2027, 40, 0)
|
||||
})
|
||||
|
||||
it('accepts a fresh server value once the row is no longer dirty', async () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined)
|
||||
const { rerender } = render(<VacationDaysTable rows={ROWS} onSave={onSave} personalDataUuid="pd-1" />)
|
||||
|
||||
fireEvent.change(screen.getByTestId('vacation-total-days-2027'), { target: { value: '40' } })
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(800)
|
||||
})
|
||||
expect(onSave).toHaveBeenCalledWith(2027, 40, 0)
|
||||
|
||||
// Once the save resolved the row is clean again — a later prop update
|
||||
// (e.g. from a real refetch confirming the save) should be reflected.
|
||||
const updatedRows = ROWS.map((r) => (r.year === 2027 ? { ...r, totalDays: 40 } : r))
|
||||
rerender(<VacationDaysTable rows={updatedRows} onSave={onSave} personalDataUuid="pd-1" />)
|
||||
expect((screen.getByTestId('vacation-total-days-2027') as HTMLInputElement).value).toBe('40')
|
||||
})
|
||||
|
||||
it('shows an empty message when there are no rows', () => {
|
||||
render(<VacationDaysTable rows={[]} onSave={() => {}} />)
|
||||
expect(screen.queryByTestId('vacation-days-table')).not.toBeInTheDocument()
|
||||
render(<VacationDaysTable rows={[]} onSave={vi.fn().mockResolvedValue(undefined)} personalDataUuid="pd-1" />)
|
||||
expect(screen.queryByTestId('vacation-days-table-pd-1')).not.toBeInTheDocument()
|
||||
expect(screen.getByText(/Inga år att visa/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('scopes its root testid to the given personalDataUuid, for multiple tables on one page', () => {
|
||||
renderTable(ROWS, vi.fn().mockResolvedValue(undefined))
|
||||
expect(screen.getByTestId('vacation-days-table-pd-1')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows an assumed badge for a year with no entered data (§3.2)', () => {
|
||||
const rows = [...ROWS, { year: 2029, totalDays: 25, savedDaysIn: 0, isAssumed: true }]
|
||||
render(<VacationDaysTable rows={rows} onSave={() => {}} />)
|
||||
renderTable(rows, vi.fn().mockResolvedValue(undefined))
|
||||
|
||||
expect(screen.getByTestId('vacation-assumed-badge-2029')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('vacation-assumed-badge-2026')).not.toBeInTheDocument()
|
||||
|
||||
@@ -20,12 +20,40 @@
|
||||
* not editable in this task) and would otherwise silently re-apply their
|
||||
* fixed 90px input width to this markup, since the class names would
|
||||
* collide even though the DOM shape changed.
|
||||
*
|
||||
* Autosave: each row debounces its own edits and saves in the background
|
||||
* (no per-year Save button — see the user complaint this replaced: "needing
|
||||
* to click save for each year is super annoying"). Each row is its own
|
||||
* component (`VacationYearRow`) purely so it can own its own
|
||||
* `useDebouncedCallback` instance — one shared debounce across every row
|
||||
* would collapse edits to two different years into a single save of
|
||||
* whichever was typed last.
|
||||
*
|
||||
* Draft-resync-vs-autosave race: `rows` (the server-confirmed values) can
|
||||
* change under a row at any time — most commonly right after this row's own
|
||||
* save triggers a background refetch (see `useVacationDays`'s
|
||||
* `isRefreshing`), but also from another browser tab/window. A naive
|
||||
* `useEffect` that always resynced `draft` from `rows` would clobber
|
||||
* whatever the user is mid-typing the moment that refetch resolves — an
|
||||
* edit made between "save fired" and "refetch landed" would silently
|
||||
* vanish. Each row tracks a `dirtyRef`, set the instant the user edits a
|
||||
* field and cleared only once ITS OWN save has resolved; the resync effect
|
||||
* bails out entirely while `dirtyRef.current` is true, so an in-flight or
|
||||
* about-to-fire edit is never overwritten by stale-relative-to-it server
|
||||
* data. Once the save resolves, the next resync is safe to apply (it will
|
||||
* either match what was just saved, or reflect a newer edit already
|
||||
* folded into the pending debounce).
|
||||
*/
|
||||
|
||||
import { ReactNode, useEffect, useState } from 'react'
|
||||
import { ReactNode, useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from '@/i18n'
|
||||
import { useDebouncedCallback } from '@/hooks'
|
||||
import './vacation-rows.css'
|
||||
|
||||
const AUTOSAVE_DELAY_MS = 800
|
||||
// How long the "Sparat" confirmation stays visible after a successful save.
|
||||
const SAVED_INDICATOR_MS = 2000
|
||||
|
||||
export interface VacationDaysTableRow {
|
||||
year: number
|
||||
totalDays: number
|
||||
@@ -38,8 +66,16 @@ export interface VacationDaysTableRow {
|
||||
|
||||
export interface VacationDaysTableProps {
|
||||
rows: VacationDaysTableRow[]
|
||||
onSave: (year: number, totalDays: number, savedDaysIn: number) => void
|
||||
isSaving?: boolean
|
||||
/** Persists one year's edited values. Awaited by the row that called it so
|
||||
* it can show a saving/saved/error indicator — never throws away the
|
||||
* rejection (a row surfaces it inline instead of a page-wide toast, since
|
||||
* several rows can be saving independently at once). */
|
||||
onSave: (year: number, totalDays: number, savedDaysIn: number) => Promise<void>
|
||||
/** Distinguishes rows rendered for different workspace members when more
|
||||
* than one `VacationDaysTable` is on the page at once (§3.3) — used only
|
||||
* for this table's own root `data-testid` so a test can scope into the
|
||||
* right one with `within()`; the per-row testids stay `year`-only. */
|
||||
personalDataUuid: string
|
||||
}
|
||||
|
||||
interface Draft {
|
||||
@@ -47,88 +83,125 @@ interface Draft {
|
||||
savedDaysIn: number
|
||||
}
|
||||
|
||||
export function VacationDaysTable({ rows, onSave, isSaving = false }: VacationDaysTableProps): ReactNode {
|
||||
type RowStatus = 'idle' | 'saving' | 'saved' | 'error'
|
||||
|
||||
interface VacationYearRowProps {
|
||||
row: VacationDaysTableRow
|
||||
onSave: (year: number, totalDays: number, savedDaysIn: number) => Promise<void>
|
||||
}
|
||||
|
||||
function VacationYearRow({ row, onSave }: VacationYearRowProps): ReactNode {
|
||||
const { t } = useTranslation()
|
||||
const [drafts, setDrafts] = useState<Record<number, Draft>>({})
|
||||
const [draft, setDraft] = useState<Draft>({ totalDays: row.totalDays, savedDaysIn: row.savedDaysIn })
|
||||
const [status, setStatus] = useState<RowStatus>('idle')
|
||||
const dirtyRef = useRef(false)
|
||||
const savedTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const next: Record<number, Draft> = {}
|
||||
for (const row of rows) {
|
||||
next[row.year] = { totalDays: row.totalDays, savedDaysIn: row.savedDaysIn }
|
||||
}
|
||||
setDrafts(next)
|
||||
}, [rows])
|
||||
if (dirtyRef.current) return
|
||||
setDraft({ totalDays: row.totalDays, savedDaysIn: row.savedDaysIn })
|
||||
}, [row.totalDays, row.savedDaysIn])
|
||||
|
||||
const updateDraft = (year: number, field: keyof Draft, value: number) => {
|
||||
setDrafts((prev) => {
|
||||
const current = prev[year] ?? { totalDays: 0, savedDaysIn: 0 }
|
||||
return { ...prev, [year]: { ...current, [field]: value } }
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (savedTimeoutRef.current) clearTimeout(savedTimeoutRef.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const debouncedSave = useDebouncedCallback((totalDays: number, savedDaysIn: number) => {
|
||||
setStatus('saving')
|
||||
onSave(row.year, totalDays, savedDaysIn)
|
||||
.then(() => {
|
||||
dirtyRef.current = false
|
||||
setStatus('saved')
|
||||
if (savedTimeoutRef.current) clearTimeout(savedTimeoutRef.current)
|
||||
savedTimeoutRef.current = setTimeout(() => setStatus('idle'), SAVED_INDICATOR_MS)
|
||||
})
|
||||
.catch(() => {
|
||||
// Leave `dirtyRef` set — a failed save must not let a stale server
|
||||
// value creep back in over what the user still sees on screen.
|
||||
setStatus('error')
|
||||
})
|
||||
}, AUTOSAVE_DELAY_MS)
|
||||
|
||||
const updateDraft = (field: keyof Draft, value: number) => {
|
||||
dirtyRef.current = true
|
||||
setStatus('idle')
|
||||
setDraft((prev) => {
|
||||
const next = { ...prev, [field]: value }
|
||||
debouncedSave(next.totalDays, next.savedDaysIn)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="vacation-rows__row" data-testid={`vacation-year-row-${row.year}`}>
|
||||
<div className="vacation-rows__year">
|
||||
{row.year}
|
||||
{row.isAssumed && (
|
||||
<span
|
||||
className="badge badge--warning vacation-rows__assumed-badge"
|
||||
data-testid={`vacation-assumed-badge-${row.year}`}
|
||||
title={t('vacationDaysTable.assumedPrompt')}
|
||||
>
|
||||
{t('vacationDaysTable.assumedBadge')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<label className="field vacation-rows__field">
|
||||
<span className="field__label">{t('vacationDaysTable.vacationDaysHeader')}</span>
|
||||
<input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={0}
|
||||
max={365}
|
||||
value={draft.totalDays}
|
||||
onChange={(e) => updateDraft('totalDays', Number(e.target.value))}
|
||||
data-testid={`vacation-total-days-${row.year}`}
|
||||
className="field__control vacation-rows__input"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="field vacation-rows__field">
|
||||
<span className="field__label">{t('vacationDaysTable.savedDaysHeader')}</span>
|
||||
<input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={0}
|
||||
max={365}
|
||||
value={draft.savedDaysIn}
|
||||
onChange={(e) => updateDraft('savedDaysIn', Number(e.target.value))}
|
||||
data-testid={`vacation-saved-days-${row.year}`}
|
||||
className="field__control vacation-rows__input"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<span
|
||||
className={`vacation-rows__status vacation-rows__status--${status}`}
|
||||
data-testid={`vacation-status-${row.year}`}
|
||||
aria-live="polite"
|
||||
>
|
||||
{status === 'saving' && t('vacationDaysTable.savingStatus')}
|
||||
{status === 'saved' && t('vacationDaysTable.savedStatus')}
|
||||
{status === 'error' && t('vacationDaysTable.saveErrorStatus')}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function VacationDaysTable({ rows, onSave, personalDataUuid }: VacationDaysTableProps): ReactNode {
|
||||
const { t } = useTranslation()
|
||||
|
||||
if (rows.length === 0) {
|
||||
return <p className="vacation-rows__empty">{t('vacationDaysTable.empty')}</p>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="vacation-rows" data-testid="vacation-days-table">
|
||||
{rows.map((row) => {
|
||||
const draft = drafts[row.year] ?? { totalDays: row.totalDays, savedDaysIn: row.savedDaysIn }
|
||||
return (
|
||||
<div key={row.year} className="vacation-rows__row" data-testid={`vacation-year-row-${row.year}`}>
|
||||
<div className="vacation-rows__year">
|
||||
{row.year}
|
||||
{row.isAssumed && (
|
||||
<span
|
||||
className="badge badge--warning vacation-rows__assumed-badge"
|
||||
data-testid={`vacation-assumed-badge-${row.year}`}
|
||||
title={t('vacationDaysTable.assumedPrompt')}
|
||||
>
|
||||
{t('vacationDaysTable.assumedBadge')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<label className="field vacation-rows__field">
|
||||
<span className="field__label">{t('vacationDaysTable.vacationDaysHeader')}</span>
|
||||
<input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={0}
|
||||
max={365}
|
||||
value={draft.totalDays}
|
||||
onChange={(e) => updateDraft(row.year, 'totalDays', Number(e.target.value))}
|
||||
data-testid={`vacation-total-days-${row.year}`}
|
||||
className="field__control vacation-rows__input"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="field vacation-rows__field">
|
||||
<span className="field__label">{t('vacationDaysTable.savedDaysHeader')}</span>
|
||||
<input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={0}
|
||||
max={365}
|
||||
value={draft.savedDaysIn}
|
||||
onChange={(e) => updateDraft(row.year, 'savedDaysIn', Number(e.target.value))}
|
||||
data-testid={`vacation-saved-days-${row.year}`}
|
||||
className="field__control vacation-rows__input"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSave(row.year, draft.totalDays, draft.savedDaysIn)}
|
||||
disabled={isSaving}
|
||||
data-testid={`vacation-save-${row.year}`}
|
||||
className="btn btn--secondary btn--sm vacation-rows__save"
|
||||
>
|
||||
{t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<div className="vacation-rows" data-testid={`vacation-days-table-${personalDataUuid}`}>
|
||||
{rows.map((row) => (
|
||||
<VacationYearRow key={row.year} row={row} onSave={onSave} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -46,8 +46,19 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.vacation-rows__save {
|
||||
.vacation-rows__status {
|
||||
align-self: center;
|
||||
min-width: 4.5rem;
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--c-text-muted);
|
||||
}
|
||||
|
||||
.vacation-rows__status--saved {
|
||||
color: var(--c-success, var(--c-text-muted));
|
||||
}
|
||||
|
||||
.vacation-rows__status--error {
|
||||
color: var(--c-danger);
|
||||
}
|
||||
|
||||
.vacation-rows__assumed-badge {
|
||||
|
||||
Reference in New Issue
Block a user