feat(calendar): U9 frontend store, API client and types
CalendarApiClient with 20 methods covering all backend endpoints (listEvents, createEvent, importIcs, searchUsers, syncNow, etc.). useCalendarStore Pinia store with events/eventTypes/tags state, view mode switching, and handleWsEvent dispatch for 4 calendar WS message types. WsServerMessage union extended with calendar variants. - src/agentkit/server/frontend/src/api/calendar.ts — API client + types - src/agentkit/server/frontend/src/stores/calendar.ts — Pinia store - src/agentkit/server/frontend/src/api/types.ts — WS message types
This commit is contained in:
parent
40d326cd3f
commit
40bc27822f
|
|
@ -0,0 +1,324 @@
|
|||
/** Calendar API client — thin wrapper over /api/v1/calendar endpoints. */
|
||||
|
||||
import { BaseApiClient, getDynamicBaseURL } from './base'
|
||||
|
||||
// ── Domain types (co-located with API client) ──────────────────────────
|
||||
|
||||
export interface ICalendarEvent {
|
||||
id: string
|
||||
user_id: string
|
||||
title: string
|
||||
description: string
|
||||
start_time: string // ISO 8601 UTC (KTD-11)
|
||||
end_time: string // ISO 8601 UTC
|
||||
is_all_day: boolean
|
||||
location: string
|
||||
event_type_id: string | null
|
||||
rrule: string | null // RFC 5545 RRULE string
|
||||
source: 'manual' | 'agent' | 'post_extract'
|
||||
is_invited: boolean
|
||||
conversation_id: string | null
|
||||
external_id: string | null
|
||||
external_provider: string | null
|
||||
last_modified: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface IEventType {
|
||||
id: string
|
||||
user_id: string
|
||||
name: string
|
||||
color: string
|
||||
is_default: boolean
|
||||
}
|
||||
|
||||
export interface ITag {
|
||||
id: string
|
||||
user_id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface IInvitation {
|
||||
id: string
|
||||
event_id: string
|
||||
inviter_user_id: string
|
||||
invitee_email: string
|
||||
status: 'pending' | 'accepted' | 'declined' | 'tentative'
|
||||
responded_at: string | null
|
||||
}
|
||||
|
||||
export interface IExternalCalendarConfig {
|
||||
id: string
|
||||
user_id: string
|
||||
provider: 'caldav' | 'outlook'
|
||||
credentials: string // '***' on read-back; never real credentials
|
||||
sync_frequency: number
|
||||
sync_scope: string[]
|
||||
last_sync: string | null
|
||||
sync_token: string | null
|
||||
}
|
||||
|
||||
export interface IUserSearchResult {
|
||||
username: string
|
||||
email: string
|
||||
}
|
||||
|
||||
// ── Request types ──────────────────────────────────────────────────────
|
||||
|
||||
export interface ICreateEventRequest {
|
||||
title: string
|
||||
start_time: string
|
||||
end_time: string
|
||||
description?: string
|
||||
location?: string
|
||||
is_all_day?: boolean
|
||||
event_type_id?: string | null
|
||||
rrule?: string | null
|
||||
tag_ids?: string[]
|
||||
}
|
||||
|
||||
export interface IUpdateEventRequest {
|
||||
title?: string
|
||||
start_time?: string
|
||||
end_time?: string
|
||||
description?: string
|
||||
location?: string
|
||||
is_all_day?: boolean
|
||||
event_type_id?: string | null
|
||||
rrule?: string | null
|
||||
}
|
||||
|
||||
export interface ICreateEventTypeRequest {
|
||||
name: string
|
||||
color?: string
|
||||
}
|
||||
|
||||
export interface ICreateTagRequest {
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface ICreateInvitationRequest {
|
||||
event_id: string
|
||||
invitee_email: string
|
||||
}
|
||||
|
||||
export interface ICreateExternalConfigRequest {
|
||||
provider: 'caldav' | 'outlook'
|
||||
credentials: string // JSON string with provider-specific auth
|
||||
sync_frequency?: number
|
||||
sync_scope?: string[]
|
||||
}
|
||||
|
||||
// ── Runtime type guard ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Runtime guard for ICalendarEvent — validates the minimum fields required
|
||||
* for the calendar store to function safely.
|
||||
* ponytail: checks only the keys the store actually reads; full schema
|
||||
* validation belongs at the API boundary, not in the WS event handler.
|
||||
*/
|
||||
export function isCalendarEvent(value: unknown): value is ICalendarEvent {
|
||||
if (typeof value !== 'object' || value === null) return false
|
||||
const v = value as Record<string, unknown>
|
||||
return (
|
||||
typeof v.id === 'string' &&
|
||||
typeof v.title === 'string' &&
|
||||
typeof v.start_time === 'string' &&
|
||||
typeof v.end_time === 'string' &&
|
||||
typeof v.is_all_day === 'boolean'
|
||||
)
|
||||
}
|
||||
|
||||
// ── API client ─────────────────────────────────────────────────────────
|
||||
|
||||
const API_BASE = '/api/v1/calendar'
|
||||
|
||||
class CalendarApiClient extends BaseApiClient {
|
||||
constructor(baseUrl: string = API_BASE) {
|
||||
super(baseUrl)
|
||||
}
|
||||
|
||||
/** List events with optional date/type/tag filters */
|
||||
async listEvents(
|
||||
start?: string,
|
||||
end?: string,
|
||||
eventTypeId?: string,
|
||||
tagId?: string,
|
||||
): Promise<{ success: boolean; events: ICalendarEvent[]; count: number }> {
|
||||
const params = new URLSearchParams()
|
||||
if (start) params.set('start', start)
|
||||
if (end) params.set('end', end)
|
||||
if (eventTypeId) params.set('type_id', eventTypeId)
|
||||
if (tagId) params.set('tag_id', tagId)
|
||||
const qs = params.toString()
|
||||
const path = qs ? `/events?${qs}` : '/events'
|
||||
return this.request(path, { method: 'GET' })
|
||||
}
|
||||
|
||||
/** Create a new event */
|
||||
async createEvent(
|
||||
data: ICreateEventRequest,
|
||||
): Promise<{ success: boolean; event: ICalendarEvent }> {
|
||||
return this.request('/events', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
}
|
||||
|
||||
/** Get a single event by id */
|
||||
async getEvent(id: string): Promise<{ success: boolean; event: ICalendarEvent }> {
|
||||
return this.request(`/events/${id}`, { method: 'GET' })
|
||||
}
|
||||
|
||||
/** Update specific fields of an event (PATCH — partial update) */
|
||||
async updateEvent(
|
||||
id: string,
|
||||
data: IUpdateEventRequest,
|
||||
): Promise<{ success: boolean; event: ICalendarEvent; updated: boolean }> {
|
||||
return this.request(`/events/${id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
}
|
||||
|
||||
/** Delete an event */
|
||||
async deleteEvent(id: string): Promise<{ success: boolean; deleted: boolean }> {
|
||||
return this.request(`/events/${id}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
/** List event types for the current user */
|
||||
async listEventTypes(): Promise<{
|
||||
success: boolean
|
||||
event_types: IEventType[]
|
||||
count: number
|
||||
}> {
|
||||
return this.request('/event-types', { method: 'GET' })
|
||||
}
|
||||
|
||||
/** Create an event type */
|
||||
async createEventType(
|
||||
data: ICreateEventTypeRequest,
|
||||
): Promise<{ success: boolean; event_type: IEventType }> {
|
||||
return this.request('/event-types', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
}
|
||||
|
||||
/** List tags for the current user */
|
||||
async listTags(): Promise<{ success: boolean; tags: ITag[]; count: number }> {
|
||||
return this.request('/tags', { method: 'GET' })
|
||||
}
|
||||
|
||||
/** Create a tag */
|
||||
async createTag(data: ICreateTagRequest): Promise<{ success: boolean; tag: ITag }> {
|
||||
return this.request('/tags', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
}
|
||||
|
||||
/** Import events from an uploaded .ics file (multipart) */
|
||||
async importIcs(file: File): Promise<{
|
||||
success: boolean
|
||||
imported: number
|
||||
skipped: number
|
||||
[key: string]: unknown
|
||||
}> {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
return this.request('/import-ics', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
headers: {}, // Let browser set Content-Type for FormData
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the export-ics download URL for a date range.
|
||||
* Returns an absolute or relative URL the caller can open/download.
|
||||
* ponytail: the endpoint returns binary text/calendar, not JSON, so we
|
||||
* hand back a URL rather than going through request<T> (which JSON-parses).
|
||||
*/
|
||||
exportIcs(start?: string, end?: string): string {
|
||||
const base = getDynamicBaseURL()
|
||||
const params = new URLSearchParams()
|
||||
if (start) params.set('start', start)
|
||||
if (end) params.set('end', end)
|
||||
const qs = params.toString()
|
||||
const path = qs ? `/api/v1/calendar/export-ics?${qs}` : '/api/v1/calendar/export-ics'
|
||||
return base ? `${base}${path}` : path
|
||||
}
|
||||
|
||||
/** Create an invitation (invite a user to an event by email) */
|
||||
async createInvitation(
|
||||
data: ICreateInvitationRequest,
|
||||
): Promise<{ success: boolean; invitation: IInvitation }> {
|
||||
return this.request(`/events/${data.event_id}/invitations`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ invitee_email: data.invitee_email }),
|
||||
})
|
||||
}
|
||||
|
||||
/** List invitations for the current user */
|
||||
async listInvitations(): Promise<{
|
||||
success: boolean
|
||||
invitations: IInvitation[]
|
||||
count: number
|
||||
}> {
|
||||
return this.request('/invitations', { method: 'GET' })
|
||||
}
|
||||
|
||||
/** Respond to an invitation (accept/decline/tentative) */
|
||||
async respondToInvitation(
|
||||
id: string,
|
||||
status: 'accepted' | 'declined' | 'tentative',
|
||||
): Promise<{ success: boolean; status: string }> {
|
||||
return this.request(`/invitations/${id}/respond`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ status }),
|
||||
})
|
||||
}
|
||||
|
||||
/** Search users by username or email — returns top 10 matches (G5/A3) */
|
||||
async searchUsers(q: string): Promise<{
|
||||
success: boolean
|
||||
users: IUserSearchResult[]
|
||||
count: number
|
||||
}> {
|
||||
return this.request(`/users/search?q=${encodeURIComponent(q)}`, { method: 'GET' })
|
||||
}
|
||||
|
||||
/** List external calendar configs for the current user */
|
||||
async listExternalConfigs(): Promise<{
|
||||
success: boolean
|
||||
configs: IExternalCalendarConfig[]
|
||||
count: number
|
||||
}> {
|
||||
return this.request('/external-configs', { method: 'GET' })
|
||||
}
|
||||
|
||||
/** Create an external calendar config */
|
||||
async createExternalConfig(
|
||||
data: ICreateExternalConfigRequest,
|
||||
): Promise<{ success: boolean; config: IExternalCalendarConfig }> {
|
||||
return this.request('/external-configs', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
}
|
||||
|
||||
/** Test connection to an external calendar */
|
||||
async testExternalConnection(
|
||||
id: string,
|
||||
): Promise<{ success: boolean; connected: boolean; error?: string }> {
|
||||
return this.request(`/external-configs/${id}/test`, { method: 'POST' })
|
||||
}
|
||||
|
||||
/** Trigger an immediate sync for an external calendar config */
|
||||
async syncNow(id: string): Promise<{ success: boolean; synced: boolean; error?: string }> {
|
||||
return this.request(`/external-configs/${id}/sync`, { method: 'POST' })
|
||||
}
|
||||
}
|
||||
|
||||
export const calendarApi = new CalendarApiClient()
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
import type { ICalendarEvent, IInvitation } from './calendar'
|
||||
|
||||
/** Chat request payload */
|
||||
export interface IChatRequest {
|
||||
message: string
|
||||
|
|
@ -130,6 +132,11 @@ export type WsServerMessage =
|
|||
| { type: 'round_summary'; data: IRoundSummaryData }
|
||||
| { type: 'user_intervention'; data: IUserInterventionData }
|
||||
| { type: 'board_concluded'; data: IBoardConcludedData }
|
||||
// Calendar 事件 (KTD-10 — piggyback on chat WS)
|
||||
| { type: 'calendar_event_created'; data: ICalendarEventCreatedData }
|
||||
| { type: 'calendar_reminder'; data: ICalendarReminderData }
|
||||
| { type: 'calendar_invitation'; data: ICalendarInvitationData }
|
||||
| { type: 'calendar_sync_conflict'; data: ICalendarSyncConflictData }
|
||||
|
||||
/** Expert info within a team */
|
||||
export interface IExpertInfo {
|
||||
|
|
@ -233,6 +240,39 @@ export interface IBoardMessage {
|
|||
timestamp: number
|
||||
}
|
||||
|
||||
// ── Calendar WS 事件 payload 类型 ───────────────────────────────────
|
||||
|
||||
/** calendar_event_created payload */
|
||||
export interface ICalendarEventCreatedData {
|
||||
event: ICalendarEvent
|
||||
}
|
||||
|
||||
/** calendar_reminder payload */
|
||||
export interface ICalendarReminderData {
|
||||
event_id: string
|
||||
title: string
|
||||
start_time: string
|
||||
offset_minutes: number
|
||||
channels: string[]
|
||||
}
|
||||
|
||||
/** calendar_invitation payload (G6) */
|
||||
export interface ICalendarInvitationData {
|
||||
invitation: IInvitation
|
||||
event_title: string
|
||||
inviter_name: string
|
||||
}
|
||||
|
||||
/** calendar_sync_conflict payload (G4) */
|
||||
export interface ICalendarSyncConflictData {
|
||||
event_id: string
|
||||
event_title: string
|
||||
provider: string
|
||||
local_modified: string
|
||||
remote_modified: string
|
||||
resolution: string
|
||||
}
|
||||
|
||||
/** Expert template (matches backend GET /api/v1/experts response item) */
|
||||
export interface IExpertTemplate {
|
||||
name: string
|
||||
|
|
|
|||
|
|
@ -0,0 +1,264 @@
|
|||
/**
|
||||
* Pinia store for calendar feature — events, event types, tags,
|
||||
* invitations, and WebSocket event dispatch.
|
||||
*
|
||||
* ponytail: stores/chat.ts handleWsMessage dispatch is deferred — the
|
||||
* orchestrator will wire the 4 calendar_* WS cases to call handleWsEvent().
|
||||
* This store owns the calendar-specific state mutations + notifications.
|
||||
*/
|
||||
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { notification } from 'ant-design-vue'
|
||||
import { calendarApi, isCalendarEvent } from '@/api/calendar'
|
||||
import type {
|
||||
ICalendarEvent,
|
||||
IEventType,
|
||||
ITag,
|
||||
IInvitation,
|
||||
ICreateEventRequest,
|
||||
IUpdateEventRequest,
|
||||
IUserSearchResult,
|
||||
} from '@/api/calendar'
|
||||
import type { WsServerMessage, ICalendarSyncConflictData } from '@/api/types'
|
||||
|
||||
export type CalendarViewMode = 'calendar' | 'card' | 'list'
|
||||
|
||||
export const useCalendarStore = defineStore('calendar', () => {
|
||||
// --- State ---
|
||||
const events = ref<ICalendarEvent[]>([])
|
||||
const eventTypes = ref<IEventType[]>([])
|
||||
const tags = ref<ITag[]>([])
|
||||
const isLoading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const selectedEvent = ref<ICalendarEvent | null>(null)
|
||||
const viewMode = ref<CalendarViewMode>('calendar')
|
||||
const dateRange = ref<{ start: string | null; end: string | null }>({
|
||||
start: null,
|
||||
end: null,
|
||||
})
|
||||
const pendingInvitations = ref<IInvitation[]>([])
|
||||
const syncConflicts = ref<ICalendarSyncConflictData[]>([])
|
||||
|
||||
// --- Getters ---
|
||||
const upcomingEvents = computed(() => {
|
||||
const now = new Date().toISOString()
|
||||
return events.value
|
||||
.filter((e) => e.start_time >= now)
|
||||
.sort((a, b) => a.start_time.localeCompare(b.start_time))
|
||||
})
|
||||
|
||||
// --- Actions ---
|
||||
|
||||
/** Load events with current dateRange filter */
|
||||
async function loadEvents(): Promise<void> {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const resp = await calendarApi.listEvents(
|
||||
dateRange.value.start ?? undefined,
|
||||
dateRange.value.end ?? undefined,
|
||||
)
|
||||
events.value = resp.events || []
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : '加载日程失败'
|
||||
console.warn('Failed to load calendar events:', err)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** Create a new event and add it to state */
|
||||
async function createEvent(data: ICreateEventRequest): Promise<ICalendarEvent | null> {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const resp = await calendarApi.createEvent(data)
|
||||
if (resp.event) {
|
||||
events.value.push(resp.event)
|
||||
}
|
||||
return resp.event ?? null
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : '创建日程失败'
|
||||
console.error('Failed to create event:', err)
|
||||
throw err
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** Update an existing event in state */
|
||||
async function updateEvent(id: string, data: IUpdateEventRequest): Promise<void> {
|
||||
error.value = null
|
||||
try {
|
||||
const resp = await calendarApi.updateEvent(id, data)
|
||||
if (resp.event) {
|
||||
const idx = events.value.findIndex((e) => e.id === id)
|
||||
if (idx !== -1) {
|
||||
events.value[idx] = resp.event
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : '更新日程失败'
|
||||
console.error('Failed to update event:', err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
/** Delete an event from server and state */
|
||||
async function deleteEvent(id: string): Promise<void> {
|
||||
error.value = null
|
||||
try {
|
||||
await calendarApi.deleteEvent(id)
|
||||
events.value = events.value.filter((e) => e.id !== id)
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : '删除日程失败'
|
||||
console.error('Failed to delete event:', err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
/** Load event types for the current user */
|
||||
async function loadEventTypes(): Promise<void> {
|
||||
try {
|
||||
const resp = await calendarApi.listEventTypes()
|
||||
eventTypes.value = resp.event_types || []
|
||||
} catch (err) {
|
||||
console.warn('Failed to load event types:', err)
|
||||
}
|
||||
}
|
||||
|
||||
/** Load tags for the current user */
|
||||
async function loadTags(): Promise<void> {
|
||||
try {
|
||||
const resp = await calendarApi.listTags()
|
||||
tags.value = resp.tags || []
|
||||
} catch (err) {
|
||||
console.warn('Failed to load tags:', err)
|
||||
}
|
||||
}
|
||||
|
||||
/** Set the calendar view mode */
|
||||
function setViewMode(mode: CalendarViewMode): void {
|
||||
viewMode.value = mode
|
||||
}
|
||||
|
||||
/** Load pending invitations for the current user */
|
||||
async function loadInvitations(): Promise<void> {
|
||||
try {
|
||||
const resp = await calendarApi.listInvitations()
|
||||
pendingInvitations.value = (resp.invitations || []).filter(
|
||||
(i) => i.status === 'pending',
|
||||
)
|
||||
} catch (err) {
|
||||
console.warn('Failed to load invitations:', err)
|
||||
}
|
||||
}
|
||||
|
||||
/** Respond to an invitation and remove it from pending list */
|
||||
async function respondToInvitation(
|
||||
id: string,
|
||||
status: 'accepted' | 'declined' | 'tentative',
|
||||
): Promise<void> {
|
||||
try {
|
||||
await calendarApi.respondToInvitation(id, status)
|
||||
pendingInvitations.value = pendingInvitations.value.filter((i) => i.id !== id)
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : '回复邀请失败'
|
||||
console.error('Failed to respond to invitation:', err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
/** Search users by username or email (G5/A3) */
|
||||
async function searchUsers(q: string): Promise<IUserSearchResult[]> {
|
||||
if (!q.trim()) return []
|
||||
try {
|
||||
const resp = await calendarApi.searchUsers(q)
|
||||
return resp.users || []
|
||||
} catch (err) {
|
||||
console.warn('Failed to search users:', err)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch calendar WS messages to the appropriate handler.
|
||||
* Handles 4 message types: calendar_event_created, calendar_reminder,
|
||||
* calendar_invitation (G6), calendar_sync_conflict (G4).
|
||||
* Non-calendar message types are silently ignored.
|
||||
*/
|
||||
function handleWsEvent(msg: WsServerMessage): void {
|
||||
switch (msg.type) {
|
||||
case 'calendar_event_created': {
|
||||
const event = msg.data.event
|
||||
if (isCalendarEvent(event)) {
|
||||
// Avoid duplicates if the event was created locally
|
||||
if (!events.value.some((e) => e.id === event.id)) {
|
||||
events.value.push(event)
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'calendar_reminder': {
|
||||
const d = msg.data
|
||||
notification.warning({
|
||||
message: '日程提醒',
|
||||
description: `${d.title} · ${d.start_time}`,
|
||||
duration: 0,
|
||||
})
|
||||
break
|
||||
}
|
||||
case 'calendar_invitation': {
|
||||
const d = msg.data
|
||||
if (d.invitation) {
|
||||
pendingInvitations.value.push(d.invitation)
|
||||
}
|
||||
notification.info({
|
||||
message: '收到日程邀请',
|
||||
description: `${d.inviter_name} 邀请你参加「${d.event_title}」`,
|
||||
duration: 0,
|
||||
})
|
||||
break
|
||||
}
|
||||
case 'calendar_sync_conflict': {
|
||||
const d = msg.data
|
||||
syncConflicts.value.push(d)
|
||||
notification.warning({
|
||||
message: '日历同步冲突',
|
||||
description: `「${d.event_title}」与 ${d.provider} 同步冲突,已按 ${d.resolution} 策略处理`,
|
||||
duration: 0,
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
events,
|
||||
eventTypes,
|
||||
tags,
|
||||
isLoading,
|
||||
error,
|
||||
selectedEvent,
|
||||
viewMode,
|
||||
dateRange,
|
||||
pendingInvitations,
|
||||
syncConflicts,
|
||||
// Getters
|
||||
upcomingEvents,
|
||||
// Actions
|
||||
loadEvents,
|
||||
createEvent,
|
||||
updateEvent,
|
||||
deleteEvent,
|
||||
loadEventTypes,
|
||||
loadTags,
|
||||
setViewMode,
|
||||
loadInvitations,
|
||||
respondToInvitation,
|
||||
searchUsers,
|
||||
handleWsEvent,
|
||||
}
|
||||
})
|
||||
Loading…
Reference in New Issue