Redesign the setup flow and consolidate the CSS into one primitives layer
Onboarding asks explicitly for one or two parents and shows one card per parent for balances; the balance form is per parent with inline validation; the partner can be filled from the FK deduction or added from it. Settings and Dashboard reuse the same form. CSS: bare buttons carry no colour any more; buttons, cards, choice cards, fields, alerts, badges, eyebrow and modal chrome are defined once in primitives.css. Media queries live only in responsive.css. A style guard test blocks hex, !important and stray media queries. Bilingual fixes: calendar selects size by content, header no longer wraps. Vitest is capped at four workers. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
+217
-167
@@ -9,17 +9,21 @@ import {
|
||||
useWorkspaceMembers,
|
||||
} from '@/hooks'
|
||||
import * as api from '@/services/api'
|
||||
// Imported directly (not via the `@/hooks` barrel) so this page's tests can
|
||||
// drive it through a mocked `api.getParentalLeave` — see the hook's doc
|
||||
// comment for why.
|
||||
import { useLatestBalance } from '@/hooks/useLatestBalance'
|
||||
import { isApiError } from '@/services/errors'
|
||||
import { useTranslation, type Locale, type TranslationKey } from '@/i18n'
|
||||
import { Layout } from '@/components/layout/Layout'
|
||||
import { LoadingSpinner } from '@/components/common/LoadingSpinner'
|
||||
import { ChildBirthdayInput } from '@/components/inputs/ChildBirthdayInput'
|
||||
import { ParentProfileInput, type SalaryType, type EmploymentStatus } from '@/components/inputs/ParentProfileInput'
|
||||
import { PlanningModeSelector, type PlanningMode } from '@/components/inputs/PlanningModeSelector'
|
||||
import { VacationDaysInput } from '@/components/inputs/VacationDaysInput'
|
||||
import { VacationDaysTable, type VacationDaysTableRow } from '@/components/vacation-days/VacationDaysTable'
|
||||
import { DayOverridesInput } from '@/components/inputs/DayOverridesInput'
|
||||
import { RedDaysList } from '@/components/calendar/RedDaysList'
|
||||
import { ParentBalanceForm } from '@/components/modals/ParentBalanceForm'
|
||||
|
||||
const RED_DAYS_WINDOW_YEARS = 4
|
||||
|
||||
@@ -131,7 +135,6 @@ export function Settings(): ReactNode {
|
||||
const [childBirthDate, setChildBirthDate] = useState('')
|
||||
const [preschoolPlanningDays, setPreschoolPlanningDays] = useState(6)
|
||||
const [parentData, setParentData] = useState<{ salaryType?: SalaryType; employmentStatus?: EmploymentStatus }>({})
|
||||
const [planningMode, setPlanningMode] = useState<PlanningMode>(workspace?.planningMode ?? 'solo')
|
||||
const [saveMessage, setSaveMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [newPartnerName, setNewPartnerName] = useState('')
|
||||
@@ -139,6 +142,16 @@ export function Settings(): ReactNode {
|
||||
const [renameValue, setRenameValue] = useState('')
|
||||
const [removeConfirmUuid, setRemoveConfirmUuid] = useState<string | null>(null)
|
||||
const [memberActionError, setMemberActionError] = useState<{ uuid: string; text: string } | null>(null)
|
||||
const [balanceFormUuid, setBalanceFormUuid] = useState<string | null>(null)
|
||||
const [isSavingBalance, setIsSavingBalance] = useState(false)
|
||||
|
||||
// Settings isn't scoped to a single child the way Onboarding/Dashboard are
|
||||
// — mirrors the fallback `handleSaveBalance` below uses when saving.
|
||||
const balanceChildId = selectedChildId ?? children[0]?.id ?? null
|
||||
const { initial: balanceInitial, isLoading: balanceInitialLoading } = useLatestBalance(
|
||||
balanceFormUuid,
|
||||
balanceChildId,
|
||||
)
|
||||
|
||||
const isLoading = wsLoading || vacLoading || childDetailsLoading || dayOverridesLoading
|
||||
|
||||
@@ -149,12 +162,6 @@ export function Settings(): ReactNode {
|
||||
}
|
||||
}, [childDetails])
|
||||
|
||||
useEffect(() => {
|
||||
if (workspace?.planningMode) {
|
||||
setPlanningMode(workspace.planningMode)
|
||||
}
|
||||
}, [workspace?.planningMode])
|
||||
|
||||
const selectedChild = children.find((c) => c.id === selectedChildId)
|
||||
const hasPartner = members.some((m) => m.uuid !== personalDataUuid)
|
||||
|
||||
@@ -247,21 +254,6 @@ export function Settings(): ReactNode {
|
||||
}
|
||||
}
|
||||
|
||||
const handleSavePlanning = async () => {
|
||||
try {
|
||||
if (workspace?.id) {
|
||||
setIsSaving(true)
|
||||
await api.updateWorkspace(workspace.id, { planningMode })
|
||||
await refreshContext()
|
||||
showMessage('success', t('settings.savePlanningSuccess'))
|
||||
}
|
||||
} catch (err) {
|
||||
showMessage('error', err instanceof Error ? err.message : t('settings.savePlanningError'))
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSaveVacationYear = async (year: number, totalDays: number, savedDaysIn: number) => {
|
||||
try {
|
||||
if (workspace?.id && personalDataUuid) {
|
||||
@@ -369,6 +361,44 @@ export function Settings(): ReactNode {
|
||||
}
|
||||
}
|
||||
|
||||
const handleOpenBalanceForm = (uuid: string) => {
|
||||
setBalanceFormUuid(uuid)
|
||||
}
|
||||
|
||||
// No try/catch around the save call — a rejection propagates to
|
||||
// `ParentBalanceForm`'s own `onSave` handler, which owns showing it
|
||||
// (inline under the offending field for a mapped 422, or its own top
|
||||
// banner otherwise).
|
||||
const handleSaveBalance = async (data: {
|
||||
sjukpenningLeft: number
|
||||
lagstaNivaLeft: number
|
||||
dubbeldagarLeft: number
|
||||
reservedDaysLeft: number | null
|
||||
}) => {
|
||||
// Settings isn't scoped to a single child the way Onboarding/Dashboard
|
||||
// are guaranteed to be — `selectedChildId` can be unset before a child
|
||||
// is chosen elsewhere in the app. Fall back to the workspace's first
|
||||
// child so the balance still has somewhere to attach.
|
||||
const childId = selectedChildId ?? children[0]?.id
|
||||
if (!workspace?.id || !balanceFormUuid || !childId) return
|
||||
try {
|
||||
setIsSavingBalance(true)
|
||||
await api.saveManualFetch({
|
||||
personalDataUuid: balanceFormUuid,
|
||||
workspaceId: workspace.id,
|
||||
childId,
|
||||
sjukpenningLeft: data.sjukpenningLeft,
|
||||
lagstaNivaLeft: data.lagstaNivaLeft,
|
||||
dubbeldagarLeft: data.dubbeldagarLeft,
|
||||
reservedDaysLeft: data.reservedDaysLeft,
|
||||
source: 'manual',
|
||||
})
|
||||
setBalanceFormUuid(null)
|
||||
} finally {
|
||||
setIsSavingBalance(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div data-testid="settings-page" className="settings-page">
|
||||
@@ -376,7 +406,7 @@ export function Settings(): ReactNode {
|
||||
|
||||
{saveMessage && (
|
||||
<div
|
||||
className={`settings-page__message settings-page__message--${saveMessage.type}`}
|
||||
className={`alert alert--${saveMessage.type}`}
|
||||
data-testid="save-message"
|
||||
role="alert"
|
||||
>
|
||||
@@ -407,8 +437,9 @@ export function Settings(): ReactNode {
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSaveChild}
|
||||
className="settings-page__button"
|
||||
className="btn btn--primary"
|
||||
disabled={isSaving}
|
||||
data-testid="save-child-button"
|
||||
>
|
||||
@@ -426,7 +457,7 @@ export function Settings(): ReactNode {
|
||||
<ParentProfileInput parent={parentData} onChange={handleParentChange} />
|
||||
<button
|
||||
onClick={handleSaveParentProfile}
|
||||
className="settings-page__button"
|
||||
className="btn btn--primary"
|
||||
disabled={isSaving}
|
||||
data-testid="save-parent-button"
|
||||
>
|
||||
@@ -435,17 +466,142 @@ export function Settings(): ReactNode {
|
||||
</section>
|
||||
|
||||
<section className="settings-page__section">
|
||||
<h2>{t('settings.planningSectionTitle')}</h2>
|
||||
<p className="settings-page__help">{t('settings.planningHelp')}</p>
|
||||
<PlanningModeSelector value={planningMode} onChange={setPlanningMode} />
|
||||
<button
|
||||
onClick={handleSavePlanning}
|
||||
className="settings-page__button"
|
||||
disabled={isSaving}
|
||||
data-testid="save-planning-goal-button"
|
||||
>
|
||||
{isSaving ? t('common.saving') : t('settings.savePlanningButton')}
|
||||
</button>
|
||||
<h2>{t('settings.partnerSectionTitle')}</h2>
|
||||
<p>{hasPartner ? t('settings.partnerLinkedIntro') : t('settings.partnerIntro')}</p>
|
||||
|
||||
{membersLoading ? (
|
||||
<p>{t('settings.membersLoading')}</p>
|
||||
) : (
|
||||
<ul className="settings-page__member-list" data-testid="member-list">
|
||||
{members.map((member) => {
|
||||
const isSelf = member.uuid === personalDataUuid
|
||||
const isRenaming = renamingUuid === member.uuid
|
||||
const isConfirmingRemove = removeConfirmUuid === member.uuid
|
||||
return (
|
||||
<li
|
||||
key={member.uuid}
|
||||
className="settings-page__member-row"
|
||||
data-testid={`member-row-${member.uuid}`}
|
||||
>
|
||||
{isRenaming ? (
|
||||
<>
|
||||
<input
|
||||
type="text"
|
||||
value={renameValue}
|
||||
onChange={(e) => setRenameValue(e.target.value)}
|
||||
disabled={isSaving}
|
||||
className="field__control settings-page__partner-input"
|
||||
data-testid={`rename-input-${member.uuid}`}
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleSaveRename(member.uuid)}
|
||||
className="btn btn--secondary btn--sm"
|
||||
disabled={isSaving}
|
||||
data-testid={`rename-save-${member.uuid}`}
|
||||
>
|
||||
{t('settings.saveRenameButton')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCancelRename}
|
||||
className="btn btn--secondary btn--sm"
|
||||
disabled={isSaving}
|
||||
data-testid={`rename-cancel-${member.uuid}`}
|
||||
>
|
||||
{t('settings.cancelButton')}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span data-testid={`member-name-${member.uuid}`}>
|
||||
{member.name}
|
||||
{isSelf && (
|
||||
<span className="badge badge--muted"> {t('settings.youBadge')}</span>
|
||||
)}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handleStartRename(member)}
|
||||
className="btn btn--secondary btn--sm"
|
||||
disabled={isSaving}
|
||||
data-testid={`rename-button-${member.uuid}`}
|
||||
>
|
||||
{t('settings.renameButton')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleOpenBalanceForm(member.uuid)}
|
||||
className="btn btn--secondary btn--sm"
|
||||
disabled={isSaving}
|
||||
data-testid={`settings-balance-${member.uuid}`}
|
||||
>
|
||||
{t('settings.balanceButton')}
|
||||
</button>
|
||||
{!isSelf &&
|
||||
(isConfirmingRemove ? (
|
||||
<span className="settings-page__remove-confirm">
|
||||
{t('settings.removeConfirmPrompt', { name: member.name })}
|
||||
<button
|
||||
onClick={() => handleConfirmRemove(member.uuid)}
|
||||
className="btn btn--danger btn--sm"
|
||||
disabled={isSaving}
|
||||
data-testid={`remove-confirm-${member.uuid}`}
|
||||
>
|
||||
{t('settings.removeConfirmButton')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCancelRemove}
|
||||
className="btn btn--secondary btn--sm"
|
||||
disabled={isSaving}
|
||||
data-testid={`remove-cancel-${member.uuid}`}
|
||||
>
|
||||
{t('settings.cancelButton')}
|
||||
</button>
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => handleRequestRemove(member.uuid)}
|
||||
className="btn btn--danger btn--sm"
|
||||
disabled={isSaving}
|
||||
data-testid={`remove-button-${member.uuid}`}
|
||||
>
|
||||
{t('settings.removePartnerButton')}
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{memberActionError?.uuid === member.uuid && (
|
||||
<p
|
||||
role="alert"
|
||||
className="alert alert--error"
|
||||
data-testid={`member-action-error-${member.uuid}`}
|
||||
>
|
||||
{memberActionError.text}
|
||||
</p>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{!hasPartner && (
|
||||
<div className="settings-page__partner-row">
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('settings.partnerNamePlaceholder')}
|
||||
value={newPartnerName}
|
||||
onChange={(e) => setNewPartnerName(e.target.value)}
|
||||
disabled={isSaving}
|
||||
className="field__control settings-page__partner-input"
|
||||
/>
|
||||
<button
|
||||
onClick={handleAddPartner}
|
||||
className="btn btn--primary"
|
||||
disabled={isSaving}
|
||||
data-testid="add-partner-button"
|
||||
>
|
||||
{isSaving ? t('settings.addingPartner') : t('settings.addPartnerButton')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="settings-page__section">
|
||||
@@ -474,7 +630,7 @@ export function Settings(): ReactNode {
|
||||
</label>
|
||||
<select
|
||||
id="language-select"
|
||||
className="settings-page__select"
|
||||
className="field__control settings-page__select"
|
||||
value={explicitLocale ?? 'auto'}
|
||||
onChange={(e) => handleLanguageChange(e.target.value)}
|
||||
data-testid="language-select"
|
||||
@@ -514,136 +670,30 @@ export function Settings(): ReactNode {
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="settings-page__section">
|
||||
<h2>{t('settings.partnerSectionTitle')}</h2>
|
||||
<p>{hasPartner ? t('settings.partnerLinkedIntro') : t('settings.partnerIntro')}</p>
|
||||
|
||||
{membersLoading ? (
|
||||
<p>{t('settings.membersLoading')}</p>
|
||||
) : (
|
||||
<ul className="settings-page__member-list" data-testid="member-list">
|
||||
{members.map((member) => {
|
||||
const isSelf = member.uuid === personalDataUuid
|
||||
const isRenaming = renamingUuid === member.uuid
|
||||
const isConfirmingRemove = removeConfirmUuid === member.uuid
|
||||
return (
|
||||
<li
|
||||
key={member.uuid}
|
||||
className="settings-page__member-row"
|
||||
data-testid={`member-row-${member.uuid}`}
|
||||
>
|
||||
{isRenaming ? (
|
||||
<>
|
||||
<input
|
||||
type="text"
|
||||
value={renameValue}
|
||||
onChange={(e) => setRenameValue(e.target.value)}
|
||||
disabled={isSaving}
|
||||
className="settings-page__partner-input"
|
||||
data-testid={`rename-input-${member.uuid}`}
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleSaveRename(member.uuid)}
|
||||
className="settings-page__button"
|
||||
disabled={isSaving}
|
||||
data-testid={`rename-save-${member.uuid}`}
|
||||
>
|
||||
{t('settings.saveRenameButton')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCancelRename}
|
||||
className="settings-page__button settings-page__button--secondary"
|
||||
disabled={isSaving}
|
||||
data-testid={`rename-cancel-${member.uuid}`}
|
||||
>
|
||||
{t('settings.cancelButton')}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span data-testid={`member-name-${member.uuid}`}>
|
||||
{member.name}
|
||||
{isSelf && (
|
||||
<span className="settings-page__you-badge"> {t('settings.youBadge')}</span>
|
||||
)}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handleStartRename(member)}
|
||||
className="settings-page__button settings-page__button--secondary"
|
||||
disabled={isSaving}
|
||||
data-testid={`rename-button-${member.uuid}`}
|
||||
>
|
||||
{t('settings.renameButton')}
|
||||
</button>
|
||||
{!isSelf &&
|
||||
(isConfirmingRemove ? (
|
||||
<span className="settings-page__remove-confirm">
|
||||
{t('settings.removeConfirmPrompt', { name: member.name })}
|
||||
<button
|
||||
onClick={() => handleConfirmRemove(member.uuid)}
|
||||
className="settings-page__button settings-page__button--danger"
|
||||
disabled={isSaving}
|
||||
data-testid={`remove-confirm-${member.uuid}`}
|
||||
>
|
||||
{t('settings.removeConfirmButton')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCancelRemove}
|
||||
className="settings-page__button settings-page__button--secondary"
|
||||
disabled={isSaving}
|
||||
data-testid={`remove-cancel-${member.uuid}`}
|
||||
>
|
||||
{t('settings.cancelButton')}
|
||||
</button>
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => handleRequestRemove(member.uuid)}
|
||||
className="settings-page__button settings-page__button--danger"
|
||||
disabled={isSaving}
|
||||
data-testid={`remove-button-${member.uuid}`}
|
||||
>
|
||||
{t('settings.removePartnerButton')}
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{memberActionError?.uuid === member.uuid && (
|
||||
<p
|
||||
role="alert"
|
||||
className="settings-page__message settings-page__message--error"
|
||||
data-testid={`member-action-error-${member.uuid}`}
|
||||
>
|
||||
{memberActionError.text}
|
||||
</p>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{!hasPartner && (
|
||||
<div className="settings-page__partner-row">
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('settings.partnerNamePlaceholder')}
|
||||
value={newPartnerName}
|
||||
onChange={(e) => setNewPartnerName(e.target.value)}
|
||||
disabled={isSaving}
|
||||
className="settings-page__partner-input"
|
||||
{balanceFormUuid &&
|
||||
(() => {
|
||||
const balanceFormMember = members.find((m) => m.uuid === balanceFormUuid)
|
||||
if (!balanceFormMember) return null
|
||||
if (balanceInitialLoading) {
|
||||
return (
|
||||
<div className="modal__overlay" data-testid="settings-balance-loading">
|
||||
<LoadingSpinner message={t('parentBalance.loadingBalance')} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<ParentBalanceForm
|
||||
parentName={balanceFormMember.name}
|
||||
isSelf={balanceFormMember.uuid === personalDataUuid}
|
||||
totalDays={members.length >= 2 ? 240 : 480}
|
||||
initial={balanceInitial}
|
||||
onSave={handleSaveBalance}
|
||||
onClose={() => setBalanceFormUuid(null)}
|
||||
isSaving={isSavingBalance}
|
||||
error={null}
|
||||
/>
|
||||
<button
|
||||
onClick={handleAddPartner}
|
||||
className="settings-page__button"
|
||||
disabled={isSaving}
|
||||
data-testid="add-partner-button"
|
||||
>
|
||||
{isSaving ? t('settings.addingPartner') : t('settings.addPartnerButton')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
</Layout>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user