20f45d6c7b
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
1110 lines
33 KiB
TypeScript
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 }),
|
|
})
|
|
}
|