chore(rules): rewrite 101_vue.mdc as slim principles file
Drops 17 KB of embedded code templates that had drifted from actual implementations in apps/app/src/ (auth store template still used localStorage; portal router guards still showed dual-mode logic that was consolidated to /portal/* routes within apps/app in PR-B1/B2a). Slim rewrite: principles + file structure + pointers to actual reference code in apps/app/src/. Globs narrowed to apps/app/**/* since apps/portal/ no longer exists. Vuexy component selection deferred to dev-docs/VUEXY_COMPONENTS.md as canonical registry. Net: ~17 KB -> ~3 KB, less drift surface, points at living code instead of duplicating it.
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
description: Vue 3, TypeScript, and Vuexy patterns for Crewli platform
|
description: Vue 3, TypeScript, and Vuexy patterns for Crewli
|
||||||
globs: ["apps/**/*.{vue,ts,tsx}"]
|
globs: ["apps/app/**/*.{vue,ts,tsx}"]
|
||||||
alwaysApply: true
|
alwaysApply: true
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -8,675 +8,78 @@ alwaysApply: true
|
|||||||
|
|
||||||
## Core Principles
|
## Core Principles
|
||||||
|
|
||||||
1. **Composition API only** - Always `<script setup lang="ts">`
|
1. **Composition API only** — always `<script setup lang="ts">`, never Options API
|
||||||
2. **TypeScript strict mode** - No `any` types
|
2. **No `any` types** — use proper typing or `unknown` + narrowing
|
||||||
3. **TanStack Query for API** - Never raw axios in components
|
3. **TanStack Query for API** — never raw axios in components
|
||||||
4. **Pinia for client state** - Server data stays in TanStack Query
|
4. **Pinia for cross-component client state** — server data lives in TanStack Query, never duplicated in stores
|
||||||
5. **Vuexy/Vuetify components** - Never custom CSS if a Vuetify class exists
|
5. **Vuetify components first** — custom CSS only when no Vuetify class fits the use case
|
||||||
6. **VeeValidate + Zod** - For all form validation
|
6. **VeeValidate + Zod** for all form validation
|
||||||
7. **Mobile-first** - Minimum 375px width
|
7. **Mobile-first** — minimum 375px width, responsive at every breakpoint
|
||||||
|
|
||||||
## App-Specific Rules
|
## File structure
|
||||||
|
|
||||||
### `apps/app/` (Organizer + Platform Admin - Main App)
|
|
||||||
- Sidebar nav customized for Crewli structure
|
|
||||||
- Remove Vuexy demo/customizer components
|
|
||||||
- Full Vuetify component usage
|
|
||||||
- 90% of development work happens here
|
|
||||||
- Super admin functionality under `/platform/*` routes for `super_admin` users
|
|
||||||
|
|
||||||
### `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
|
apps/app/src/
|
||||||
src/@layouts/ # Vuexy layouts
|
├── lib/axios.ts # Single axios instance (do not duplicate)
|
||||||
|
├── composables/api/use*.ts # TanStack Query composables (one per resource)
|
||||||
|
├── stores/use*Store.ts # Pinia stores — client state only
|
||||||
|
├── types/*.ts # TypeScript interfaces (mirror backend Resources)
|
||||||
|
├── pages/ # File-based routing via unplugin-vue-router
|
||||||
|
├── layouts/ # Layout components
|
||||||
|
├── components/ # Reusable components
|
||||||
|
└── @core/ # Vuexy core — DO NOT MODIFY
|
||||||
```
|
```
|
||||||
|
|
||||||
### Customize
|
## Reference patterns (read these for templates)
|
||||||
```
|
|
||||||
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
|
For working examples in the actual codebase:
|
||||||
|
|
||||||
### Axios Instance (ONE per app)
|
- **Composable pattern:** `apps/app/src/composables/api/useEvents.ts`
|
||||||
|
- **Pinia store pattern:** `apps/app/src/stores/useAuthStore.ts`
|
||||||
|
- **Page pattern:** `apps/app/src/pages/events/index.vue`
|
||||||
|
- **Form pattern:** `apps/app/src/components/events/CreateEventDialog.vue`
|
||||||
|
- **Layout pattern:** `apps/app/src/layouts/OrganizerLayout.vue`
|
||||||
|
|
||||||
```typescript
|
For Vuexy component selection, consult `dev-docs/VUEXY_COMPONENTS.md` — the registry of @core wrappers and patterns. Always check that registry before writing a custom component.
|
||||||
// src/lib/axios.ts
|
|
||||||
import axios from 'axios'
|
|
||||||
import type { AxiosInstance, InternalAxiosRequestConfig } from 'axios'
|
|
||||||
import { useAuthStore } from '@/stores/useAuthStore'
|
|
||||||
|
|
||||||
const api: AxiosInstance = axios.create({
|
For auth and routing, see `dev-docs/AUTH_ARCHITECTURE.md` (httpOnly cookies, dual-axios for portal-token routes, route guard logic).
|
||||||
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) => {
|
## Strict rules
|
||||||
const authStore = useAuthStore()
|
|
||||||
if (authStore.token) {
|
|
||||||
config.headers.Authorization = `Bearer ${authStore.token}`
|
|
||||||
}
|
|
||||||
return config
|
|
||||||
})
|
|
||||||
|
|
||||||
api.interceptors.response.use(
|
### TypeScript
|
||||||
response => response,
|
- Use `import type { ... }` for type-only imports
|
||||||
error => {
|
- Mirror backend PHP Enums as const objects with `as const` in `apps/app/src/types/`
|
||||||
if (error.response?.status === 401) {
|
- Generic API response shape: `{ data: T, meta?: PaginationMeta }`
|
||||||
const authStore = useAuthStore()
|
|
||||||
authStore.logout()
|
|
||||||
}
|
|
||||||
return Promise.reject(error)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
export { api }
|
### Architecture
|
||||||
```
|
- Components never import axios directly — always via composables
|
||||||
|
- Composables call axios via the singleton in `apps/app/src/lib/axios.ts`
|
||||||
|
- Mutations invalidate query keys after success
|
||||||
|
- No prop drilling — use Pinia stores when state crosses two component boundaries
|
||||||
|
|
||||||
### TypeScript Types
|
### UI
|
||||||
|
- Three states for every list view: **loading** (VSkeletonLoader), **error** (VAlert with retry button), **empty** (helpful message explaining what action to take)
|
||||||
|
- Custom CSS forbidden when a Vuetify utility class exists
|
||||||
|
- Tables on mobile (<768px) collapse to VList or card view — never horizontal scroll without a visual indicator
|
||||||
|
|
||||||
```typescript
|
### Forms
|
||||||
// src/types/events.ts
|
- Zod schema mirrors backend FormRequest validation
|
||||||
|
- Errors shown inline via VeeValidate's `errors` object
|
||||||
|
- Submit button disabled while `isPending`
|
||||||
|
|
||||||
export type EventStatus = 'draft' | 'published' | 'registration_open' | 'buildup' | 'showday' | 'teardown' | 'closed'
|
### Routing
|
||||||
export type PersonStatus = 'invited' | 'applied' | 'pending' | 'approved' | 'rejected' | 'no_show'
|
- File-based routing via unplugin-vue-router
|
||||||
export type BookingStatus = 'concept' | 'requested' | 'option' | 'confirmed' | 'contracted' | 'cancelled'
|
- Guards in `apps/app/src/plugins/1.router/guards.ts`
|
||||||
export type ShiftAssignmentStatus = 'pending_approval' | 'approved' | 'rejected' | 'cancelled' | 'completed'
|
- Portal routes are at `/portal/*` (within apps/app), NOT a separate SPA
|
||||||
export type CrowdSystemType = 'CREW' | 'GUEST' | 'ARTIST' | 'VOLUNTEER' | 'PRESS' | 'PARTNER' | 'SUPPLIER'
|
- Platform admin routes are at `/platform/*`, gated by `super_admin` role
|
||||||
|
|
||||||
export interface Organisation {
|
## Avoid
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
slug: string
|
|
||||||
billing_status: string
|
|
||||||
created_at: string
|
|
||||||
updated_at: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Event {
|
- Options API (`export default { ... }`)
|
||||||
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
|
- `any` types
|
||||||
- Raw axios calls in components (use composables)
|
- Raw axios calls in components
|
||||||
- Inline styles (use Vuetify utility classes)
|
- Inline styles
|
||||||
- Direct DOM manipulation
|
- Direct DOM manipulation
|
||||||
- Mutating props
|
- Mutating props
|
||||||
- Prop drilling (use Pinia stores)
|
- Custom CSS when a Vuetify class exists
|
||||||
- Custom CSS when Vuetify class exists
|
- Hardcoded URLs or string-literal status values
|
||||||
|
|
||||||
## 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/
|
|
||||||
```
|
|
||||||
|
|||||||
Reference in New Issue
Block a user