- Update API: events, users, policies, routes, resources, migrations - Remove deprecated models/resources (customers, setlists, invitations, etc.) - Refresh admin app and docs; remove apps/band Made-with: Cursor
686 lines
17 KiB
Plaintext
686 lines
17 KiB
Plaintext
---
|
|
description: Vue 3, TypeScript, and Vuexy patterns for EventCrew platform
|
|
globs: ["apps/**/*.{vue,ts,tsx}"]
|
|
alwaysApply: true
|
|
---
|
|
|
|
# Vue + Vuexy Rules
|
|
|
|
## Core Principles
|
|
|
|
1. **Composition API only** - Always `<script setup lang="ts">`
|
|
2. **TypeScript strict mode** - No `any` types
|
|
3. **TanStack Query for API** - Never raw axios in components
|
|
4. **Pinia for client state** - Server data stays in TanStack Query
|
|
5. **Vuexy/Vuetify components** - Never custom CSS if a Vuetify class exists
|
|
6. **VeeValidate + Zod** - For all form validation
|
|
7. **Mobile-first** - Minimum 375px width
|
|
|
|
## App-Specific Rules
|
|
|
|
### `apps/admin/` (Super Admin)
|
|
- Full Vuexy template unchanged (sidebar, dark mode, customizer)
|
|
- Minimal modifications needed
|
|
|
|
### `apps/app/` (Organizer - Main App)
|
|
- Sidebar nav customized for EventCrew structure
|
|
- Remove Vuexy demo/customizer components
|
|
- Full Vuetify component usage
|
|
- 90% of development work happens here
|
|
|
|
### `apps/portal/` (External Portal)
|
|
- Stripped Vuexy: no sidebar, no customizer, no dark mode toggle
|
|
- Custom layout: top-bar with event logo + name
|
|
- Uses Vuetify components + Vuexy SCSS variables
|
|
- Two access modes: login (volunteers) and token (artists/suppliers)
|
|
- Mobile-first design
|
|
|
|
## Vuexy Folder Rules
|
|
|
|
### Never Modify
|
|
```
|
|
src/@core/ # Vuexy core
|
|
src/@layouts/ # Vuexy layouts
|
|
```
|
|
|
|
### Customize
|
|
```
|
|
src/
|
|
├── components/ # Custom components
|
|
├── composables/ # useModule.ts composables (TanStack Query)
|
|
├── layouts/ # App layout customizations
|
|
├── lib/ # axios.ts (SINGLE axios instance per app)
|
|
├── navigation/ # Sidebar menu items
|
|
├── pages/ # Page components
|
|
├── plugins/ # vue-query, casl, vuetify
|
|
├── stores/ # Pinia stores (client state only)
|
|
└── types/ # TypeScript interfaces
|
|
```
|
|
|
|
## File Templates
|
|
|
|
### Axios Instance (ONE per app)
|
|
|
|
```typescript
|
|
// src/lib/axios.ts
|
|
import axios from 'axios'
|
|
import type { AxiosInstance, InternalAxiosRequestConfig } from 'axios'
|
|
import { useAuthStore } from '@/stores/useAuthStore'
|
|
|
|
const api: AxiosInstance = axios.create({
|
|
baseURL: `${import.meta.env.VITE_API_URL}/api/v1`,
|
|
withCredentials: true,
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
Accept: 'application/json',
|
|
},
|
|
timeout: 30000,
|
|
})
|
|
|
|
api.interceptors.request.use((config: InternalAxiosRequestConfig) => {
|
|
const authStore = useAuthStore()
|
|
if (authStore.token) {
|
|
config.headers.Authorization = `Bearer ${authStore.token}`
|
|
}
|
|
return config
|
|
})
|
|
|
|
api.interceptors.response.use(
|
|
response => response,
|
|
error => {
|
|
if (error.response?.status === 401) {
|
|
const authStore = useAuthStore()
|
|
authStore.logout()
|
|
}
|
|
return Promise.reject(error)
|
|
},
|
|
)
|
|
|
|
export { api }
|
|
```
|
|
|
|
### TypeScript Types
|
|
|
|
```typescript
|
|
// src/types/events.ts
|
|
|
|
export type EventStatus = 'draft' | 'published' | 'registration_open' | 'buildup' | 'showday' | 'teardown' | 'closed'
|
|
export type PersonStatus = 'invited' | 'applied' | 'pending' | 'approved' | 'rejected' | 'no_show'
|
|
export type BookingStatus = 'concept' | 'requested' | 'option' | 'confirmed' | 'contracted' | 'cancelled'
|
|
export type ShiftAssignmentStatus = 'pending_approval' | 'approved' | 'rejected' | 'cancelled' | 'completed'
|
|
export type CrowdSystemType = 'CREW' | 'GUEST' | 'ARTIST' | 'VOLUNTEER' | 'PRESS' | 'PARTNER' | 'SUPPLIER'
|
|
|
|
export interface Organisation {
|
|
id: string
|
|
name: string
|
|
slug: string
|
|
billing_status: string
|
|
created_at: string
|
|
updated_at: string
|
|
}
|
|
|
|
export interface Event {
|
|
id: string
|
|
organisation_id: string
|
|
name: string
|
|
slug: string
|
|
start_date: string
|
|
end_date: string
|
|
timezone: string
|
|
status: EventStatus
|
|
status_label: string
|
|
status_color: string
|
|
festival_sections?: FestivalSection[]
|
|
persons_count?: number
|
|
created_at: string
|
|
updated_at: string
|
|
}
|
|
|
|
export interface FestivalSection {
|
|
id: string
|
|
event_id: string
|
|
name: string
|
|
sort_order: number
|
|
}
|
|
|
|
export interface TimeSlot {
|
|
id: string
|
|
event_id: string
|
|
name: string
|
|
person_type: CrowdSystemType
|
|
date: string
|
|
start_time: string
|
|
end_time: string
|
|
}
|
|
|
|
export interface Shift {
|
|
id: string
|
|
festival_section_id: string
|
|
time_slot_id: string
|
|
location_id: string | null
|
|
slots_total: number
|
|
slots_open_for_claiming: number
|
|
slots_filled: number
|
|
fill_rate: number
|
|
status: string
|
|
festival_section?: FestivalSection
|
|
time_slot?: TimeSlot
|
|
assignments?: ShiftAssignment[]
|
|
}
|
|
|
|
export interface ShiftAssignment {
|
|
id: string
|
|
shift_id: string
|
|
person_id: string
|
|
time_slot_id: string
|
|
status: ShiftAssignmentStatus
|
|
auto_approved: boolean
|
|
person?: Person
|
|
}
|
|
|
|
export interface Person {
|
|
id: string
|
|
event_id: string
|
|
crowd_type_id: string
|
|
user_id: string | null
|
|
name: string
|
|
email: string
|
|
phone: string | null
|
|
status: PersonStatus
|
|
is_blacklisted: boolean
|
|
crowd_type?: CrowdType
|
|
}
|
|
|
|
export interface CrowdType {
|
|
id: string
|
|
organisation_id: string
|
|
name: string
|
|
system_type: CrowdSystemType
|
|
color: string
|
|
icon: string
|
|
}
|
|
|
|
export interface Artist {
|
|
id: string
|
|
event_id: string
|
|
name: string
|
|
booking_status: BookingStatus
|
|
star_rating: number
|
|
}
|
|
|
|
// API response types
|
|
export interface PaginatedResponse<T> {
|
|
data: T[]
|
|
meta: {
|
|
current_page: number
|
|
per_page: number
|
|
total: number
|
|
last_page: number
|
|
}
|
|
}
|
|
|
|
// Form types
|
|
export interface CreateEventData {
|
|
organisation_id: string
|
|
name: string
|
|
slug: string
|
|
start_date: string
|
|
end_date: string
|
|
timezone?: string
|
|
status?: EventStatus
|
|
}
|
|
|
|
export interface UpdateEventData extends Partial<CreateEventData> {}
|
|
```
|
|
|
|
### TanStack Query Setup
|
|
|
|
```typescript
|
|
// src/plugins/vue-query.ts
|
|
import type { VueQueryPluginOptions } from '@tanstack/vue-query'
|
|
import { QueryClient } from '@tanstack/vue-query'
|
|
|
|
export const queryClient = new QueryClient({
|
|
defaultOptions: {
|
|
queries: {
|
|
staleTime: 1000 * 60 * 5, // 5 minutes
|
|
retry: 1,
|
|
},
|
|
},
|
|
})
|
|
|
|
export const vueQueryPluginOptions: VueQueryPluginOptions = {
|
|
queryClient,
|
|
}
|
|
```
|
|
|
|
### Composable (useEvents)
|
|
|
|
```typescript
|
|
// src/composables/useEvents.ts
|
|
import { computed } from 'vue'
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/vue-query'
|
|
import { api } from '@/lib/axios'
|
|
import type { Event, CreateEventData, UpdateEventData, PaginatedResponse } from '@/types/events'
|
|
|
|
export function useEventList(organisationId: string) {
|
|
return useQuery({
|
|
queryKey: ['organisations', organisationId, 'events'],
|
|
queryFn: async () => {
|
|
const { data } = await api.get<PaginatedResponse<Event>>(
|
|
`/organisations/${organisationId}/events`
|
|
)
|
|
return data
|
|
},
|
|
enabled: !!organisationId,
|
|
})
|
|
}
|
|
|
|
export function useEventDetail(eventId: string) {
|
|
return useQuery({
|
|
queryKey: ['events', eventId],
|
|
queryFn: async () => {
|
|
const { data } = await api.get<{ data: Event }>(`/events/${eventId}`)
|
|
return data.data
|
|
},
|
|
enabled: !!eventId,
|
|
})
|
|
}
|
|
|
|
export function useCreateEvent() {
|
|
const queryClient = useQueryClient()
|
|
|
|
return useMutation({
|
|
mutationFn: async (payload: { organisationId: string; data: CreateEventData }) => {
|
|
const { data } = await api.post<{ data: Event }>(
|
|
`/organisations/${payload.organisationId}/events`,
|
|
payload.data,
|
|
)
|
|
return data.data
|
|
},
|
|
onSuccess: (_data, variables) => {
|
|
queryClient.invalidateQueries({
|
|
queryKey: ['organisations', variables.organisationId, 'events'],
|
|
})
|
|
},
|
|
})
|
|
}
|
|
|
|
export function useUpdateEvent() {
|
|
const queryClient = useQueryClient()
|
|
|
|
return useMutation({
|
|
mutationFn: async (payload: { eventId: string; data: UpdateEventData }) => {
|
|
const { data } = await api.put<{ data: Event }>(
|
|
`/events/${payload.eventId}`,
|
|
payload.data,
|
|
)
|
|
return data.data
|
|
},
|
|
onSuccess: (data) => {
|
|
queryClient.invalidateQueries({ queryKey: ['events', data.id] })
|
|
queryClient.invalidateQueries({
|
|
queryKey: ['organisations', data.organisation_id, 'events'],
|
|
})
|
|
},
|
|
})
|
|
}
|
|
|
|
export function useDeleteEvent() {
|
|
const queryClient = useQueryClient()
|
|
|
|
return useMutation({
|
|
mutationFn: async (eventId: string) => {
|
|
await api.delete(`/events/${eventId}`)
|
|
},
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['events'] })
|
|
},
|
|
})
|
|
}
|
|
```
|
|
|
|
### Pinia Store (Auth)
|
|
|
|
```typescript
|
|
// src/stores/useAuthStore.ts
|
|
import { defineStore } from 'pinia'
|
|
import { ref, computed } from 'vue'
|
|
import { api } from '@/lib/axios'
|
|
import type { Organisation } from '@/types/events'
|
|
|
|
interface AuthUser {
|
|
id: string
|
|
name: string
|
|
email: string
|
|
timezone: string
|
|
locale: string
|
|
organisations: Organisation[]
|
|
event_roles: Array<{ event_id: string; role: string }>
|
|
}
|
|
|
|
export const useAuthStore = defineStore('auth', () => {
|
|
const user = ref<AuthUser | null>(null)
|
|
const token = ref<string | null>(localStorage.getItem('auth_token'))
|
|
const currentOrganisationId = ref<string | null>(localStorage.getItem('current_organisation_id'))
|
|
|
|
const isAuthenticated = computed(() => !!token.value && !!user.value)
|
|
const currentOrganisation = computed(() =>
|
|
user.value?.organisations.find(o => o.id === currentOrganisationId.value) ?? null
|
|
)
|
|
|
|
async function login(email: string, password: string): Promise<boolean> {
|
|
try {
|
|
const { data } = await api.post('/auth/login', { email, password })
|
|
user.value = data.data.user
|
|
token.value = data.data.token
|
|
localStorage.setItem('auth_token', data.data.token)
|
|
|
|
if (data.data.user.organisations.length > 0) {
|
|
setCurrentOrganisation(data.data.user.organisations[0].id)
|
|
}
|
|
return true
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
async function fetchMe(): Promise<boolean> {
|
|
if (!token.value) return false
|
|
try {
|
|
const { data } = await api.get('/auth/me')
|
|
user.value = data.data
|
|
return true
|
|
} catch {
|
|
logout()
|
|
return false
|
|
}
|
|
}
|
|
|
|
function setCurrentOrganisation(orgId: string) {
|
|
currentOrganisationId.value = orgId
|
|
localStorage.setItem('current_organisation_id', orgId)
|
|
}
|
|
|
|
function logout() {
|
|
user.value = null
|
|
token.value = null
|
|
currentOrganisationId.value = null
|
|
localStorage.removeItem('auth_token')
|
|
localStorage.removeItem('current_organisation_id')
|
|
}
|
|
|
|
return {
|
|
user, token, currentOrganisationId,
|
|
isAuthenticated, currentOrganisation,
|
|
login, fetchMe, setCurrentOrganisation, logout,
|
|
}
|
|
})
|
|
```
|
|
|
|
### Page Component (Event List)
|
|
|
|
```vue
|
|
<!-- src/pages/events/index.vue -->
|
|
<script setup lang="ts">
|
|
import { computed } from 'vue'
|
|
import { useAuthStore } from '@/stores/useAuthStore'
|
|
import { useEventList } from '@/composables/useEvents'
|
|
|
|
const authStore = useAuthStore()
|
|
const organisationId = computed(() => authStore.currentOrganisationId ?? '')
|
|
const { data, isLoading, isError, error } = useEventList(organisationId.value)
|
|
|
|
const events = computed(() => data.value?.data ?? [])
|
|
|
|
function getStatusColor(status: string): string {
|
|
const colors: Record<string, string> = {
|
|
draft: 'secondary',
|
|
published: 'info',
|
|
registration_open: 'primary',
|
|
buildup: 'warning',
|
|
showday: 'success',
|
|
teardown: 'warning',
|
|
closed: 'secondary',
|
|
}
|
|
return colors[status] ?? 'secondary'
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div>
|
|
<!-- Page Header -->
|
|
<div class="d-flex justify-space-between align-center mb-6">
|
|
<div>
|
|
<h4 class="text-h4 mb-1">Events</h4>
|
|
<p class="text-body-1 text-medium-emphasis">
|
|
Manage events for your organisation
|
|
</p>
|
|
</div>
|
|
<VBtn
|
|
color="primary"
|
|
prepend-icon="tabler-plus"
|
|
:to="{ name: 'events-create' }"
|
|
>
|
|
Create Event
|
|
</VBtn>
|
|
</div>
|
|
|
|
<!-- Loading State -->
|
|
<VCard v-if="isLoading">
|
|
<VCardText class="text-center py-8">
|
|
<VProgressCircular indeterminate color="primary" />
|
|
</VCardText>
|
|
</VCard>
|
|
|
|
<!-- Error State -->
|
|
<VAlert v-else-if="isError" type="error" class="mb-4">
|
|
{{ error?.message ?? 'Failed to load events' }}
|
|
</VAlert>
|
|
|
|
<!-- Events Table -->
|
|
<VCard v-else>
|
|
<VDataTable
|
|
:items="events"
|
|
:headers="[
|
|
{ title: 'Name', key: 'name' },
|
|
{ title: 'Dates', key: 'start_date' },
|
|
{ title: 'Status', key: 'status' },
|
|
{ title: 'Actions', key: 'actions', sortable: false },
|
|
]"
|
|
>
|
|
<template #item.name="{ item }">
|
|
<RouterLink :to="{ name: 'events-show', params: { id: item.id } }">
|
|
{{ item.name }}
|
|
</RouterLink>
|
|
</template>
|
|
|
|
<template #item.start_date="{ item }">
|
|
{{ item.start_date }} - {{ item.end_date }}
|
|
</template>
|
|
|
|
<template #item.status="{ item }">
|
|
<VChip :color="getStatusColor(item.status)" size="small">
|
|
{{ item.status_label }}
|
|
</VChip>
|
|
</template>
|
|
|
|
<template #item.actions="{ item }">
|
|
<VBtn
|
|
icon
|
|
variant="text"
|
|
size="small"
|
|
:to="{ name: 'events-edit', params: { id: item.id } }"
|
|
>
|
|
<VIcon icon="tabler-edit" />
|
|
</VBtn>
|
|
</template>
|
|
</VDataTable>
|
|
</VCard>
|
|
</div>
|
|
</template>
|
|
```
|
|
|
|
### Navigation Menu (Organizer App)
|
|
|
|
```typescript
|
|
// src/navigation/vertical/index.ts
|
|
import type { VerticalNavItems } from '@/@layouts/types'
|
|
|
|
export default [
|
|
{
|
|
title: 'Dashboard',
|
|
to: { name: 'dashboard' },
|
|
icon: { icon: 'tabler-smart-home' },
|
|
},
|
|
{ heading: 'Event Management' },
|
|
{
|
|
title: 'Events',
|
|
to: { name: 'events' },
|
|
icon: { icon: 'tabler-calendar-event' },
|
|
},
|
|
{
|
|
title: 'Festival Sections',
|
|
to: { name: 'festival-sections' },
|
|
icon: { icon: 'tabler-layout-grid' },
|
|
},
|
|
{
|
|
title: 'Time Slots & Shifts',
|
|
to: { name: 'shifts' },
|
|
icon: { icon: 'tabler-clock' },
|
|
},
|
|
{ heading: 'People' },
|
|
{
|
|
title: 'Persons',
|
|
to: { name: 'persons' },
|
|
icon: { icon: 'tabler-users' },
|
|
},
|
|
{
|
|
title: 'Artists',
|
|
to: { name: 'artists' },
|
|
icon: { icon: 'tabler-music' },
|
|
},
|
|
{
|
|
title: 'Volunteers',
|
|
to: { name: 'volunteers' },
|
|
icon: { icon: 'tabler-heart-handshake' },
|
|
},
|
|
{ heading: 'Operations' },
|
|
{
|
|
title: 'Accreditation',
|
|
to: { name: 'accreditation' },
|
|
icon: { icon: 'tabler-id-badge-2' },
|
|
},
|
|
{
|
|
title: 'Briefings',
|
|
to: { name: 'briefings' },
|
|
icon: { icon: 'tabler-mail' },
|
|
},
|
|
{
|
|
title: 'Mission Control',
|
|
to: { name: 'mission-control' },
|
|
icon: { icon: 'tabler-broadcast' },
|
|
},
|
|
{ heading: 'Insights' },
|
|
{
|
|
title: 'Reports',
|
|
to: { name: 'reports' },
|
|
icon: { icon: 'tabler-chart-bar' },
|
|
},
|
|
] as VerticalNavItems
|
|
```
|
|
|
|
## Forms with VeeValidate + Zod
|
|
|
|
```vue
|
|
<script setup lang="ts">
|
|
import { useForm } from 'vee-validate'
|
|
import { toTypedSchema } from '@vee-validate/zod'
|
|
import { z } from 'zod'
|
|
import { useCreateEvent } from '@/composables/useEvents'
|
|
|
|
const schema = toTypedSchema(
|
|
z.object({
|
|
name: z.string().min(1, 'Name is required'),
|
|
slug: z.string().min(1, 'Slug is required'),
|
|
start_date: z.string().min(1, 'Start date is required'),
|
|
end_date: z.string().min(1, 'End date is required'),
|
|
timezone: z.string().default('Europe/Amsterdam'),
|
|
})
|
|
)
|
|
|
|
const { handleSubmit, errors, defineField } = useForm({ validationSchema: schema })
|
|
const [name, nameAttrs] = defineField('name')
|
|
const [startDate, startDateAttrs] = defineField('start_date')
|
|
|
|
const { mutate: createEvent, isPending } = useCreateEvent()
|
|
|
|
const onSubmit = handleSubmit(values => {
|
|
createEvent({
|
|
organisationId: authStore.currentOrganisationId!,
|
|
data: values as CreateEventData,
|
|
})
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<form @submit="onSubmit">
|
|
<VTextField
|
|
v-model="name"
|
|
v-bind="nameAttrs"
|
|
label="Event Name"
|
|
:error-messages="errors.name"
|
|
/>
|
|
<VTextField
|
|
v-model="startDate"
|
|
v-bind="startDateAttrs"
|
|
label="Start Date"
|
|
type="date"
|
|
:error-messages="errors.start_date"
|
|
/>
|
|
<VBtn type="submit" color="primary" :loading="isPending">
|
|
Create Event
|
|
</VBtn>
|
|
</form>
|
|
</template>
|
|
```
|
|
|
|
## Best Practices
|
|
|
|
### Always Use
|
|
- `<script setup lang="ts">` for components
|
|
- Props: `defineProps<{...}>()`
|
|
- Emits: `defineEmits<{...}>()`
|
|
- TanStack Query for all API calls via composables
|
|
- Computed properties for derived state
|
|
- Vuexy/Vuetify components (VBtn, VCard, VDataTable, VDialog, etc.)
|
|
- `import type { ... }` for type-only imports
|
|
- Status KPI tiles as clickable VCards on list pages
|
|
- VSkeleton loader during loading
|
|
- VAlert with retry on errors
|
|
- Mobile: table collapses to VList below 768px
|
|
|
|
### Avoid
|
|
- Options API
|
|
- `any` types
|
|
- Raw axios calls in components (use composables)
|
|
- Inline styles (use Vuetify utility classes)
|
|
- Direct DOM manipulation
|
|
- Mutating props
|
|
- Prop drilling (use Pinia stores)
|
|
- Custom CSS when Vuetify class exists
|
|
|
|
## Portal Router Guards
|
|
|
|
```typescript
|
|
// apps/portal/src/router/guards.ts
|
|
export function determineAccessMode(route: RouteLocationNormalized): 'token' | 'login' | 'unauthenticated' {
|
|
if (route.query.token) return 'token'
|
|
if (authStore.isAuthenticated) return 'login'
|
|
return 'unauthenticated'
|
|
}
|
|
|
|
// Token-based: POST /api/v1/portal/token-auth { token: '...' } -> returns person context
|
|
// Login-based: Same /api/v1/auth/login as app/
|
|
```
|