Files
milsim-site-v4/ui/src/pages/Calendar.vue

566 lines
18 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { ref, watch, nextTick, computed, onMounted } from 'vue'
import FullCalendar from '@fullcalendar/vue3'
import dayGridPlugin from '@fullcalendar/daygrid'
import interactionPlugin from '@fullcalendar/interaction'
import { X, Clock, MapPin, User, ListTodo, ChevronLeft, ChevronRight, Plus } from 'lucide-vue-next'
import CreateCalendarEvent from '@/components/calendar/CreateCalendarEvent.vue'
const monthLabels = [
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December'
]
const selectedMonth = ref<number>(new Date().getMonth())
const selectedYear = ref<number>(new Date().getFullYear())
const years = Array.from({ length: 41 }, (_, i) => selectedYear.value - 20 + i) // +/- 20 yrs
function api() {
return calendarRef.value?.getApi()
}
// keep dropdowns in sync whenever the calendar navigates
function onDatesSet() {
const d = api()?.getDate() ?? new Date()
selectedMonth.value = d.getMonth()
selectedYear.value = d.getFullYear()
}
function buildFullDate(month: number, year: number): Date {
return new Date(year, month, 1); //default to first of month
}
watch([selectedMonth, selectedYear], () => {
console.log('Selected date changed:', selectedMonth.value, selectedYear.value)
})
onMounted(() => {
// fetchEventsFor(selectedMonth.value, selectedYear.value)
})
function goPrev() { api()?.prev() }
function goNext() { api()?.next() }
function goToday() { api()?.today() }
// jump to the selected month/year
function goToSelectedDate() {
api()?.gotoDate(new Date(selectedYear.value, selectedMonth.value, 1))
}
type CalEvent = {
id: string
title: string
start: string
end?: string
extendedProps?: Record<string, any>
}
const events = ref<CalEvent[]>([
{ id: '1', title: 'Squad Training', start: '2025-10-08T19:00:00', extendedProps: { trainer: 'Alex', location: 'Range A', color: '#88C4FF' } },
{ id: '2', title: 'Ops Briefing', start: '2025-10-09T20:30:00', extendedProps: { owner: 'CO', agenda: ['Weather', 'Route', 'Risks'], color: '#dba42c' } },
])
const panelOpen = ref(false)
const activeEvent = ref<CalEvent | null>(null)
const calendarRef = ref<InstanceType<typeof FullCalendar> | null>(null)
function onEventClick(arg: any) {
activeEvent.value = {
id: arg.event.id,
title: arg.event.title,
start: arg.event.startStr,
end: arg.event.endStr,
extendedProps: arg.event.extendedProps
}
panelOpen.value = true
}
const dialogRef = ref<any>(null)
// NEW: handle day/time slot clicks to start creating an event
function onDateClick(arg: { dateStr: string }) {
dialogRef.value?.openDialog();
// For now, just open the panel with a draft payload.
// activeEvent.value = {
// id: '__draft__',
// title: 'New event',
// start: arg.dateStr,
// extendedProps: { draft: true }
// }
// panelOpen.value = true
}
const calendarOptions = ref({
plugins: [dayGridPlugin, interactionPlugin],
initialView: 'dayGridMonth',
height: '100%',
expandRows: true,
headerToolbar: {
left: '',
center: '',
right: ''
},
events,
selectable: false,
navLinks: false,
dateClick: onDateClick,
eventClick: onEventClick,
editable: true,
// force block-mode in dayGrid so we can lay it out on one line
eventDisplay: 'block',
// compact time like "19:00"
eventTimeFormat: {
hour: "numeric",
minute: "2-digit",
hour12: true
} as const,
// custom renderer -> one-line pill
eventContent(arg) {
const ext = arg.event.extendedProps || {}
const c = ext.color || arg.backgroundColor || arg.borderColor || ''
const wrap = document.createElement('div')
wrap.className = 'ev-pill'
if (c) wrap.style.setProperty('--ev-color', String(c)) // dot color
const dot = document.createElement('span')
dot.className = 'ev-dot'
const time = document.createElement('span')
time.className = 'ev-time'
time.textContent = arg.timeText
const title = document.createElement('span')
title.className = 'ev-title'
title.textContent = arg.event.title
wrap.append(dot, time, title)
return { domNodes: [wrap] }
},
})
//@ts-ignore (shhh)
calendarOptions.value.datesSet = onDatesSet
watch(panelOpen, async () => {
await nextTick()
calendarRef.value?.getApi().updateSize()
})
const startFmt = new Intl.DateTimeFormat(undefined, {
weekday: 'short', year: 'numeric', month: 'short', day: 'numeric',
hour: 'numeric', minute: '2-digit'
})
const endFmt = new Intl.DateTimeFormat(undefined, {
hour: 'numeric', minute: '2-digit'
})
const whenText = computed(() => {
if (!activeEvent.value?.start) return ''
const s = new Date(activeEvent.value.start)
const e = activeEvent.value?.end ? new Date(activeEvent.value.end) : null
return e
? `${startFmt.format(s)} ${endFmt.format(e)}`
: `${startFmt.format(s)}`
})
function onCreateEvent() {
const iso = new Date().toISOString().slice(0, 10) // YYYY-MM-DD
onDateClick({ dateStr: iso })
}
onMounted(() => {
onDatesSet()
})
const ext = computed(() => activeEvent.value?.extendedProps ?? {})
</script>
<template>
<CreateCalendarEvent ref="dialogRef"></CreateCalendarEvent>
<div class="flex">
<div class="flex-1 min-h-0 mt-5">
<div class="h-[80vh] min-h-0">
<!-- calendar header -->
<div class="flex items-center justify-between mx-5">
<!-- Left: title + pickers -->
<div class="flex items-center gap-2">
<!-- <h2 class="text-xl font-semibold tracking-tight">
{{ monthLabels[selectedMonth] }} {{ selectedYear }}
</h2> -->
<!-- Month dropdown -->
<select v-model.number="selectedMonth" @change="goToSelectedDate"
class="ml-2 rounded-md border bg-transparent px-2 py-1 text-sm" aria-label="Select month">
<option v-for="(m, i) in monthLabels" :key="m" :value="i" class="bg-card">
{{ m }}
</option>
</select>
<!-- Year dropdown -->
<select v-model.number="selectedYear" @change="goToSelectedDate"
class="rounded-md border bg-transparent px-2 py-1 text-sm" aria-label="Select year">
<option v-for="y in years" :key="y" :value="y" class="bg-card">
{{ y }}
</option>
</select>
</div>
<!-- Right: nav + today + create -->
<div class="flex items-center gap-2">
<button
class="cursor-pointer inline-flex h-8 w-8 items-center justify-center rounded-md border hover:bg-muted/40"
aria-label="Previous month" @click="goPrev">
<ChevronLeft class="h-4 w-4" />
</button>
<button
class="cursor-pointer inline-flex h-8 w-8 items-center justify-center rounded-md border hover:bg-muted/40"
aria-label="Next month" @click="goNext">
<ChevronRight class="h-4 w-4" />
</button>
<button class="cursor-pointer ml-1 rounded-md border px-3 py-1.5 text-sm hover:bg-muted/40"
@click="goToday">
Today
</button>
<button
class="cursor-pointer ml-1 inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-sm text-primary-foreground hover:opacity-90"
@click="onCreateEvent">
<Plus class="h-4 w-4" />
Create
</button>
</div>
</div>
<FullCalendar ref="calendarRef" :options="calendarOptions" />
</div>
</div>
<aside v-if="panelOpen" class="3xl:w-lg 2xl:w-md border-l bg-card text-foreground flex flex-col"
:style="{ height: 'calc(100vh - 61px)', position: 'sticky', top: '64px' }">
<!-- Header -->
<div class="flex items-center justify-between gap-3 border-b px-4 py-3">
<h2 class="text-lg font-semibold line-clamp-2">
{{ activeEvent?.title || 'Event' }}
</h2>
<button
class="inline-flex size-8 items-center justify-center rounded-md border hover:bg-muted/40 transition"
aria-label="Close" @click="panelOpen = false">
<X class="size-4" />
</button>
</div>
<!-- Body -->
<div class="flex-1 min-h-0 overflow-y-auto px-4 py-4 space-y-6">
<!-- When -->
<section v-if="whenText" class="space-y-2">
<div class="inline-flex items-center gap-2 rounded-md border px-2.5 py-1.5 text-sm">
<Clock class="size-4 opacity-80" />
<span class="font-medium">{{ whenText }}</span>
</div>
</section>
<!-- Quick meta chips -->
<section class="flex flex-wrap gap-2">
<span v-if="ext.location"
class="inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-xs">
<MapPin class="size-3.5 opacity-80" />
<span class="font-medium">{{ ext.location }}</span>
</span>
<span v-if="ext.owner" class="inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-xs">
<User class="size-3.5 opacity-80" />
<span class="font-medium">Owner: {{ ext.owner }}</span>
</span>
<span v-if="ext.trainer"
class="inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-xs">
<User class="size-3.5 opacity-80" />
<span class="font-medium">Trainer: {{ ext.trainer }}</span>
</span>
</section>
<!-- Agenda (special-cased array) -->
<section v-if="Array.isArray(ext.agenda) && ext.agenda.length" class="space-y-2">
<div class="flex items-center gap-2 text-sm font-medium">
<ListTodo class="size-4 opacity-80" />
Agenda
</div>
<ul class="space-y-1.5">
<li v-for="(item, i) in ext.agenda" :key="i" class="flex items-start gap-2 text-sm">
<span class="mt-1.5 size-1.5 rounded-full bg-foreground/50"></span>
<span>{{ item }}</span>
</li>
</ul>
</section>
<!-- Generic details (extendedProps minus the ones above) -->
<section v-if="ext && Object.keys(ext).length" class="space-y-3">
<div class="text-sm font-medium opacity-80">Details</div>
<dl class="grid grid-cols-1 gap-y-3">
<template v-for="(val, key) in ext" :key="key">
<template v-if="!['agenda', 'owner', 'trainer', 'location'].includes(String(key))">
<div class="grid grid-cols-[120px_1fr] items-start gap-3">
<dt class="text-xs uppercase tracking-wide opacity-60">{{ key }}</dt>
<dd class="text-sm">
<span v-if="Array.isArray(val)">{{ val.join(', ') }}</span>
<span v-else>{{ String(val) }}</span>
</dd>
</div>
</template>
</template>
</dl>
</section>
</div>
<!-- Footer (optional actions) -->
<div class="border-t px-4 py-3 flex items-center justify-end gap-2">
<button class="rounded-md border px-3 py-1.5 text-sm hover:bg-muted/40 transition">
Edit
</button>
<button
class="rounded-md bg-primary text-primary-foreground px-3 py-1.5 text-sm hover:opacity-90 transition">
Open details
</button>
</div>
</aside>
</div>
</template>
<style scoped>
/* ---------- Optional container "card" around the calendar ---------- */
:global(.fc) {
height: 100% !important;
}
.calendar-card {
background: var(--color-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-sm);
padding: 12px;
}
@media (min-width: 640px) {
.calendar-card {
padding: 16px;
}
}
/* ---------- FullCalendar base ---------- */
:global(.fc) {
color: var(--color-foreground);
background: transparent;
font-family: var(--font-sans), system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif;
font-size: 0.94rem;
/* compact */
}
:global(.fc .fc-daygrid .fc-scroller) {
overflow: visible !important;
/* no internal scroll for month grid */
}
/* Subtle borders everywhere */
:global(.fc .fc-scrollgrid),
:global(.fc .fc-scrollgrid td),
:global(.fc .fc-scrollgrid th) {
border-color: var(--color-border);
}
/* ---------- Built-in toolbar (if you keep it) ---------- */
:global(.fc .fc-toolbar) {
gap: 8px;
}
:global(.fc .fc-toolbar-title) {
font-weight: 600;
letter-spacing: 0.01em;
color: var(--color-foreground);
}
:global(.fc .fc-button) {
background: transparent;
color: var(--color-foreground);
border: 1px solid var(--color-border);
border-radius: calc(var(--radius-lg) + 2px);
box-shadow: none;
padding: 6px 15px;
line-height: 1;
transition: background 120ms ease, border-color 120ms ease, color 120ms ease, box-shadow 120ms ease;
}
:global(.fc .fc-button:hover) {
background: color-mix(in oklab, var(--color-foreground) 5%, transparent);
}
:global(.fc .fc-button:focus) {
outline: none;
box-shadow: 0 0 0 4px color-mix(in oklab, var(--color-ring) 55%, transparent);
}
:global(.fc .fc-button.fc-button-active) {
background: var(--color-primary);
color: var(--color-primary-foreground);
border-color: var(--color-primary);
}
/* ---------- Month header + day numbers ---------- */
:global(.fc .fc-col-header-cell-cushion) {
color: var(--color-muted-foreground);
font-weight: 600;
padding: 8px 12px;
text-decoration: none;
}
:global(.fc .fc-daygrid-day-top) {
padding: 8px 8px 0 8px;
}
:global(.fc .fc-daygrid-day-number) {
color: var(--color-muted-foreground);
font-weight: 600;
font-size: 12px;
text-decoration: none;
}
/* Today: soft background + stronger number */
:global(.fc .fc-day-today) {
background: color-mix(in oklab, var(--color-primary) 12%, transparent);
}
:global(.fc .fc-day-today .fc-daygrid-day-number) {
color: var(--color-foreground);
}
/* Hover affordance inside a day cell (very subtle) */
:global(.fc .fc-daygrid-day:hover) {
background: color-mix(in oklab, var(--color-foreground) 3%, transparent);
}
/* ---------- Event chips (dayGrid) ---------- */
:global(.fc .fc-daygrid-event) {
display: block;
border: none;
background: transparent;
color: var(--color-foreground);
border-radius: 4px;
padding: 4px 8px;
margin: 2px 6px;
text-decoration: none;
transition: background 120ms ease, border-color 120ms ease, transform 120ms ease;
}
:global(.fc .fc-daygrid-event:hover) {
background: color-mix(in oklab, var(--color-foreground) 6%, transparent);
border-color: color-mix(in oklab, var(--color-foreground) 12%, var(--color-border));
}
/* One-line custom pill content (our renderer) */
:global(.ev-pill) {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
color: inherit;
}
:global(.ev-dot) {
width: 8px;
height: 8px;
border-radius: 50%;
flex: 0 0 auto;
background: var(--ev-color, currentColor);
margin-right: 4px;
}
:global(.ev-time) {
font-weight: 600;
margin-right: 4px;
white-space: nowrap;
flex: 0 0 auto;
}
:global(.ev-title) {
flex: 1 1 auto;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* One-line custom pill */
.ev-pill {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
padding: 4px 8px;
border-radius: 14px;
border: 1px solid var(--color-border);
background: color-mix(in oklab, var(--color-primary) 14%, transparent);
color: var(--color-foreground);
text-decoration: none;
transition: background 120ms ease, border-color 120ms ease, transform 120ms ease;
}
.ev-pill:hover {
background: color-mix(in oklab, var(--color-primary) 20%, transparent);
border-color: color-mix(in oklab, var(--color-primary) 45%, var(--color-border));
}
.ev-dot {
width: 6px;
height: 6px;
border-radius: 50%;
flex: 0 0 auto;
}
.ev-time {
font-weight: 600;
margin-right: 4px;
white-space: nowrap;
flex: 0 0 auto;
}
.ev-title {
flex: 1 1 auto;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ev-pill.is-colored {
color: var(--color-primary-foreground);
}
/* --- Replace the default today highlight with a round badge --- */
:global(.fc .fc-daygrid-day.fc-day-today) {
background: transparent;
}
:global(.fc .fc-daygrid-day.fc-day-today .fc-daygrid-day-number) {
display: inline-block;
padding: 2px 8px;
border-radius: 9999px;
background: color-mix(in oklab, var(--color-primary) 100%, transparent);
font-weight: 700;
text-decoration: none;
color: var(--color-primary-foreground);
}
:global(.fc .fc-daygrid-day:hover) {
background: color-mix(in oklab, var(--color-foreground) 3%, transparent);
}
</style>