mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-21 22:31:46 +00:00
refactoring: TypeScript migration, security fixes,
This commit is contained in:
@@ -1,8 +1,42 @@
|
||||
import { create } from 'zustand'
|
||||
import { authApi } from '../api/client'
|
||||
import { connect, disconnect } from '../api/websocket'
|
||||
import type { User } from '../types'
|
||||
import { getApiErrorMessage } from '../types'
|
||||
|
||||
export const useAuthStore = create((set, get) => ({
|
||||
interface AuthResponse {
|
||||
user: User
|
||||
token: string
|
||||
}
|
||||
|
||||
interface AvatarResponse {
|
||||
avatar_url: string
|
||||
}
|
||||
|
||||
interface AuthState {
|
||||
user: User | null
|
||||
token: string | null
|
||||
isAuthenticated: boolean
|
||||
isLoading: boolean
|
||||
error: string | null
|
||||
demoMode: boolean
|
||||
hasMapsKey: boolean
|
||||
|
||||
login: (email: string, password: string) => Promise<AuthResponse>
|
||||
register: (username: string, email: string, password: string) => Promise<AuthResponse>
|
||||
logout: () => void
|
||||
loadUser: () => Promise<void>
|
||||
updateMapsKey: (key: string | null) => Promise<void>
|
||||
updateApiKeys: (keys: Record<string, string | null>) => Promise<void>
|
||||
updateProfile: (profileData: Partial<User>) => Promise<void>
|
||||
uploadAvatar: (file: File) => Promise<AvatarResponse>
|
||||
deleteAvatar: () => Promise<void>
|
||||
setDemoMode: (val: boolean) => void
|
||||
setHasMapsKey: (val: boolean) => void
|
||||
demoLogin: () => Promise<AuthResponse>
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
user: null,
|
||||
token: localStorage.getItem('auth_token') || null,
|
||||
isAuthenticated: !!localStorage.getItem('auth_token'),
|
||||
@@ -11,7 +45,7 @@ export const useAuthStore = create((set, get) => ({
|
||||
demoMode: localStorage.getItem('demo_mode') === 'true',
|
||||
hasMapsKey: false,
|
||||
|
||||
login: async (email, password) => {
|
||||
login: async (email: string, password: string) => {
|
||||
set({ isLoading: true, error: null })
|
||||
try {
|
||||
const data = await authApi.login({ email, password })
|
||||
@@ -25,14 +59,14 @@ export const useAuthStore = create((set, get) => ({
|
||||
})
|
||||
connect(data.token)
|
||||
return data
|
||||
} catch (err) {
|
||||
const error = err.response?.data?.error || 'Login failed'
|
||||
} catch (err: unknown) {
|
||||
const error = getApiErrorMessage(err, 'Login failed')
|
||||
set({ isLoading: false, error })
|
||||
throw new Error(error)
|
||||
}
|
||||
},
|
||||
|
||||
register: async (username, email, password) => {
|
||||
register: async (username: string, email: string, password: string) => {
|
||||
set({ isLoading: true, error: null })
|
||||
try {
|
||||
const data = await authApi.register({ username, email, password })
|
||||
@@ -46,8 +80,8 @@ export const useAuthStore = create((set, get) => ({
|
||||
})
|
||||
connect(data.token)
|
||||
return data
|
||||
} catch (err) {
|
||||
const error = err.response?.data?.error || 'Registration failed'
|
||||
} catch (err: unknown) {
|
||||
const error = getApiErrorMessage(err, 'Registration failed')
|
||||
set({ isLoading: false, error })
|
||||
throw new Error(error)
|
||||
}
|
||||
@@ -79,7 +113,7 @@ export const useAuthStore = create((set, get) => ({
|
||||
isLoading: false,
|
||||
})
|
||||
connect(token)
|
||||
} catch (err) {
|
||||
} catch (err: unknown) {
|
||||
localStorage.removeItem('auth_token')
|
||||
set({
|
||||
user: null,
|
||||
@@ -90,55 +124,55 @@ export const useAuthStore = create((set, get) => ({
|
||||
}
|
||||
},
|
||||
|
||||
updateMapsKey: async (key) => {
|
||||
updateMapsKey: async (key: string | null) => {
|
||||
try {
|
||||
await authApi.updateMapsKey(key)
|
||||
set(state => ({
|
||||
user: { ...state.user, maps_api_key: key || null }
|
||||
set((state) => ({
|
||||
user: state.user ? { ...state.user, maps_api_key: key || null } : null,
|
||||
}))
|
||||
} catch (err) {
|
||||
throw new Error(err.response?.data?.error || 'Error saving API key')
|
||||
} catch (err: unknown) {
|
||||
throw new Error(getApiErrorMessage(err, 'Error saving API key'))
|
||||
}
|
||||
},
|
||||
|
||||
updateApiKeys: async (keys) => {
|
||||
updateApiKeys: async (keys: Record<string, string | null>) => {
|
||||
try {
|
||||
const data = await authApi.updateApiKeys(keys)
|
||||
set({ user: data.user })
|
||||
} catch (err) {
|
||||
throw new Error(err.response?.data?.error || 'Error saving API keys')
|
||||
} catch (err: unknown) {
|
||||
throw new Error(getApiErrorMessage(err, 'Error saving API keys'))
|
||||
}
|
||||
},
|
||||
|
||||
updateProfile: async (profileData) => {
|
||||
updateProfile: async (profileData: Partial<User>) => {
|
||||
try {
|
||||
const data = await authApi.updateSettings(profileData)
|
||||
set({ user: data.user })
|
||||
} catch (err) {
|
||||
throw new Error(err.response?.data?.error || 'Error updating profile')
|
||||
} catch (err: unknown) {
|
||||
throw new Error(getApiErrorMessage(err, 'Error updating profile'))
|
||||
}
|
||||
},
|
||||
|
||||
uploadAvatar: async (file) => {
|
||||
uploadAvatar: async (file: File) => {
|
||||
const formData = new FormData()
|
||||
formData.append('avatar', file)
|
||||
const data = await authApi.uploadAvatar(formData)
|
||||
set(state => ({ user: { ...state.user, avatar_url: data.avatar_url } }))
|
||||
set((state) => ({ user: state.user ? { ...state.user, avatar_url: data.avatar_url } : null }))
|
||||
return data
|
||||
},
|
||||
|
||||
deleteAvatar: async () => {
|
||||
await authApi.deleteAvatar()
|
||||
set(state => ({ user: { ...state.user, avatar_url: null } }))
|
||||
set((state) => ({ user: state.user ? { ...state.user, avatar_url: null } : null }))
|
||||
},
|
||||
|
||||
setDemoMode: (val) => {
|
||||
setDemoMode: (val: boolean) => {
|
||||
if (val) localStorage.setItem('demo_mode', 'true')
|
||||
else localStorage.removeItem('demo_mode')
|
||||
set({ demoMode: val })
|
||||
},
|
||||
|
||||
setHasMapsKey: (val) => set({ hasMapsKey: val }),
|
||||
setHasMapsKey: (val: boolean) => set({ hasMapsKey: val }),
|
||||
|
||||
demoLogin: async () => {
|
||||
set({ isLoading: true, error: null })
|
||||
@@ -155,8 +189,8 @@ export const useAuthStore = create((set, get) => ({
|
||||
})
|
||||
connect(data.token)
|
||||
return data
|
||||
} catch (err) {
|
||||
const error = err.response?.data?.error || 'Demo login failed'
|
||||
} catch (err: unknown) {
|
||||
const error = getApiErrorMessage(err, 'Demo login failed')
|
||||
set({ isLoading: false, error })
|
||||
throw new Error(error)
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
import { create } from 'zustand'
|
||||
import { settingsApi } from '../api/client'
|
||||
|
||||
export const useSettingsStore = create((set, get) => ({
|
||||
settings: {
|
||||
map_tile_url: '',
|
||||
default_lat: 48.8566,
|
||||
default_lng: 2.3522,
|
||||
default_zoom: 10,
|
||||
dark_mode: false,
|
||||
default_currency: 'USD',
|
||||
language: localStorage.getItem('app_language') || 'en',
|
||||
temperature_unit: 'fahrenheit',
|
||||
time_format: '12h',
|
||||
show_place_description: false,
|
||||
},
|
||||
isLoaded: false,
|
||||
|
||||
loadSettings: async () => {
|
||||
try {
|
||||
const data = await settingsApi.get()
|
||||
set(state => ({
|
||||
settings: { ...state.settings, ...data.settings },
|
||||
isLoaded: true,
|
||||
}))
|
||||
} catch (err) {
|
||||
set({ isLoaded: true })
|
||||
console.error('Failed to load settings:', err)
|
||||
}
|
||||
},
|
||||
|
||||
updateSetting: async (key, value) => {
|
||||
set(state => ({
|
||||
settings: { ...state.settings, [key]: value }
|
||||
}))
|
||||
if (key === 'language') localStorage.setItem('app_language', value)
|
||||
try {
|
||||
await settingsApi.set(key, value)
|
||||
} catch (err) {
|
||||
console.error('Failed to save setting:', err)
|
||||
throw new Error(err.response?.data?.error || 'Error saving setting')
|
||||
}
|
||||
},
|
||||
|
||||
setLanguageLocal: (lang) => {
|
||||
localStorage.setItem('app_language', lang)
|
||||
set(state => ({ settings: { ...state.settings, language: lang } }))
|
||||
},
|
||||
|
||||
updateSettings: async (settingsObj) => {
|
||||
set(state => ({
|
||||
settings: { ...state.settings, ...settingsObj }
|
||||
}))
|
||||
try {
|
||||
await settingsApi.setBulk(settingsObj)
|
||||
} catch (err) {
|
||||
console.error('Failed to save settings:', err)
|
||||
throw new Error(err.response?.data?.error || 'Error saving settings')
|
||||
}
|
||||
},
|
||||
}))
|
||||
@@ -0,0 +1,73 @@
|
||||
import { create } from 'zustand'
|
||||
import { settingsApi } from '../api/client'
|
||||
import type { Settings } from '../types'
|
||||
import { getApiErrorMessage } from '../types'
|
||||
|
||||
interface SettingsState {
|
||||
settings: Settings
|
||||
isLoaded: boolean
|
||||
|
||||
loadSettings: () => Promise<void>
|
||||
updateSetting: (key: keyof Settings, value: Settings[keyof Settings]) => Promise<void>
|
||||
setLanguageLocal: (lang: string) => void
|
||||
updateSettings: (settingsObj: Partial<Settings>) => Promise<void>
|
||||
}
|
||||
|
||||
export const useSettingsStore = create<SettingsState>((set, get) => ({
|
||||
settings: {
|
||||
map_tile_url: '',
|
||||
default_lat: 48.8566,
|
||||
default_lng: 2.3522,
|
||||
default_zoom: 10,
|
||||
dark_mode: false,
|
||||
default_currency: 'USD',
|
||||
language: localStorage.getItem('app_language') || 'en',
|
||||
temperature_unit: 'fahrenheit',
|
||||
time_format: '12h',
|
||||
show_place_description: false,
|
||||
},
|
||||
isLoaded: false,
|
||||
|
||||
loadSettings: async () => {
|
||||
try {
|
||||
const data = await settingsApi.get()
|
||||
set((state) => ({
|
||||
settings: { ...state.settings, ...data.settings },
|
||||
isLoaded: true,
|
||||
}))
|
||||
} catch (err: unknown) {
|
||||
set({ isLoaded: true })
|
||||
console.error('Failed to load settings:', err)
|
||||
}
|
||||
},
|
||||
|
||||
updateSetting: async (key: keyof Settings, value: Settings[keyof Settings]) => {
|
||||
set((state) => ({
|
||||
settings: { ...state.settings, [key]: value },
|
||||
}))
|
||||
if (key === 'language') localStorage.setItem('app_language', value as string)
|
||||
try {
|
||||
await settingsApi.set(key, value)
|
||||
} catch (err: unknown) {
|
||||
console.error('Failed to save setting:', err)
|
||||
throw new Error(getApiErrorMessage(err, 'Error saving setting'))
|
||||
}
|
||||
},
|
||||
|
||||
setLanguageLocal: (lang: string) => {
|
||||
localStorage.setItem('app_language', lang)
|
||||
set((state) => ({ settings: { ...state.settings, language: lang } }))
|
||||
},
|
||||
|
||||
updateSettings: async (settingsObj: Partial<Settings>) => {
|
||||
set((state) => ({
|
||||
settings: { ...state.settings, ...settingsObj },
|
||||
}))
|
||||
try {
|
||||
await settingsApi.setBulk(settingsObj)
|
||||
} catch (err: unknown) {
|
||||
console.error('Failed to save settings:', err)
|
||||
throw new Error(getApiErrorMessage(err, 'Error saving settings'))
|
||||
}
|
||||
},
|
||||
}))
|
||||
@@ -0,0 +1,168 @@
|
||||
import { assignmentsApi } from '../../api/client'
|
||||
import type { StoreApi } from 'zustand'
|
||||
import type { TripStoreState } from '../tripStore'
|
||||
import type { Assignment, AssignmentsMap } from '../../types'
|
||||
import { getApiErrorMessage } from '../../types'
|
||||
|
||||
type SetState = StoreApi<TripStoreState>['setState']
|
||||
type GetState = StoreApi<TripStoreState>['getState']
|
||||
|
||||
export interface AssignmentsSlice {
|
||||
assignPlaceToDay: (tripId: number | string, dayId: number | string, placeId: number | string, position?: number | null) => Promise<Assignment | undefined>
|
||||
removeAssignment: (tripId: number | string, dayId: number | string, assignmentId: number) => Promise<void>
|
||||
reorderAssignments: (tripId: number | string, dayId: number | string, orderedIds: number[]) => Promise<void>
|
||||
moveAssignment: (tripId: number | string, assignmentId: number, fromDayId: number | string, toDayId: number | string, toOrderIndex?: number | null) => Promise<void>
|
||||
setAssignments: (assignments: AssignmentsMap) => void
|
||||
}
|
||||
|
||||
export const createAssignmentsSlice = (set: SetState, get: GetState): AssignmentsSlice => ({
|
||||
assignPlaceToDay: async (tripId, dayId, placeId, position) => {
|
||||
const state = get()
|
||||
const place = state.places.find(p => p.id === parseInt(String(placeId)))
|
||||
if (!place) return
|
||||
|
||||
const tempId = Date.now() * -1
|
||||
const current = [...(state.assignments[String(dayId)] || [])]
|
||||
const insertIdx = position != null ? position : current.length
|
||||
const tempAssignment: Assignment = {
|
||||
id: tempId,
|
||||
day_id: parseInt(String(dayId)),
|
||||
order_index: insertIdx,
|
||||
notes: null,
|
||||
place,
|
||||
}
|
||||
|
||||
current.splice(insertIdx, 0, tempAssignment)
|
||||
set(state => ({
|
||||
assignments: {
|
||||
...state.assignments,
|
||||
[String(dayId)]: current,
|
||||
}
|
||||
}))
|
||||
|
||||
try {
|
||||
const data = await assignmentsApi.create(tripId, dayId, { place_id: placeId })
|
||||
const newAssignment: Assignment = {
|
||||
...data.assignment,
|
||||
place: data.assignment.place || place,
|
||||
order_index: position != null ? insertIdx : data.assignment.order_index,
|
||||
}
|
||||
set(state => ({
|
||||
assignments: {
|
||||
...state.assignments,
|
||||
[String(dayId)]: state.assignments[String(dayId)].map(
|
||||
a => a.id === tempId ? newAssignment : a
|
||||
),
|
||||
}
|
||||
}))
|
||||
if (position != null) {
|
||||
const updated = get().assignments[String(dayId)] || []
|
||||
const orderedIds = updated.map(a => a.id).filter(id => id > 0)
|
||||
if (orderedIds.length > 0) {
|
||||
try {
|
||||
await assignmentsApi.reorder(tripId, dayId, orderedIds)
|
||||
set(state => {
|
||||
const items = state.assignments[String(dayId)] || []
|
||||
const reordered = orderedIds.map((id, idx) => {
|
||||
const item = items.find(a => a.id === id)
|
||||
return item ? { ...item, order_index: idx } : null
|
||||
}).filter((item): item is Assignment => item !== null)
|
||||
return {
|
||||
assignments: {
|
||||
...state.assignments,
|
||||
[String(dayId)]: reordered,
|
||||
}
|
||||
}
|
||||
})
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
return data.assignment
|
||||
} catch (err: unknown) {
|
||||
set(state => ({
|
||||
assignments: {
|
||||
...state.assignments,
|
||||
[String(dayId)]: state.assignments[String(dayId)].filter(a => a.id !== tempId),
|
||||
}
|
||||
}))
|
||||
throw new Error(getApiErrorMessage(err, 'Error assigning place'))
|
||||
}
|
||||
},
|
||||
|
||||
removeAssignment: async (tripId, dayId, assignmentId) => {
|
||||
const prevAssignments = get().assignments
|
||||
|
||||
set(state => ({
|
||||
assignments: {
|
||||
...state.assignments,
|
||||
[String(dayId)]: state.assignments[String(dayId)].filter(a => a.id !== assignmentId),
|
||||
}
|
||||
}))
|
||||
|
||||
try {
|
||||
await assignmentsApi.delete(tripId, dayId, assignmentId)
|
||||
} catch (err: unknown) {
|
||||
set({ assignments: prevAssignments })
|
||||
throw new Error(getApiErrorMessage(err, 'Error removing assignment'))
|
||||
}
|
||||
},
|
||||
|
||||
reorderAssignments: async (tripId, dayId, orderedIds) => {
|
||||
const prevAssignments = get().assignments
|
||||
const dayItems = get().assignments[String(dayId)] || []
|
||||
const reordered = orderedIds.map((id, idx) => {
|
||||
const item = dayItems.find(a => a.id === id)
|
||||
return item ? { ...item, order_index: idx } : null
|
||||
}).filter((item): item is Assignment => item !== null)
|
||||
|
||||
set(state => ({
|
||||
assignments: {
|
||||
...state.assignments,
|
||||
[String(dayId)]: reordered,
|
||||
}
|
||||
}))
|
||||
|
||||
try {
|
||||
await assignmentsApi.reorder(tripId, dayId, orderedIds)
|
||||
} catch (err: unknown) {
|
||||
set({ assignments: prevAssignments })
|
||||
throw new Error(getApiErrorMessage(err, 'Error reordering'))
|
||||
}
|
||||
},
|
||||
|
||||
moveAssignment: async (tripId, assignmentId, fromDayId, toDayId, toOrderIndex = null) => {
|
||||
const state = get()
|
||||
const prevAssignments = state.assignments
|
||||
const assignment = (state.assignments[String(fromDayId)] || []).find(a => a.id === assignmentId)
|
||||
if (!assignment) return
|
||||
|
||||
const toItems = (state.assignments[String(toDayId)] || []).slice().sort((a, b) => a.order_index - b.order_index)
|
||||
const insertAt = toOrderIndex !== null ? toOrderIndex : toItems.length
|
||||
|
||||
const newToItems = [...toItems]
|
||||
newToItems.splice(insertAt, 0, { ...assignment, day_id: parseInt(String(toDayId)) })
|
||||
newToItems.forEach((a, i) => { a.order_index = i })
|
||||
|
||||
set(s => ({
|
||||
assignments: {
|
||||
...s.assignments,
|
||||
[String(fromDayId)]: s.assignments[String(fromDayId)].filter(a => a.id !== assignmentId),
|
||||
[String(toDayId)]: newToItems,
|
||||
}
|
||||
}))
|
||||
|
||||
try {
|
||||
await assignmentsApi.move(tripId, assignmentId, toDayId, insertAt)
|
||||
if (newToItems.length > 1) {
|
||||
await assignmentsApi.reorder(tripId, toDayId, newToItems.map(a => a.id))
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
set({ assignments: prevAssignments })
|
||||
throw new Error(getApiErrorMessage(err, 'Error moving assignment'))
|
||||
}
|
||||
},
|
||||
|
||||
setAssignments: (assignments) => {
|
||||
set({ assignments })
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,82 @@
|
||||
import { budgetApi } from '../../api/client'
|
||||
import type { StoreApi } from 'zustand'
|
||||
import type { TripStoreState } from '../tripStore'
|
||||
import type { BudgetItem, BudgetMember } from '../../types'
|
||||
import { getApiErrorMessage } from '../../types'
|
||||
|
||||
type SetState = StoreApi<TripStoreState>['setState']
|
||||
type GetState = StoreApi<TripStoreState>['getState']
|
||||
|
||||
export interface BudgetSlice {
|
||||
loadBudgetItems: (tripId: number | string) => Promise<void>
|
||||
addBudgetItem: (tripId: number | string, data: Partial<BudgetItem>) => Promise<BudgetItem>
|
||||
updateBudgetItem: (tripId: number | string, id: number, data: Partial<BudgetItem>) => Promise<BudgetItem>
|
||||
deleteBudgetItem: (tripId: number | string, id: number) => Promise<void>
|
||||
setBudgetItemMembers: (tripId: number | string, itemId: number, userIds: number[]) => Promise<{ members: BudgetMember[]; item: BudgetItem }>
|
||||
toggleBudgetMemberPaid: (tripId: number | string, itemId: number, userId: number, paid: boolean) => Promise<void>
|
||||
}
|
||||
|
||||
export const createBudgetSlice = (set: SetState, get: GetState): BudgetSlice => ({
|
||||
loadBudgetItems: async (tripId) => {
|
||||
try {
|
||||
const data = await budgetApi.list(tripId)
|
||||
set({ budgetItems: data.items })
|
||||
} catch (err: unknown) {
|
||||
console.error('Failed to load budget items:', err)
|
||||
}
|
||||
},
|
||||
|
||||
addBudgetItem: async (tripId, data) => {
|
||||
try {
|
||||
const result = await budgetApi.create(tripId, data)
|
||||
set(state => ({ budgetItems: [...state.budgetItems, result.item] }))
|
||||
return result.item
|
||||
} catch (err: unknown) {
|
||||
throw new Error(getApiErrorMessage(err, 'Error adding budget item'))
|
||||
}
|
||||
},
|
||||
|
||||
updateBudgetItem: async (tripId, id, data) => {
|
||||
try {
|
||||
const result = await budgetApi.update(tripId, id, data)
|
||||
set(state => ({
|
||||
budgetItems: state.budgetItems.map(item => item.id === id ? result.item : item)
|
||||
}))
|
||||
return result.item
|
||||
} catch (err: unknown) {
|
||||
throw new Error(getApiErrorMessage(err, 'Error updating budget item'))
|
||||
}
|
||||
},
|
||||
|
||||
deleteBudgetItem: async (tripId, id) => {
|
||||
const prev = get().budgetItems
|
||||
set(state => ({ budgetItems: state.budgetItems.filter(item => item.id !== id) }))
|
||||
try {
|
||||
await budgetApi.delete(tripId, id)
|
||||
} catch (err: unknown) {
|
||||
set({ budgetItems: prev })
|
||||
throw new Error(getApiErrorMessage(err, 'Error deleting budget item'))
|
||||
}
|
||||
},
|
||||
|
||||
setBudgetItemMembers: async (tripId, itemId, userIds) => {
|
||||
const result = await budgetApi.setMembers(tripId, itemId, userIds);
|
||||
set(state => ({
|
||||
budgetItems: state.budgetItems.map(item =>
|
||||
item.id === itemId ? { ...item, members: result.members, persons: result.item.persons } : item
|
||||
)
|
||||
}));
|
||||
return result;
|
||||
},
|
||||
|
||||
toggleBudgetMemberPaid: async (tripId, itemId, userId, paid) => {
|
||||
await budgetApi.togglePaid(tripId, itemId, userId, paid);
|
||||
set(state => ({
|
||||
budgetItems: state.budgetItems.map(item =>
|
||||
item.id === itemId
|
||||
? { ...item, members: (item.members || []).map(m => m.user_id === userId ? { ...m, paid } : m) }
|
||||
: item
|
||||
)
|
||||
}));
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,135 @@
|
||||
import { daysApi, dayNotesApi } from '../../api/client'
|
||||
import type { StoreApi } from 'zustand'
|
||||
import type { TripStoreState } from '../tripStore'
|
||||
import type { DayNote } from '../../types'
|
||||
import { getApiErrorMessage } from '../../types'
|
||||
|
||||
type SetState = StoreApi<TripStoreState>['setState']
|
||||
type GetState = StoreApi<TripStoreState>['getState']
|
||||
|
||||
export interface DayNotesSlice {
|
||||
updateDayNotes: (tripId: number | string, dayId: number | string, notes: string) => Promise<void>
|
||||
updateDayTitle: (tripId: number | string, dayId: number | string, title: string) => Promise<void>
|
||||
addDayNote: (tripId: number | string, dayId: number | string, data: Partial<DayNote>) => Promise<DayNote>
|
||||
updateDayNote: (tripId: number | string, dayId: number | string, id: number, data: Partial<DayNote>) => Promise<DayNote>
|
||||
deleteDayNote: (tripId: number | string, dayId: number | string, id: number) => Promise<void>
|
||||
moveDayNote: (tripId: number | string, fromDayId: number | string, toDayId: number | string, noteId: number, sort_order?: number) => Promise<void>
|
||||
}
|
||||
|
||||
export const createDayNotesSlice = (set: SetState, get: GetState): DayNotesSlice => ({
|
||||
updateDayNotes: async (tripId, dayId, notes) => {
|
||||
try {
|
||||
await daysApi.update(tripId, dayId, { notes })
|
||||
set(state => ({
|
||||
days: state.days.map(d => d.id === parseInt(String(dayId)) ? { ...d, notes } : d)
|
||||
}))
|
||||
} catch (err: unknown) {
|
||||
throw new Error(getApiErrorMessage(err, 'Error updating notes'))
|
||||
}
|
||||
},
|
||||
|
||||
updateDayTitle: async (tripId, dayId, title) => {
|
||||
try {
|
||||
await daysApi.update(tripId, dayId, { title })
|
||||
set(state => ({
|
||||
days: state.days.map(d => d.id === parseInt(String(dayId)) ? { ...d, title } : d)
|
||||
}))
|
||||
} catch (err: unknown) {
|
||||
throw new Error(getApiErrorMessage(err, 'Error updating day name'))
|
||||
}
|
||||
},
|
||||
|
||||
addDayNote: async (tripId, dayId, data) => {
|
||||
const tempId = Date.now() * -1
|
||||
const tempNote: DayNote = { id: tempId, day_id: dayId as number, ...data, created_at: new Date().toISOString() } as DayNote
|
||||
set(state => ({
|
||||
dayNotes: {
|
||||
...state.dayNotes,
|
||||
[String(dayId)]: [...(state.dayNotes[String(dayId)] || []), tempNote],
|
||||
}
|
||||
}))
|
||||
try {
|
||||
const result = await dayNotesApi.create(tripId, dayId, data)
|
||||
set(state => ({
|
||||
dayNotes: {
|
||||
...state.dayNotes,
|
||||
[String(dayId)]: (state.dayNotes[String(dayId)] || []).map(n => n.id === tempId ? result.note : n),
|
||||
}
|
||||
}))
|
||||
return result.note
|
||||
} catch (err: unknown) {
|
||||
set(state => ({
|
||||
dayNotes: {
|
||||
...state.dayNotes,
|
||||
[String(dayId)]: (state.dayNotes[String(dayId)] || []).filter(n => n.id !== tempId),
|
||||
}
|
||||
}))
|
||||
throw new Error(getApiErrorMessage(err, 'Error adding note'))
|
||||
}
|
||||
},
|
||||
|
||||
updateDayNote: async (tripId, dayId, id, data) => {
|
||||
try {
|
||||
const result = await dayNotesApi.update(tripId, dayId, id, data)
|
||||
set(state => ({
|
||||
dayNotes: {
|
||||
...state.dayNotes,
|
||||
[String(dayId)]: (state.dayNotes[String(dayId)] || []).map(n => n.id === id ? result.note : n),
|
||||
}
|
||||
}))
|
||||
return result.note
|
||||
} catch (err: unknown) {
|
||||
throw new Error(getApiErrorMessage(err, 'Error updating note'))
|
||||
}
|
||||
},
|
||||
|
||||
deleteDayNote: async (tripId, dayId, id) => {
|
||||
const prev = get().dayNotes
|
||||
set(state => ({
|
||||
dayNotes: {
|
||||
...state.dayNotes,
|
||||
[String(dayId)]: (state.dayNotes[String(dayId)] || []).filter(n => n.id !== id),
|
||||
}
|
||||
}))
|
||||
try {
|
||||
await dayNotesApi.delete(tripId, dayId, id)
|
||||
} catch (err: unknown) {
|
||||
set({ dayNotes: prev })
|
||||
throw new Error(getApiErrorMessage(err, 'Error deleting note'))
|
||||
}
|
||||
},
|
||||
|
||||
moveDayNote: async (tripId, fromDayId, toDayId, noteId, sort_order = 9999) => {
|
||||
const state = get()
|
||||
const note = (state.dayNotes[String(fromDayId)] || []).find(n => n.id === noteId)
|
||||
if (!note) return
|
||||
|
||||
set(s => ({
|
||||
dayNotes: {
|
||||
...s.dayNotes,
|
||||
[String(fromDayId)]: (s.dayNotes[String(fromDayId)] || []).filter(n => n.id !== noteId),
|
||||
}
|
||||
}))
|
||||
|
||||
try {
|
||||
await dayNotesApi.delete(tripId, fromDayId, noteId)
|
||||
const result = await dayNotesApi.create(tripId, toDayId, {
|
||||
text: note.text, time: note.time, icon: note.icon, sort_order,
|
||||
})
|
||||
set(s => ({
|
||||
dayNotes: {
|
||||
...s.dayNotes,
|
||||
[String(toDayId)]: [...(s.dayNotes[String(toDayId)] || []), result.note],
|
||||
}
|
||||
}))
|
||||
} catch (err: unknown) {
|
||||
set(s => ({
|
||||
dayNotes: {
|
||||
...s.dayNotes,
|
||||
[String(fromDayId)]: [...(s.dayNotes[String(fromDayId)] || []), note],
|
||||
}
|
||||
}))
|
||||
throw new Error(getApiErrorMessage(err, 'Error moving note'))
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,44 @@
|
||||
import { filesApi } from '../../api/client'
|
||||
import type { StoreApi } from 'zustand'
|
||||
import type { TripStoreState } from '../tripStore'
|
||||
import type { TripFile } from '../../types'
|
||||
import { getApiErrorMessage } from '../../types'
|
||||
|
||||
type SetState = StoreApi<TripStoreState>['setState']
|
||||
type GetState = StoreApi<TripStoreState>['getState']
|
||||
|
||||
export interface FilesSlice {
|
||||
loadFiles: (tripId: number | string) => Promise<void>
|
||||
addFile: (tripId: number | string, formData: FormData) => Promise<TripFile>
|
||||
deleteFile: (tripId: number | string, id: number) => Promise<void>
|
||||
}
|
||||
|
||||
export const createFilesSlice = (set: SetState, get: GetState): FilesSlice => ({
|
||||
loadFiles: async (tripId) => {
|
||||
try {
|
||||
const data = await filesApi.list(tripId)
|
||||
set({ files: data.files })
|
||||
} catch (err: unknown) {
|
||||
console.error('Failed to load files:', err)
|
||||
}
|
||||
},
|
||||
|
||||
addFile: async (tripId, formData) => {
|
||||
try {
|
||||
const data = await filesApi.upload(tripId, formData)
|
||||
set(state => ({ files: [data.file, ...state.files] }))
|
||||
return data.file
|
||||
} catch (err: unknown) {
|
||||
throw new Error(getApiErrorMessage(err, 'Error uploading file'))
|
||||
}
|
||||
},
|
||||
|
||||
deleteFile: async (tripId, id) => {
|
||||
try {
|
||||
await filesApi.delete(tripId, id)
|
||||
set(state => ({ files: state.files.filter(f => f.id !== id) }))
|
||||
} catch (err: unknown) {
|
||||
throw new Error(getApiErrorMessage(err, 'Error deleting file'))
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,67 @@
|
||||
import { packingApi } from '../../api/client'
|
||||
import type { StoreApi } from 'zustand'
|
||||
import type { TripStoreState } from '../tripStore'
|
||||
import type { PackingItem } from '../../types'
|
||||
import { getApiErrorMessage } from '../../types'
|
||||
|
||||
type SetState = StoreApi<TripStoreState>['setState']
|
||||
type GetState = StoreApi<TripStoreState>['getState']
|
||||
|
||||
export interface PackingSlice {
|
||||
addPackingItem: (tripId: number | string, data: Partial<PackingItem>) => Promise<PackingItem>
|
||||
updatePackingItem: (tripId: number | string, id: number, data: Partial<PackingItem>) => Promise<PackingItem>
|
||||
deletePackingItem: (tripId: number | string, id: number) => Promise<void>
|
||||
togglePackingItem: (tripId: number | string, id: number, checked: boolean) => Promise<void>
|
||||
}
|
||||
|
||||
export const createPackingSlice = (set: SetState, get: GetState): PackingSlice => ({
|
||||
addPackingItem: async (tripId, data) => {
|
||||
try {
|
||||
const result = await packingApi.create(tripId, data)
|
||||
set(state => ({ packingItems: [...state.packingItems, result.item] }))
|
||||
return result.item
|
||||
} catch (err: unknown) {
|
||||
throw new Error(getApiErrorMessage(err, 'Error adding item'))
|
||||
}
|
||||
},
|
||||
|
||||
updatePackingItem: async (tripId, id, data) => {
|
||||
try {
|
||||
const result = await packingApi.update(tripId, id, data)
|
||||
set(state => ({
|
||||
packingItems: state.packingItems.map(item => item.id === id ? result.item : item)
|
||||
}))
|
||||
return result.item
|
||||
} catch (err: unknown) {
|
||||
throw new Error(getApiErrorMessage(err, 'Error updating item'))
|
||||
}
|
||||
},
|
||||
|
||||
deletePackingItem: async (tripId, id) => {
|
||||
const prev = get().packingItems
|
||||
set(state => ({ packingItems: state.packingItems.filter(item => item.id !== id) }))
|
||||
try {
|
||||
await packingApi.delete(tripId, id)
|
||||
} catch (err: unknown) {
|
||||
set({ packingItems: prev })
|
||||
throw new Error(getApiErrorMessage(err, 'Error deleting item'))
|
||||
}
|
||||
},
|
||||
|
||||
togglePackingItem: async (tripId, id, checked) => {
|
||||
set(state => ({
|
||||
packingItems: state.packingItems.map(item =>
|
||||
item.id === id ? { ...item, checked: checked ? 1 : 0 } : item
|
||||
)
|
||||
}))
|
||||
try {
|
||||
await packingApi.update(tripId, id, { checked })
|
||||
} catch {
|
||||
set(state => ({
|
||||
packingItems: state.packingItems.map(item =>
|
||||
item.id === id ? { ...item, checked: checked ? 0 : 1 } : item
|
||||
)
|
||||
}))
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,71 @@
|
||||
import { placesApi } from '../../api/client'
|
||||
import type { StoreApi } from 'zustand'
|
||||
import type { TripStoreState } from '../tripStore'
|
||||
import type { Place, Assignment } from '../../types'
|
||||
import { getApiErrorMessage } from '../../types'
|
||||
|
||||
type SetState = StoreApi<TripStoreState>['setState']
|
||||
type GetState = StoreApi<TripStoreState>['getState']
|
||||
|
||||
export interface PlacesSlice {
|
||||
refreshPlaces: (tripId: number | string) => Promise<void>
|
||||
addPlace: (tripId: number | string, placeData: Partial<Place>) => Promise<Place>
|
||||
updatePlace: (tripId: number | string, placeId: number, placeData: Partial<Place>) => Promise<Place>
|
||||
deletePlace: (tripId: number | string, placeId: number) => Promise<void>
|
||||
}
|
||||
|
||||
export const createPlacesSlice = (set: SetState, get: GetState): PlacesSlice => ({
|
||||
refreshPlaces: async (tripId) => {
|
||||
try {
|
||||
const data = await placesApi.list(tripId)
|
||||
set({ places: data.places })
|
||||
} catch (err: unknown) {
|
||||
console.error('Failed to refresh places:', err)
|
||||
}
|
||||
},
|
||||
|
||||
addPlace: async (tripId, placeData) => {
|
||||
try {
|
||||
const data = await placesApi.create(tripId, placeData)
|
||||
set(state => ({ places: [data.place, ...state.places] }))
|
||||
return data.place
|
||||
} catch (err: unknown) {
|
||||
throw new Error(getApiErrorMessage(err, 'Error adding place'))
|
||||
}
|
||||
},
|
||||
|
||||
updatePlace: async (tripId, placeId, placeData) => {
|
||||
try {
|
||||
const data = await placesApi.update(tripId, placeId, placeData)
|
||||
set(state => ({
|
||||
places: state.places.map(p => p.id === placeId ? data.place : p),
|
||||
assignments: Object.fromEntries(
|
||||
Object.entries(state.assignments).map(([dayId, items]) => [
|
||||
dayId,
|
||||
items.map((a: Assignment) => a.place?.id === placeId ? { ...a, place: { ...data.place, place_time: a.place.place_time, end_time: a.place.end_time } } : a)
|
||||
])
|
||||
),
|
||||
}))
|
||||
return data.place
|
||||
} catch (err: unknown) {
|
||||
throw new Error(getApiErrorMessage(err, 'Error updating place'))
|
||||
}
|
||||
},
|
||||
|
||||
deletePlace: async (tripId, placeId) => {
|
||||
try {
|
||||
await placesApi.delete(tripId, placeId)
|
||||
set(state => ({
|
||||
places: state.places.filter(p => p.id !== placeId),
|
||||
assignments: Object.fromEntries(
|
||||
Object.entries(state.assignments).map(([dayId, items]) => [
|
||||
dayId,
|
||||
items.filter((a: Assignment) => a.place?.id !== placeId)
|
||||
])
|
||||
),
|
||||
}))
|
||||
} catch (err: unknown) {
|
||||
throw new Error(getApiErrorMessage(err, 'Error deleting place'))
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,239 @@
|
||||
import type { StoreApi } from 'zustand'
|
||||
import type { TripStoreState } from '../tripStore'
|
||||
import type { Assignment, Place, Day, DayNote, PackingItem, BudgetItem, BudgetMember, Reservation, Trip, TripFile, WebSocketEvent } from '../../types'
|
||||
|
||||
type SetState = StoreApi<TripStoreState>['setState']
|
||||
|
||||
/**
|
||||
* Applies a remote WebSocket event to the local Zustand store, keeping state in sync across collaborators.
|
||||
* Each event type maps to an immutable state update (create/update/delete) for the relevant entity.
|
||||
*/
|
||||
export function handleRemoteEvent(set: SetState, event: WebSocketEvent): void {
|
||||
const { type, ...payload } = event
|
||||
|
||||
set(state => {
|
||||
switch (type) {
|
||||
// Places
|
||||
case 'place:created':
|
||||
if (state.places.some(p => p.id === (payload.place as Place).id)) return {}
|
||||
return { places: [payload.place as Place, ...state.places] }
|
||||
case 'place:updated':
|
||||
return {
|
||||
places: state.places.map(p => p.id === (payload.place as Place).id ? payload.place as Place : p),
|
||||
assignments: Object.fromEntries(
|
||||
Object.entries(state.assignments).map(([dayId, items]) => [
|
||||
dayId,
|
||||
items.map(a => a.place?.id === (payload.place as Place).id ? { ...a, place: payload.place as Place } : a)
|
||||
])
|
||||
),
|
||||
}
|
||||
case 'place:deleted':
|
||||
return {
|
||||
places: state.places.filter(p => p.id !== payload.placeId),
|
||||
assignments: Object.fromEntries(
|
||||
Object.entries(state.assignments).map(([dayId, items]) => [
|
||||
dayId,
|
||||
items.filter(a => a.place?.id !== payload.placeId)
|
||||
])
|
||||
),
|
||||
}
|
||||
|
||||
// Assignments
|
||||
case 'assignment:created': {
|
||||
const dayKey = String((payload.assignment as Assignment).day_id)
|
||||
const existing = (state.assignments[dayKey] || [])
|
||||
const placeId = (payload.assignment as Assignment).place?.id || (payload.assignment as Assignment).place_id
|
||||
if (existing.some(a => a.id === (payload.assignment as Assignment).id || (placeId && a.place?.id === placeId))) {
|
||||
const hasTempVersion = existing.some(a => a.id < 0 && a.place?.id === placeId)
|
||||
if (hasTempVersion) {
|
||||
return {
|
||||
assignments: {
|
||||
...state.assignments,
|
||||
[dayKey]: existing.map(a => (a.id < 0 && a.place?.id === placeId) ? payload.assignment as Assignment : a),
|
||||
}
|
||||
}
|
||||
}
|
||||
return {}
|
||||
}
|
||||
return {
|
||||
assignments: {
|
||||
...state.assignments,
|
||||
[dayKey]: [...existing, payload.assignment as Assignment],
|
||||
}
|
||||
}
|
||||
}
|
||||
case 'assignment:updated': {
|
||||
const dayKey = String((payload.assignment as Assignment).day_id)
|
||||
return {
|
||||
assignments: {
|
||||
...state.assignments,
|
||||
[dayKey]: (state.assignments[dayKey] || []).map(a =>
|
||||
a.id === (payload.assignment as Assignment).id ? { ...a, ...(payload.assignment as Assignment) } : a
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
case 'assignment:deleted': {
|
||||
const dayKey = String(payload.dayId)
|
||||
return {
|
||||
assignments: {
|
||||
...state.assignments,
|
||||
[dayKey]: (state.assignments[dayKey] || []).filter(a => a.id !== payload.assignmentId),
|
||||
}
|
||||
}
|
||||
}
|
||||
case 'assignment:moved': {
|
||||
const oldKey = String(payload.oldDayId)
|
||||
const newKey = String(payload.newDayId)
|
||||
const movedAssignment = payload.assignment as Assignment
|
||||
return {
|
||||
assignments: {
|
||||
...state.assignments,
|
||||
[oldKey]: (state.assignments[oldKey] || []).filter(a => a.id !== movedAssignment.id),
|
||||
[newKey]: [...(state.assignments[newKey] || []).filter(a => a.id !== movedAssignment.id), movedAssignment],
|
||||
}
|
||||
}
|
||||
}
|
||||
case 'assignment:reordered': {
|
||||
const dayKey = String(payload.dayId)
|
||||
const currentItems = state.assignments[dayKey] || []
|
||||
const orderedIds: number[] = payload.orderedIds || []
|
||||
const reordered = orderedIds.map((id, idx) => {
|
||||
const item = currentItems.find(a => a.id === id)
|
||||
return item ? { ...item, order_index: idx } : null
|
||||
}).filter((item): item is Assignment => item !== null)
|
||||
return {
|
||||
assignments: {
|
||||
...state.assignments,
|
||||
[dayKey]: reordered,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Days
|
||||
case 'day:created':
|
||||
if (state.days.some(d => d.id === (payload.day as Day).id)) return {}
|
||||
return { days: [...state.days, payload.day as Day] }
|
||||
case 'day:updated':
|
||||
return {
|
||||
days: state.days.map(d => d.id === (payload.day as Day).id ? payload.day as Day : d),
|
||||
}
|
||||
case 'day:deleted': {
|
||||
const removedDayId = String(payload.dayId)
|
||||
const newAssignments = { ...state.assignments }
|
||||
delete newAssignments[removedDayId]
|
||||
const newDayNotes = { ...state.dayNotes }
|
||||
delete newDayNotes[removedDayId]
|
||||
return {
|
||||
days: state.days.filter(d => d.id !== payload.dayId),
|
||||
assignments: newAssignments,
|
||||
dayNotes: newDayNotes,
|
||||
}
|
||||
}
|
||||
|
||||
// Day Notes
|
||||
case 'dayNote:created': {
|
||||
const dayKey = String(payload.dayId)
|
||||
const existingNotes = (state.dayNotes[dayKey] || [])
|
||||
if (existingNotes.some(n => n.id === (payload.note as DayNote).id)) return {}
|
||||
return {
|
||||
dayNotes: {
|
||||
...state.dayNotes,
|
||||
[dayKey]: [...existingNotes, payload.note as DayNote],
|
||||
}
|
||||
}
|
||||
}
|
||||
case 'dayNote:updated': {
|
||||
const dayKey = String(payload.dayId)
|
||||
return {
|
||||
dayNotes: {
|
||||
...state.dayNotes,
|
||||
[dayKey]: (state.dayNotes[dayKey] || []).map(n => n.id === (payload.note as DayNote).id ? payload.note as DayNote : n),
|
||||
}
|
||||
}
|
||||
}
|
||||
case 'dayNote:deleted': {
|
||||
const dayKey = String(payload.dayId)
|
||||
return {
|
||||
dayNotes: {
|
||||
...state.dayNotes,
|
||||
[dayKey]: (state.dayNotes[dayKey] || []).filter(n => n.id !== payload.noteId),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Packing
|
||||
case 'packing:created':
|
||||
if (state.packingItems.some(i => i.id === (payload.item as PackingItem).id)) return {}
|
||||
return { packingItems: [...state.packingItems, payload.item as PackingItem] }
|
||||
case 'packing:updated':
|
||||
return {
|
||||
packingItems: state.packingItems.map(i => i.id === (payload.item as PackingItem).id ? payload.item as PackingItem : i),
|
||||
}
|
||||
case 'packing:deleted':
|
||||
return {
|
||||
packingItems: state.packingItems.filter(i => i.id !== payload.itemId),
|
||||
}
|
||||
|
||||
// Budget
|
||||
case 'budget:created':
|
||||
if (state.budgetItems.some(i => i.id === (payload.item as BudgetItem).id)) return {}
|
||||
return { budgetItems: [...state.budgetItems, payload.item as BudgetItem] }
|
||||
case 'budget:updated':
|
||||
return {
|
||||
budgetItems: state.budgetItems.map(i => i.id === (payload.item as BudgetItem).id ? payload.item as BudgetItem : i),
|
||||
}
|
||||
case 'budget:deleted':
|
||||
return {
|
||||
budgetItems: state.budgetItems.filter(i => i.id !== payload.itemId),
|
||||
}
|
||||
case 'budget:members-updated':
|
||||
return {
|
||||
budgetItems: state.budgetItems.map(i =>
|
||||
i.id === payload.itemId ? { ...i, members: payload.members as BudgetMember[], persons: payload.persons as number } : i
|
||||
),
|
||||
}
|
||||
case 'budget:member-paid-updated':
|
||||
return {
|
||||
budgetItems: state.budgetItems.map(i =>
|
||||
i.id === payload.itemId
|
||||
? { ...i, members: (i.members || []).map(m => m.user_id === payload.userId ? { ...m, paid: payload.paid } : m) }
|
||||
: i
|
||||
),
|
||||
}
|
||||
|
||||
// Reservations
|
||||
case 'reservation:created':
|
||||
if (state.reservations.some(r => r.id === (payload.reservation as Reservation).id)) return {}
|
||||
return { reservations: [payload.reservation as Reservation, ...state.reservations] }
|
||||
case 'reservation:updated':
|
||||
return {
|
||||
reservations: state.reservations.map(r => r.id === (payload.reservation as Reservation).id ? payload.reservation as Reservation : r),
|
||||
}
|
||||
case 'reservation:deleted':
|
||||
return {
|
||||
reservations: state.reservations.filter(r => r.id !== payload.reservationId),
|
||||
}
|
||||
|
||||
// Trip
|
||||
case 'trip:updated':
|
||||
return { trip: payload.trip as Trip }
|
||||
|
||||
// Files
|
||||
case 'file:created':
|
||||
if (state.files.some(f => f.id === (payload.file as TripFile).id)) return {}
|
||||
return { files: [payload.file as TripFile, ...state.files] }
|
||||
case 'file:updated':
|
||||
return {
|
||||
files: state.files.map(f => f.id === (payload.file as TripFile).id ? payload.file as TripFile : f),
|
||||
}
|
||||
case 'file:deleted':
|
||||
return {
|
||||
files: state.files.filter(f => f.id !== payload.fileId),
|
||||
}
|
||||
|
||||
default:
|
||||
return {}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { reservationsApi } from '../../api/client'
|
||||
import type { StoreApi } from 'zustand'
|
||||
import type { TripStoreState } from '../tripStore'
|
||||
import type { Reservation } from '../../types'
|
||||
import { getApiErrorMessage } from '../../types'
|
||||
|
||||
type SetState = StoreApi<TripStoreState>['setState']
|
||||
type GetState = StoreApi<TripStoreState>['getState']
|
||||
|
||||
export interface ReservationsSlice {
|
||||
loadReservations: (tripId: number | string) => Promise<void>
|
||||
addReservation: (tripId: number | string, data: Partial<Reservation>) => Promise<Reservation>
|
||||
updateReservation: (tripId: number | string, id: number, data: Partial<Reservation>) => Promise<Reservation>
|
||||
toggleReservationStatus: (tripId: number | string, id: number) => Promise<void>
|
||||
deleteReservation: (tripId: number | string, id: number) => Promise<void>
|
||||
}
|
||||
|
||||
export const createReservationsSlice = (set: SetState, get: GetState): ReservationsSlice => ({
|
||||
loadReservations: async (tripId) => {
|
||||
try {
|
||||
const data = await reservationsApi.list(tripId)
|
||||
set({ reservations: data.reservations })
|
||||
} catch (err: unknown) {
|
||||
console.error('Failed to load reservations:', err)
|
||||
}
|
||||
},
|
||||
|
||||
addReservation: async (tripId, data) => {
|
||||
try {
|
||||
const result = await reservationsApi.create(tripId, data)
|
||||
set(state => ({ reservations: [result.reservation, ...state.reservations] }))
|
||||
return result.reservation
|
||||
} catch (err: unknown) {
|
||||
throw new Error(getApiErrorMessage(err, 'Error creating reservation'))
|
||||
}
|
||||
},
|
||||
|
||||
updateReservation: async (tripId, id, data) => {
|
||||
try {
|
||||
const result = await reservationsApi.update(tripId, id, data)
|
||||
set(state => ({
|
||||
reservations: state.reservations.map(r => r.id === id ? result.reservation : r)
|
||||
}))
|
||||
return result.reservation
|
||||
} catch (err: unknown) {
|
||||
throw new Error(getApiErrorMessage(err, 'Error updating reservation'))
|
||||
}
|
||||
},
|
||||
|
||||
toggleReservationStatus: async (tripId, id) => {
|
||||
const prev = get().reservations
|
||||
const current = prev.find(r => r.id === id)
|
||||
if (!current) return
|
||||
const newStatus: 'pending' | 'confirmed' = current.status === 'confirmed' ? 'pending' : 'confirmed'
|
||||
set(state => ({
|
||||
reservations: state.reservations.map(r => r.id === id ? { ...r, status: newStatus } : r)
|
||||
}))
|
||||
try {
|
||||
await reservationsApi.update(tripId, id, { status: newStatus })
|
||||
} catch {
|
||||
set({ reservations: prev })
|
||||
}
|
||||
},
|
||||
|
||||
deleteReservation: async (tripId, id) => {
|
||||
try {
|
||||
await reservationsApi.delete(tripId, id)
|
||||
set(state => ({ reservations: state.reservations.filter(r => r.id !== id) }))
|
||||
} catch (err: unknown) {
|
||||
throw new Error(getApiErrorMessage(err, 'Error deleting reservation'))
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -1,863 +0,0 @@
|
||||
import { create } from 'zustand'
|
||||
import { tripsApi, daysApi, placesApi, assignmentsApi, packingApi, tagsApi, categoriesApi, budgetApi, filesApi, reservationsApi, dayNotesApi } from '../api/client'
|
||||
|
||||
export const useTripStore = create((set, get) => ({
|
||||
trip: null,
|
||||
days: [],
|
||||
places: [],
|
||||
assignments: {}, // { [dayId]: [assignment objects] }
|
||||
dayNotes: {}, // { [dayId]: [note objects] }
|
||||
packingItems: [],
|
||||
tags: [],
|
||||
categories: [],
|
||||
budgetItems: [],
|
||||
files: [],
|
||||
reservations: [],
|
||||
selectedDayId: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
|
||||
setSelectedDay: (dayId) => set({ selectedDayId: dayId }),
|
||||
|
||||
// Handle remote WebSocket events without making API calls
|
||||
handleRemoteEvent: (event) => {
|
||||
const { type, ...payload } = event
|
||||
|
||||
set(state => {
|
||||
switch (type) {
|
||||
// Places
|
||||
case 'place:created':
|
||||
if (state.places.some(p => p.id === payload.place.id)) return {}
|
||||
return { places: [payload.place, ...state.places] }
|
||||
case 'place:updated':
|
||||
return {
|
||||
places: state.places.map(p => p.id === payload.place.id ? payload.place : p),
|
||||
assignments: Object.fromEntries(
|
||||
Object.entries(state.assignments).map(([dayId, items]) => [
|
||||
dayId,
|
||||
items.map(a => a.place?.id === payload.place.id ? { ...a, place: payload.place } : a)
|
||||
])
|
||||
),
|
||||
}
|
||||
case 'place:deleted':
|
||||
return {
|
||||
places: state.places.filter(p => p.id !== payload.placeId),
|
||||
assignments: Object.fromEntries(
|
||||
Object.entries(state.assignments).map(([dayId, items]) => [
|
||||
dayId,
|
||||
items.filter(a => a.place?.id !== payload.placeId)
|
||||
])
|
||||
),
|
||||
}
|
||||
|
||||
// Assignments
|
||||
case 'assignment:created': {
|
||||
const dayKey = String(payload.assignment.day_id)
|
||||
const existing = (state.assignments[dayKey] || [])
|
||||
// Skip if already present (by id OR by place_id to handle optimistic temp ids)
|
||||
const placeId = payload.assignment.place?.id || payload.assignment.place_id
|
||||
if (existing.some(a => a.id === payload.assignment.id || (placeId && a.place?.id === placeId))) {
|
||||
// Replace temp entry with server version if needed
|
||||
const hasTempVersion = existing.some(a => a.id < 0 && a.place?.id === placeId)
|
||||
if (hasTempVersion) {
|
||||
return {
|
||||
assignments: {
|
||||
...state.assignments,
|
||||
[dayKey]: existing.map(a => (a.id < 0 && a.place?.id === placeId) ? payload.assignment : a),
|
||||
}
|
||||
}
|
||||
}
|
||||
return {}
|
||||
}
|
||||
return {
|
||||
assignments: {
|
||||
...state.assignments,
|
||||
[dayKey]: [...existing, payload.assignment],
|
||||
}
|
||||
}
|
||||
}
|
||||
case 'assignment:updated': {
|
||||
const dayKey = String(payload.assignment.day_id)
|
||||
return {
|
||||
assignments: {
|
||||
...state.assignments,
|
||||
[dayKey]: (state.assignments[dayKey] || []).map(a =>
|
||||
a.id === payload.assignment.id ? { ...a, ...payload.assignment } : a
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
case 'assignment:deleted': {
|
||||
const dayKey = String(payload.dayId)
|
||||
return {
|
||||
assignments: {
|
||||
...state.assignments,
|
||||
[dayKey]: (state.assignments[dayKey] || []).filter(a => a.id !== payload.assignmentId),
|
||||
}
|
||||
}
|
||||
}
|
||||
case 'assignment:moved': {
|
||||
const oldKey = String(payload.oldDayId)
|
||||
const newKey = String(payload.newDayId)
|
||||
const movedAssignment = payload.assignment
|
||||
return {
|
||||
assignments: {
|
||||
...state.assignments,
|
||||
[oldKey]: (state.assignments[oldKey] || []).filter(a => a.id !== movedAssignment.id),
|
||||
[newKey]: [...(state.assignments[newKey] || []).filter(a => a.id !== movedAssignment.id), movedAssignment],
|
||||
}
|
||||
}
|
||||
}
|
||||
case 'assignment:reordered': {
|
||||
const dayKey = String(payload.dayId)
|
||||
const currentItems = state.assignments[dayKey] || []
|
||||
const orderedIds = payload.orderedIds || []
|
||||
const reordered = orderedIds.map((id, idx) => {
|
||||
const item = currentItems.find(a => a.id === id)
|
||||
return item ? { ...item, order_index: idx } : null
|
||||
}).filter(Boolean)
|
||||
return {
|
||||
assignments: {
|
||||
...state.assignments,
|
||||
[dayKey]: reordered,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Days
|
||||
case 'day:created':
|
||||
if (state.days.some(d => d.id === payload.day.id)) return {}
|
||||
return { days: [...state.days, payload.day] }
|
||||
case 'day:updated':
|
||||
return {
|
||||
days: state.days.map(d => d.id === payload.day.id ? payload.day : d),
|
||||
}
|
||||
case 'day:deleted': {
|
||||
const removedDayId = String(payload.dayId)
|
||||
const newAssignments = { ...state.assignments }
|
||||
delete newAssignments[removedDayId]
|
||||
const newDayNotes = { ...state.dayNotes }
|
||||
delete newDayNotes[removedDayId]
|
||||
return {
|
||||
days: state.days.filter(d => d.id !== payload.dayId),
|
||||
assignments: newAssignments,
|
||||
dayNotes: newDayNotes,
|
||||
}
|
||||
}
|
||||
|
||||
// Day Notes
|
||||
case 'dayNote:created': {
|
||||
const dayKey = String(payload.dayId)
|
||||
const existingNotes = (state.dayNotes[dayKey] || [])
|
||||
if (existingNotes.some(n => n.id === payload.note.id)) return {}
|
||||
return {
|
||||
dayNotes: {
|
||||
...state.dayNotes,
|
||||
[dayKey]: [...existingNotes, payload.note],
|
||||
}
|
||||
}
|
||||
}
|
||||
case 'dayNote:updated': {
|
||||
const dayKey = String(payload.dayId)
|
||||
return {
|
||||
dayNotes: {
|
||||
...state.dayNotes,
|
||||
[dayKey]: (state.dayNotes[dayKey] || []).map(n => n.id === payload.note.id ? payload.note : n),
|
||||
}
|
||||
}
|
||||
}
|
||||
case 'dayNote:deleted': {
|
||||
const dayKey = String(payload.dayId)
|
||||
return {
|
||||
dayNotes: {
|
||||
...state.dayNotes,
|
||||
[dayKey]: (state.dayNotes[dayKey] || []).filter(n => n.id !== payload.noteId),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Packing
|
||||
case 'packing:created':
|
||||
if (state.packingItems.some(i => i.id === payload.item.id)) return {}
|
||||
return { packingItems: [...state.packingItems, payload.item] }
|
||||
case 'packing:updated':
|
||||
return {
|
||||
packingItems: state.packingItems.map(i => i.id === payload.item.id ? payload.item : i),
|
||||
}
|
||||
case 'packing:deleted':
|
||||
return {
|
||||
packingItems: state.packingItems.filter(i => i.id !== payload.itemId),
|
||||
}
|
||||
|
||||
// Budget
|
||||
case 'budget:created':
|
||||
if (state.budgetItems.some(i => i.id === payload.item.id)) return {}
|
||||
return { budgetItems: [...state.budgetItems, payload.item] }
|
||||
case 'budget:updated':
|
||||
return {
|
||||
budgetItems: state.budgetItems.map(i => i.id === payload.item.id ? payload.item : i),
|
||||
}
|
||||
case 'budget:deleted':
|
||||
return {
|
||||
budgetItems: state.budgetItems.filter(i => i.id !== payload.itemId),
|
||||
}
|
||||
case 'budget:members-updated':
|
||||
return {
|
||||
budgetItems: state.budgetItems.map(i =>
|
||||
i.id === payload.itemId ? { ...i, members: payload.members, persons: payload.persons } : i
|
||||
),
|
||||
}
|
||||
case 'budget:member-paid-updated':
|
||||
return {
|
||||
budgetItems: state.budgetItems.map(i =>
|
||||
i.id === payload.itemId
|
||||
? { ...i, members: (i.members || []).map(m => m.user_id === payload.userId ? { ...m, paid: payload.paid } : m) }
|
||||
: i
|
||||
),
|
||||
}
|
||||
|
||||
// Reservations
|
||||
case 'reservation:created':
|
||||
if (state.reservations.some(r => r.id === payload.reservation.id)) return {}
|
||||
return { reservations: [payload.reservation, ...state.reservations] }
|
||||
case 'reservation:updated':
|
||||
return {
|
||||
reservations: state.reservations.map(r => r.id === payload.reservation.id ? payload.reservation : r),
|
||||
}
|
||||
case 'reservation:deleted':
|
||||
return {
|
||||
reservations: state.reservations.filter(r => r.id !== payload.reservationId),
|
||||
}
|
||||
|
||||
// Trip
|
||||
case 'trip:updated':
|
||||
return { trip: payload.trip }
|
||||
|
||||
// Files
|
||||
case 'file:created':
|
||||
if (state.files.some(f => f.id === payload.file.id)) return {}
|
||||
return { files: [payload.file, ...state.files] }
|
||||
case 'file:updated':
|
||||
return {
|
||||
files: state.files.map(f => f.id === payload.file.id ? payload.file : f),
|
||||
}
|
||||
case 'file:deleted':
|
||||
return {
|
||||
files: state.files.filter(f => f.id !== payload.fileId),
|
||||
}
|
||||
|
||||
default:
|
||||
return {}
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
// Load everything for a trip
|
||||
loadTrip: async (tripId) => {
|
||||
set({ isLoading: true, error: null })
|
||||
try {
|
||||
const [tripData, daysData, placesData, packingData, tagsData, categoriesData] = await Promise.all([
|
||||
tripsApi.get(tripId),
|
||||
daysApi.list(tripId),
|
||||
placesApi.list(tripId),
|
||||
packingApi.list(tripId),
|
||||
tagsApi.list(),
|
||||
categoriesApi.list(),
|
||||
])
|
||||
|
||||
const assignmentsMap = {}
|
||||
const dayNotesMap = {}
|
||||
for (const day of daysData.days) {
|
||||
assignmentsMap[String(day.id)] = day.assignments || []
|
||||
dayNotesMap[String(day.id)] = day.notes_items || []
|
||||
}
|
||||
|
||||
set({
|
||||
trip: tripData.trip,
|
||||
days: daysData.days,
|
||||
places: placesData.places,
|
||||
assignments: assignmentsMap,
|
||||
dayNotes: dayNotesMap,
|
||||
packingItems: packingData.items,
|
||||
tags: tagsData.tags,
|
||||
categories: categoriesData.categories,
|
||||
isLoading: false,
|
||||
})
|
||||
} catch (err) {
|
||||
set({ isLoading: false, error: err.message })
|
||||
throw err
|
||||
}
|
||||
},
|
||||
|
||||
refreshDays: async (tripId) => {
|
||||
try {
|
||||
const daysData = await daysApi.list(tripId)
|
||||
const assignmentsMap = {}
|
||||
const dayNotesMap = {}
|
||||
for (const day of daysData.days) {
|
||||
assignmentsMap[String(day.id)] = day.assignments || []
|
||||
dayNotesMap[String(day.id)] = day.notes_items || []
|
||||
}
|
||||
set({ days: daysData.days, assignments: assignmentsMap, dayNotes: dayNotesMap })
|
||||
} catch (err) {
|
||||
console.error('Failed to refresh days:', err)
|
||||
}
|
||||
},
|
||||
|
||||
refreshPlaces: async (tripId) => {
|
||||
try {
|
||||
const data = await placesApi.list(tripId)
|
||||
set({ places: data.places })
|
||||
} catch (err) {
|
||||
console.error('Failed to refresh places:', err)
|
||||
}
|
||||
},
|
||||
|
||||
addPlace: async (tripId, placeData) => {
|
||||
try {
|
||||
const data = await placesApi.create(tripId, placeData)
|
||||
set(state => ({ places: [data.place, ...state.places] }))
|
||||
return data.place
|
||||
} catch (err) {
|
||||
throw new Error(err.response?.data?.error || 'Error adding place')
|
||||
}
|
||||
},
|
||||
|
||||
updatePlace: async (tripId, placeId, placeData) => {
|
||||
try {
|
||||
const data = await placesApi.update(tripId, placeId, placeData)
|
||||
set(state => ({
|
||||
places: state.places.map(p => p.id === placeId ? data.place : p),
|
||||
assignments: Object.fromEntries(
|
||||
Object.entries(state.assignments).map(([dayId, items]) => [
|
||||
dayId,
|
||||
items.map(a => a.place?.id === placeId ? { ...a, place: { ...data.place, place_time: a.place.place_time, end_time: a.place.end_time } } : a)
|
||||
])
|
||||
),
|
||||
}))
|
||||
return data.place
|
||||
} catch (err) {
|
||||
throw new Error(err.response?.data?.error || 'Error updating place')
|
||||
}
|
||||
},
|
||||
|
||||
deletePlace: async (tripId, placeId) => {
|
||||
try {
|
||||
await placesApi.delete(tripId, placeId)
|
||||
set(state => ({
|
||||
places: state.places.filter(p => p.id !== placeId),
|
||||
assignments: Object.fromEntries(
|
||||
Object.entries(state.assignments).map(([dayId, items]) => [
|
||||
dayId,
|
||||
items.filter(a => a.place?.id !== placeId)
|
||||
])
|
||||
),
|
||||
}))
|
||||
} catch (err) {
|
||||
throw new Error(err.response?.data?.error || 'Error deleting place')
|
||||
}
|
||||
},
|
||||
|
||||
assignPlaceToDay: async (tripId, dayId, placeId, position) => {
|
||||
const state = get()
|
||||
const place = state.places.find(p => p.id === parseInt(placeId))
|
||||
if (!place) return
|
||||
|
||||
const tempId = Date.now() * -1
|
||||
const current = [...(state.assignments[String(dayId)] || [])]
|
||||
const insertIdx = position != null ? position : current.length
|
||||
const tempAssignment = {
|
||||
id: tempId,
|
||||
day_id: parseInt(dayId),
|
||||
order_index: insertIdx,
|
||||
notes: null,
|
||||
place,
|
||||
}
|
||||
|
||||
current.splice(insertIdx, 0, tempAssignment)
|
||||
set(state => ({
|
||||
assignments: {
|
||||
...state.assignments,
|
||||
[String(dayId)]: current,
|
||||
}
|
||||
}))
|
||||
|
||||
try {
|
||||
const data = await assignmentsApi.create(tripId, dayId, { place_id: placeId })
|
||||
const newAssignment = {
|
||||
...data.assignment,
|
||||
place: data.assignment.place || place,
|
||||
order_index: position != null ? insertIdx : data.assignment.order_index,
|
||||
}
|
||||
set(state => ({
|
||||
assignments: {
|
||||
...state.assignments,
|
||||
[String(dayId)]: state.assignments[String(dayId)].map(
|
||||
a => a.id === tempId ? newAssignment : a
|
||||
),
|
||||
}
|
||||
}))
|
||||
// Reihenfolge am Server aktualisieren und lokalen State mit Server-Antwort synchronisieren
|
||||
if (position != null) {
|
||||
const updated = get().assignments[String(dayId)] || []
|
||||
const orderedIds = updated.map(a => a.id).filter(id => id > 0)
|
||||
if (orderedIds.length > 0) {
|
||||
try {
|
||||
await assignmentsApi.reorder(tripId, dayId, orderedIds)
|
||||
// Lokalen State auf die gesendete Reihenfolge setzen
|
||||
set(state => {
|
||||
const items = state.assignments[String(dayId)] || []
|
||||
const reordered = orderedIds.map((id, idx) => {
|
||||
const item = items.find(a => a.id === id)
|
||||
return item ? { ...item, order_index: idx } : null
|
||||
}).filter(Boolean)
|
||||
return {
|
||||
assignments: {
|
||||
...state.assignments,
|
||||
[String(dayId)]: reordered,
|
||||
}
|
||||
}
|
||||
})
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
return data.assignment
|
||||
} catch (err) {
|
||||
set(state => ({
|
||||
assignments: {
|
||||
...state.assignments,
|
||||
[String(dayId)]: state.assignments[String(dayId)].filter(a => a.id !== tempId),
|
||||
}
|
||||
}))
|
||||
throw new Error(err.response?.data?.error || 'Error assigning place')
|
||||
}
|
||||
},
|
||||
|
||||
removeAssignment: async (tripId, dayId, assignmentId) => {
|
||||
const prevAssignments = get().assignments
|
||||
|
||||
set(state => ({
|
||||
assignments: {
|
||||
...state.assignments,
|
||||
[String(dayId)]: state.assignments[String(dayId)].filter(a => a.id !== assignmentId),
|
||||
}
|
||||
}))
|
||||
|
||||
try {
|
||||
await assignmentsApi.delete(tripId, dayId, assignmentId)
|
||||
} catch (err) {
|
||||
set({ assignments: prevAssignments })
|
||||
throw new Error(err.response?.data?.error || 'Error removing assignment')
|
||||
}
|
||||
},
|
||||
|
||||
reorderAssignments: async (tripId, dayId, orderedIds) => {
|
||||
const prevAssignments = get().assignments
|
||||
const dayItems = get().assignments[String(dayId)] || []
|
||||
const reordered = orderedIds.map((id, idx) => {
|
||||
const item = dayItems.find(a => a.id === id)
|
||||
return item ? { ...item, order_index: idx } : null
|
||||
}).filter(Boolean)
|
||||
|
||||
set(state => ({
|
||||
assignments: {
|
||||
...state.assignments,
|
||||
[String(dayId)]: reordered,
|
||||
}
|
||||
}))
|
||||
|
||||
try {
|
||||
await assignmentsApi.reorder(tripId, dayId, orderedIds)
|
||||
} catch (err) {
|
||||
set({ assignments: prevAssignments })
|
||||
throw new Error(err.response?.data?.error || 'Error reordering')
|
||||
}
|
||||
},
|
||||
|
||||
moveAssignment: async (tripId, assignmentId, fromDayId, toDayId, toOrderIndex = null) => {
|
||||
const state = get()
|
||||
const prevAssignments = state.assignments
|
||||
const assignment = (state.assignments[String(fromDayId)] || []).find(a => a.id === assignmentId)
|
||||
if (!assignment) return
|
||||
|
||||
const toItems = (state.assignments[String(toDayId)] || []).slice().sort((a, b) => a.order_index - b.order_index)
|
||||
const insertAt = toOrderIndex !== null ? toOrderIndex : toItems.length
|
||||
|
||||
// Build new order for target day with item inserted at correct position
|
||||
const newToItems = [...toItems]
|
||||
newToItems.splice(insertAt, 0, { ...assignment, day_id: parseInt(toDayId) })
|
||||
newToItems.forEach((a, i) => { a.order_index = i })
|
||||
|
||||
set(s => ({
|
||||
assignments: {
|
||||
...s.assignments,
|
||||
[String(fromDayId)]: s.assignments[String(fromDayId)].filter(a => a.id !== assignmentId),
|
||||
[String(toDayId)]: newToItems,
|
||||
}
|
||||
}))
|
||||
|
||||
try {
|
||||
await assignmentsApi.move(tripId, assignmentId, toDayId, insertAt)
|
||||
if (newToItems.length > 1) {
|
||||
await assignmentsApi.reorder(tripId, toDayId, newToItems.map(a => a.id))
|
||||
}
|
||||
} catch (err) {
|
||||
set({ assignments: prevAssignments })
|
||||
throw new Error(err.response?.data?.error || 'Error moving assignment')
|
||||
}
|
||||
},
|
||||
|
||||
moveDayNote: async (tripId, fromDayId, toDayId, noteId, sort_order = 9999) => {
|
||||
const state = get()
|
||||
const note = (state.dayNotes[String(fromDayId)] || []).find(n => n.id === noteId)
|
||||
if (!note) return
|
||||
|
||||
set(s => ({
|
||||
dayNotes: {
|
||||
...s.dayNotes,
|
||||
[String(fromDayId)]: (s.dayNotes[String(fromDayId)] || []).filter(n => n.id !== noteId),
|
||||
}
|
||||
}))
|
||||
|
||||
try {
|
||||
await dayNotesApi.delete(tripId, fromDayId, noteId)
|
||||
const result = await dayNotesApi.create(tripId, toDayId, {
|
||||
text: note.text, time: note.time, icon: note.icon, sort_order,
|
||||
})
|
||||
set(s => ({
|
||||
dayNotes: {
|
||||
...s.dayNotes,
|
||||
[String(toDayId)]: [...(s.dayNotes[String(toDayId)] || []), result.note],
|
||||
}
|
||||
}))
|
||||
} catch (err) {
|
||||
set(s => ({
|
||||
dayNotes: {
|
||||
...s.dayNotes,
|
||||
[String(fromDayId)]: [...(s.dayNotes[String(fromDayId)] || []), note],
|
||||
}
|
||||
}))
|
||||
throw new Error(err.response?.data?.error || 'Error moving note')
|
||||
}
|
||||
},
|
||||
|
||||
setAssignments: (assignments) => {
|
||||
set({ assignments })
|
||||
},
|
||||
|
||||
addPackingItem: async (tripId, data) => {
|
||||
try {
|
||||
const result = await packingApi.create(tripId, data)
|
||||
set(state => ({ packingItems: [...state.packingItems, result.item] }))
|
||||
return result.item
|
||||
} catch (err) {
|
||||
throw new Error(err.response?.data?.error || 'Error adding item')
|
||||
}
|
||||
},
|
||||
|
||||
updatePackingItem: async (tripId, id, data) => {
|
||||
try {
|
||||
const result = await packingApi.update(tripId, id, data)
|
||||
set(state => ({
|
||||
packingItems: state.packingItems.map(item => item.id === id ? result.item : item)
|
||||
}))
|
||||
return result.item
|
||||
} catch (err) {
|
||||
throw new Error(err.response?.data?.error || 'Error updating item')
|
||||
}
|
||||
},
|
||||
|
||||
deletePackingItem: async (tripId, id) => {
|
||||
const prev = get().packingItems
|
||||
set(state => ({ packingItems: state.packingItems.filter(item => item.id !== id) }))
|
||||
try {
|
||||
await packingApi.delete(tripId, id)
|
||||
} catch (err) {
|
||||
set({ packingItems: prev })
|
||||
throw new Error(err.response?.data?.error || 'Error deleting item')
|
||||
}
|
||||
},
|
||||
|
||||
togglePackingItem: async (tripId, id, checked) => {
|
||||
set(state => ({
|
||||
packingItems: state.packingItems.map(item =>
|
||||
item.id === id ? { ...item, checked: checked ? 1 : 0 } : item
|
||||
)
|
||||
}))
|
||||
try {
|
||||
await packingApi.update(tripId, id, { checked })
|
||||
} catch (err) {
|
||||
set(state => ({
|
||||
packingItems: state.packingItems.map(item =>
|
||||
item.id === id ? { ...item, checked: checked ? 0 : 1 } : item
|
||||
)
|
||||
}))
|
||||
}
|
||||
},
|
||||
|
||||
updateDayNotes: async (tripId, dayId, notes) => {
|
||||
try {
|
||||
await daysApi.update(tripId, dayId, { notes })
|
||||
set(state => ({
|
||||
days: state.days.map(d => d.id === parseInt(dayId) ? { ...d, notes } : d)
|
||||
}))
|
||||
} catch (err) {
|
||||
throw new Error(err.response?.data?.error || 'Error updating notes')
|
||||
}
|
||||
},
|
||||
|
||||
updateDayTitle: async (tripId, dayId, title) => {
|
||||
try {
|
||||
await daysApi.update(tripId, dayId, { title })
|
||||
set(state => ({
|
||||
days: state.days.map(d => d.id === parseInt(dayId) ? { ...d, title } : d)
|
||||
}))
|
||||
} catch (err) {
|
||||
throw new Error(err.response?.data?.error || 'Error updating day name')
|
||||
}
|
||||
},
|
||||
|
||||
addTag: async (data) => {
|
||||
try {
|
||||
const result = await tagsApi.create(data)
|
||||
set(state => ({ tags: [...state.tags, result.tag] }))
|
||||
return result.tag
|
||||
} catch (err) {
|
||||
throw new Error(err.response?.data?.error || 'Error creating tag')
|
||||
}
|
||||
},
|
||||
|
||||
addCategory: async (data) => {
|
||||
try {
|
||||
const result = await categoriesApi.create(data)
|
||||
set(state => ({ categories: [...state.categories, result.category] }))
|
||||
return result.category
|
||||
} catch (err) {
|
||||
throw new Error(err.response?.data?.error || 'Error creating category')
|
||||
}
|
||||
},
|
||||
|
||||
updateTrip: async (tripId, data) => {
|
||||
try {
|
||||
const result = await tripsApi.update(tripId, data)
|
||||
set({ trip: result.trip })
|
||||
const daysData = await daysApi.list(tripId)
|
||||
const assignmentsMap = {}
|
||||
const dayNotesMap = {}
|
||||
for (const day of daysData.days) {
|
||||
assignmentsMap[String(day.id)] = day.assignments || []
|
||||
dayNotesMap[String(day.id)] = day.notes_items || []
|
||||
}
|
||||
set({ days: daysData.days, assignments: assignmentsMap, dayNotes: dayNotesMap })
|
||||
return result.trip
|
||||
} catch (err) {
|
||||
throw new Error(err.response?.data?.error || 'Error updating trip')
|
||||
}
|
||||
},
|
||||
|
||||
loadBudgetItems: async (tripId) => {
|
||||
try {
|
||||
const data = await budgetApi.list(tripId)
|
||||
set({ budgetItems: data.items })
|
||||
} catch (err) {
|
||||
console.error('Failed to load budget items:', err)
|
||||
}
|
||||
},
|
||||
|
||||
addBudgetItem: async (tripId, data) => {
|
||||
try {
|
||||
const result = await budgetApi.create(tripId, data)
|
||||
set(state => ({ budgetItems: [...state.budgetItems, result.item] }))
|
||||
return result.item
|
||||
} catch (err) {
|
||||
throw new Error(err.response?.data?.error || 'Error adding budget item')
|
||||
}
|
||||
},
|
||||
|
||||
updateBudgetItem: async (tripId, id, data) => {
|
||||
try {
|
||||
const result = await budgetApi.update(tripId, id, data)
|
||||
set(state => ({
|
||||
budgetItems: state.budgetItems.map(item => item.id === id ? result.item : item)
|
||||
}))
|
||||
return result.item
|
||||
} catch (err) {
|
||||
throw new Error(err.response?.data?.error || 'Error updating budget item')
|
||||
}
|
||||
},
|
||||
|
||||
deleteBudgetItem: async (tripId, id) => {
|
||||
const prev = get().budgetItems
|
||||
set(state => ({ budgetItems: state.budgetItems.filter(item => item.id !== id) }))
|
||||
try {
|
||||
await budgetApi.delete(tripId, id)
|
||||
} catch (err) {
|
||||
set({ budgetItems: prev })
|
||||
throw new Error(err.response?.data?.error || 'Error deleting budget item')
|
||||
}
|
||||
},
|
||||
|
||||
setBudgetItemMembers: async (tripId, itemId, userIds) => {
|
||||
const result = await budgetApi.setMembers(tripId, itemId, userIds);
|
||||
set(state => ({
|
||||
budgetItems: state.budgetItems.map(item =>
|
||||
item.id === itemId ? { ...item, members: result.members, persons: result.item.persons } : item
|
||||
)
|
||||
}));
|
||||
return result;
|
||||
},
|
||||
|
||||
toggleBudgetMemberPaid: async (tripId, itemId, userId, paid) => {
|
||||
await budgetApi.togglePaid(tripId, itemId, userId, paid);
|
||||
set(state => ({
|
||||
budgetItems: state.budgetItems.map(item =>
|
||||
item.id === itemId
|
||||
? { ...item, members: (item.members || []).map(m => m.user_id === userId ? { ...m, paid } : m) }
|
||||
: item
|
||||
)
|
||||
}));
|
||||
},
|
||||
|
||||
loadFiles: async (tripId) => {
|
||||
try {
|
||||
const data = await filesApi.list(tripId)
|
||||
set({ files: data.files })
|
||||
} catch (err) {
|
||||
console.error('Failed to load files:', err)
|
||||
}
|
||||
},
|
||||
|
||||
addFile: async (tripId, formData) => {
|
||||
try {
|
||||
const data = await filesApi.upload(tripId, formData)
|
||||
set(state => ({ files: [data.file, ...state.files] }))
|
||||
return data.file
|
||||
} catch (err) {
|
||||
throw new Error(err.response?.data?.error || 'Error uploading file')
|
||||
}
|
||||
},
|
||||
|
||||
deleteFile: async (tripId, id) => {
|
||||
try {
|
||||
await filesApi.delete(tripId, id)
|
||||
set(state => ({ files: state.files.filter(f => f.id !== id) }))
|
||||
} catch (err) {
|
||||
throw new Error(err.response?.data?.error || 'Error deleting file')
|
||||
}
|
||||
},
|
||||
|
||||
loadReservations: async (tripId) => {
|
||||
try {
|
||||
const data = await reservationsApi.list(tripId)
|
||||
set({ reservations: data.reservations })
|
||||
} catch (err) {
|
||||
console.error('Failed to load reservations:', err)
|
||||
}
|
||||
},
|
||||
|
||||
addReservation: async (tripId, data) => {
|
||||
try {
|
||||
const result = await reservationsApi.create(tripId, data)
|
||||
set(state => ({ reservations: [result.reservation, ...state.reservations] }))
|
||||
return result.reservation
|
||||
} catch (err) {
|
||||
throw new Error(err.response?.data?.error || 'Error creating reservation')
|
||||
}
|
||||
},
|
||||
|
||||
updateReservation: async (tripId, id, data) => {
|
||||
try {
|
||||
const result = await reservationsApi.update(tripId, id, data)
|
||||
set(state => ({
|
||||
reservations: state.reservations.map(r => r.id === id ? result.reservation : r)
|
||||
}))
|
||||
return result.reservation
|
||||
} catch (err) {
|
||||
throw new Error(err.response?.data?.error || 'Error updating reservation')
|
||||
}
|
||||
},
|
||||
|
||||
toggleReservationStatus: async (tripId, id) => {
|
||||
const prev = get().reservations
|
||||
const current = prev.find(r => r.id === id)
|
||||
if (!current) return
|
||||
const newStatus = current.status === 'confirmed' ? 'pending' : 'confirmed'
|
||||
set(state => ({
|
||||
reservations: state.reservations.map(r => r.id === id ? { ...r, status: newStatus } : r)
|
||||
}))
|
||||
try {
|
||||
await reservationsApi.update(tripId, id, { status: newStatus })
|
||||
} catch {
|
||||
set({ reservations: prev })
|
||||
}
|
||||
},
|
||||
|
||||
deleteReservation: async (tripId, id) => {
|
||||
try {
|
||||
await reservationsApi.delete(tripId, id)
|
||||
set(state => ({ reservations: state.reservations.filter(r => r.id !== id) }))
|
||||
} catch (err) {
|
||||
throw new Error(err.response?.data?.error || 'Error deleting reservation')
|
||||
}
|
||||
},
|
||||
|
||||
addDayNote: async (tripId, dayId, data) => {
|
||||
const tempId = Date.now() * -1
|
||||
const tempNote = { id: tempId, day_id: dayId, ...data, created_at: new Date().toISOString() }
|
||||
set(state => ({
|
||||
dayNotes: {
|
||||
...state.dayNotes,
|
||||
[String(dayId)]: [...(state.dayNotes[String(dayId)] || []), tempNote],
|
||||
}
|
||||
}))
|
||||
try {
|
||||
const result = await dayNotesApi.create(tripId, dayId, data)
|
||||
set(state => ({
|
||||
dayNotes: {
|
||||
...state.dayNotes,
|
||||
[String(dayId)]: (state.dayNotes[String(dayId)] || []).map(n => n.id === tempId ? result.note : n),
|
||||
}
|
||||
}))
|
||||
return result.note
|
||||
} catch (err) {
|
||||
set(state => ({
|
||||
dayNotes: {
|
||||
...state.dayNotes,
|
||||
[String(dayId)]: (state.dayNotes[String(dayId)] || []).filter(n => n.id !== tempId),
|
||||
}
|
||||
}))
|
||||
throw new Error(err.response?.data?.error || 'Error adding note')
|
||||
}
|
||||
},
|
||||
|
||||
updateDayNote: async (tripId, dayId, id, data) => {
|
||||
try {
|
||||
const result = await dayNotesApi.update(tripId, dayId, id, data)
|
||||
set(state => ({
|
||||
dayNotes: {
|
||||
...state.dayNotes,
|
||||
[String(dayId)]: (state.dayNotes[String(dayId)] || []).map(n => n.id === id ? result.note : n),
|
||||
}
|
||||
}))
|
||||
return result.note
|
||||
} catch (err) {
|
||||
throw new Error(err.response?.data?.error || 'Error updating note')
|
||||
}
|
||||
},
|
||||
|
||||
deleteDayNote: async (tripId, dayId, id) => {
|
||||
const prev = get().dayNotes
|
||||
set(state => ({
|
||||
dayNotes: {
|
||||
...state.dayNotes,
|
||||
[String(dayId)]: (state.dayNotes[String(dayId)] || []).filter(n => n.id !== id),
|
||||
}
|
||||
}))
|
||||
try {
|
||||
await dayNotesApi.delete(tripId, dayId, id)
|
||||
} catch (err) {
|
||||
set({ dayNotes: prev })
|
||||
throw new Error(err.response?.data?.error || 'Error deleting note')
|
||||
}
|
||||
},
|
||||
}))
|
||||
@@ -0,0 +1,175 @@
|
||||
import { create } from 'zustand'
|
||||
import type { StoreApi } from 'zustand'
|
||||
import { tripsApi, daysApi, placesApi, packingApi, tagsApi, categoriesApi } from '../api/client'
|
||||
import { createPlacesSlice } from './slices/placesSlice'
|
||||
import { createAssignmentsSlice } from './slices/assignmentsSlice'
|
||||
import { createDayNotesSlice } from './slices/dayNotesSlice'
|
||||
import { createPackingSlice } from './slices/packingSlice'
|
||||
import { createBudgetSlice } from './slices/budgetSlice'
|
||||
import { createReservationsSlice } from './slices/reservationsSlice'
|
||||
import { createFilesSlice } from './slices/filesSlice'
|
||||
import { handleRemoteEvent } from './slices/remoteEventHandler'
|
||||
import type {
|
||||
Trip, Day, Place, Assignment, DayNote, PackingItem,
|
||||
Tag, Category, BudgetItem, TripFile, Reservation,
|
||||
AssignmentsMap, DayNotesMap, WebSocketEvent,
|
||||
} from '../types'
|
||||
import { getApiErrorMessage } from '../types'
|
||||
import type { PlacesSlice } from './slices/placesSlice'
|
||||
import type { AssignmentsSlice } from './slices/assignmentsSlice'
|
||||
import type { DayNotesSlice } from './slices/dayNotesSlice'
|
||||
import type { PackingSlice } from './slices/packingSlice'
|
||||
import type { BudgetSlice } from './slices/budgetSlice'
|
||||
import type { ReservationsSlice } from './slices/reservationsSlice'
|
||||
import type { FilesSlice } from './slices/filesSlice'
|
||||
|
||||
export interface TripStoreState
|
||||
extends PlacesSlice,
|
||||
AssignmentsSlice,
|
||||
DayNotesSlice,
|
||||
PackingSlice,
|
||||
BudgetSlice,
|
||||
ReservationsSlice,
|
||||
FilesSlice {
|
||||
trip: Trip | null
|
||||
days: Day[]
|
||||
places: Place[]
|
||||
assignments: AssignmentsMap
|
||||
dayNotes: DayNotesMap
|
||||
packingItems: PackingItem[]
|
||||
tags: Tag[]
|
||||
categories: Category[]
|
||||
budgetItems: BudgetItem[]
|
||||
files: TripFile[]
|
||||
reservations: Reservation[]
|
||||
selectedDayId: number | null
|
||||
isLoading: boolean
|
||||
error: string | null
|
||||
|
||||
setSelectedDay: (dayId: number | null) => void
|
||||
handleRemoteEvent: (event: WebSocketEvent) => void
|
||||
loadTrip: (tripId: number | string) => Promise<void>
|
||||
refreshDays: (tripId: number | string) => Promise<void>
|
||||
updateTrip: (tripId: number | string, data: Partial<Trip>) => Promise<Trip>
|
||||
addTag: (data: Partial<Tag>) => Promise<Tag>
|
||||
addCategory: (data: Partial<Category>) => Promise<Category>
|
||||
}
|
||||
|
||||
export const useTripStore = create<TripStoreState>((set, get) => ({
|
||||
trip: null,
|
||||
days: [],
|
||||
places: [],
|
||||
assignments: {},
|
||||
dayNotes: {},
|
||||
packingItems: [],
|
||||
tags: [],
|
||||
categories: [],
|
||||
budgetItems: [],
|
||||
files: [],
|
||||
reservations: [],
|
||||
selectedDayId: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
|
||||
setSelectedDay: (dayId: number | null) => set({ selectedDayId: dayId }),
|
||||
|
||||
handleRemoteEvent: (event: WebSocketEvent) => handleRemoteEvent(set, event),
|
||||
|
||||
loadTrip: async (tripId: number | string) => {
|
||||
set({ isLoading: true, error: null })
|
||||
try {
|
||||
const [tripData, daysData, placesData, packingData, tagsData, categoriesData] = await Promise.all([
|
||||
tripsApi.get(tripId),
|
||||
daysApi.list(tripId),
|
||||
placesApi.list(tripId),
|
||||
packingApi.list(tripId),
|
||||
tagsApi.list(),
|
||||
categoriesApi.list(),
|
||||
])
|
||||
|
||||
const assignmentsMap: AssignmentsMap = {}
|
||||
const dayNotesMap: DayNotesMap = {}
|
||||
for (const day of daysData.days) {
|
||||
assignmentsMap[String(day.id)] = day.assignments || []
|
||||
dayNotesMap[String(day.id)] = day.notes_items || []
|
||||
}
|
||||
|
||||
set({
|
||||
trip: tripData.trip,
|
||||
days: daysData.days,
|
||||
places: placesData.places,
|
||||
assignments: assignmentsMap,
|
||||
dayNotes: dayNotesMap,
|
||||
packingItems: packingData.items,
|
||||
tags: tagsData.tags,
|
||||
categories: categoriesData.categories,
|
||||
isLoading: false,
|
||||
})
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : 'Unknown error'
|
||||
set({ isLoading: false, error: message })
|
||||
throw err
|
||||
}
|
||||
},
|
||||
|
||||
refreshDays: async (tripId: number | string) => {
|
||||
try {
|
||||
const daysData = await daysApi.list(tripId)
|
||||
const assignmentsMap: AssignmentsMap = {}
|
||||
const dayNotesMap: DayNotesMap = {}
|
||||
for (const day of daysData.days) {
|
||||
assignmentsMap[String(day.id)] = day.assignments || []
|
||||
dayNotesMap[String(day.id)] = day.notes_items || []
|
||||
}
|
||||
set({ days: daysData.days, assignments: assignmentsMap, dayNotes: dayNotesMap })
|
||||
} catch (err: unknown) {
|
||||
console.error('Failed to refresh days:', err)
|
||||
}
|
||||
},
|
||||
|
||||
updateTrip: async (tripId: number | string, data: Partial<Trip>) => {
|
||||
try {
|
||||
const result = await tripsApi.update(tripId, data)
|
||||
set({ trip: result.trip })
|
||||
const daysData = await daysApi.list(tripId)
|
||||
const assignmentsMap: AssignmentsMap = {}
|
||||
const dayNotesMap: DayNotesMap = {}
|
||||
for (const day of daysData.days) {
|
||||
assignmentsMap[String(day.id)] = day.assignments || []
|
||||
dayNotesMap[String(day.id)] = day.notes_items || []
|
||||
}
|
||||
set({ days: daysData.days, assignments: assignmentsMap, dayNotes: dayNotesMap })
|
||||
return result.trip
|
||||
} catch (err: unknown) {
|
||||
throw new Error(getApiErrorMessage(err, 'Error updating trip'))
|
||||
}
|
||||
},
|
||||
|
||||
addTag: async (data: Partial<Tag>) => {
|
||||
try {
|
||||
const result = await tagsApi.create(data)
|
||||
set((state) => ({ tags: [...state.tags, result.tag] }))
|
||||
return result.tag
|
||||
} catch (err: unknown) {
|
||||
throw new Error(getApiErrorMessage(err, 'Error creating tag'))
|
||||
}
|
||||
},
|
||||
|
||||
addCategory: async (data: Partial<Category>) => {
|
||||
try {
|
||||
const result = await categoriesApi.create(data)
|
||||
set((state) => ({ categories: [...state.categories, result.category] }))
|
||||
return result.category
|
||||
} catch (err: unknown) {
|
||||
throw new Error(getApiErrorMessage(err, 'Error creating category'))
|
||||
}
|
||||
},
|
||||
|
||||
...createPlacesSlice(set, get),
|
||||
...createAssignmentsSlice(set, get),
|
||||
...createDayNotesSlice(set, get),
|
||||
...createPackingSlice(set, get),
|
||||
...createBudgetSlice(set, get),
|
||||
...createReservationsSlice(set, get),
|
||||
...createFilesSlice(set, get),
|
||||
}))
|
||||
@@ -1,188 +0,0 @@
|
||||
import { create } from 'zustand'
|
||||
import apiClient from '../api/client'
|
||||
|
||||
const ax = apiClient
|
||||
const api = {
|
||||
getPlan: () => ax.get('/addons/vacay/plan').then(r => r.data),
|
||||
updatePlan: (data) => ax.put('/addons/vacay/plan', data).then(r => r.data),
|
||||
updateColor: (color, targetUserId) => ax.put('/addons/vacay/color', { color, target_user_id: targetUserId }).then(r => r.data),
|
||||
invite: (userId) => ax.post('/addons/vacay/invite', { user_id: userId }).then(r => r.data),
|
||||
acceptInvite: (planId) => ax.post('/addons/vacay/invite/accept', { plan_id: planId }).then(r => r.data),
|
||||
declineInvite: (planId) => ax.post('/addons/vacay/invite/decline', { plan_id: planId }).then(r => r.data),
|
||||
cancelInvite: (userId) => ax.post('/addons/vacay/invite/cancel', { user_id: userId }).then(r => r.data),
|
||||
dissolve: () => ax.post('/addons/vacay/dissolve').then(r => r.data),
|
||||
availableUsers: () => ax.get('/addons/vacay/available-users').then(r => r.data),
|
||||
getYears: () => ax.get('/addons/vacay/years').then(r => r.data),
|
||||
addYear: (year) => ax.post('/addons/vacay/years', { year }).then(r => r.data),
|
||||
removeYear: (year) => ax.delete(`/addons/vacay/years/${year}`).then(r => r.data),
|
||||
getEntries: (year) => ax.get(`/addons/vacay/entries/${year}`).then(r => r.data),
|
||||
toggleEntry: (date, targetUserId) => ax.post('/addons/vacay/entries/toggle', { date, target_user_id: targetUserId }).then(r => r.data),
|
||||
toggleCompanyHoliday: (date) => ax.post('/addons/vacay/entries/company-holiday', { date }).then(r => r.data),
|
||||
getStats: (year) => ax.get(`/addons/vacay/stats/${year}`).then(r => r.data),
|
||||
updateStats: (year, days, targetUserId) => ax.put(`/addons/vacay/stats/${year}`, { vacation_days: days, target_user_id: targetUserId }).then(r => r.data),
|
||||
getCountries: () => ax.get('/addons/vacay/holidays/countries').then(r => r.data),
|
||||
getHolidays: (year, country) => ax.get(`/addons/vacay/holidays/${year}/${country}`).then(r => r.data),
|
||||
}
|
||||
|
||||
export const useVacayStore = create((set, get) => ({
|
||||
plan: null,
|
||||
users: [],
|
||||
pendingInvites: [],
|
||||
incomingInvites: [],
|
||||
isOwner: true,
|
||||
isFused: false,
|
||||
years: [],
|
||||
entries: [],
|
||||
companyHolidays: [],
|
||||
stats: [],
|
||||
selectedYear: new Date().getFullYear(),
|
||||
selectedUserId: null,
|
||||
holidays: {}, // date -> { name, localName }
|
||||
loading: false,
|
||||
|
||||
setSelectedYear: (year) => set({ selectedYear: year }),
|
||||
setSelectedUserId: (id) => set({ selectedUserId: id }),
|
||||
|
||||
loadPlan: async () => {
|
||||
const data = await api.getPlan()
|
||||
set({
|
||||
plan: data.plan,
|
||||
users: data.users,
|
||||
pendingInvites: data.pendingInvites,
|
||||
incomingInvites: data.incomingInvites,
|
||||
isOwner: data.isOwner,
|
||||
isFused: data.isFused,
|
||||
})
|
||||
},
|
||||
|
||||
updatePlan: async (updates) => {
|
||||
const data = await api.updatePlan(updates)
|
||||
set({ plan: data.plan })
|
||||
await get().loadEntries()
|
||||
await get().loadStats()
|
||||
await get().loadHolidays()
|
||||
},
|
||||
|
||||
updateColor: async (color, targetUserId) => {
|
||||
await api.updateColor(color, targetUserId)
|
||||
await get().loadPlan()
|
||||
await get().loadEntries()
|
||||
},
|
||||
|
||||
invite: async (userId) => {
|
||||
await api.invite(userId)
|
||||
await get().loadPlan()
|
||||
},
|
||||
|
||||
acceptInvite: async (planId) => {
|
||||
await api.acceptInvite(planId)
|
||||
await get().loadAll()
|
||||
},
|
||||
|
||||
declineInvite: async (planId) => {
|
||||
await api.declineInvite(planId)
|
||||
await get().loadPlan()
|
||||
},
|
||||
|
||||
cancelInvite: async (userId) => {
|
||||
await api.cancelInvite(userId)
|
||||
await get().loadPlan()
|
||||
},
|
||||
|
||||
dissolve: async () => {
|
||||
await api.dissolve()
|
||||
await get().loadAll()
|
||||
},
|
||||
|
||||
loadYears: async () => {
|
||||
const data = await api.getYears()
|
||||
set({ years: data.years })
|
||||
if (data.years.length > 0) {
|
||||
set({ selectedYear: data.years[data.years.length - 1] })
|
||||
}
|
||||
},
|
||||
|
||||
addYear: async (year) => {
|
||||
const data = await api.addYear(year)
|
||||
set({ years: data.years })
|
||||
await get().loadStats(year)
|
||||
},
|
||||
|
||||
removeYear: async (year) => {
|
||||
const data = await api.removeYear(year)
|
||||
set({ years: data.years })
|
||||
},
|
||||
|
||||
loadEntries: async (year) => {
|
||||
const y = year || get().selectedYear
|
||||
const data = await api.getEntries(y)
|
||||
set({ entries: data.entries, companyHolidays: data.companyHolidays })
|
||||
},
|
||||
|
||||
toggleEntry: async (date, targetUserId) => {
|
||||
await api.toggleEntry(date, targetUserId)
|
||||
await get().loadEntries()
|
||||
await get().loadStats()
|
||||
},
|
||||
|
||||
toggleCompanyHoliday: async (date) => {
|
||||
await api.toggleCompanyHoliday(date)
|
||||
await get().loadEntries()
|
||||
},
|
||||
|
||||
loadStats: async (year) => {
|
||||
const y = year || get().selectedYear
|
||||
const data = await api.getStats(y)
|
||||
set({ stats: data.stats })
|
||||
},
|
||||
|
||||
updateVacationDays: async (year, days, targetUserId) => {
|
||||
await api.updateStats(year, days, targetUserId)
|
||||
await get().loadStats(year)
|
||||
},
|
||||
|
||||
loadHolidays: async (year) => {
|
||||
const y = year || get().selectedYear
|
||||
const plan = get().plan
|
||||
if (!plan?.holidays_enabled || !plan?.holidays_region) {
|
||||
set({ holidays: {} })
|
||||
return
|
||||
}
|
||||
const country = plan.holidays_region.split('-')[0]
|
||||
const region = plan.holidays_region.includes('-') ? plan.holidays_region : null
|
||||
try {
|
||||
const data = await api.getHolidays(y, country)
|
||||
// Check if this country HAS regional holidays
|
||||
const hasRegions = data.some(h => h.counties && h.counties.length > 0)
|
||||
// If country has regions but no region selected yet, only show global ones
|
||||
// Actually: don't show ANY holidays until region is selected
|
||||
if (hasRegions && !region) {
|
||||
set({ holidays: {} })
|
||||
return
|
||||
}
|
||||
const map = {}
|
||||
data.forEach(h => {
|
||||
if (h.global || !h.counties || (region && h.counties.includes(region))) {
|
||||
map[h.date] = { name: h.name, localName: h.localName }
|
||||
}
|
||||
})
|
||||
set({ holidays: map })
|
||||
} catch {
|
||||
set({ holidays: {} })
|
||||
}
|
||||
},
|
||||
|
||||
loadAll: async () => {
|
||||
set({ loading: true })
|
||||
try {
|
||||
await get().loadPlan()
|
||||
await get().loadYears()
|
||||
const year = get().selectedYear
|
||||
await get().loadEntries(year)
|
||||
await get().loadStats(year)
|
||||
await get().loadHolidays(year)
|
||||
} finally {
|
||||
set({ loading: false })
|
||||
}
|
||||
},
|
||||
}))
|
||||
@@ -0,0 +1,288 @@
|
||||
import { create } from 'zustand'
|
||||
import apiClient from '../api/client'
|
||||
import type { AxiosResponse } from 'axios'
|
||||
import type { VacayPlan, VacayUser, VacayEntry, VacayStat, HolidaysMap, HolidayInfo } from '../types'
|
||||
|
||||
const ax = apiClient
|
||||
|
||||
interface PendingInvite {
|
||||
user_id: number
|
||||
username: string
|
||||
}
|
||||
|
||||
interface IncomingInvite {
|
||||
plan_id: number
|
||||
owner_username: string
|
||||
}
|
||||
|
||||
interface VacayPlanResponse {
|
||||
plan: VacayPlan
|
||||
users: VacayUser[]
|
||||
pendingInvites: PendingInvite[]
|
||||
incomingInvites: IncomingInvite[]
|
||||
isOwner: boolean
|
||||
isFused: boolean
|
||||
}
|
||||
|
||||
interface VacayYearsResponse {
|
||||
years: number[]
|
||||
}
|
||||
|
||||
interface VacayEntriesResponse {
|
||||
entries: VacayEntry[]
|
||||
companyHolidays: string[]
|
||||
}
|
||||
|
||||
interface VacayStatsResponse {
|
||||
stats: VacayStat[]
|
||||
}
|
||||
|
||||
interface VacayHolidayRaw {
|
||||
date: string
|
||||
name: string
|
||||
localName: string
|
||||
global: boolean
|
||||
counties: string[] | null
|
||||
}
|
||||
|
||||
interface VacayApi {
|
||||
getPlan: () => Promise<VacayPlanResponse>
|
||||
updatePlan: (data: Partial<VacayPlan>) => Promise<{ plan: VacayPlan }>
|
||||
updateColor: (color: string, targetUserId?: number) => Promise<unknown>
|
||||
invite: (userId: number) => Promise<unknown>
|
||||
acceptInvite: (planId: number) => Promise<unknown>
|
||||
declineInvite: (planId: number) => Promise<unknown>
|
||||
cancelInvite: (userId: number) => Promise<unknown>
|
||||
dissolve: () => Promise<unknown>
|
||||
availableUsers: () => Promise<{ users: VacayUser[] }>
|
||||
getYears: () => Promise<VacayYearsResponse>
|
||||
addYear: (year: number) => Promise<VacayYearsResponse>
|
||||
removeYear: (year: number) => Promise<VacayYearsResponse>
|
||||
getEntries: (year: number) => Promise<VacayEntriesResponse>
|
||||
toggleEntry: (date: string, targetUserId?: number) => Promise<unknown>
|
||||
toggleCompanyHoliday: (date: string) => Promise<unknown>
|
||||
getStats: (year: number) => Promise<VacayStatsResponse>
|
||||
updateStats: (year: number, days: number, targetUserId?: number) => Promise<unknown>
|
||||
getCountries: () => Promise<{ countries: string[] }>
|
||||
getHolidays: (year: number, country: string) => Promise<VacayHolidayRaw[]>
|
||||
}
|
||||
|
||||
const api: VacayApi = {
|
||||
getPlan: () => ax.get('/addons/vacay/plan').then((r: AxiosResponse) => r.data),
|
||||
updatePlan: (data) => ax.put('/addons/vacay/plan', data).then((r: AxiosResponse) => r.data),
|
||||
updateColor: (color, targetUserId) => ax.put('/addons/vacay/color', { color, target_user_id: targetUserId }).then((r: AxiosResponse) => r.data),
|
||||
invite: (userId) => ax.post('/addons/vacay/invite', { user_id: userId }).then((r: AxiosResponse) => r.data),
|
||||
acceptInvite: (planId) => ax.post('/addons/vacay/invite/accept', { plan_id: planId }).then((r: AxiosResponse) => r.data),
|
||||
declineInvite: (planId) => ax.post('/addons/vacay/invite/decline', { plan_id: planId }).then((r: AxiosResponse) => r.data),
|
||||
cancelInvite: (userId) => ax.post('/addons/vacay/invite/cancel', { user_id: userId }).then((r: AxiosResponse) => r.data),
|
||||
dissolve: () => ax.post('/addons/vacay/dissolve').then((r: AxiosResponse) => r.data),
|
||||
availableUsers: () => ax.get('/addons/vacay/available-users').then((r: AxiosResponse) => r.data),
|
||||
getYears: () => ax.get('/addons/vacay/years').then((r: AxiosResponse) => r.data),
|
||||
addYear: (year) => ax.post('/addons/vacay/years', { year }).then((r: AxiosResponse) => r.data),
|
||||
removeYear: (year) => ax.delete(`/addons/vacay/years/${year}`).then((r: AxiosResponse) => r.data),
|
||||
getEntries: (year) => ax.get(`/addons/vacay/entries/${year}`).then((r: AxiosResponse) => r.data),
|
||||
toggleEntry: (date, targetUserId) => ax.post('/addons/vacay/entries/toggle', { date, target_user_id: targetUserId }).then((r: AxiosResponse) => r.data),
|
||||
toggleCompanyHoliday: (date) => ax.post('/addons/vacay/entries/company-holiday', { date }).then((r: AxiosResponse) => r.data),
|
||||
getStats: (year) => ax.get(`/addons/vacay/stats/${year}`).then((r: AxiosResponse) => r.data),
|
||||
updateStats: (year, days, targetUserId) => ax.put(`/addons/vacay/stats/${year}`, { vacation_days: days, target_user_id: targetUserId }).then((r: AxiosResponse) => r.data),
|
||||
getCountries: () => ax.get('/addons/vacay/holidays/countries').then((r: AxiosResponse) => r.data),
|
||||
getHolidays: (year, country) => ax.get(`/addons/vacay/holidays/${year}/${country}`).then((r: AxiosResponse) => r.data),
|
||||
}
|
||||
|
||||
interface VacayState {
|
||||
plan: VacayPlan | null
|
||||
users: VacayUser[]
|
||||
pendingInvites: PendingInvite[]
|
||||
incomingInvites: IncomingInvite[]
|
||||
isOwner: boolean
|
||||
isFused: boolean
|
||||
years: number[]
|
||||
entries: VacayEntry[]
|
||||
companyHolidays: string[]
|
||||
stats: VacayStat[]
|
||||
selectedYear: number
|
||||
selectedUserId: number | null
|
||||
holidays: HolidaysMap
|
||||
loading: boolean
|
||||
|
||||
setSelectedYear: (year: number) => void
|
||||
setSelectedUserId: (id: number | null) => void
|
||||
loadPlan: () => Promise<void>
|
||||
updatePlan: (updates: Partial<VacayPlan>) => Promise<void>
|
||||
updateColor: (color: string, targetUserId?: number) => Promise<void>
|
||||
invite: (userId: number) => Promise<void>
|
||||
acceptInvite: (planId: number) => Promise<void>
|
||||
declineInvite: (planId: number) => Promise<void>
|
||||
cancelInvite: (userId: number) => Promise<void>
|
||||
dissolve: () => Promise<void>
|
||||
loadYears: () => Promise<void>
|
||||
addYear: (year: number) => Promise<void>
|
||||
removeYear: (year: number) => Promise<void>
|
||||
loadEntries: (year?: number) => Promise<void>
|
||||
toggleEntry: (date: string, targetUserId?: number) => Promise<void>
|
||||
toggleCompanyHoliday: (date: string) => Promise<void>
|
||||
loadStats: (year?: number) => Promise<void>
|
||||
updateVacationDays: (year: number, days: number, targetUserId?: number) => Promise<void>
|
||||
loadHolidays: (year?: number) => Promise<void>
|
||||
loadAll: () => Promise<void>
|
||||
}
|
||||
|
||||
export const useVacayStore = create<VacayState>((set, get) => ({
|
||||
plan: null,
|
||||
users: [],
|
||||
pendingInvites: [],
|
||||
incomingInvites: [],
|
||||
isOwner: true,
|
||||
isFused: false,
|
||||
years: [],
|
||||
entries: [],
|
||||
companyHolidays: [],
|
||||
stats: [],
|
||||
selectedYear: new Date().getFullYear(),
|
||||
selectedUserId: null,
|
||||
holidays: {},
|
||||
loading: false,
|
||||
|
||||
setSelectedYear: (year: number) => set({ selectedYear: year }),
|
||||
setSelectedUserId: (id: number | null) => set({ selectedUserId: id }),
|
||||
|
||||
loadPlan: async () => {
|
||||
const data = await api.getPlan()
|
||||
set({
|
||||
plan: data.plan,
|
||||
users: data.users,
|
||||
pendingInvites: data.pendingInvites,
|
||||
incomingInvites: data.incomingInvites,
|
||||
isOwner: data.isOwner,
|
||||
isFused: data.isFused,
|
||||
})
|
||||
},
|
||||
|
||||
updatePlan: async (updates: Partial<VacayPlan>) => {
|
||||
const data = await api.updatePlan(updates)
|
||||
set({ plan: data.plan })
|
||||
await get().loadEntries()
|
||||
await get().loadStats()
|
||||
await get().loadHolidays()
|
||||
},
|
||||
|
||||
updateColor: async (color: string, targetUserId?: number) => {
|
||||
await api.updateColor(color, targetUserId)
|
||||
await get().loadPlan()
|
||||
await get().loadEntries()
|
||||
},
|
||||
|
||||
invite: async (userId: number) => {
|
||||
await api.invite(userId)
|
||||
await get().loadPlan()
|
||||
},
|
||||
|
||||
acceptInvite: async (planId: number) => {
|
||||
await api.acceptInvite(planId)
|
||||
await get().loadAll()
|
||||
},
|
||||
|
||||
declineInvite: async (planId: number) => {
|
||||
await api.declineInvite(planId)
|
||||
await get().loadPlan()
|
||||
},
|
||||
|
||||
cancelInvite: async (userId: number) => {
|
||||
await api.cancelInvite(userId)
|
||||
await get().loadPlan()
|
||||
},
|
||||
|
||||
dissolve: async () => {
|
||||
await api.dissolve()
|
||||
await get().loadAll()
|
||||
},
|
||||
|
||||
loadYears: async () => {
|
||||
const data = await api.getYears()
|
||||
set({ years: data.years })
|
||||
if (data.years.length > 0) {
|
||||
set({ selectedYear: data.years[data.years.length - 1] })
|
||||
}
|
||||
},
|
||||
|
||||
addYear: async (year: number) => {
|
||||
const data = await api.addYear(year)
|
||||
set({ years: data.years })
|
||||
await get().loadStats(year)
|
||||
},
|
||||
|
||||
removeYear: async (year: number) => {
|
||||
const data = await api.removeYear(year)
|
||||
set({ years: data.years })
|
||||
},
|
||||
|
||||
loadEntries: async (year?: number) => {
|
||||
const y = year || get().selectedYear
|
||||
const data = await api.getEntries(y)
|
||||
set({ entries: data.entries, companyHolidays: data.companyHolidays })
|
||||
},
|
||||
|
||||
toggleEntry: async (date: string, targetUserId?: number) => {
|
||||
await api.toggleEntry(date, targetUserId)
|
||||
await get().loadEntries()
|
||||
await get().loadStats()
|
||||
},
|
||||
|
||||
toggleCompanyHoliday: async (date: string) => {
|
||||
await api.toggleCompanyHoliday(date)
|
||||
await get().loadEntries()
|
||||
},
|
||||
|
||||
loadStats: async (year?: number) => {
|
||||
const y = year || get().selectedYear
|
||||
const data = await api.getStats(y)
|
||||
set({ stats: data.stats })
|
||||
},
|
||||
|
||||
updateVacationDays: async (year: number, days: number, targetUserId?: number) => {
|
||||
await api.updateStats(year, days, targetUserId)
|
||||
await get().loadStats(year)
|
||||
},
|
||||
|
||||
loadHolidays: async (year?: number) => {
|
||||
const y = year || get().selectedYear
|
||||
const plan = get().plan
|
||||
if (!plan?.holidays_enabled || !plan?.holidays_region) {
|
||||
set({ holidays: {} })
|
||||
return
|
||||
}
|
||||
const country = plan.holidays_region.split('-')[0]
|
||||
const region = plan.holidays_region.includes('-') ? plan.holidays_region : null
|
||||
try {
|
||||
const data = await api.getHolidays(y, country)
|
||||
const hasRegions = data.some((h: VacayHolidayRaw) => h.counties && h.counties.length > 0)
|
||||
if (hasRegions && !region) {
|
||||
set({ holidays: {} })
|
||||
return
|
||||
}
|
||||
const map: HolidaysMap = {}
|
||||
data.forEach((h: VacayHolidayRaw) => {
|
||||
if (h.global || !h.counties || (region && h.counties.includes(region))) {
|
||||
map[h.date] = { name: h.name, localName: h.localName }
|
||||
}
|
||||
})
|
||||
set({ holidays: map })
|
||||
} catch {
|
||||
set({ holidays: {} })
|
||||
}
|
||||
},
|
||||
|
||||
loadAll: async () => {
|
||||
set({ loading: true })
|
||||
try {
|
||||
await get().loadPlan()
|
||||
await get().loadYears()
|
||||
const year = get().selectedYear
|
||||
await get().loadEntries(year)
|
||||
await get().loadStats(year)
|
||||
await get().loadHolidays(year)
|
||||
} finally {
|
||||
set({ loading: false })
|
||||
}
|
||||
},
|
||||
}))
|
||||
Reference in New Issue
Block a user