Files
F-r-ldraledigplannerare/frontend/src/services/api.ts
T
jacobh2 20f45d6c7b Translate wish names in plan proposal output
Preset wish names follow the UI language in the wish editor, but the plan
proposal still rendered outcome.wishName straight from the stored
entity.name, so a proposal could show "Höstlov" with the UI in English.

The proposal now carries wish_name_message_key alongside each wish name,
and the frontend resolves it with the same helper the editor uses --
falling back to the stored literal when the key is absent or unknown.
Nothing is translated on the backend; text crosses the wire as a key.

Four surfaces carried a name and all four are covered: WindowOutcome
itself, the explanation summary's interpolated data, and two remedies --
including REMEDY_LOWER_PRIORITY_WISH, which names a *different* sibling
wish whose key can differ from the outcome's own.

The proposal response mapping is hand-written and has dropped new fields
twice before, so this is covered by an API-level test asserting the field
reaches the response body, not only a service test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RXMhc9nT6sNz1SL4j8dznL
2026-09-14 22:51:15 +02:00

1110 lines
33 KiB
TypeScript

/**
* Typed API client. All requests go through `apiFetch`, which:
* - Attaches the Authorization header from the stored access token.
* - Parses error bodies into ApiRequestError.
* - Returns typed data (no `any`).
*
* This is the reference implementation — add one function per endpoint group.
*/
import { ApiRequestError } from '@/services/errors'
import type {
LoginRequest,
LeaveWishCreateRequest,
LeaveWishUpdateRequest,
ManualFetchRequest,
ParentalLeaveFetch,
RegisterRequest,
TokenResponse,
VacationDays,
VacationDaysCreateRequest,
WorkspaceCreateRequest,
WorkspaceUpdateRequest,
WorkspaceOverview,
} from '@/types'
import type {
Workspace,
LeaveWish,
PlanProposal,
CostBreakdown,
Tip,
PlannedLeaveDay,
TierData,
Age4Projection,
Child,
PersonalData,
CalendarWindow,
CalendarDayEntry,
} from '@/types'
const BASE = '/api'
// ---------------------------------------------------------------------------
// Wire format converters (snake_case → camelCase)
// ---------------------------------------------------------------------------
export interface RawTierData {
sjukpenning_total: number
sjukpenning_used: number
sjukpenning_left: number
lagsta_niva_total: number
lagsta_niva_used: number
lagsta_niva_left: number
}
function toTierData(raw: RawTierData): TierData {
return {
sjukpenningTotal: raw.sjukpenning_total,
sjukpenningUsed: raw.sjukpenning_used,
sjukpenningLeft: raw.sjukpenning_left,
lagstaNivaTotal: raw.lagsta_niva_total,
lagstaNivaUsed: raw.lagsta_niva_used,
lagstaNivaLeft: raw.lagsta_niva_left,
}
}
export interface RawParentalLeaveFetch {
id: string
personal_data_uuid: string
workspace_id: string
child_id: string
source: 'scraped' | 'manual'
fetched_at: string
total_days: number
days_used: number
days_left: number
reserved_days_total: number
reserved_days_left: number
transferable_days_left: number
dubbeldagar_left: number
tier_data: RawTierData
created_at: string
updated_at: string
}
function toParentalLeaveFetch(raw: RawParentalLeaveFetch): ParentalLeaveFetch {
return {
id: raw.id,
personalDataUuid: raw.personal_data_uuid,
workspaceId: raw.workspace_id,
childId: raw.child_id,
source: raw.source,
fetchedAt: raw.fetched_at,
totalDays: raw.total_days,
daysUsed: raw.days_used,
daysLeft: raw.days_left,
reservedDaysTotal: raw.reserved_days_total,
reservedDaysLeft: raw.reserved_days_left,
transferableDaysLeft: raw.transferable_days_left,
dubbeldagarLeft: raw.dubbeldagar_left,
tierData: toTierData(raw.tier_data),
createdAt: raw.created_at,
updatedAt: raw.updated_at,
}
}
export interface RawAge4Projection {
personal_data_uuid: string
child_id: string
birth_date: string
fourth_birthday: string
total_days: number
days_used: number
planned_days_before_b4: number
projected_left_at_b4: number
savable_after_b4: number
expiring_at_b4: number
}
function toAge4Projection(raw: RawAge4Projection): Age4Projection {
return {
personalDataUuid: raw.personal_data_uuid,
childId: raw.child_id,
birthDate: raw.birth_date,
fourthBirthday: raw.fourth_birthday,
totalDays: raw.total_days,
daysUsed: raw.days_used,
plannedDaysBeforeB4: raw.planned_days_before_b4,
projectedLeftAtB4: raw.projected_left_at_b4,
savableAfterB4: raw.savable_after_b4,
expiringAtB4: raw.expiring_at_b4,
}
}
export interface RawWorkspaceOverview {
workspace_id: string
child_id: string
parent_projections: RawAge4Projection[]
combined_days_used: number
combined_days_left: number
combined_expiring_at_b4: number
}
function toWorkspaceOverview(raw: RawWorkspaceOverview): WorkspaceOverview {
return {
workspaceId: raw.workspace_id,
childId: raw.child_id,
parentProjections: raw.parent_projections.map(toAge4Projection),
combinedDaysUsed: raw.combined_days_used,
combinedDaysLeft: raw.combined_days_left,
combinedExpiringAtB4: raw.combined_expiring_at_b4,
}
}
interface RawVacationDays {
id: string
personal_data_uuid: string
workspace_id: string
year: number
total_days: number
saved_days_in: number
created_at: string | null
updated_at: string | null
}
function toVacationDays(raw: RawVacationDays): VacationDays {
return {
id: raw.id,
personalDataUuid: raw.personal_data_uuid,
workspaceId: raw.workspace_id,
year: raw.year,
totalDays: raw.total_days,
savedDaysIn: raw.saved_days_in,
createdAt: raw.created_at,
updatedAt: raw.updated_at,
}
}
interface RawPlannedLeaveDay {
id: string
workspace_id: string
child_id: string
parent_personal_data_uuid: string
date: string
day_type: 'sjukpenning' | 'lagsta' | 'vacation'
is_dubbeldag: boolean
created_at: string
updated_at: string
}
function toPlannedLeaveDay(raw: RawPlannedLeaveDay): PlannedLeaveDay {
return {
id: raw.id,
workspaceId: raw.workspace_id,
childId: raw.child_id,
parentPersonalDataUuid: raw.parent_personal_data_uuid,
date: raw.date,
dayType: raw.day_type,
isDubbeldag: raw.is_dubbeldag,
createdAt: raw.created_at,
updatedAt: raw.updated_at,
}
}
interface RawLeaveWish {
id: string
workspace_id: string
child_id: string | null
name: string
name_message_key: string | null
window_start_month: number
window_start_day: number
window_end_month: number
window_end_day: number
target_weeks: number
recurrence: 'yearly' | 'once'
first_year: number
last_year: number | null
priority: number
parent_mode: 'either' | 'both' | 'specific'
parent_personal_data_uuid: string | null
goal_override: 'max_time' | 'max_money' | 'balanced' | null
window_mode: 'fixed' | 'flexible'
created_at: string | null
updated_at: string | null
}
function toLeaveWish(raw: RawLeaveWish): LeaveWish {
return {
id: raw.id,
workspaceId: raw.workspace_id,
childId: raw.child_id,
name: raw.name,
nameMessageKey: raw.name_message_key,
windowStartMonth: raw.window_start_month,
windowStartDay: raw.window_start_day,
windowEndMonth: raw.window_end_month,
windowEndDay: raw.window_end_day,
targetWeeks: raw.target_weeks,
recurrence: raw.recurrence,
firstYear: raw.first_year,
lastYear: raw.last_year,
priority: raw.priority,
parentMode: raw.parent_mode,
parentPersonalDataUuid: raw.parent_personal_data_uuid,
goalOverride: raw.goal_override,
windowMode: raw.window_mode ?? 'fixed',
createdAt: raw.created_at,
updatedAt: raw.updated_at,
}
}
interface RawProposedDay {
date: string
parent_personal_data_uuid: string
child_id: string | null
day_type: 'free' | 'vacation' | 'sjukpenning' | 'lagsta'
is_dubbeldag: boolean
wish_id: string
reason: string
}
interface RawShortfallRemedy {
kind: string
message_key: string
data: Record<string, unknown>
days_freed: number
}
interface RawWindowOutcome {
wish_id: string
wish_name: string
wish_name_message_key: string | null
year: number
start_date: string
end_date: string
weeks_wished: number
weeks_funded: number
shortfall_days: number
strategy: string
message_key: string
data: Record<string, unknown>
cost_breakdown: Record<string, number>
remedies: RawShortfallRemedy[]
}
interface RawPlanWarning {
kind: string
message_key: string
data: Record<string, unknown>
}
interface RawPoolBalance {
parent_personal_data_uuid: string
child_id: string
sjukpenning_left: number
lagsta_left: number
dubbeldagar_left: number
}
interface RawVacationBalance {
parent_personal_data_uuid: string
year: number
total_days: number
saved_days_in: number
already_used: number
is_assumed: boolean
}
interface RawSurplusAdvice {
kind: string
message_key: string
days_involved: number
data: Record<string, unknown>
}
interface RawPlanAge4Projection {
parent_personal_data_uuid: string
child_id: string
fourth_birthday: string
projected_left_at_b4: number
savable_after_b4: number
expiring_at_b4: number
}
interface RawPlanProposal {
days: RawProposedDay[]
outcomes: RawWindowOutcome[]
warnings: RawPlanWarning[]
pool_balances: RawPoolBalance[]
vacation_balances: RawVacationBalance[]
surplus_advice: RawSurplusAdvice[]
age4_projections: RawPlanAge4Projection[]
}
function toCostBreakdown(raw: Record<string, number>): CostBreakdown {
return {
free: raw.free ?? 0,
vacation: raw.vacation ?? 0,
sjukpenning: raw.sjukpenning ?? 0,
lagsta: raw.lagsta ?? 0,
}
}
function toPlanProposal(raw: RawPlanProposal): PlanProposal {
return {
days: raw.days.map((d) => ({
date: d.date,
parentPersonalDataUuid: d.parent_personal_data_uuid,
childId: d.child_id,
dayType: d.day_type,
isDubbeldag: d.is_dubbeldag,
wishId: d.wish_id,
reason: d.reason,
})),
outcomes: raw.outcomes.map((o) => ({
wishId: o.wish_id,
wishName: o.wish_name,
wishNameMessageKey: o.wish_name_message_key,
year: o.year,
startDate: o.start_date,
endDate: o.end_date,
weeksWished: o.weeks_wished,
weeksFunded: o.weeks_funded,
shortfallDays: o.shortfall_days,
strategy: o.strategy,
messageKey: o.message_key,
data: o.data,
costBreakdown: toCostBreakdown(o.cost_breakdown),
remedies: o.remedies.map((r) => ({
kind: r.kind,
messageKey: r.message_key,
data: r.data,
daysFreed: r.days_freed,
})),
})),
warnings: raw.warnings.map((w) => ({ kind: w.kind, messageKey: w.message_key, data: w.data })),
poolBalances: raw.pool_balances.map((p) => ({
parentPersonalDataUuid: p.parent_personal_data_uuid,
childId: p.child_id,
sjukpenningLeft: p.sjukpenning_left,
lagstaLeft: p.lagsta_left,
dubbeldagarLeft: p.dubbeldagar_left,
})),
vacationBalances: raw.vacation_balances.map((v) => ({
parentPersonalDataUuid: v.parent_personal_data_uuid,
year: v.year,
totalDays: v.total_days,
savedDaysIn: v.saved_days_in,
alreadyUsed: v.already_used,
isAssumed: v.is_assumed,
})),
surplusAdvice: raw.surplus_advice.map((a) => ({
kind: a.kind,
messageKey: a.message_key,
daysInvolved: a.days_involved,
data: a.data,
})),
age4Projections: raw.age4_projections.map((p) => ({
parentPersonalDataUuid: p.parent_personal_data_uuid,
childId: p.child_id,
fourthBirthday: p.fourth_birthday,
projectedLeftAtB4: p.projected_left_at_b4,
savableAfterB4: p.savable_after_b4,
expiringAtB4: p.expiring_at_b4,
})),
}
}
interface RawPersonalData {
uuid: string
workspace_id: string
name: string
salary_type: 'monthly' | 'hourly'
employment_status: 'employed' | 'jobseeking' | 'none'
created_at: string | null
}
function toPersonalData(raw: RawPersonalData): PersonalData {
return {
uuid: raw.uuid,
workspaceId: raw.workspace_id,
name: raw.name,
salaryType: raw.salary_type,
employmentStatus: raw.employment_status,
createdAt: raw.created_at ?? '',
}
}
interface RawWorkspace {
id: string
name: string
planning_goal: 'max_time' | 'max_money' | 'balanced'
planning_mode: 'solo' | 'couple'
created_at: string
}
function toWorkspace(raw: RawWorkspace): Workspace {
return {
id: raw.id,
name: raw.name,
planningGoal: raw.planning_goal,
planningMode: raw.planning_mode,
createdAt: raw.created_at,
}
}
interface RawChild {
id: string
workspace_id: string
name: string
birth_date: string
preschool_start_date: string | null
preschool_planning_days_per_year: number
created_at: string | null
}
function toChild(raw: RawChild): Child {
return {
id: raw.id,
workspaceId: raw.workspace_id,
name: raw.name,
birthDate: raw.birth_date,
preschoolStartDate: raw.preschool_start_date,
preschoolPlanningDaysPerYear: raw.preschool_planning_days_per_year,
createdAt: raw.created_at ?? '',
}
}
interface RawDayOverride {
id: string
workspace_id: string
date: string
is_working_day: boolean
note: string | null
created_at: string | null
updated_at: string | null
}
function toDayOverride(raw: RawDayOverride): import('@/types').DayOverride {
return {
id: raw.id,
workspaceId: raw.workspace_id,
date: raw.date,
isWorkingDay: raw.is_working_day,
note: raw.note,
createdAt: raw.created_at,
updatedAt: raw.updated_at,
}
}
let _accessToken: string | null = null
export function setAccessToken(token: string | null): void {
_accessToken = token
}
async function apiFetch<T>(
path: string,
options: RequestInit = {},
): Promise<T> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...(options.headers as Record<string, string> | undefined),
}
if (_accessToken) {
headers['Authorization'] = `Bearer ${_accessToken}`
}
const response = await fetch(`${BASE}${path}`, { ...options, headers })
if (!response.ok) {
let error: { code: string; message: string; details?: Record<string, unknown> } = {
code: String(response.status),
message: response.statusText,
}
try {
const body = (await response.json()) as {
detail?: string | Array<{ msg?: string }> | { message_key: string; data?: Record<string, unknown> }
code?: string
message?: string
}
if (
body.detail &&
typeof body.detail === 'object' &&
!Array.isArray(body.detail) &&
'message_key' in body.detail
) {
// Structured `{message_key, data}` error (see workspace.py's
// exception mapping) — kept out of `message` so callers never
// accidentally render the raw key; `details.messageKey`/`data` is
// the contract callers (e.g. Settings.tsx's `describePartnerError`)
// read instead.
const structured = body.detail
error = {
code: structured.message_key,
message: structured.message_key,
details: { messageKey: structured.message_key, data: structured.data ?? {} },
}
} else {
const detail = Array.isArray(body.detail)
? body.detail.map((e) => e.msg ?? JSON.stringify(e)).join('; ')
: body.detail
error = {
code: body.code ?? String(response.status),
message: detail ?? body.message ?? response.statusText,
}
}
} catch {
// response body wasn't JSON — use status text
}
throw new ApiRequestError(response.status, error)
}
if (response.status === 204) return undefined as T
return response.json() as Promise<T>
}
// ---------------------------------------------------------------------------
// Auth
// ---------------------------------------------------------------------------
export async function register(req: RegisterRequest): Promise<TokenResponse> {
return apiFetch('/auth/register', {
method: 'POST',
body: JSON.stringify({
email: req.email,
password: req.password,
display_name: req.displayName,
}),
})
}
export async function login(req: LoginRequest): Promise<TokenResponse> {
return apiFetch('/auth/login', {
method: 'POST',
body: JSON.stringify({ email: req.email, password: req.password }),
})
}
export async function logout(): Promise<void> {
return apiFetch('/auth/logout', { method: 'POST' })
}
export async function refreshToken(): Promise<TokenResponse> {
return apiFetch('/auth/refresh', { method: 'POST' })
}
export interface UserContext {
personal_data_uuid: string
workspace_id: string
parent_name: string
children: Array<{ id: string; name: string; birth_date: string }>
}
export async function getUserContext(): Promise<UserContext> {
return apiFetch('/auth/context')
}
export interface CreateChildRequest {
workspace_id: string
name: string
birth_date: string
}
export async function createChild(req: CreateChildRequest): Promise<{ id: string; name: string }> {
return apiFetch('/children', {
method: 'POST',
body: JSON.stringify(req),
})
}
export async function getChild(childId: string): Promise<Child> {
const raw = (await apiFetch(`/children/${childId}`)) as RawChild
return toChild(raw)
}
// ---------------------------------------------------------------------------
// Workspace
// ---------------------------------------------------------------------------
export async function createWorkspace(req: WorkspaceCreateRequest): Promise<Workspace> {
return apiFetch('/workspace', { method: 'POST', body: JSON.stringify({ name: req.name }) })
}
export async function getWorkspace(workspaceId: string): Promise<Workspace> {
const raw = (await apiFetch(`/workspace/${workspaceId}`)) as RawWorkspace
return toWorkspace(raw)
}
export async function updateWorkspace(
workspaceId: string,
req: WorkspaceUpdateRequest,
): Promise<Workspace> {
const raw = (await apiFetch(`/workspace/${workspaceId}`, {
method: 'PUT',
body: JSON.stringify({
name: req.name,
planning_goal: req.planningGoal,
planning_mode: req.planningMode,
}),
})) as RawWorkspace
return toWorkspace(raw)
}
export async function addPartner(
workspaceId: string,
displayName: string,
): Promise<{ uuid: string; name: string; salary_type: string; employment_status: string }> {
return apiFetch(`/workspace/${workspaceId}/parents`, {
method: 'POST',
body: JSON.stringify({ display_name: displayName }),
})
}
// ---------------------------------------------------------------------------
// Vacation days (reference endpoint group — mirror this pattern for others)
// ---------------------------------------------------------------------------
export async function upsertVacationDays(req: VacationDaysCreateRequest): Promise<VacationDays> {
return apiFetch('/vacation-days', {
method: 'POST',
body: JSON.stringify({
personal_data_uuid: req.personalDataUuid,
workspace_id: req.workspaceId,
year: req.year,
total_days: req.totalDays,
saved_days_in: req.savedDaysIn ?? 0,
}),
})
}
export async function getVacationDaysByWorkspace(workspaceId: string): Promise<VacationDays[]> {
const raw = (await apiFetch(`/vacation-days/workspace/${workspaceId}`)) as RawVacationDays[]
return raw.map(toVacationDays)
}
// ---------------------------------------------------------------------------
// Parental leave
// ---------------------------------------------------------------------------
export async function saveManualFetch(req: ManualFetchRequest): Promise<ParentalLeaveFetch> {
const raw = (await apiFetch('/parental-leave/manual', {
method: 'POST',
body: JSON.stringify({
personal_data_uuid: req.personalDataUuid,
workspace_id: req.workspaceId,
child_id: req.childId,
total_days: req.totalDays,
days_used: req.daysUsed,
reserved_days_total: req.reservedDaysTotal ?? 90,
reserved_days_left: req.reservedDaysLeft,
transferable_days_left: req.transferableDaysLeft,
dubbeldagar_left: req.dubbeldagarLeft ?? 60,
sjukpenning_total: req.sjukpenningTotal ?? 390,
sjukpenning_used: req.sjukpenningUsed,
lagsta_niva_total: req.lagstaNivaTotal ?? 90,
lagsta_niva_used: req.lagstaNivaUsed,
}),
})) as RawParentalLeaveFetch
return toParentalLeaveFetch(raw)
}
// ---------------------------------------------------------------------------
// Analytics
// ---------------------------------------------------------------------------
export async function getWorkspaceOverview(
workspaceId: string,
childId: string,
): Promise<WorkspaceOverview> {
const raw = (await apiFetch(`/analytics/${workspaceId}/overview?child_id=${childId}`)) as RawWorkspaceOverview
return toWorkspaceOverview(raw)
}
// ---------------------------------------------------------------------------
// Settings
// ---------------------------------------------------------------------------
interface ParentSettingsUpdate {
salary_type?: string | undefined
employment_status?: string | undefined
}
export async function updateParentSettings(
personalDataUuid: string,
payload: ParentSettingsUpdate,
): Promise<{ uuid: string; name: string; salary_type: string; employment_status: string }> {
return apiFetch(`/settings/parent/${personalDataUuid}`, {
method: 'PUT',
body: JSON.stringify(payload),
})
}
interface ChildSettingsUpdate {
birth_date?: string
preschool_start_date?: string
preschool_planning_days_per_year?: number
}
export async function updateChildSettings(
childId: string,
payload: ChildSettingsUpdate,
): Promise<Child> {
const raw = (await apiFetch(`/settings/child/${childId}`, {
method: 'PUT',
body: JSON.stringify(payload),
})) as RawChild
return toChild(raw)
}
// ---------------------------------------------------------------------------
// Leave wishes ("N weeks off inside this yearly window") + planning engine
// ---------------------------------------------------------------------------
export async function getLeaveWishes(workspaceId: string): Promise<LeaveWish[]> {
const raw = (await apiFetch(`/leave-wishes/${workspaceId}`)) as RawLeaveWish[]
return raw.map(toLeaveWish)
}
export async function createLeaveWish(payload: LeaveWishCreateRequest): Promise<LeaveWish> {
const raw = (await apiFetch('/leave-wishes', {
method: 'POST',
body: JSON.stringify({
workspace_id: payload.workspaceId,
child_id: payload.childId ?? null,
name: payload.name,
name_message_key: payload.nameMessageKey ?? null,
window_start_month: payload.windowStartMonth,
window_start_day: payload.windowStartDay,
window_end_month: payload.windowEndMonth,
window_end_day: payload.windowEndDay,
target_weeks: payload.targetWeeks,
recurrence: payload.recurrence,
first_year: payload.firstYear,
last_year: payload.lastYear ?? null,
priority: payload.priority ?? 1,
parent_mode: payload.parentMode ?? 'either',
parent_personal_data_uuid: payload.parentPersonalDataUuid ?? null,
goal_override: payload.goalOverride ?? null,
window_mode: payload.windowMode ?? 'fixed',
}),
})) as RawLeaveWish
return toLeaveWish(raw)
}
export async function updateLeaveWish(
id: string,
payload: LeaveWishUpdateRequest,
): Promise<LeaveWish> {
const body: Record<string, unknown> = {}
if (payload.childId !== undefined) body.child_id = payload.childId
if (payload.name !== undefined) body.name = payload.name
if (payload.nameMessageKey !== undefined) body.name_message_key = payload.nameMessageKey
if (payload.windowStartMonth !== undefined) body.window_start_month = payload.windowStartMonth
if (payload.windowStartDay !== undefined) body.window_start_day = payload.windowStartDay
if (payload.windowEndMonth !== undefined) body.window_end_month = payload.windowEndMonth
if (payload.windowEndDay !== undefined) body.window_end_day = payload.windowEndDay
if (payload.targetWeeks !== undefined) body.target_weeks = payload.targetWeeks
if (payload.recurrence !== undefined) body.recurrence = payload.recurrence
if (payload.firstYear !== undefined) body.first_year = payload.firstYear
if (payload.lastYear !== undefined) body.last_year = payload.lastYear
if (payload.priority !== undefined) body.priority = payload.priority
if (payload.parentMode !== undefined) body.parent_mode = payload.parentMode
if (payload.parentPersonalDataUuid !== undefined) {
body.parent_personal_data_uuid = payload.parentPersonalDataUuid
}
if (payload.goalOverride !== undefined) body.goal_override = payload.goalOverride
if (payload.windowMode !== undefined) body.window_mode = payload.windowMode
const raw = (await apiFetch(`/leave-wishes/${id}`, {
method: 'PUT',
body: JSON.stringify(body),
})) as RawLeaveWish
return toLeaveWish(raw)
}
export async function deleteLeaveWish(id: string): Promise<void> {
return apiFetch(`/leave-wishes/${id}`, { method: 'DELETE' })
}
/** Read-only preview of the planning engine's proposal — persists nothing. */
export async function proposePlan(workspaceId: string): Promise<PlanProposal> {
const raw = (await apiFetch('/plan/propose', {
method: 'POST',
body: JSON.stringify({ workspace_id: workspaceId }),
})) as RawPlanProposal
return toPlanProposal(raw)
}
interface RawDayCollision {
date: string
parent_personal_data_uuid: string
child_id: string
existing_day_type: string
proposed_day_type: string
}
interface RawRejection {
message_key: string
data: Record<string, unknown>
}
interface RawPlanApplyResponse {
accepted: boolean
created_count: number
skipped_identical_count: number
skipped_free_count: number
replaced_count: number
collisions: RawDayCollision[]
rejections: RawRejection[]
created_days: object[]
}
function toPlanApplyResponse(raw: RawPlanApplyResponse): import('@/types').PlanApplyResponse {
return {
accepted: raw.accepted,
createdCount: raw.created_count,
skippedIdenticalCount: raw.skipped_identical_count,
skippedFreeCount: raw.skipped_free_count,
replacedCount: raw.replaced_count,
collisions: raw.collisions.map((c) => ({
date: c.date,
parentPersonalDataUuid: c.parent_personal_data_uuid,
childId: c.child_id,
existingDayType: c.existing_day_type,
proposedDayType: c.proposed_day_type,
})),
rejections: raw.rejections.map((r) => ({ messageKey: r.message_key, data: r.data })),
createdDays: raw.created_days,
}
}
export async function applyPlan(
workspaceId: string,
days: import('@/types').ProposedDayApplyItem[],
replace: boolean,
): Promise<import('@/types').PlanApplyResponse> {
const raw = (await apiFetch('/plan/apply', {
method: 'POST',
body: JSON.stringify({
workspace_id: workspaceId,
days: days.map((d) => ({
date: d.date,
parent_personal_data_uuid: d.parentPersonalDataUuid,
child_id: d.childId,
day_type: d.dayType,
is_dubbeldag: d.isDubbeldag ?? false,
})),
replace,
}),
})) as RawPlanApplyResponse
return toPlanApplyResponse(raw)
}
// ---------------------------------------------------------------------------
// Workspace members (used to resolve/select which parent a wish belongs to)
// ---------------------------------------------------------------------------
export async function listWorkspaceMembers(workspaceId: string): Promise<PersonalData[]> {
const raw = (await apiFetch(`/workspace/${workspaceId}/members`)) as RawPersonalData[]
return raw.map(toPersonalData)
}
/** Renames a workspace member (partner or the logged-in user themselves —
* the backend deliberately allows self-rename through this route). */
export async function renameWorkspaceParent(
workspaceId: string,
personalDataUuid: string,
displayName: string,
): Promise<PersonalData> {
const raw = (await apiFetch(`/workspace/${workspaceId}/parents/${personalDataUuid}`, {
method: 'PATCH',
body: JSON.stringify({ display_name: displayName }),
})) as RawPersonalData
return toPersonalData(raw)
}
/** Soft-deletes a workspace member. Refused (422) for the caller's own
* profile, a member with their own login, or one with dependent data —
* see `describePartnerError` in `Settings.tsx` for how those surface. */
export async function removeWorkspaceParent(
workspaceId: string,
personalDataUuid: string,
): Promise<void> {
return apiFetch(`/workspace/${workspaceId}/parents/${personalDataUuid}`, {
method: 'DELETE',
})
}
// ---------------------------------------------------------------------------
// Tips
// ---------------------------------------------------------------------------
export async function getTips(workspaceId: string): Promise<Tip[]> {
return apiFetch(`/tips/${workspaceId}`)
}
// ---------------------------------------------------------------------------
// Parental Leave Fetch
// ---------------------------------------------------------------------------
export async function getParentalLeave(
personalDataUuid: string,
childId: string,
): Promise<ParentalLeaveFetch> {
const raw = (await apiFetch(`/parental-leave/${personalDataUuid}/${childId}`)) as RawParentalLeaveFetch
return toParentalLeaveFetch(raw)
}
// ---------------------------------------------------------------------------
// Planned Days
// ---------------------------------------------------------------------------
export async function getPlannedDays(childId?: string): Promise<PlannedLeaveDay[]> {
const query = childId ? `?child_id=${childId}` : ''
const raw = (await apiFetch(`/planned-days${query}`)) as RawPlannedLeaveDay[]
return raw.map(toPlannedLeaveDay)
}
export interface CreatePlannedDayPayload {
workspace_id: string
child_id: string
parent_personal_data_uuid: string
date: string
day_type: 'sjukpenning' | 'lagsta' | 'vacation'
is_dubbeldag?: boolean
}
export async function createPlannedDay(payload: CreatePlannedDayPayload): Promise<PlannedLeaveDay> {
const raw = (await apiFetch(`/planned-days`, {
method: 'POST',
body: JSON.stringify(payload),
})) as RawPlannedLeaveDay
return toPlannedLeaveDay(raw)
}
export interface UpdatePlannedDayPayload {
day_type?: 'sjukpenning' | 'lagsta' | 'vacation'
is_dubbeldag?: boolean
}
export async function updatePlannedDay(id: string, payload: UpdatePlannedDayPayload): Promise<PlannedLeaveDay> {
const raw = (await apiFetch(`/planned-days/${id}`, {
method: 'PUT',
body: JSON.stringify(payload),
})) as RawPlannedLeaveDay
return toPlannedLeaveDay(raw)
}
export async function deletePlannedDay(id: string): Promise<void> {
return apiFetch(`/planned-days/${id}`, { method: 'DELETE' })
}
// ---------------------------------------------------------------------------
// Day overrides (calendar exceptions)
// ---------------------------------------------------------------------------
export async function upsertDayOverride(
req: import('@/types').DayOverrideCreateRequest,
): Promise<import('@/types').DayOverride> {
const raw = (await apiFetch('/day-overrides', {
method: 'POST',
body: JSON.stringify({
workspace_id: req.workspaceId,
date: req.date,
is_working_day: req.isWorkingDay,
note: req.note ?? null,
}),
})) as RawDayOverride
return toDayOverride(raw)
}
export async function listDayOverridesByWorkspace(workspaceId: string): Promise<import('@/types').DayOverride[]> {
const raw = (await apiFetch(`/day-overrides/workspace/${workspaceId}`)) as RawDayOverride[]
return raw.map(toDayOverride)
}
export async function deleteDayOverride(id: string): Promise<void> {
return apiFetch(`/day-overrides/${id}`, { method: 'DELETE' })
}
// ---------------------------------------------------------------------------
// Calendar (röda dagar / klämdagar) — GET /api/calendar/days, backs §3.4
// (Kalender shading), §3.6 (click-to-override) and §3.8 (Settings red-days
// list) from one endpoint. See ARCHITECTURE.md §13.3.
// ---------------------------------------------------------------------------
interface RawCalendarDay {
date: string
name: string | null
day_type: 'red_day' | 'klamdag' | 'weekend' | 'working_day'
is_override: boolean
override_is_working_day: boolean | null
is_free: boolean
}
interface RawCalendarWindow {
start_year: number
end_year: number
min_year: number | null
max_year: number | null
days: RawCalendarDay[]
}
function toCalendarDayEntry(raw: RawCalendarDay): CalendarDayEntry {
return {
date: raw.date,
name: raw.name,
dayType: raw.day_type,
isOverride: raw.is_override,
overrideIsWorkingDay: raw.override_is_working_day,
isFree: raw.is_free,
}
}
function toCalendarWindow(raw: RawCalendarWindow): CalendarWindow {
return {
startYear: raw.start_year,
endYear: raw.end_year,
minYear: raw.min_year,
maxYear: raw.max_year,
days: raw.days.map(toCalendarDayEntry),
}
}
/** Fetches `years` years (1-4, capped server-side) of calendar metadata
* starting at `startYear`, merged with the workspace's day overrides. */
export async function getCalendarDays(
workspaceId: string,
startYear: number,
years: number = 4,
): Promise<CalendarWindow> {
const raw = (await apiFetch(
`/calendar/days?workspace_id=${encodeURIComponent(workspaceId)}&start_year=${startYear}&years=${years}`,
)) as RawCalendarWindow
return toCalendarWindow(raw)
}
// ---------------------------------------------------------------------------
// FK Scraper (dev / authenticated users only — requires scraper_enabled=true)
// ---------------------------------------------------------------------------
interface ScraperStartRequest {
workspace_id?: string
personal_data_uuid?: string
child_id?: string
}
interface ScraperStartResponse {
session_id: string
status: string
workspace_id: string
personal_data_uuid: string
child_id: string
}
export async function startScraperSession(
payload: ScraperStartRequest,
): Promise<ScraperStartResponse> {
return apiFetch('/fetch-data/start', { method: 'POST', body: JSON.stringify(payload) })
}
export async function pollScraperSession(sessionId: string): Promise<unknown> {
return apiFetch(`/fetch-data/poll?session_id=${encodeURIComponent(sessionId)}`)
}
export async function cancelScraperSession(sessionId: string): Promise<void> {
return apiFetch('/fetch-data/cancel', {
method: 'POST',
body: JSON.stringify({ session_id: sessionId }),
})
}