Files
band-management/.cursor/rules/101_vue.mdc
bert.hausmans 1cb7674d52 refactor: align codebase with EventCrew domain and trim legacy band stack
- 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
2026-03-29 23:19:06 +02:00

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/
```