6a7c0ef255
A six-week summer used to render as a row of disconnected bars, because bars were derived per week row and grouped by parent and day type. They carried no identity and ignored pointer events, so there was nothing to click. Bands now group by the wish that produced them, bridge the weekends and red days inside a period, and round only the ends that are genuinely the first and last day. Each day still shows what pays for it, and a hand-edited day is marked as one the planner will leave alone. Sundays and public holidays now render red and Saturdays a neutral grey, following the printed almanacka people compare against, and a holiday's name is visible in the cell rather than hidden in a tooltip. This also explains the report that months looked unshaded: weekends had no styling at all, since the day component handled only red days and klämdagar. The year selector follows the range the calendar API actually serves instead of a hardcoded span.
1107 lines
33 KiB
TypeScript
1107 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
|
|
wish_id: string | null
|
|
is_pinned: 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,
|
|
wishId: raw.wish_id,
|
|
isPinned: raw.is_pinned,
|
|
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
|
|
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,
|
|
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_mode: 'solo' | 'couple'
|
|
created_at: string
|
|
}
|
|
|
|
function toWorkspace(raw: RawWorkspace): Workspace {
|
|
return {
|
|
id: raw.id,
|
|
name: raw.name,
|
|
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_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,
|
|
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.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 }),
|
|
})
|
|
}
|