0e9b20ec37
Three strands of work that grew together across the same settings screen and
API client, so they land as one change.
Per-year preschool planning days ("studiedagar"): new table, repository
(SQLite + Postgres), service, router and migration 007, plus the hooks and
Settings UI to plan and record them per child and year. Allocation now
reserves the recorded figure for a year where one exists and only falls back
to the child-level estimate otherwise, marking that fallback as assumed.
Partner invitations: workspaces issue and list invitations, registration
accepts an invite token to join the inviter's workspace instead of creating a
personal one, and a /join/:token page plus the settings invite panel drive it.
Vacation forfeiture: tips and the dashboard now distinguish days a parent
genuinely loses from days that merely age, so the warning stops firing on
balances that were never at risk.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1535 lines
47 KiB
TypeScript
1535 lines
47 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,
|
|
LeaveWishScope,
|
|
LeaveWishUpdateRequest,
|
|
ManualFetchRequest,
|
|
ParentalLeaveFetch,
|
|
RegisterRequest,
|
|
TokenResponse,
|
|
VacationDays,
|
|
VacationDaysCreateRequest,
|
|
WorkspaceCreateRequest,
|
|
WorkspaceUpdateRequest,
|
|
WorkspaceOverview,
|
|
PlanSyncResponse,
|
|
PreschoolPlanningDaysPlannedRequest,
|
|
PreschoolPlanningDaysTakenRequest,
|
|
CreateInvitationResponse,
|
|
} from '@/types'
|
|
import type {
|
|
Workspace,
|
|
LeaveWish,
|
|
PlanProposal,
|
|
PlanChange,
|
|
CostBreakdown,
|
|
Tip,
|
|
PlannedLeaveDay,
|
|
TierData,
|
|
Age4Projection,
|
|
Child,
|
|
PersonalData,
|
|
CalendarWindow,
|
|
CalendarDayEntry,
|
|
VacationYearBalance,
|
|
ParentalLeaveYearBalance,
|
|
ParentYearBalance,
|
|
YearTotals,
|
|
PreschoolPlanningDays,
|
|
Invitation,
|
|
} 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' | 'deduced'
|
|
fetched_at: string
|
|
total_days: number
|
|
days_used: number
|
|
days_left: number
|
|
reserved_days_total: number
|
|
reserved_days_left: number
|
|
transferable_days_left: number
|
|
transferable_days_reported: boolean
|
|
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,
|
|
transferableDaysReported: raw.transferable_days_reported,
|
|
dubbeldagarLeft: raw.dubbeldagar_left,
|
|
tierData: toTierData(raw.tier_data),
|
|
createdAt: raw.created_at,
|
|
updatedAt: raw.updated_at,
|
|
}
|
|
}
|
|
|
|
export interface RawVacationYearBalance {
|
|
year: number
|
|
earned: number
|
|
is_assumed: boolean
|
|
carried_in: number
|
|
planned_use: number
|
|
left: number
|
|
carried_out: number
|
|
expiring: number
|
|
expiring_age: number
|
|
expiring_over_cap: number
|
|
}
|
|
|
|
function toVacationYearBalance(raw: RawVacationYearBalance): VacationYearBalance {
|
|
return {
|
|
year: raw.year,
|
|
earned: raw.earned,
|
|
isAssumed: raw.is_assumed,
|
|
carriedIn: raw.carried_in,
|
|
plannedUse: raw.planned_use,
|
|
left: raw.left,
|
|
carriedOut: raw.carried_out,
|
|
expiring: raw.expiring,
|
|
expiringAge: raw.expiring_age,
|
|
expiringOverCap: raw.expiring_over_cap,
|
|
}
|
|
}
|
|
|
|
export interface RawParentalLeaveYearBalance {
|
|
year: number
|
|
sjukpenning_used: number
|
|
lagsta_used: number
|
|
sjukpenning_left: number
|
|
lagsta_left: number
|
|
}
|
|
|
|
function toParentalLeaveYearBalance(
|
|
raw: RawParentalLeaveYearBalance,
|
|
): ParentalLeaveYearBalance {
|
|
return {
|
|
year: raw.year,
|
|
sjukpenningUsed: raw.sjukpenning_used,
|
|
lagstaUsed: raw.lagsta_used,
|
|
sjukpenningLeft: raw.sjukpenning_left,
|
|
lagstaLeft: raw.lagsta_left,
|
|
}
|
|
}
|
|
|
|
export interface RawParentYearBalance {
|
|
personal_data_uuid: string
|
|
child_id: string
|
|
year: number
|
|
vacation: RawVacationYearBalance
|
|
parental_leave: RawParentalLeaveYearBalance
|
|
}
|
|
|
|
function toParentYearBalance(raw: RawParentYearBalance): ParentYearBalance {
|
|
return {
|
|
personalDataUuid: raw.personal_data_uuid,
|
|
childId: raw.child_id,
|
|
year: raw.year,
|
|
vacation: toVacationYearBalance(raw.vacation),
|
|
parentalLeave: toParentalLeaveYearBalance(raw.parental_leave),
|
|
}
|
|
}
|
|
|
|
export interface RawYearTotals {
|
|
year: number
|
|
pl_left: number
|
|
vacation_left: number
|
|
}
|
|
|
|
function toYearTotals(raw: RawYearTotals): YearTotals {
|
|
return {
|
|
year: raw.year,
|
|
plLeft: raw.pl_left,
|
|
vacationLeft: raw.vacation_left,
|
|
}
|
|
}
|
|
|
|
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
|
|
savable_cap_at_b4: number
|
|
year_balances: RawParentYearBalance[]
|
|
}
|
|
|
|
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,
|
|
savableCapAtB4: raw.savable_cap_at_b4,
|
|
yearBalances: raw.year_balances.map(toParentYearBalance),
|
|
}
|
|
}
|
|
|
|
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
|
|
year_totals: RawYearTotals[]
|
|
}
|
|
|
|
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,
|
|
yearTotals: raw.year_totals.map(toYearTotals),
|
|
}
|
|
}
|
|
|
|
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,
|
|
}
|
|
}
|
|
|
|
export interface RawTip {
|
|
id: string
|
|
severity: 'info' | 'warning' | 'action'
|
|
message_key: string
|
|
data: Record<string, unknown>
|
|
anchor_date: string | null
|
|
anchor_range: [string, string] | null
|
|
}
|
|
|
|
function toTip(raw: RawTip): Tip {
|
|
return {
|
|
id: raw.id,
|
|
severity: raw.severity,
|
|
messageKey: raw.message_key,
|
|
data: raw.data,
|
|
anchorDate: raw.anchor_date,
|
|
anchorRange: raw.anchor_range,
|
|
}
|
|
}
|
|
|
|
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
|
|
target_days?: number | null
|
|
first_year: number
|
|
last_year: number | null
|
|
series_id: string | null
|
|
priority: number
|
|
parent_mode: 'either' | 'both' | 'specific'
|
|
parent_personal_data_uuid: string | null
|
|
window_mode: 'fixed' | 'flexible'
|
|
wants_dubbeldag?: boolean
|
|
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,
|
|
targetDays: raw.target_days ?? null,
|
|
firstYear: raw.first_year,
|
|
seriesId: raw.series_id ?? null,
|
|
lastYear: raw.last_year,
|
|
priority: raw.priority,
|
|
parentMode: raw.parent_mode,
|
|
parentPersonalDataUuid: raw.parent_personal_data_uuid,
|
|
windowMode: raw.window_mode ?? 'fixed',
|
|
wantsDubbeldag: raw.wants_dubbeldag ?? false,
|
|
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
|
|
created_at: string
|
|
take_pl_on_red_days: boolean
|
|
}
|
|
|
|
function toWorkspace(raw: RawWorkspace): Workspace {
|
|
return {
|
|
id: raw.id,
|
|
name: raw.name,
|
|
createdAt: raw.created_at,
|
|
takePlOnRedDays: raw.take_pl_on_red_days,
|
|
}
|
|
}
|
|
|
|
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,
|
|
}
|
|
}
|
|
|
|
interface RawPlannedDayExclusion {
|
|
id: string
|
|
workspace_id: string
|
|
child_id: string
|
|
parent_personal_data_uuid: string
|
|
date: string
|
|
created_at: string | null
|
|
updated_at: string | null
|
|
}
|
|
|
|
function toPlannedDayExclusion(
|
|
raw: RawPlannedDayExclusion,
|
|
): import('@/types').PlannedDayExclusion {
|
|
return {
|
|
id: raw.id,
|
|
workspaceId: raw.workspace_id,
|
|
childId: raw.child_id,
|
|
parentPersonalDataUuid: raw.parent_personal_data_uuid,
|
|
date: raw.date,
|
|
createdAt: raw.created_at,
|
|
updatedAt: raw.updated_at,
|
|
}
|
|
}
|
|
|
|
let _accessToken: string | null = null
|
|
|
|
export function setAccessToken(token: string | null): void {
|
|
_accessToken = token
|
|
}
|
|
|
|
// Endpoints that must never trigger a refresh-and-retry on 401 — retrying
|
|
// them would either be nonsensical (the refresh call retrying itself) or
|
|
// mask the real "not logged in" outcome (login/logout).
|
|
const AUTH_NO_RETRY_PATHS = new Set(['/auth/login', '/auth/refresh', '/auth/logout'])
|
|
|
|
// Dedupe concurrent 401s (e.g. Dashboard's ~7 parallel requests all
|
|
// expiring at once) into a single in-flight refresh call, shared across
|
|
// every apiFetch caller.
|
|
let refreshPromise: Promise<TokenResponse> | null = null
|
|
|
|
// AuthContext registers this so a refresh-and-retry failure can log the
|
|
// user out immediately, instead of leaving `isAuthenticated: true` while
|
|
// every subsequent request 401s. Kept out of this module so api.ts stays
|
|
// framework-agnostic.
|
|
let onUnauthorized: (() => void) | null = null
|
|
|
|
export function setUnauthorizedHandler(handler: (() => void) | null): void {
|
|
onUnauthorized = handler
|
|
}
|
|
|
|
async function apiFetch<T>(
|
|
path: string,
|
|
options: RequestInit = {},
|
|
isRetry = false,
|
|
): 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.status === 401 && !isRetry && !AUTH_NO_RETRY_PATHS.has(path)) {
|
|
let refreshed: TokenResponse | null = null
|
|
try {
|
|
refreshPromise ??= refreshToken().finally(() => {
|
|
refreshPromise = null
|
|
})
|
|
refreshed = await refreshPromise
|
|
} catch {
|
|
// Refresh itself failed (expired/invalid refresh cookie, or a
|
|
// network error) — clear the token and let the caller's original
|
|
// 401 surface below so AuthContext can log the user out.
|
|
setAccessToken(null)
|
|
onUnauthorized?.()
|
|
}
|
|
if (refreshed) {
|
|
setAccessToken(refreshed.access_token)
|
|
// Retry exactly once, outside the try/catch above — `isRetry: true`
|
|
// means a 401 on this attempt is final and must propagate as-is
|
|
// rather than being caught here and re-reported as the original
|
|
// (now stale) 401.
|
|
return await apiFetch<T>(path, options, true)
|
|
}
|
|
}
|
|
|
|
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; loc?: unknown[] }>
|
|
| { 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
|
|
// FastAPI's default pydantic 422 shape puts the offending field name
|
|
// in each error's `loc` (e.g. ['body', 'sjukpenning_left']), not in
|
|
// `msg` — surface the last segment of each `loc` as `details.fields`
|
|
// so a caller (e.g. ParentBalanceForm) can map the failure back to
|
|
// one input instead of only showing the raw message.
|
|
const fields = Array.isArray(body.detail)
|
|
? body.detail
|
|
.map((e) => (Array.isArray(e.loc) ? e.loc[e.loc.length - 1] : undefined))
|
|
.filter((f): f is string => typeof f === 'string')
|
|
: []
|
|
error = {
|
|
code: body.code ?? String(response.status),
|
|
message: detail ?? body.message ?? response.statusText,
|
|
...(fields.length > 0 ? { details: { fields } } : {}),
|
|
}
|
|
}
|
|
} 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,
|
|
// Omitted (rather than sent as `undefined`) so the backend's
|
|
// "no invite" default (`None`) applies untouched.
|
|
...(req.inviteToken !== undefined ? { invite_token: req.inviteToken } : {}),
|
|
}),
|
|
})
|
|
}
|
|
|
|
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,
|
|
take_pl_on_red_days: req.takePlOnRedDays,
|
|
}),
|
|
})) 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> {
|
|
// Optional keys are omitted entirely (rather than sent as `undefined`,
|
|
// which `JSON.stringify` would drop anyway but less explicitly) so the
|
|
// backend's "null/omitted = derive it" defaulting kicks in — see
|
|
// `ManualFetchRequest`'s doc comment.
|
|
const body: Record<string, unknown> = {
|
|
personal_data_uuid: req.personalDataUuid,
|
|
workspace_id: req.workspaceId,
|
|
child_id: req.childId,
|
|
sjukpenning_left: req.sjukpenningLeft,
|
|
lagsta_niva_left: req.lagstaNivaLeft,
|
|
dubbeldagar_left: req.dubbeldagarLeft,
|
|
}
|
|
if (req.reservedDaysLeft !== undefined) body.reserved_days_left = req.reservedDaysLeft
|
|
if (req.totalDays !== undefined) body.total_days = req.totalDays
|
|
if (req.source !== undefined) body.source = req.source
|
|
|
|
const raw = (await apiFetch('/parental-leave/manual', {
|
|
method: 'POST',
|
|
body: JSON.stringify(body),
|
|
})) 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,
|
|
target_days: payload.targetDays ?? null,
|
|
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',
|
|
series_id: payload.seriesId ?? null,
|
|
wants_dubbeldag: payload.wantsDubbeldag ?? false,
|
|
}),
|
|
})) as RawLeaveWish
|
|
return toLeaveWish(raw)
|
|
}
|
|
|
|
/** `scope` decides how far the edit reaches on a repeating wish — see
|
|
* `LeaveWishScope`. Omitted means "this year only", matching the backend's
|
|
* own default, so existing single-occurrence callers need no change. */
|
|
export async function updateLeaveWish(
|
|
id: string,
|
|
payload: LeaveWishUpdateRequest,
|
|
scope: LeaveWishScope = 'occurrence',
|
|
): 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.targetDays !== undefined) body.target_days = payload.targetDays
|
|
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
|
|
if (payload.wantsDubbeldag !== undefined) body.wants_dubbeldag = payload.wantsDubbeldag
|
|
|
|
const raw = (await apiFetch(`/leave-wishes/${id}?scope=${scope}`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify(body),
|
|
})) as RawLeaveWish
|
|
return toLeaveWish(raw)
|
|
}
|
|
|
|
/** `scope: 'series'` removes every year of a repeating wish; the default
|
|
* removes just this one year's row. */
|
|
export async function deleteLeaveWish(
|
|
id: string,
|
|
scope: LeaveWishScope = 'occurrence',
|
|
): Promise<void> {
|
|
return apiFetch(`/leave-wishes/${id}?scope=${scope}`, { 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,
|
|
}
|
|
}
|
|
|
|
interface RawPlanChange {
|
|
kind: string
|
|
date: string
|
|
parent_personal_data_uuid: string
|
|
child_id: string
|
|
old_day_type: string | null
|
|
new_day_type: string | null
|
|
old_is_dubbeldag: boolean | null
|
|
new_is_dubbeldag: boolean | null
|
|
old_wish_id: string | null
|
|
new_wish_id: string | null
|
|
}
|
|
|
|
function toPlanChange(raw: RawPlanChange): PlanChange {
|
|
return {
|
|
kind: raw.kind as PlanChange['kind'],
|
|
date: raw.date,
|
|
parentPersonalDataUuid: raw.parent_personal_data_uuid,
|
|
childId: raw.child_id,
|
|
oldDayType: raw.old_day_type as PlanChange['oldDayType'],
|
|
newDayType: raw.new_day_type as PlanChange['newDayType'],
|
|
oldIsDubbeldag: raw.old_is_dubbeldag,
|
|
newIsDubbeldag: raw.new_is_dubbeldag,
|
|
oldWishId: raw.old_wish_id,
|
|
newWishId: raw.new_wish_id,
|
|
}
|
|
}
|
|
|
|
interface RawPlanSyncResponse {
|
|
proposal: RawPlanProposal
|
|
apply_result: RawPlanApplyResponse
|
|
changes: RawPlanChange[]
|
|
}
|
|
|
|
/** POST /api/plan/sync — the direct-manipulation calendar's one call:
|
|
* recompute the plan from the workspace's `LeaveWish`es and persist it in
|
|
* one step (no separate preview), never retyping/overwriting a pinned day,
|
|
* and reporting exactly what changed via `changes` so the UI can render a
|
|
* toast ("Sommar 2028 flyttades 2 dagar") instead of silently rewriting the
|
|
* calendar underneath the user.
|
|
*/
|
|
export async function syncPlan(workspaceId: string): Promise<PlanSyncResponse> {
|
|
const raw = (await apiFetch('/plan/sync', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ workspace_id: workspaceId }),
|
|
})) as RawPlanSyncResponse
|
|
return {
|
|
proposal: toPlanProposal(raw.proposal),
|
|
applyResult: toPlanApplyResponse(raw.apply_result),
|
|
changes: raw.changes.map(toPlanChange),
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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[]> {
|
|
const raw = (await apiFetch(`/tips/${workspaceId}`)) as RawTip[]
|
|
return raw.map(toTip)
|
|
}
|
|
|
|
export async function dismissTip(workspaceId: string, tipId: string): Promise<void> {
|
|
return apiFetch(`/tips/${workspaceId}/dismissals`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ tip_id: tipId }),
|
|
})
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Parental Leave Fetch
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export async function getParentalLeave(
|
|
personalDataUuid: string,
|
|
childId: string,
|
|
): Promise<ParentalLeaveFetch | null> {
|
|
// The backend returns `null` (200 OK) rather than 404 when nothing has
|
|
// been saved yet for this parent/child — a brand-new partner, or self
|
|
// before any fetch. `toParentalLeaveFetch` assumes a real record, so it
|
|
// must not be called on that `null`.
|
|
const raw = (await apiFetch(
|
|
`/parental-leave/${personalDataUuid}/${childId}`,
|
|
)) as RawParentalLeaveFetch | null
|
|
return raw ? toParentalLeaveFetch(raw) : null
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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
|
|
// True for a day hand-edited through the calendar's day popover — the
|
|
// planner (POST /api/plan/sync) must not silently retype it. See
|
|
// `PlannedLeaveDay.isPinned`.
|
|
is_pinned?: 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
|
|
// See `CreatePlannedDayPayload.is_pinned`.
|
|
is_pinned?: 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' })
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Planned-day exclusions — the days the user removed by hand and the planner
|
|
// must therefore leave empty on every subsequent sync.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export async function createPlannedDayExclusion(
|
|
req: import('@/types').PlannedDayExclusionCreateRequest,
|
|
): Promise<import('@/types').PlannedDayExclusion> {
|
|
const raw = (await apiFetch('/planned-day-exclusions', {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
workspace_id: req.workspaceId,
|
|
child_id: req.childId,
|
|
parent_personal_data_uuid: req.parentPersonalDataUuid,
|
|
date: req.date,
|
|
}),
|
|
})) as RawPlannedDayExclusion
|
|
return toPlannedDayExclusion(raw)
|
|
}
|
|
|
|
export async function listPlannedDayExclusionsByWorkspace(
|
|
workspaceId: string,
|
|
): Promise<import('@/types').PlannedDayExclusion[]> {
|
|
const raw = (await apiFetch(
|
|
`/planned-day-exclusions/workspace/${workspaceId}`,
|
|
)) as RawPlannedDayExclusion[]
|
|
return raw.map(toPlannedDayExclusion)
|
|
}
|
|
|
|
export async function deletePlannedDayExclusion(id: string): Promise<void> {
|
|
return apiFetch(`/planned-day-exclusions/${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 }),
|
|
})
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Preschool planning days ("studiedagar") — /api/preschool-planning-days
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface RawPreschoolPlanningDays {
|
|
id: string | null
|
|
child_id: string
|
|
year: number
|
|
planned_days: number
|
|
taken_days: number | null
|
|
is_default: boolean
|
|
created_at: string | null
|
|
updated_at: string | null
|
|
}
|
|
|
|
function toPreschoolPlanningDays(raw: RawPreschoolPlanningDays): PreschoolPlanningDays {
|
|
return {
|
|
id: raw.id,
|
|
childId: raw.child_id,
|
|
year: raw.year,
|
|
plannedDays: raw.planned_days,
|
|
takenDays: raw.taken_days,
|
|
isDefault: raw.is_default,
|
|
createdAt: raw.created_at,
|
|
updatedAt: raw.updated_at,
|
|
}
|
|
}
|
|
|
|
export async function upsertPreschoolPlanningDaysPlanned(
|
|
childId: string,
|
|
year: number,
|
|
req: PreschoolPlanningDaysPlannedRequest,
|
|
): Promise<PreschoolPlanningDays> {
|
|
const raw = (await apiFetch(`/preschool-planning-days/${childId}/${year}/planned`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify({ planned_days: req.plannedDays }),
|
|
})) as RawPreschoolPlanningDays
|
|
return toPreschoolPlanningDays(raw)
|
|
}
|
|
|
|
export async function recordPreschoolPlanningDaysTaken(
|
|
childId: string,
|
|
year: number,
|
|
req: PreschoolPlanningDaysTakenRequest,
|
|
): Promise<PreschoolPlanningDays> {
|
|
const raw = (await apiFetch(`/preschool-planning-days/${childId}/${year}/taken`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify({ taken_days: req.takenDays }),
|
|
})) as RawPreschoolPlanningDays
|
|
return toPreschoolPlanningDays(raw)
|
|
}
|
|
|
|
export async function getPreschoolPlanningDaysYear(
|
|
childId: string,
|
|
year: number,
|
|
): Promise<PreschoolPlanningDays> {
|
|
const raw = (await apiFetch(
|
|
`/preschool-planning-days/${childId}/${year}`,
|
|
)) as RawPreschoolPlanningDays
|
|
return toPreschoolPlanningDays(raw)
|
|
}
|
|
|
|
export async function listPreschoolPlanningDays(childId: string): Promise<PreschoolPlanningDays[]> {
|
|
const raw = (await apiFetch(`/preschool-planning-days/${childId}`)) as RawPreschoolPlanningDays[]
|
|
return raw.map(toPreschoolPlanningDays)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Invitations — /api/workspace/{id}/invitations, /api/invitations/*
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface RawInvitation {
|
|
id: string
|
|
workspace_id: string
|
|
status: 'pending' | 'accepted' | 'revoked' | 'expired'
|
|
expires_at: string
|
|
created_at: string
|
|
}
|
|
|
|
function toInvitation(raw: RawInvitation): Invitation {
|
|
return {
|
|
id: raw.id,
|
|
workspaceId: raw.workspace_id,
|
|
status: raw.status,
|
|
expiresAt: raw.expires_at,
|
|
createdAt: raw.created_at,
|
|
}
|
|
}
|
|
|
|
/** Creates a one-time invite for `workspaceId`; the raw `token` is returned
|
|
* once and never stored server-side — share it out-of-band (the backend
|
|
* only ever persists its hash). */
|
|
export async function createInvitation(workspaceId: string): Promise<CreateInvitationResponse> {
|
|
return apiFetch(`/workspace/${workspaceId}/invitations`, { method: 'POST' })
|
|
}
|
|
|
|
/** Lists this workspace's invitations. Never includes `token_hash` — the
|
|
* backend omits it from the wire response entirely. */
|
|
export async function listInvitations(workspaceId: string): Promise<Invitation[]> {
|
|
const raw = (await apiFetch(`/workspace/${workspaceId}/invitations`)) as RawInvitation[]
|
|
return raw.map(toInvitation)
|
|
}
|
|
|
|
export async function redeemInvitation(token: string): Promise<Invitation> {
|
|
const raw = (await apiFetch('/invitations/redeem', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ token }),
|
|
})) as RawInvitation
|
|
return toInvitation(raw)
|
|
}
|
|
|
|
/** The caller's own workspace is inferred server-side from their session —
|
|
* no workspace id is sent. */
|
|
export async function revokeInvitation(invitationId: string): Promise<void> {
|
|
return apiFetch(`/invitations/${invitationId}`, { method: 'DELETE' })
|
|
}
|