Initial commit
This commit is contained in:
29
.gitignore
vendored
Normal file
29
.gitignore
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
# Frontend development
|
||||
node_modules/
|
||||
*.local
|
||||
.env.local
|
||||
.env.*.local
|
||||
.cache/
|
||||
.vite/
|
||||
.temp/
|
||||
.tmp/
|
||||
|
||||
# Frontend build
|
||||
/static/
|
||||
|
||||
# Backend development
|
||||
/lib/vendor/
|
||||
coverage/
|
||||
phpunit.xml.cache
|
||||
.phpunit.result.cache
|
||||
.php-cs-fixer.cache
|
||||
.phpstan.cache
|
||||
.phpactor/
|
||||
|
||||
# Editors
|
||||
.DS_Store
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
9
composer.json
Normal file
9
composer.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "ktxm/chrono",
|
||||
"type": "project",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"KTXM\\Chrono\\": "lib/"
|
||||
}
|
||||
}
|
||||
}
|
||||
66
lib/Module.php
Normal file
66
lib/Module.php
Normal file
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace KTXM\Chrono;
|
||||
|
||||
use KTXF\Module\ModuleBrowserInterface;
|
||||
use KTXF\Module\ModuleInstanceAbstract;
|
||||
|
||||
/**
|
||||
* Chrono Module - Calendaring and Tasks
|
||||
*/
|
||||
class Module extends ModuleInstanceAbstract implements ModuleBrowserInterface
|
||||
{
|
||||
|
||||
public function __construct()
|
||||
{ }
|
||||
|
||||
public function handle(): string
|
||||
{
|
||||
return 'chrono';
|
||||
}
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return 'Chrono';
|
||||
}
|
||||
|
||||
public function author(): string
|
||||
{
|
||||
return 'Ktrix';
|
||||
}
|
||||
|
||||
public function description(): string
|
||||
{
|
||||
return 'Calendar and task management interface - provides event scheduling, task tracking, and time management capabilities';
|
||||
}
|
||||
|
||||
public function version(): string
|
||||
{
|
||||
return '0.0.1';
|
||||
}
|
||||
|
||||
public function permissions(): array
|
||||
{
|
||||
return [
|
||||
'chrono' => [
|
||||
'label' => 'Access Chrono',
|
||||
'description' => 'View and access the calendar and task management module',
|
||||
'group' => 'Calendar & Tasks'
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function registerBI(): array
|
||||
{
|
||||
return [
|
||||
'handle' => $this->handle(),
|
||||
'namespace' => 'Chrono',
|
||||
'version' => $this->version(),
|
||||
'label' => $this->label(),
|
||||
'author' => $this->author(),
|
||||
'description' => $this->description(),
|
||||
'boot' => 'static/module.mjs',
|
||||
];
|
||||
}
|
||||
|
||||
}
|
||||
1527
package-lock.json
generated
Normal file
1527
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
29
package.json
Normal file
29
package.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "chrono",
|
||||
"description": "Ktrix Chrono Module - Calendaring and Tasks",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"author": "Ktrix",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "vite build --mode production --config vite.config.ts",
|
||||
"dev": "vite build --mode development --config vite.config.ts",
|
||||
"watch": "vite build --mode development --watch --config vite.config.ts",
|
||||
"typecheck": "vue-tsc --noEmit",
|
||||
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore"
|
||||
},
|
||||
"dependencies": {
|
||||
"pinia": "^2.3.1",
|
||||
"vue": "^3.5.18",
|
||||
"vue-router": "^4.5.1",
|
||||
"vuetify": "^3.10.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
"@vue/tsconfig": "^0.7.0",
|
||||
"typescript": "~5.8.3",
|
||||
"vite": "^7.1.2",
|
||||
"vue-tsc": "^3.0.5"
|
||||
}
|
||||
}
|
||||
226
src/components/AgendaView.vue
Normal file
226
src/components/AgendaView.vue
Normal file
@@ -0,0 +1,226 @@
|
||||
<template>
|
||||
<div class="agenda-view">
|
||||
<div class="view-controls">
|
||||
<div class="view-navigation">
|
||||
<v-btn size="x-small" variant="text" icon="mdi-chevron-left" @click="previousPeriod" />
|
||||
<v-btn size="x-small" variant="tonal" @click="goToToday">Today</v-btn>
|
||||
<v-btn size="x-small" variant="text" icon="mdi-chevron-right" @click="nextPeriod" />
|
||||
<span class="current-period">{{ dateRangeLabel }}</span>
|
||||
</div>
|
||||
<v-spacer />
|
||||
<div class="view-selector">
|
||||
<v-btn size="x-small" variant="text" @click="setSpan('1d')" :color="selectedSpan === '1d' ? 'primary' : undefined">1D</v-btn>
|
||||
<v-btn size="x-small" variant="text" @click="setSpan('3d')" :color="selectedSpan === '3d' ? 'primary' : undefined">3D</v-btn>
|
||||
<v-btn size="x-small" variant="text" @click="setSpan('1w')" :color="selectedSpan === '1w' ? 'primary' : undefined">1W</v-btn>
|
||||
<v-btn size="x-small" variant="text" @click="setSpan('2w')" :color="selectedSpan === '2w' ? 'primary' : undefined">2W</v-btn>
|
||||
<v-btn size="x-small" variant="text" @click="setSpan('3w')" :color="selectedSpan === '3w' ? 'primary' : undefined">3W</v-btn>
|
||||
<v-btn size="x-small" variant="text" @click="setSpan('1m')" :color="selectedSpan === '1m' ? 'primary' : undefined">1M</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
<v-list class="agenda-list">
|
||||
<template v-if="Object.keys(groupedEvents).length === 0">
|
||||
<v-list-item>
|
||||
<v-list-item-title class="text-center text-medium-emphasis">No events in this period</v-list-item-title>
|
||||
</v-list-item>
|
||||
</template>
|
||||
<template v-for="(dayEvents, date) in groupedEvents" :key="date">
|
||||
<v-list-subheader>{{ formatAgendaDate(date) }}</v-list-subheader>
|
||||
<v-list-item
|
||||
v-for="entity in dayEvents"
|
||||
:key="entity.id"
|
||||
@click="$emit('event-click', entity)"
|
||||
>
|
||||
<template #prepend>
|
||||
<v-icon :color="getEventColor(entity)">mdi-circle</v-icon>
|
||||
</template>
|
||||
<v-list-item-title>{{ entity.data?.label || 'Untitled' }}</v-list-item-title>
|
||||
<v-list-item-subtitle>
|
||||
{{ entity.data?.allDay ? 'All day' : `${entity.data?.startsOn ? formatTime(new Date(entity.data.startsOn)) : ''} - ${entity.data?.endsOn ? formatTime(new Date(entity.data.endsOn)) : ''}` }}
|
||||
</v-list-item-subtitle>
|
||||
</v-list-item>
|
||||
</template>
|
||||
</v-list>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue';
|
||||
|
||||
type SpanType = '1d' | '3d' | '1w' | '2w' | '3w' | '1m';
|
||||
|
||||
const props = defineProps<{
|
||||
events: any[];
|
||||
calendars: any[];
|
||||
currentDate?: Date;
|
||||
initialSpan?: SpanType;
|
||||
}>();
|
||||
|
||||
defineEmits<{
|
||||
'event-click': [event: any];
|
||||
}>();
|
||||
|
||||
const selectedSpan = ref<SpanType>(props.initialSpan ?? '1w');
|
||||
const localDate = ref(new Date(props.currentDate ?? new Date()));
|
||||
|
||||
// Watch for external date changes (e.g., from mini calendar)
|
||||
watch(() => props.currentDate, (newDate) => {
|
||||
if (newDate) {
|
||||
localDate.value = new Date(newDate);
|
||||
}
|
||||
});
|
||||
|
||||
function setSpan(span: SpanType) {
|
||||
selectedSpan.value = span;
|
||||
}
|
||||
|
||||
function getSpanDays(): number {
|
||||
switch (selectedSpan.value) {
|
||||
case '1d': return 1;
|
||||
case '3d': return 3;
|
||||
case '1w': return 7;
|
||||
case '2w': return 14;
|
||||
case '3w': return 21;
|
||||
case '1m': return 30;
|
||||
default: return 7;
|
||||
}
|
||||
}
|
||||
|
||||
function previousPeriod() {
|
||||
const date = new Date(localDate.value);
|
||||
date.setDate(date.getDate() - getSpanDays());
|
||||
localDate.value = date;
|
||||
}
|
||||
|
||||
function nextPeriod() {
|
||||
const date = new Date(localDate.value);
|
||||
date.setDate(date.getDate() + getSpanDays());
|
||||
localDate.value = date;
|
||||
}
|
||||
|
||||
function goToToday() {
|
||||
localDate.value = new Date();
|
||||
}
|
||||
|
||||
const dateRange = computed(() => {
|
||||
const start = new Date(localDate.value);
|
||||
start.setHours(0, 0, 0, 0);
|
||||
|
||||
const end = new Date(start);
|
||||
end.setDate(end.getDate() + getSpanDays() - 1);
|
||||
end.setHours(23, 59, 59, 999);
|
||||
|
||||
return { start, end };
|
||||
});
|
||||
|
||||
const dateRangeLabel = computed(() => {
|
||||
const { start, end } = dateRange.value;
|
||||
const formatOptions: Intl.DateTimeFormatOptions = { month: 'short', day: 'numeric' };
|
||||
|
||||
if (getSpanDays() === 1) {
|
||||
return start.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' });
|
||||
}
|
||||
|
||||
if (start.getMonth() === end.getMonth()) {
|
||||
return `${start.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })} - ${end.getDate()}, ${start.getFullYear()}`;
|
||||
}
|
||||
|
||||
return `${start.toLocaleDateString('en-US', formatOptions)} - ${end.toLocaleDateString('en-US', formatOptions)}, ${end.getFullYear()}`;
|
||||
});
|
||||
|
||||
const groupedEvents = computed(() => {
|
||||
const grouped: Record<string, any[]> = {};
|
||||
const { start, end } = dateRange.value;
|
||||
|
||||
const filtered = props.events.filter(e => {
|
||||
if (!e.data?.startsOn) return false;
|
||||
const eventStart = new Date(e.data.startsOn);
|
||||
return eventStart >= start && eventStart <= end;
|
||||
});
|
||||
|
||||
const sorted = filtered.sort((a, b) =>
|
||||
new Date(a.data.startsOn).getTime() - new Date(b.data.startsOn).getTime()
|
||||
);
|
||||
|
||||
sorted.forEach(entity => {
|
||||
const dateKey = new Date(entity.data.startsOn).toDateString();
|
||||
if (!grouped[dateKey]) {
|
||||
grouped[dateKey] = [];
|
||||
}
|
||||
grouped[dateKey].push(entity);
|
||||
});
|
||||
|
||||
return grouped;
|
||||
});
|
||||
|
||||
function getEventColor(entity: any): string {
|
||||
if (entity.data?.color) return entity.data.color;
|
||||
const calendar = props.calendars.find(cal => cal.id === entity.in);
|
||||
return calendar?.color || '#1976D2';
|
||||
}
|
||||
|
||||
function formatTime(date: Date): string {
|
||||
return date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' });
|
||||
}
|
||||
|
||||
function formatAgendaDate(dateString: string): string {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('en-US', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.agenda-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.view-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 4px 8px;
|
||||
border-bottom: 1px solid rgb(var(--v-border-color));
|
||||
background-color: rgb(var(--v-theme-surface));
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.view-navigation {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.current-period {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
margin-left: 8px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.view-selector {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.agenda-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.agenda-list :deep(.v-list-item) {
|
||||
margin: 4px 8px;
|
||||
border-radius: 4px;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.agenda-list :deep(.v-list-item:hover) {
|
||||
background-color: rgba(var(--v-theme-primary), 0.04);
|
||||
}
|
||||
</style>
|
||||
96
src/components/CalendarView.vue
Normal file
96
src/components/CalendarView.vue
Normal file
@@ -0,0 +1,96 @@
|
||||
<template>
|
||||
<div class="calendar-view-container">
|
||||
<EventViewerPopup
|
||||
:event="hoveredEvent"
|
||||
:position="popupPosition"
|
||||
:visible="showPopup"
|
||||
@click="$emit('event-click', $event)"
|
||||
/>
|
||||
<MonthView
|
||||
v-if="view === 'month'"
|
||||
:current-date="currentDate"
|
||||
:events="events"
|
||||
:calendars="calendars"
|
||||
@event-click="$emit('event-click', $event)"
|
||||
@date-click="$emit('date-click', $event)"
|
||||
@event-hover="handleEventHover"
|
||||
@event-hover-end="hidePopup"
|
||||
/>
|
||||
<DaysView
|
||||
v-else-if="view === 'days'"
|
||||
:current-date="currentDate"
|
||||
:events="events"
|
||||
:calendars="calendars"
|
||||
@event-click="$emit('event-click', $event)"
|
||||
@date-click="$emit('date-click', $event)"
|
||||
@event-hover="handleEventHover"
|
||||
@event-hover-end="hidePopup"
|
||||
/>
|
||||
<AgendaView
|
||||
v-else-if="view === 'agenda'"
|
||||
:events="events"
|
||||
:calendars="calendars"
|
||||
@event-click="$emit('event-click', $event)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import MonthView from './MonthView.vue';
|
||||
import DaysView from './DaysView.vue';
|
||||
import AgendaView from './AgendaView.vue';
|
||||
import EventViewerPopup from './EventViewerPopup.vue';
|
||||
|
||||
defineProps<{
|
||||
view: 'days' | 'month' | 'agenda';
|
||||
currentDate: Date;
|
||||
events: any[];
|
||||
calendars: any[];
|
||||
}>();
|
||||
|
||||
defineEmits<{
|
||||
'event-click': [event: any];
|
||||
'date-click': [date: Date];
|
||||
}>();
|
||||
|
||||
// Popup state
|
||||
const hoveredEvent = ref<any>(null);
|
||||
const popupPosition = ref({ x: 0, y: 0 });
|
||||
const showPopup = ref(false);
|
||||
let hideTimeout: NodeJS.Timeout | null = null;
|
||||
|
||||
function handleEventHover(data: { event: MouseEvent; entity: any }) {
|
||||
if (hideTimeout) {
|
||||
clearTimeout(hideTimeout);
|
||||
hideTimeout = null;
|
||||
}
|
||||
hoveredEvent.value = data.entity;
|
||||
popupPosition.value = {
|
||||
x: data.event.clientX,
|
||||
y: data.event.clientY
|
||||
};
|
||||
showPopup.value = true;
|
||||
}
|
||||
|
||||
function hidePopup() {
|
||||
hideTimeout = setTimeout(() => {
|
||||
showPopup.value = false;
|
||||
hoveredEvent.value = null;
|
||||
}, 200);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.calendar-view-container {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.calendar-view-container > * {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
</style>
|
||||
329
src/components/CollectionEditor.vue
Normal file
329
src/components/CollectionEditor.vue
Normal file
@@ -0,0 +1,329 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useServicesStore } from '@ChronoManager/stores/servicesStore'
|
||||
import { CollectionObject } from '@ChronoManager/models/collection'
|
||||
import { ServiceObject } from '@ChronoManager/models/service'
|
||||
|
||||
// Store
|
||||
const servicesStore = useServicesStore()
|
||||
|
||||
// Props
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
collection: CollectionObject | null
|
||||
mode: 'create' | 'edit'
|
||||
type?: 'calendar' | 'tasklist'
|
||||
}>()
|
||||
|
||||
// Emits
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
'save': [collection: CollectionObject, service: ServiceObject]
|
||||
'delete': [collection: CollectionObject]
|
||||
}>()
|
||||
|
||||
// State
|
||||
const services = ref<ServiceObject[]>([])
|
||||
const editingCollection = ref<CollectionObject | null>(null)
|
||||
const editingCollectionService = ref<ServiceObject | null>(null)
|
||||
const editingCollectionValidated = ref(false)
|
||||
const colorMenuOpen = ref(false)
|
||||
const colorOptions = [
|
||||
'#FF6B6B', '#FF8E4D', '#FFB347', '#FFD166', '#E5FF7F', '#B5E86D',
|
||||
'#7CD992', '#46CBB7', '#2ECCCB', '#27B1E6', '#2196F3', '#2E6FE3',
|
||||
'#3A4CC9', '#5A3FC0', '#7F3FBF', '#B339AE', '#DE3A9B', '#FF6FB7'
|
||||
]
|
||||
|
||||
const COLORS_PER_ROW = 6
|
||||
const colorRows = computed(() => {
|
||||
const rows: string[][] = []
|
||||
for (let i = 0; i < colorOptions.length; i += COLORS_PER_ROW) {
|
||||
rows.push(colorOptions.slice(i, i + COLORS_PER_ROW))
|
||||
}
|
||||
return rows
|
||||
})
|
||||
|
||||
// Computed
|
||||
const dialogOpen = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value: boolean) => emit('update:modelValue', value)
|
||||
})
|
||||
|
||||
const dialogTitle = computed(() => {
|
||||
const itemType = props.type === 'tasklist' ? 'Task List' : 'Calendar'
|
||||
return props.mode === 'create' ? `Create New ${itemType}` : `Edit ${itemType}`
|
||||
})
|
||||
|
||||
const dialogIcon = computed(() => {
|
||||
return props.type === 'tasklist' ? 'mdi-checkbox-marked-outline' : 'mdi-calendar'
|
||||
})
|
||||
|
||||
// Functions
|
||||
const onOpen = async () => {
|
||||
if (services.value.length === 0) {
|
||||
services.value = await servicesStore.list()
|
||||
}
|
||||
|
||||
if (!props.collection) {
|
||||
return
|
||||
}
|
||||
|
||||
// Clone the collection to avoid mutating the original
|
||||
editingCollection.value = props.collection.clone()
|
||||
|
||||
if (props.collection.id !== null) {
|
||||
// Edit mode - find the service
|
||||
editingCollectionService.value = services.value.find(s =>
|
||||
s.provider === props.collection!.provider && s.id === props.collection!.service
|
||||
) || null
|
||||
} else {
|
||||
// Create mode - use first service that can create
|
||||
editingCollectionService.value = services.value.filter(s => s.capabilities?.CollectionCreate)[0] || null
|
||||
}
|
||||
}
|
||||
|
||||
const onSave = () => {
|
||||
if (!editingCollection.value || !editingCollectionService.value) {
|
||||
return
|
||||
}
|
||||
emit('save', editingCollection.value, editingCollectionService.value)
|
||||
dialogOpen.value = false
|
||||
}
|
||||
|
||||
const onDelete = () => {
|
||||
if (!editingCollection.value) {
|
||||
return
|
||||
}
|
||||
emit('delete', editingCollection.value)
|
||||
dialogOpen.value = false
|
||||
}
|
||||
|
||||
const onColorSelect = (color: string | null, closeMenu = true) => {
|
||||
if (!editingCollection.value) {
|
||||
return
|
||||
}
|
||||
editingCollection.value.color = color
|
||||
if (closeMenu) {
|
||||
colorMenuOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const onCustomColorChange = (color: string) => {
|
||||
if (!color) {
|
||||
return
|
||||
}
|
||||
onColorSelect(color, false)
|
||||
}
|
||||
|
||||
// Watch for dialog opening
|
||||
watch(() => props.modelValue, async (newValue) => {
|
||||
if (newValue) {
|
||||
await onOpen()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-dialog
|
||||
v-model="dialogOpen"
|
||||
max-width="500"
|
||||
persistent
|
||||
>
|
||||
<v-card>
|
||||
<v-card-title>
|
||||
<v-icon :icon="dialogIcon" class="mr-2" />
|
||||
{{ dialogTitle }}
|
||||
</v-card-title>
|
||||
|
||||
<v-divider />
|
||||
|
||||
<v-card-text v-if="editingCollection" class="pt-4">
|
||||
<v-form v-model="editingCollectionValidated" ref="collectionEditor">
|
||||
<v-combobox v-show="mode === 'create' && services.length > 1"
|
||||
v-model="editingCollectionService"
|
||||
label="Service"
|
||||
:items="services.filter(s => s.capabilities?.CollectionCreate)"
|
||||
item-title="label"
|
||||
item-value="id"
|
||||
required
|
||||
:rules="[(v: ServiceObject) => !!v || 'Service is required']"
|
||||
/>
|
||||
|
||||
<div v-if="mode === 'edit'" class="mb-4"><strong>Service:</strong> {{ editingCollection.service }}</div>
|
||||
|
||||
<v-text-field
|
||||
v-model="editingCollection.label"
|
||||
label="Label"
|
||||
required
|
||||
:rules="[(v: string) => !!v || 'Label is required']"
|
||||
>
|
||||
<template #append-inner>
|
||||
<v-menu
|
||||
v-model="colorMenuOpen"
|
||||
:close-on-content-click="false"
|
||||
location="bottom end"
|
||||
offset="8"
|
||||
>
|
||||
<template #activator="{ props: menuProps }">
|
||||
<v-btn
|
||||
v-bind="menuProps"
|
||||
icon
|
||||
variant="text"
|
||||
size="small"
|
||||
:style="{ color: editingCollection.color || 'var(--v-theme-on-surface)' }"
|
||||
aria-label="Select color"
|
||||
title="Select color"
|
||||
>
|
||||
<v-icon icon="mdi-palette" />
|
||||
</v-btn>
|
||||
</template>
|
||||
|
||||
<v-card class="pa-2 color-menu" elevation="4">
|
||||
<div class="color-menu-header">
|
||||
<v-btn
|
||||
variant="text"
|
||||
size="small"
|
||||
class="color-menu-header__close"
|
||||
aria-label="Close"
|
||||
title="Close"
|
||||
@click="colorMenuOpen = false"
|
||||
>
|
||||
<v-icon icon="mdi-close" />
|
||||
</v-btn>
|
||||
</div>
|
||||
<div class="color-menu-body__presets">
|
||||
<div
|
||||
v-for="(rowColors, rowIndex) in colorRows"
|
||||
:key="`color-row-${rowIndex}`"
|
||||
class="color-menu-body__row"
|
||||
>
|
||||
<v-btn
|
||||
v-for="color in rowColors"
|
||||
:key="color"
|
||||
variant="flat"
|
||||
size="small"
|
||||
class="color-menu-body__presets--swatch"
|
||||
:class="{ 'color-menu-body__presets--swatch--active': editingCollection.color === color }"
|
||||
:style="{ backgroundColor: color }"
|
||||
@click="onColorSelect(color)">
|
||||
<v-icon
|
||||
v-if="editingCollection.color === color"
|
||||
icon="mdi-check"
|
||||
size="x-small"
|
||||
color="white"
|
||||
/>
|
||||
</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="color-menu-body__picker">
|
||||
<v-color-picker
|
||||
v-model="editingCollection.color"
|
||||
mode="hex"
|
||||
hide-canvas
|
||||
width="100%"
|
||||
elevation="0"
|
||||
@update:model-value="onCustomColorChange"
|
||||
/>
|
||||
</div>
|
||||
</v-card>
|
||||
</v-menu>
|
||||
</template>
|
||||
</v-text-field>
|
||||
|
||||
<v-textarea
|
||||
v-model="editingCollection.description"
|
||||
label="Description"
|
||||
rows="2"
|
||||
/>
|
||||
|
||||
<v-row>
|
||||
<v-col v-if="mode === 'edit'" cols="6">
|
||||
<v-switch
|
||||
v-model="editingCollection.enabled"
|
||||
label="Enabled"
|
||||
color="primary"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-form>
|
||||
</v-card-text>
|
||||
|
||||
<v-divider />
|
||||
|
||||
<v-card-actions class="justify-space-between align-center">
|
||||
<div>
|
||||
<v-btn
|
||||
v-if="mode === 'edit' && editingCollectionService?.capabilities?.CollectionDestroy"
|
||||
color="error"
|
||||
variant="text"
|
||||
@click="onDelete"
|
||||
>
|
||||
Delete
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<div class="d-flex align-center">
|
||||
<v-btn
|
||||
variant="text"
|
||||
@click="dialogOpen = false"
|
||||
>
|
||||
Cancel
|
||||
</v-btn>
|
||||
<v-btn
|
||||
color="primary"
|
||||
variant="flat"
|
||||
:disabled="!editingCollectionValidated"
|
||||
@click="onSave"
|
||||
>
|
||||
{{ mode === 'create' ? 'Create' : 'Save' }}
|
||||
</v-btn>
|
||||
</div>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.color-menu {
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
.color-menu-header {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
width: 100%;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.color-menu-header__close {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.color-menu-body__presets {
|
||||
width: 100%;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.color-menu-body__row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.color-menu-body__picker {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.color-menu-body__presets--swatch {
|
||||
min-width: 32px !important;
|
||||
height: 32px !important;
|
||||
padding: 0 !important;
|
||||
border-radius: 4px !important;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.color-menu-body__presets--swatch--active {
|
||||
outline: 2px solid rgba(var(--v-theme-on-surface), 0.5);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
</style>
|
||||
172
src/components/CollectionList.vue
Normal file
172
src/components/CollectionList.vue
Normal file
@@ -0,0 +1,172 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useCollectionsStore } from '@ChronoManager/stores/collectionsStore'
|
||||
import { CollectionObject } from '@ChronoManager/models/collection';
|
||||
|
||||
// Store
|
||||
const collectionsStore = useCollectionsStore()
|
||||
|
||||
// Props
|
||||
const props = defineProps<{
|
||||
selectedCollection?: CollectionObject | null
|
||||
type?: 'calendar' | 'tasklist'
|
||||
}>()
|
||||
|
||||
// Emits
|
||||
const emit = defineEmits<{
|
||||
'select': [collection: CollectionObject]
|
||||
'edit': [collection: CollectionObject]
|
||||
'toggle-visibility': [collection: CollectionObject]
|
||||
}>()
|
||||
|
||||
// State
|
||||
const loading = ref(false)
|
||||
const collections = ref<CollectionObject[]>([])
|
||||
|
||||
// Computed
|
||||
const filteredCollections = computed(() => {
|
||||
if (!props.type) return collections.value
|
||||
|
||||
return collections.value.filter(collection => {
|
||||
if (props.type === 'calendar') {
|
||||
return collection.contents?.event
|
||||
} else if (props.type === 'tasklist') {
|
||||
return collection.contents?.task
|
||||
}
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
const displayTitle = computed(() => {
|
||||
if (props.type === 'calendar') return 'Calendars'
|
||||
if (props.type === 'tasklist') return 'Task Lists'
|
||||
return 'Collections'
|
||||
})
|
||||
|
||||
const displayIcon = computed(() => {
|
||||
if (props.type === 'calendar') return 'mdi-calendar'
|
||||
if (props.type === 'tasklist') return 'mdi-checkbox-marked-outline'
|
||||
return 'mdi-folder'
|
||||
})
|
||||
|
||||
// Lifecycle
|
||||
onMounted(async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
collections.value = await collectionsStore.list()
|
||||
} catch (error) {
|
||||
console.error('[Chrono] - Failed to load collections:', error)
|
||||
}
|
||||
loading.value = false
|
||||
})
|
||||
|
||||
// Functions
|
||||
const onCollectionSelect = (collection: CollectionObject) => {
|
||||
console.log('[Chrono] - Collection selected', collection)
|
||||
emit('select', collection)
|
||||
}
|
||||
|
||||
const onCollectionEdit = (collection: CollectionObject) => {
|
||||
emit('edit', collection)
|
||||
}
|
||||
|
||||
const onToggleVisibility = (collection: CollectionObject) => {
|
||||
collection.enabled = !collection.enabled
|
||||
emit('toggle-visibility', collection)
|
||||
}
|
||||
|
||||
// Expose refresh method
|
||||
defineExpose({
|
||||
async refresh() {
|
||||
loading.value = true
|
||||
try {
|
||||
collections.value = await collectionsStore.list()
|
||||
} catch (error) {
|
||||
console.error('[Chrono] - Failed to load collections:', error)
|
||||
}
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="collection-selector">
|
||||
<div class="collection-selector-header">
|
||||
<div class="d-flex align-center mb-3">
|
||||
<v-icon :icon="displayIcon" size="small" class="mr-2" />
|
||||
<span class="text-subtitle-2 font-weight-bold">{{ displayTitle }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<v-divider class="my-2" />
|
||||
|
||||
<div class="collection-selector-content">
|
||||
<v-progress-linear v-if="loading" indeterminate color="primary" />
|
||||
|
||||
<v-list v-else density="compact" nav class="pa-0">
|
||||
<v-list-item
|
||||
v-for="collection in filteredCollections"
|
||||
:key="collection.id"
|
||||
:value="collection.id"
|
||||
:active="selectedCollection?.id === collection.id"
|
||||
@click="onCollectionSelect(collection)"
|
||||
rounded="lg"
|
||||
class="mb-1"
|
||||
>
|
||||
<template #prepend>
|
||||
<v-checkbox-btn
|
||||
:model-value="collection.enabled !== false"
|
||||
:color="collection.color || 'primary'"
|
||||
hide-details
|
||||
@click.stop="onToggleVisibility(collection)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<v-list-item-title>{{ collection.label || 'Unnamed Collection' }}</v-list-item-title>
|
||||
|
||||
<template #append>
|
||||
<div class="d-flex align-center">
|
||||
<v-icon
|
||||
:color="collection.color || 'primary'"
|
||||
size="small"
|
||||
class="mr-1"
|
||||
>mdi-circle</v-icon>
|
||||
<v-btn
|
||||
icon="mdi-pencil"
|
||||
size="x-small"
|
||||
variant="text"
|
||||
@click.stop="onCollectionEdit(collection)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
|
||||
<v-alert v-if="!loading && filteredCollections.length === 0" type="info" variant="tonal" density="compact" class="mt-2">
|
||||
No {{ displayTitle.toLowerCase() }} found
|
||||
</v-alert>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.collection-selector {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.collection-selector-header {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.collection-selector-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.v-list-item {
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
580
src/components/DaysView.vue
Normal file
580
src/components/DaysView.vue
Normal file
@@ -0,0 +1,580 @@
|
||||
<template>
|
||||
<div class="days-view">
|
||||
<div class="view-controls">
|
||||
<div class="view-navigation">
|
||||
<v-btn size="x-small" variant="text" icon="mdi-chevron-left" @click="previousPeriod" />
|
||||
<v-btn size="x-small" variant="tonal" @click="goToToday">Today</v-btn>
|
||||
<v-btn size="x-small" variant="text" icon="mdi-chevron-right" @click="nextPeriod" />
|
||||
<span class="current-period">{{ dateRangeLabel }}</span>
|
||||
</div>
|
||||
<v-spacer />
|
||||
<div class="view-selector">
|
||||
<v-btn size="x-small" variant="text" @click="setDays(1)" :color="daysCount === 1 ? 'primary' : undefined">1D</v-btn>
|
||||
<v-btn size="x-small" variant="text" @click="setDays(3)" :color="daysCount === 3 ? 'primary' : undefined">3D</v-btn>
|
||||
<v-btn size="x-small" variant="text" @click="setDays(5)" :color="daysCount === 5 ? 'primary' : undefined">5D</v-btn>
|
||||
<v-btn size="x-small" variant="text" @click="setDays(7)" :color="daysCount === 7 ? 'primary' : undefined">7D</v-btn>
|
||||
<v-btn size="x-small" variant="text" @click="setDays(9)" :color="daysCount === 9 ? 'primary' : undefined">9D</v-btn>
|
||||
<v-btn size="x-small" variant="text" @click="setDays(11)" :color="daysCount === 11 ? 'primary' : undefined">11D</v-btn>
|
||||
<v-btn size="x-small" variant="text" @click="setDays(14)" :color="daysCount === 14 ? 'primary' : undefined">14D</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
<div class="days-grid">
|
||||
<!-- Fixed Headers Row -->
|
||||
<div class="day-headers">
|
||||
<div class="time-header-spacer"></div>
|
||||
<div class="day-headers-row">
|
||||
<div v-for="day in visibleDates" :key="day.toISOString() + '-header'" class="day-header-cell" :class="{ 'single-day': daysCount === 1 }">
|
||||
<span class="day-name">{{ formatWeekDay(day) }}</span>
|
||||
<span class="day-number" :class="{ 'today': isToday(day) }">{{ day.getDate() }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- All-Day Events Section -->
|
||||
<div v-if="allDaySegments.laneCount > 0" class="all-day-section">
|
||||
<div class="all-day-label">
|
||||
<span>All day</span>
|
||||
</div>
|
||||
<div class="all-day-events-container" :style="{ height: `${allDaySegments.laneCount * 24 + 4}px` }">
|
||||
<div class="all-day-columns">
|
||||
<div v-for="day in visibleDates" :key="day.toISOString() + '-allday'" class="all-day-column" :class="{ 'single-day': daysCount === 1 }"></div>
|
||||
</div>
|
||||
<div class="all-day-events-overlay">
|
||||
<div
|
||||
v-for="segment in allDaySegments.segments"
|
||||
:key="segment.entity.id"
|
||||
class="all-day-event"
|
||||
:class="{
|
||||
'is-start': segment.isStart,
|
||||
'is-end': segment.isEnd,
|
||||
}"
|
||||
:style="getAllDayEventStyle(segment)"
|
||||
@click="emit('event-click', segment.entity)"
|
||||
@mouseenter="emit('event-hover', { event: $event, entity: segment.entity })"
|
||||
@mouseleave="emit('event-hover-end')"
|
||||
>
|
||||
<span v-if="segment.isStart" class="event-label">{{ segment.entity.data?.label || 'Untitled' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Scrollable Content -->
|
||||
<div class="days-content">
|
||||
<div class="time-column">
|
||||
<div v-for="hour in 24" :key="hour" class="time-slot">
|
||||
{{ formatHour(hour - 1) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="days-columns">
|
||||
<div v-for="day in visibleDates" :key="day.toISOString()" class="day-column" :class="{ 'single-day': daysCount === 1 }">
|
||||
<div class="day-events" @click="handleDayClick($event, day)">
|
||||
<div
|
||||
v-for="entity in getTimedEvents(day)"
|
||||
:key="entity.id"
|
||||
class="day-event"
|
||||
:style="getEventStyle(entity)"
|
||||
@click.stop="emit('event-click', entity)"
|
||||
@mouseenter="emit('event-hover', { event: $event, entity })"
|
||||
@mouseleave="emit('event-hover-end')"
|
||||
>
|
||||
<template v-if="daysCount <= 3">
|
||||
<div class="event-time">
|
||||
{{ entity.data?.startsOn ? formatTime(new Date(entity.data.startsOn)) : '' }} - {{ entity.data?.endsOn ? formatTime(new Date(entity.data.endsOn)) : '' }}
|
||||
</div>
|
||||
<div class="event-title">{{ entity.data?.label || 'Untitled' }}</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="event-title-compact">{{ entity.data?.label || 'Untitled' }}</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import {
|
||||
startOfDay,
|
||||
getMultiDaySegments,
|
||||
getTimedEventsForDate,
|
||||
type MultiDaySegment,
|
||||
} from '@/utils/calendarHelpers';
|
||||
|
||||
const ALL_DAY_EVENT_HEIGHT = 24;
|
||||
|
||||
const props = defineProps<{
|
||||
currentDate: Date;
|
||||
events: any[];
|
||||
calendars: any[];
|
||||
initialDays?: number;
|
||||
}>();
|
||||
|
||||
const daysCount = ref(props.initialDays ?? 7);
|
||||
const localDate = ref(new Date(props.currentDate));
|
||||
|
||||
// Watch for external date changes (e.g., from mini calendar)
|
||||
watch(() => props.currentDate, (newDate) => {
|
||||
localDate.value = new Date(newDate);
|
||||
});
|
||||
|
||||
function setDays(count: number) {
|
||||
daysCount.value = count;
|
||||
}
|
||||
|
||||
function previousPeriod() {
|
||||
const date = new Date(localDate.value);
|
||||
date.setDate(date.getDate() - daysCount.value);
|
||||
localDate.value = date;
|
||||
}
|
||||
|
||||
function nextPeriod() {
|
||||
const date = new Date(localDate.value);
|
||||
date.setDate(date.getDate() + daysCount.value);
|
||||
localDate.value = date;
|
||||
}
|
||||
|
||||
function goToToday() {
|
||||
localDate.value = new Date();
|
||||
}
|
||||
|
||||
const dateRangeLabel = computed(() => {
|
||||
if (visibleDates.value.length === 0) return '';
|
||||
|
||||
const first = visibleDates.value[0];
|
||||
const last = visibleDates.value[visibleDates.value.length - 1];
|
||||
|
||||
const formatOptions: Intl.DateTimeFormatOptions = { month: 'short', day: 'numeric' };
|
||||
|
||||
if (daysCount.value === 1) {
|
||||
return first.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' });
|
||||
}
|
||||
|
||||
if (first.getMonth() === last.getMonth()) {
|
||||
return `${first.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })} - ${last.getDate()}, ${first.getFullYear()}`;
|
||||
}
|
||||
|
||||
return `${first.toLocaleDateString('en-US', formatOptions)} - ${last.toLocaleDateString('en-US', formatOptions)}, ${last.getFullYear()}`;
|
||||
});
|
||||
|
||||
const visibleDates = computed(() => {
|
||||
const dates: Date[] = [];
|
||||
const start = new Date(localDate.value);
|
||||
|
||||
for (let i = 0; i < daysCount.value; i++) {
|
||||
dates.push(new Date(start));
|
||||
start.setDate(start.getDate() + 1);
|
||||
}
|
||||
|
||||
return dates;
|
||||
});
|
||||
|
||||
// Compute all-day/multi-day event segments
|
||||
const allDaySegments = computed(() => {
|
||||
if (visibleDates.value.length === 0) {
|
||||
return { segments: [], laneCount: 0 };
|
||||
}
|
||||
|
||||
const rangeStart = startOfDay(visibleDates.value[0]);
|
||||
const rangeEnd = startOfDay(visibleDates.value[visibleDates.value.length - 1]);
|
||||
|
||||
const getColumnIndex = (date: Date): number => {
|
||||
const targetDay = startOfDay(date);
|
||||
return visibleDates.value.findIndex(d => startOfDay(d).getTime() === targetDay.getTime());
|
||||
};
|
||||
|
||||
return getMultiDaySegments(props.events, rangeStart, rangeEnd, daysCount.value, getColumnIndex);
|
||||
});
|
||||
|
||||
function isToday(date: Date): boolean {
|
||||
const today = new Date();
|
||||
return date.toDateString() === today.toDateString();
|
||||
}
|
||||
|
||||
function getTimedEvents(date: Date): any[] {
|
||||
return getTimedEventsForDate(props.events, date);
|
||||
}
|
||||
|
||||
function getEventColor(entity: any): string {
|
||||
if (entity.data?.color) return entity.data.color;
|
||||
const calendar = props.calendars.find(cal => cal.id === entity.in);
|
||||
return calendar?.color || '#1976D2';
|
||||
}
|
||||
|
||||
function getAllDayEventStyle(segment: MultiDaySegment) {
|
||||
return {
|
||||
backgroundColor: getEventColor(segment.entity),
|
||||
left: `calc(${segment.startCol} / ${daysCount.value} * 100% + 4px)`,
|
||||
width: `calc(${segment.span} / ${daysCount.value} * 100% - 8px)`,
|
||||
top: `${segment.lane * ALL_DAY_EVENT_HEIGHT}px`,
|
||||
};
|
||||
}
|
||||
|
||||
function getEventStyle(entity: any) {
|
||||
if (!entity.data?.startsOn || !entity.data?.endsOn) {
|
||||
return { display: 'none' };
|
||||
}
|
||||
const startTime = new Date(entity.data.startsOn);
|
||||
const endTime = new Date(entity.data.endsOn);
|
||||
const start = startTime.getHours() * 60 + startTime.getMinutes();
|
||||
const duration = (endTime.getTime() - startTime.getTime()) / (1000 * 60);
|
||||
|
||||
return {
|
||||
top: `${(start / 1440) * 100}%`,
|
||||
height: `${(duration / 1440) * 100}%`,
|
||||
backgroundColor: getEventColor(entity),
|
||||
};
|
||||
}
|
||||
|
||||
function formatHour(hour: number): string {
|
||||
const ampm = hour >= 12 ? 'PM' : 'AM';
|
||||
const displayHour = hour % 12 || 12;
|
||||
return `${displayHour} ${ampm}`;
|
||||
}
|
||||
|
||||
function formatTime(date: Date): string {
|
||||
return date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' });
|
||||
}
|
||||
|
||||
function formatWeekDay(date: Date): string {
|
||||
return date.toLocaleDateString('en-US', { weekday: 'short' });
|
||||
}
|
||||
|
||||
const emit = defineEmits<{
|
||||
'event-click': [event: any];
|
||||
'event-hover': [data: { event: MouseEvent; entity: any }];
|
||||
'event-hover-end': [];
|
||||
'date-click': [date: Date];
|
||||
}>();
|
||||
|
||||
function handleDayClick(event: MouseEvent, day: Date) {
|
||||
const target = event.currentTarget as HTMLElement;
|
||||
const rect = target.getBoundingClientRect();
|
||||
const clickY = event.clientY - rect.top;
|
||||
|
||||
// Calculate the hour based on click position (60px per hour)
|
||||
const totalMinutes = Math.floor((clickY / 60) * 60);
|
||||
const hours = Math.floor(totalMinutes / 60);
|
||||
const minutes = Math.round((totalMinutes % 60) / 15) * 15; // Round to nearest 15 min
|
||||
|
||||
// Create a new date with the clicked time
|
||||
const clickedDate = new Date(day);
|
||||
clickedDate.setHours(hours, minutes, 0, 0);
|
||||
|
||||
emit('date-click', clickedDate);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.days-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 600px;
|
||||
}
|
||||
|
||||
.view-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 4px 8px;
|
||||
border-bottom: 1px solid rgb(var(--v-border-color));
|
||||
background-color: rgb(var(--v-theme-surface));
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.view-navigation {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.current-period {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
margin-left: 8px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.view-selector {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.days-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgb(var(--v-border-color));
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
/* Fixed Headers Row */
|
||||
.day-headers {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
border-bottom: 2px solid rgb(var(--v-border-color));
|
||||
background-color: rgb(var(--v-theme-surface-variant));
|
||||
}
|
||||
|
||||
.time-header-spacer {
|
||||
width: 60px;
|
||||
min-width: 60px;
|
||||
flex-shrink: 0;
|
||||
border-right: 1px solid rgb(var(--v-border-color));
|
||||
}
|
||||
|
||||
.day-headers-row {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.day-header-cell {
|
||||
flex: 1;
|
||||
min-width: 80px;
|
||||
padding: 4px;
|
||||
text-align: center;
|
||||
border-right: 1px solid rgb(var(--v-border-color));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.day-header-cell:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.day-header-cell.single-day {
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.day-name {
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
opacity: 0.7;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.day-number {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.day-number.today {
|
||||
background-color: rgb(var(--v-theme-primary));
|
||||
color: rgb(var(--v-theme-on-primary));
|
||||
border-radius: 50%;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* All-Day Events Section */
|
||||
.all-day-section {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
border-bottom: 1px solid rgb(var(--v-border-color));
|
||||
background-color: rgb(var(--v-theme-surface));
|
||||
min-height: 28px;
|
||||
}
|
||||
|
||||
.all-day-label {
|
||||
width: 60px;
|
||||
min-width: 60px;
|
||||
flex-shrink: 0;
|
||||
border-right: 1px solid rgb(var(--v-border-color));
|
||||
background-color: rgb(var(--v-theme-surface-variant));
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-end;
|
||||
padding: 4px 8px;
|
||||
font-size: 10px;
|
||||
opacity: 0.6;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.all-day-events-container {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
min-height: 28px;
|
||||
}
|
||||
|
||||
.all-day-columns {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.all-day-column {
|
||||
flex: 1;
|
||||
min-width: 80px;
|
||||
border-right: 1px solid rgb(var(--v-border-color), 0.3);
|
||||
}
|
||||
|
||||
.all-day-column.single-day {
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.all-day-column:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.all-day-events-overlay {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 2px;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.all-day-event {
|
||||
position: absolute;
|
||||
height: 20px;
|
||||
padding: 0 6px;
|
||||
font-size: 12px;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
pointer-events: auto;
|
||||
box-sizing: border-box;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.all-day-event:not(.is-start) {
|
||||
border-top-left-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
padding-left: 4px;
|
||||
}
|
||||
|
||||
.all-day-event:not(.is-end) {
|
||||
border-top-right-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
|
||||
.all-day-event:hover {
|
||||
filter: brightness(1.1);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.all-day-event .event-label {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Scrollable Content Area */
|
||||
.days-content {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
min-height: 0; /* Allow flex child to shrink for scrolling */
|
||||
}
|
||||
|
||||
.time-column {
|
||||
width: 60px;
|
||||
min-width: 60px;
|
||||
height: 1440px; /* 24 hours * 60px per hour */
|
||||
border-right: 1px solid rgb(var(--v-border-color));
|
||||
background-color: rgb(var(--v-theme-surface-variant));
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.time-slot {
|
||||
height: 60px;
|
||||
padding: 4px 8px;
|
||||
font-size: 11px;
|
||||
opacity: 0.6;
|
||||
border-bottom: 1px solid rgb(var(--v-border-color), 0.5);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.days-columns {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
min-height: 1440px; /* 24 hours * 60px per hour */
|
||||
}
|
||||
|
||||
.day-column {
|
||||
flex: 1;
|
||||
min-width: 80px;
|
||||
border-right: 1px solid rgb(var(--v-border-color));
|
||||
}
|
||||
|
||||
.day-column.single-day {
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.day-column:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.day-events {
|
||||
position: relative;
|
||||
height: 1440px; /* 24 hours * 60px per hour */
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.day-events:hover {
|
||||
background-color: rgba(var(--v-theme-primary), 0.02);
|
||||
}
|
||||
|
||||
.day-event {
|
||||
position: absolute;
|
||||
left: 4px;
|
||||
right: 4px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 4px;
|
||||
color: white;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
font-weight: 500;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12);
|
||||
transition: box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.day-event:hover {
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
.event-time {
|
||||
font-weight: 600;
|
||||
margin-bottom: 2px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.event-title {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.event-title-compact {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
</style>
|
||||
277
src/components/EventEditor.vue
Normal file
277
src/components/EventEditor.vue
Normal file
@@ -0,0 +1,277 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import type { CollectionObject } from '@ChronoManager/models/collection'
|
||||
import type { EntityObject } from '@ChronoManager/models/entity'
|
||||
import type { EventObject } from '@ChronoManager/models/event'
|
||||
import EventEditorLabel from './editors/EventEditorLabel.vue'
|
||||
import EventEditorDescription from './editors/EventEditorDescription.vue'
|
||||
import EventEditorDates from './editors/EventEditorDates.vue'
|
||||
import EventEditorLocations from './editors/EventEditorLocations.vue'
|
||||
import EventEditorParticipants from './editors/EventEditorParticipants.vue'
|
||||
import EventEditorNotifications from './editors/EventEditorNotifications.vue'
|
||||
import EventEditorOccurrence from './editors/EventEditorOccurrence.vue'
|
||||
import EventEditorTags from './editors/EventEditorTags.vue'
|
||||
|
||||
// Props - wrapped entity with id, data properties
|
||||
const props = defineProps<{
|
||||
mode: 'edit' | 'view'
|
||||
entity: EntityObject | null
|
||||
collection?: CollectionObject | null
|
||||
calendars?: CollectionObject[]
|
||||
}>()
|
||||
|
||||
// Emits
|
||||
const emit = defineEmits<{
|
||||
'edit': []
|
||||
'cancel': []
|
||||
'close': []
|
||||
'save': [entity: any, collection?: any]
|
||||
'delete': [entity: any, collection?: any]
|
||||
}>()
|
||||
|
||||
// State
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const selectedCollectionId = ref(props.entity?.in || props.collection?.id || props.calendars?.[0]?.id || '')
|
||||
|
||||
// Computed
|
||||
const mode = computed(() => props.mode)
|
||||
const entity = computed(() => props.entity || null)
|
||||
const entityObject = computed(() => entity.value?.data as EventObject ?? null)
|
||||
const entityFresh = computed(() => entity.value?.id === null || entity.value?.id === undefined)
|
||||
|
||||
const calendarOptions = computed(() =>
|
||||
(props.calendars || []).map(cal => ({
|
||||
title: cal.label || 'Unnamed Calendar',
|
||||
value: cal.id,
|
||||
}))
|
||||
)
|
||||
|
||||
// Permissions
|
||||
const isExternalEvent = computed(() => {
|
||||
return false
|
||||
})
|
||||
|
||||
const canEdit = computed(() => {
|
||||
return true
|
||||
})
|
||||
|
||||
const canDelete = computed(() => {
|
||||
return !isExternalEvent.value
|
||||
})
|
||||
|
||||
// UI helpers
|
||||
const entityIcon = computed(() => 'mdi-calendar')
|
||||
|
||||
// Functions
|
||||
const startEdit = () => {
|
||||
emit('edit')
|
||||
}
|
||||
|
||||
const cancelEdit = () => {
|
||||
emit('cancel')
|
||||
}
|
||||
|
||||
const saveEntity = async () => {
|
||||
saving.value = true
|
||||
const targetCollection = (props.calendars || []).find(cal => cal.id === selectedCollectionId.value)
|
||||
emit('save', entity.value!, targetCollection || props.collection)
|
||||
saving.value = false
|
||||
}
|
||||
|
||||
// Location management
|
||||
const addLocationPhysical = () => {
|
||||
(entityObject.value as any)?.addLocationPhysical()
|
||||
}
|
||||
|
||||
const removeLocationPhysical = (key: string) => {
|
||||
(entityObject.value as any)?.removeLocationPhysical(key)
|
||||
}
|
||||
|
||||
const addLocationVirtual = () => {
|
||||
(entityObject.value as any)?.addLocationVirtual()
|
||||
}
|
||||
|
||||
const removeLocationVirtual = (key: string) => {
|
||||
(entityObject.value as any)?.removeLocationVirtual(key)
|
||||
}
|
||||
|
||||
// Participant management
|
||||
const addParticipant = () => {
|
||||
(entityObject.value as any)?.addParticipant()
|
||||
}
|
||||
|
||||
const removeParticipant = (key: string) => {
|
||||
(entityObject.value as any)?.removeParticipant(key)
|
||||
}
|
||||
|
||||
// Notification management
|
||||
const addNotification = () => {
|
||||
(entityObject.value as any)?.addNotification()
|
||||
}
|
||||
|
||||
const removeNotification = (key: string) => {
|
||||
(entityObject.value as any)?.removeNotification(key)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-sheet class="pa-2 text-start overflow-auto" min-height="84vh" max-height="84vh">
|
||||
<div class="event-editor-header d-flex justify-space-between pa-4">
|
||||
<div>
|
||||
<v-icon :icon="entityIcon" class="mr-2" />
|
||||
{{ entityObject?.label || 'Nothing Selected' }}
|
||||
</div>
|
||||
|
||||
<div v-if="!loading && entity">
|
||||
<v-btn
|
||||
v-if="mode === 'view' && canEdit"
|
||||
icon="mdi-pencil"
|
||||
size="small"
|
||||
variant="text"
|
||||
@click="startEdit"
|
||||
/>
|
||||
|
||||
<v-btn
|
||||
v-if="!entityFresh && canDelete"
|
||||
icon="mdi-delete"
|
||||
size="small"
|
||||
variant="text"
|
||||
color="error"
|
||||
@click="emit('delete', entity!, collection)"
|
||||
/>
|
||||
|
||||
<v-btn
|
||||
icon="mdi-close"
|
||||
size="small"
|
||||
variant="text"
|
||||
@click="$emit('close')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="event-editor-fields">
|
||||
<v-divider />
|
||||
<v-progress-linear v-if="loading" indeterminate color="primary" />
|
||||
|
||||
<div v-if="!entity" class="text-center pa-8">
|
||||
<v-icon icon="mdi-calendar-blank" size="64" color="grey" class="mb-4" />
|
||||
<p class="text-h6 text-grey">Select an event to view details</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="pa-4">
|
||||
<v-select
|
||||
v-if="mode === 'edit' && !entity.id"
|
||||
v-model="selectedCollectionId"
|
||||
:items="calendarOptions"
|
||||
label="Calendar"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
class="mb-4"
|
||||
/>
|
||||
|
||||
<EventEditorLabel
|
||||
:mode="mode"
|
||||
:label="entityObject!.label"
|
||||
@update:label="entityObject!.label = $event"
|
||||
/>
|
||||
|
||||
<EventEditorDescription
|
||||
:mode="mode"
|
||||
:description="entityObject!.description"
|
||||
@update:description="entityObject!.description = $event"
|
||||
/>
|
||||
|
||||
<EventEditorDates
|
||||
:mode="mode"
|
||||
:starts-on="entityObject!.startsOn"
|
||||
:ends-on="entityObject!.endsOn"
|
||||
:timeless="entityObject!.timeless"
|
||||
:time-zone="entityObject!.timeZone"
|
||||
@update:starts-on="entityObject!.startsOn = $event"
|
||||
@update:ends-on="entityObject!.endsOn = $event"
|
||||
@update:timeless="entityObject!.timeless = $event"
|
||||
@update:time-zone="entityObject!.timeZone = $event"
|
||||
/>
|
||||
|
||||
<EventEditorLocations
|
||||
:mode="mode"
|
||||
:locations-physical="entityObject!.locationsPhysical || {}"
|
||||
:locations-virtual="entityObject!.locationsVirtual || {}"
|
||||
@add-location-physical="addLocationPhysical"
|
||||
@remove-location-physical="removeLocationPhysical"
|
||||
@add-location-virtual="addLocationVirtual"
|
||||
@remove-location-virtual="removeLocationVirtual"
|
||||
/>
|
||||
|
||||
<EventEditorParticipants
|
||||
:mode="mode"
|
||||
:organizer="entityObject!.organizer"
|
||||
:participants="entityObject!.participants || {}"
|
||||
@update:organizer="entityObject!.organizer = $event"
|
||||
@add-participant="addParticipant"
|
||||
@remove-participant="removeParticipant"
|
||||
/>
|
||||
|
||||
<EventEditorNotifications
|
||||
:mode="mode"
|
||||
:notifications="entityObject!.notifications || {}"
|
||||
@add-notification="addNotification"
|
||||
@remove-notification="removeNotification"
|
||||
/>
|
||||
|
||||
<EventEditorOccurrence
|
||||
:mode="mode"
|
||||
:pattern="entityObject!.pattern"
|
||||
@update:pattern="entityObject!.pattern = $event"
|
||||
/>
|
||||
|
||||
<EventEditorTags
|
||||
:mode="mode"
|
||||
:tags="entityObject!.tags"
|
||||
@update:tags="entityObject!.tags = $event"
|
||||
/>
|
||||
|
||||
<!-- Metadata for view mode -->
|
||||
<div v-if="mode === 'view' && entityObject!.created" class="event-editor-section">
|
||||
<v-divider class="mb-4"></v-divider>
|
||||
<div class="text-caption text-medium-emphasis">
|
||||
<div>Created: {{ new Date(entityObject!.created).toLocaleString() }}</div>
|
||||
<div v-if="entityObject!.modified">Modified: {{ new Date(entityObject!.modified).toLocaleString() }}</div>
|
||||
<div v-if="isExternalEvent" class="mt-2">
|
||||
<v-chip size="small" color="info" variant="tonal">
|
||||
<v-icon start size="small">mdi-account-arrow-right</v-icon>
|
||||
External Event
|
||||
</v-chip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<v-divider/>
|
||||
</div>
|
||||
|
||||
<div class="event-editor-footer d-flex text-end justify-space-between pa-4">
|
||||
<v-btn v-if="mode === 'edit'"
|
||||
variant="text"
|
||||
@click="cancelEdit"
|
||||
:disabled="saving">
|
||||
Cancel
|
||||
</v-btn>
|
||||
|
||||
<v-btn v-if="mode === 'edit'"
|
||||
color="primary"
|
||||
variant="elevated"
|
||||
@click="saveEntity"
|
||||
:loading="saving">
|
||||
Save
|
||||
</v-btn>
|
||||
</div>
|
||||
</v-sheet>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.event-editor-section {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
</style>
|
||||
181
src/components/EventViewerPopup.vue
Normal file
181
src/components/EventViewerPopup.vue
Normal file
@@ -0,0 +1,181 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
event: any | null
|
||||
position: { x: number; y: number }
|
||||
visible: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'click': [event: any]
|
||||
}>()
|
||||
|
||||
const formatDateTime = (isoString: string | null | undefined): string => {
|
||||
if (!isoString) return 'Not set'
|
||||
const date = new Date(isoString)
|
||||
return date.toLocaleString('en-US', {
|
||||
weekday: 'short',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
const formatTime = (isoString: string | null | undefined): string => {
|
||||
if (!isoString) return 'Not set'
|
||||
const date = new Date(isoString)
|
||||
return date.toLocaleTimeString('en-US', {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
const popupStyle = computed(() => {
|
||||
return {
|
||||
left: `${props.position.x}px`,
|
||||
top: `${props.position.y}px`
|
||||
}
|
||||
})
|
||||
|
||||
const hasLocation = computed(() => {
|
||||
return props.event?.data?.locationsPhysical && Object.keys(props.event.data.locationsPhysical).length > 0 ||
|
||||
props.event?.data?.locationsVirtual && Object.keys(props.event.data.locationsVirtual).length > 0
|
||||
})
|
||||
|
||||
const firstLocation = computed(() => {
|
||||
if (props.event?.data?.locationsPhysical && Object.keys(props.event.data.locationsPhysical).length > 0) {
|
||||
const key = Object.keys(props.event.data.locationsPhysical)[0]
|
||||
return props.event.data.locationsPhysical[key]
|
||||
}
|
||||
if (props.event?.data?.locationsVirtual && Object.keys(props.event.data.locationsVirtual).length > 0) {
|
||||
const key = Object.keys(props.event.data.locationsVirtual)[0]
|
||||
return props.event.data.locationsVirtual[key]
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
const participantCount = computed(() => {
|
||||
if (!props.event?.data?.participants) return 0
|
||||
return Object.keys(props.event.data.participants).length
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Transition name="fade">
|
||||
<div
|
||||
v-if="visible && event"
|
||||
class="event-popup"
|
||||
:style="popupStyle"
|
||||
@click.stop="$emit('click', event)"
|
||||
>
|
||||
<v-card elevation="8" class="event-popup-card">
|
||||
<v-card-text class="pa-3">
|
||||
<div class="d-flex align-center mb-2">
|
||||
<v-icon icon="mdi-calendar" size="small" class="mr-2" />
|
||||
<div class="text-subtitle-1 font-weight-bold">{{ event.data?.label || 'Untitled Event' }}</div>
|
||||
</div>
|
||||
|
||||
<div class="event-popup-details">
|
||||
<div class="d-flex align-center mb-1">
|
||||
<v-icon icon="mdi-clock-outline" size="small" class="mr-2 text-grey" />
|
||||
<div class="text-caption">
|
||||
<div v-if="event.data?.timeless">
|
||||
All Day - {{ new Date(event.data.startsOn).toLocaleDateString() }}
|
||||
</div>
|
||||
<div v-else>
|
||||
{{ formatTime(event.data?.startsOn) }} - {{ formatTime(event.data?.endsOn) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="hasLocation" class="d-flex align-center mb-1">
|
||||
<v-icon icon="mdi-map-marker" size="small" class="mr-2 text-grey" />
|
||||
<div class="text-caption text-truncate">
|
||||
{{ firstLocation?.label || firstLocation?.location || 'Location' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="event.data?.organizer" class="d-flex align-center mb-1">
|
||||
<v-icon icon="mdi-account" size="small" class="mr-2 text-grey" />
|
||||
<div class="text-caption">
|
||||
{{ event.data.organizer.name || event.data.organizer.address }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="participantCount > 0" class="d-flex align-center mb-1">
|
||||
<v-icon icon="mdi-account-group" size="small" class="mr-2 text-grey" />
|
||||
<div class="text-caption">
|
||||
{{ participantCount }} participant{{ participantCount !== 1 ? 's' : '' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="event.data?.description" class="mt-2 pt-2 border-t">
|
||||
<div class="text-caption text-grey">{{ event.data.description }}</div>
|
||||
</div>
|
||||
|
||||
<div v-if="event.data?.tags && event.data.tags.length > 0" class="mt-2">
|
||||
<v-chip
|
||||
v-for="(tag, index) in event.data.tags.slice(0, 3)"
|
||||
:key="index"
|
||||
size="x-small"
|
||||
class="mr-1"
|
||||
variant="tonal"
|
||||
>
|
||||
{{ tag }}
|
||||
</v-chip>
|
||||
<span v-if="event.data.tags.length > 3" class="text-caption text-grey">
|
||||
+{{ event.data.tags.length - 3 }} more
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-caption text-grey-lighten-1 mt-2 text-center">
|
||||
Click to view details
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.event-popup {
|
||||
position: fixed;
|
||||
z-index: 9999;
|
||||
pointer-events: auto;
|
||||
transform: translate(10px, 10px);
|
||||
}
|
||||
|
||||
.event-popup-card {
|
||||
min-width: 280px;
|
||||
max-width: 350px;
|
||||
cursor: pointer;
|
||||
transition: box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.event-popup-card:hover {
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2) !important;
|
||||
}
|
||||
|
||||
.event-popup-details {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.border-t {
|
||||
border-top: 1px solid rgb(var(--v-border-color));
|
||||
}
|
||||
|
||||
/* Fade transition */
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
262
src/components/MiniCalendar.vue
Normal file
262
src/components/MiniCalendar.vue
Normal file
@@ -0,0 +1,262 @@
|
||||
<template>
|
||||
<div class="mini-calendar">
|
||||
<!-- Navigation Header -->
|
||||
<div class="mini-calendar-header">
|
||||
<v-btn size="x-small" variant="text" icon="mdi-chevron-left" density="compact" @click="previousMonth" />
|
||||
<div class="month-year-selector">
|
||||
<v-menu>
|
||||
<template #activator="{ props: menuProps }">
|
||||
<v-btn
|
||||
size="x-small"
|
||||
variant="text"
|
||||
v-bind="menuProps"
|
||||
class="month-btn"
|
||||
>
|
||||
{{ monthLabel }}
|
||||
</v-btn>
|
||||
</template>
|
||||
<v-list density="compact" class="month-list">
|
||||
<v-list-item
|
||||
v-for="(month, index) in months"
|
||||
:key="month"
|
||||
:active="index === displayDate.getMonth()"
|
||||
@click="setMonth(index)"
|
||||
>
|
||||
<v-list-item-title>{{ month }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
<v-menu>
|
||||
<template #activator="{ props: menuProps }">
|
||||
<v-btn
|
||||
size="x-small"
|
||||
variant="text"
|
||||
v-bind="menuProps"
|
||||
class="year-btn"
|
||||
>
|
||||
{{ displayDate.getFullYear() }}
|
||||
</v-btn>
|
||||
</template>
|
||||
<v-list density="compact" class="year-list">
|
||||
<v-list-item
|
||||
v-for="year in yearRange"
|
||||
:key="year"
|
||||
:active="year === displayDate.getFullYear()"
|
||||
@click="setYear(year)"
|
||||
>
|
||||
<v-list-item-title>{{ year }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
</div>
|
||||
<v-btn size="x-small" variant="text" icon="mdi-chevron-right" density="compact" @click="nextMonth" />
|
||||
</div>
|
||||
|
||||
<!-- Day Headers -->
|
||||
<div class="mini-calendar-grid">
|
||||
<div v-for="day in dayHeaders" :key="day" class="mini-day-header">
|
||||
{{ day }}
|
||||
</div>
|
||||
|
||||
<!-- Calendar Days -->
|
||||
<div
|
||||
v-for="(date, index) in calendarDates"
|
||||
:key="index"
|
||||
class="mini-day"
|
||||
:class="{
|
||||
'today': isToday(date),
|
||||
'selected': isSelected(date),
|
||||
'other-month': !isSameMonth(date, displayDate)
|
||||
}"
|
||||
@click="selectDate(date)"
|
||||
>
|
||||
{{ date.getDate() }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue';
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: Date;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [date: Date];
|
||||
}>();
|
||||
|
||||
const months = [
|
||||
'January', 'February', 'March', 'April', 'May', 'June',
|
||||
'July', 'August', 'September', 'October', 'November', 'December'
|
||||
];
|
||||
|
||||
const dayHeaders = ['S', 'M', 'T', 'W', 'T', 'F', 'S'];
|
||||
|
||||
// The display date controls which month/year is shown
|
||||
const displayDate = ref(new Date(props.modelValue));
|
||||
|
||||
// Watch for external changes to modelValue
|
||||
watch(() => props.modelValue, (newDate) => {
|
||||
displayDate.value = new Date(newDate);
|
||||
});
|
||||
|
||||
const monthLabel = computed(() => {
|
||||
return months[displayDate.value.getMonth()];
|
||||
});
|
||||
|
||||
const yearRange = computed(() => {
|
||||
const currentYear = new Date().getFullYear();
|
||||
const years: number[] = [];
|
||||
for (let y = currentYear - 10; y <= currentYear + 10; y++) {
|
||||
years.push(y);
|
||||
}
|
||||
return years;
|
||||
});
|
||||
|
||||
const calendarDates = computed(() => {
|
||||
const year = displayDate.value.getFullYear();
|
||||
const month = displayDate.value.getMonth();
|
||||
const firstDay = new Date(year, month, 1);
|
||||
const startDate = new Date(firstDay);
|
||||
startDate.setDate(startDate.getDate() - startDate.getDay());
|
||||
|
||||
const dates: Date[] = [];
|
||||
const current = new Date(startDate);
|
||||
|
||||
for (let i = 0; i < 42; i++) {
|
||||
dates.push(new Date(current));
|
||||
current.setDate(current.getDate() + 1);
|
||||
}
|
||||
|
||||
return dates;
|
||||
});
|
||||
|
||||
function previousMonth() {
|
||||
const newDate = new Date(displayDate.value);
|
||||
newDate.setMonth(newDate.getMonth() - 1);
|
||||
displayDate.value = newDate;
|
||||
}
|
||||
|
||||
function nextMonth() {
|
||||
const newDate = new Date(displayDate.value);
|
||||
newDate.setMonth(newDate.getMonth() + 1);
|
||||
displayDate.value = newDate;
|
||||
}
|
||||
|
||||
function setMonth(monthIndex: number) {
|
||||
const newDate = new Date(displayDate.value);
|
||||
newDate.setMonth(monthIndex);
|
||||
displayDate.value = newDate;
|
||||
}
|
||||
|
||||
function setYear(year: number) {
|
||||
const newDate = new Date(displayDate.value);
|
||||
newDate.setFullYear(year);
|
||||
displayDate.value = newDate;
|
||||
}
|
||||
|
||||
function selectDate(date: Date) {
|
||||
emit('update:modelValue', date);
|
||||
}
|
||||
|
||||
function isToday(date: Date): boolean {
|
||||
const today = new Date();
|
||||
return date.toDateString() === today.toDateString();
|
||||
}
|
||||
|
||||
function isSelected(date: Date): boolean {
|
||||
return date.toDateString() === props.modelValue.toDateString();
|
||||
}
|
||||
|
||||
function isSameMonth(date1: Date, date2: Date): boolean {
|
||||
return date1.getMonth() === date2.getMonth() && date1.getFullYear() === date2.getFullYear();
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.mini-calendar {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.mini-calendar-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.month-year-selector {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.month-btn, .year-btn {
|
||||
font-size: 12px !important;
|
||||
font-weight: 500;
|
||||
text-transform: none !important;
|
||||
letter-spacing: normal !important;
|
||||
min-width: 0 !important;
|
||||
padding: 0 4px !important;
|
||||
}
|
||||
|
||||
.month-list, .year-list {
|
||||
max-height: 250px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.mini-calendar-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.mini-day-header {
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
padding: 4px 0;
|
||||
opacity: 0.6;
|
||||
font-size: 10px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.mini-day {
|
||||
text-align: center;
|
||||
padding: 6px 4px;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.2s;
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 24px;
|
||||
}
|
||||
|
||||
.mini-day:hover {
|
||||
background-color: rgba(var(--v-theme-primary), 0.08);
|
||||
}
|
||||
|
||||
.mini-day.today {
|
||||
background-color: rgb(var(--v-theme-primary));
|
||||
color: rgb(var(--v-theme-on-primary));
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.mini-day.today:hover {
|
||||
background-color: rgb(var(--v-theme-primary));
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
.mini-day.selected:not(.today) {
|
||||
outline: 2px solid rgb(var(--v-theme-primary));
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.mini-day.other-month {
|
||||
opacity: 0.3;
|
||||
}
|
||||
</style>
|
||||
551
src/components/MonthView.vue
Normal file
551
src/components/MonthView.vue
Normal file
@@ -0,0 +1,551 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch, onMounted, onUnmounted } from 'vue';
|
||||
import {
|
||||
startOfDay,
|
||||
daysDiff,
|
||||
isMultiDay,
|
||||
getSingleDayEventsForDate,
|
||||
type MultiDaySegment,
|
||||
} from '@/utils/calendarHelpers';
|
||||
|
||||
const EVENT_HEIGHT = 22; // Height of each event row in pixels
|
||||
const MAX_VISIBLE_EVENTS = 3; // Maximum event rows to show before "+N more"
|
||||
|
||||
interface WeekData {
|
||||
startDate: Date;
|
||||
days: { date: Date; currentMonth: boolean }[];
|
||||
multiDaySegments: MultiDaySegment[];
|
||||
laneCount: number;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
currentDate: Date;
|
||||
events: any[];
|
||||
calendars: any[];
|
||||
}>();
|
||||
|
||||
defineEmits<{
|
||||
'event-click': [event: any];
|
||||
'date-click': [date: Date];
|
||||
'event-hover': [data: { event: MouseEvent; entity: any }];
|
||||
'event-hover-end': [];
|
||||
}>();
|
||||
|
||||
const weekDays = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||
|
||||
// Local date for navigation
|
||||
const localDate = ref(new Date(props.currentDate));
|
||||
|
||||
// Watch for external date changes (e.g., from mini calendar)
|
||||
watch(() => props.currentDate, (newDate) => {
|
||||
localDate.value = new Date(newDate);
|
||||
});
|
||||
|
||||
// Navigation functions
|
||||
function previousMonth() {
|
||||
const date = new Date(localDate.value);
|
||||
date.setMonth(date.getMonth() - 1);
|
||||
localDate.value = date;
|
||||
}
|
||||
|
||||
function nextMonth() {
|
||||
const date = new Date(localDate.value);
|
||||
date.setMonth(date.getMonth() + 1);
|
||||
localDate.value = date;
|
||||
}
|
||||
|
||||
function goToToday() {
|
||||
localDate.value = new Date();
|
||||
}
|
||||
|
||||
const monthLabel = computed(() => {
|
||||
return localDate.value.toLocaleDateString('en-US', { month: 'long', year: 'numeric' });
|
||||
});
|
||||
|
||||
// Track container size for dynamic calculations
|
||||
const containerRef = ref<HTMLElement | null>(null);
|
||||
const containerHeight = ref(600);
|
||||
|
||||
function updateContainerHeight() {
|
||||
if (containerRef.value) {
|
||||
containerHeight.value = containerRef.value.clientHeight;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
updateContainerHeight();
|
||||
window.addEventListener('resize', updateContainerHeight);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', updateContainerHeight);
|
||||
});
|
||||
|
||||
// Calculate how many weeks are needed for this month
|
||||
const weeksNeeded = computed(() => {
|
||||
const year = localDate.value.getFullYear();
|
||||
const month = localDate.value.getMonth();
|
||||
const firstDay = new Date(year, month, 1);
|
||||
const lastDay = new Date(year, month + 1, 0);
|
||||
|
||||
// Days before the 1st in the first week + days in month
|
||||
const daysBeforeFirst = firstDay.getDay();
|
||||
const daysInMonth = lastDay.getDate();
|
||||
|
||||
return Math.ceil((daysBeforeFirst + daysInMonth) / 7);
|
||||
});
|
||||
|
||||
// Build weeks with multiday event segments
|
||||
const weeks = computed<WeekData[]>(() => {
|
||||
const year = localDate.value.getFullYear();
|
||||
const month = localDate.value.getMonth();
|
||||
const firstDay = new Date(year, month, 1);
|
||||
const gridStart = new Date(firstDay);
|
||||
gridStart.setDate(gridStart.getDate() - gridStart.getDay());
|
||||
|
||||
const weeksData: WeekData[] = [];
|
||||
const current = new Date(gridStart);
|
||||
|
||||
const numWeeks = weeksNeeded.value;
|
||||
|
||||
for (let w = 0; w < numWeeks; w++) {
|
||||
const weekStart = startOfDay(new Date(current));
|
||||
const weekEnd = new Date(weekStart);
|
||||
weekEnd.setDate(weekEnd.getDate() + 6);
|
||||
|
||||
const days: { date: Date; currentMonth: boolean }[] = [];
|
||||
for (let d = 0; d < 7; d++) {
|
||||
days.push({
|
||||
date: new Date(current),
|
||||
currentMonth: current.getMonth() === month,
|
||||
});
|
||||
current.setDate(current.getDate() + 1);
|
||||
}
|
||||
|
||||
// Get multiday segments for this week
|
||||
const { segments, laneCount } = getMultiDaySegments(weekStart, weekEnd);
|
||||
|
||||
weeksData.push({
|
||||
startDate: weekStart,
|
||||
days,
|
||||
multiDaySegments: segments,
|
||||
laneCount: Math.min(laneCount, MAX_VISIBLE_EVENTS),
|
||||
});
|
||||
}
|
||||
|
||||
return weeksData;
|
||||
});
|
||||
|
||||
function getMultiDaySegments(weekStart: Date, weekEnd: Date): { segments: MultiDaySegment[]; laneCount: number } {
|
||||
const segments: MultiDaySegment[] = [];
|
||||
|
||||
// Filter multiday events that overlap this week
|
||||
const multiDayEvents = props.events.filter(entity => {
|
||||
if (!entity.data?.startsOn || !isMultiDay(entity)) return false;
|
||||
const eventStart = startOfDay(new Date(entity.data.startsOn));
|
||||
const eventEnd = startOfDay(new Date(entity.data.endsOn));
|
||||
return eventStart <= weekEnd && eventEnd >= weekStart;
|
||||
});
|
||||
|
||||
// Sort: longer events first, then by start date
|
||||
multiDayEvents.sort((a, b) => {
|
||||
const aStart = new Date(a.data.startsOn);
|
||||
const aEnd = new Date(a.data.endsOn);
|
||||
const bStart = new Date(b.data.startsOn);
|
||||
const bEnd = new Date(b.data.endsOn);
|
||||
const aDuration = daysDiff(aStart, aEnd);
|
||||
const bDuration = daysDiff(bStart, bEnd);
|
||||
if (bDuration !== aDuration) return bDuration - aDuration;
|
||||
return aStart.getTime() - bStart.getTime();
|
||||
});
|
||||
|
||||
// Lane assignment
|
||||
const lanes: boolean[][] = []; // lanes[lane][dayOfWeek] = occupied
|
||||
|
||||
for (const entity of multiDayEvents) {
|
||||
const eventStart = startOfDay(new Date(entity.data.startsOn));
|
||||
const eventEnd = startOfDay(new Date(entity.data.endsOn));
|
||||
|
||||
// Clamp to this week
|
||||
const segStart = eventStart < weekStart ? weekStart : eventStart;
|
||||
const segEnd = eventEnd > weekEnd ? weekEnd : eventEnd;
|
||||
|
||||
const startCol = segStart.getDay();
|
||||
const endCol = segEnd.getDay();
|
||||
const span = endCol - startCol + 1;
|
||||
|
||||
// Find available lane
|
||||
let lane = 0;
|
||||
while (true) {
|
||||
if (!lanes[lane]) lanes[lane] = [false, false, false, false, false, false, false];
|
||||
|
||||
let isFree = true;
|
||||
for (let d = startCol; d <= endCol; d++) {
|
||||
if (lanes[lane][d]) { isFree = false; break; }
|
||||
}
|
||||
|
||||
if (isFree) {
|
||||
for (let d = startCol; d <= endCol; d++) lanes[lane][d] = true;
|
||||
break;
|
||||
}
|
||||
lane++;
|
||||
}
|
||||
|
||||
segments.push({
|
||||
entity,
|
||||
startCol,
|
||||
span,
|
||||
lane,
|
||||
isStart: eventStart >= weekStart,
|
||||
isEnd: eventEnd <= weekEnd,
|
||||
});
|
||||
}
|
||||
|
||||
return { segments, laneCount: lanes.length };
|
||||
}
|
||||
|
||||
// Single-day events for a specific date
|
||||
function getSingleDayEvents(date: Date): any[] {
|
||||
return getSingleDayEventsForDate(props.events, date);
|
||||
}
|
||||
|
||||
// Count hidden events (multiday beyond MAX + single day beyond remaining space)
|
||||
function getHiddenCount(cell: { date: Date }, weekLaneCount: number): number {
|
||||
const singleDayEvents = getSingleDayEvents(cell.date);
|
||||
const visibleSingleDay = Math.max(0, MAX_VISIBLE_EVENTS - weekLaneCount);
|
||||
const hiddenSingleDay = Math.max(0, singleDayEvents.length - visibleSingleDay);
|
||||
|
||||
// Also count multiday events in lanes beyond MAX_VISIBLE_EVENTS for this day
|
||||
// (This would need more complex tracking - simplified for now)
|
||||
return hiddenSingleDay;
|
||||
}
|
||||
|
||||
function isToday(date: Date): boolean {
|
||||
const today = new Date();
|
||||
return date.toDateString() === today.toDateString();
|
||||
}
|
||||
|
||||
function getEventColor(entity: any): string {
|
||||
if (entity.data?.color) return entity.data.color;
|
||||
const calendar = props.calendars.find(cal => cal.id === entity.in);
|
||||
return calendar?.color || '#1976D2';
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="month-view" ref="containerRef">
|
||||
<!-- Navigation Controls -->
|
||||
<div class="view-controls">
|
||||
<div class="view-navigation">
|
||||
<v-btn size="x-small" variant="text" icon="mdi-chevron-left" @click="previousMonth" />
|
||||
<v-btn size="x-small" variant="tonal" @click="goToToday">Today</v-btn>
|
||||
<v-btn size="x-small" variant="text" icon="mdi-chevron-right" @click="nextMonth" />
|
||||
<span class="current-period">{{ monthLabel }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Header row -->
|
||||
<div class="day-headers">
|
||||
<div v-for="day in weekDays" :key="day" class="day-header-cell">
|
||||
{{ day }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Week rows -->
|
||||
<div class="calendar-body">
|
||||
<div v-for="(week, weekIndex) in weeks" :key="weekIndex" class="calendar-week">
|
||||
<!-- Base layer: Day cells grid -->
|
||||
<div class="days-grid">
|
||||
<div
|
||||
v-for="(cell, dayIndex) in week.days"
|
||||
:key="dayIndex"
|
||||
class="calendar-cell"
|
||||
:class="{
|
||||
'today': isToday(cell.date),
|
||||
'other-month': !cell.currentMonth
|
||||
}"
|
||||
@click="$emit('date-click', cell.date)"
|
||||
>
|
||||
<div class="cell-date">{{ cell.date.getDate() }}</div>
|
||||
<!-- Spacer for multiday events -->
|
||||
<div
|
||||
class="multiday-spacer"
|
||||
:style="{ height: `${week.laneCount * EVENT_HEIGHT}px` }"
|
||||
></div>
|
||||
<!-- Single-day events -->
|
||||
<div class="cell-events">
|
||||
<div
|
||||
v-for="entity in getSingleDayEvents(cell.date).slice(0, MAX_VISIBLE_EVENTS - week.laneCount)"
|
||||
:key="entity.id"
|
||||
class="single-day-event"
|
||||
:style="{ backgroundColor: getEventColor(entity) }"
|
||||
@click.stop="$emit('event-click', entity)"
|
||||
@mouseenter="$emit('event-hover', { event: $event, entity })"
|
||||
@mouseleave="$emit('event-hover-end')"
|
||||
>
|
||||
{{ entity.data?.label || 'Untitled' }}
|
||||
</div>
|
||||
<div
|
||||
v-if="getHiddenCount(cell, week.laneCount) > 0"
|
||||
class="more-events"
|
||||
@click.stop="$emit('date-click', cell.date)"
|
||||
>
|
||||
+{{ getHiddenCount(cell, week.laneCount) }} more
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Overlay layer: Multiday events (positioned absolutely over the grid) -->
|
||||
<div class="multiday-overlay">
|
||||
<div
|
||||
v-for="segment in week.multiDaySegments"
|
||||
:key="`${segment.entity.id}-${weekIndex}`"
|
||||
class="multiday-event"
|
||||
:class="{
|
||||
'is-start': segment.isStart,
|
||||
'is-end': segment.isEnd,
|
||||
}"
|
||||
:style="{
|
||||
backgroundColor: getEventColor(segment.entity),
|
||||
left: `calc(${segment.startCol} / 7 * 100% + 4px)`,
|
||||
width: `calc(${segment.span} / 7 * 100% - 8px)`,
|
||||
top: `${34 + segment.lane * EVENT_HEIGHT}px`,
|
||||
}"
|
||||
@click.stop="$emit('event-click', segment.entity)"
|
||||
@mouseenter="$emit('event-hover', { event: $event, entity: segment.entity })"
|
||||
@mouseleave="$emit('event-hover-end')"
|
||||
>
|
||||
<span v-if="segment.isStart" class="event-label">
|
||||
{{ segment.entity.data?.label || 'Untitled' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.month-view {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
border: 1px solid rgb(var(--v-border-color));
|
||||
}
|
||||
|
||||
.view-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 4px 8px;
|
||||
border-bottom: 1px solid rgb(var(--v-border-color));
|
||||
background-color: rgb(var(--v-theme-surface));
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.view-navigation {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.current-period {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
margin-left: 8px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.day-headers {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
flex-shrink: 0;
|
||||
border-bottom: 2px solid rgb(var(--v-border-color));
|
||||
background-color: rgb(var(--v-theme-surface-variant));
|
||||
}
|
||||
|
||||
.day-header-cell {
|
||||
padding: 4px;
|
||||
text-align: center;
|
||||
font-weight: 500;
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
opacity: 0.7;
|
||||
border-right: 1px solid rgb(var(--v-border-color));
|
||||
}
|
||||
|
||||
.day-header-cell:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.calendar-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.calendar-week {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* Base layer: Day cells */
|
||||
.days-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.calendar-cell {
|
||||
background-color: transparent;
|
||||
padding: 4px;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
border-right: 1px solid rgb(var(--v-border-color));
|
||||
border-bottom: 1px solid rgb(var(--v-border-color));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.calendar-cell:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.calendar-cell.other-month {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.calendar-cell:hover {
|
||||
background-color: rgba(var(--v-theme-primary), 0.04);
|
||||
}
|
||||
|
||||
.calendar-cell.today {
|
||||
background-color: rgba(var(--v-theme-primary), 0.06);
|
||||
}
|
||||
|
||||
.calendar-cell.today:hover {
|
||||
background-color: rgba(var(--v-theme-primary), 0.1);
|
||||
}
|
||||
|
||||
.cell-date {
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
padding: 0 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.calendar-cell.today .cell-date {
|
||||
background-color: rgb(var(--v-theme-primary));
|
||||
color: rgb(var(--v-theme-on-primary));
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Spacer to reserve room for multiday events overlay */
|
||||
.multiday-spacer {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.cell-events {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.single-day-event {
|
||||
height: 20px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.single-day-event:hover {
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
.more-events {
|
||||
font-size: 11px;
|
||||
color: rgb(var(--v-theme-primary));
|
||||
padding: 2px 6px;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.more-events:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Overlay layer: Multiday events */
|
||||
.multiday-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.multiday-event {
|
||||
position: absolute;
|
||||
height: 20px;
|
||||
padding: 0 6px;
|
||||
font-size: 12px;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
pointer-events: auto;
|
||||
box-sizing: border-box;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.multiday-event:not(.is-start) {
|
||||
border-top-left-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
padding-left: 4px;
|
||||
}
|
||||
|
||||
.multiday-event:not(.is-end) {
|
||||
border-top-right-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
|
||||
.multiday-event:hover {
|
||||
filter: brightness(1.1);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.event-label {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
</style>
|
||||
324
src/components/TaskEditor.vue
Normal file
324
src/components/TaskEditor.vue
Normal file
@@ -0,0 +1,324 @@
|
||||
<template>
|
||||
<v-card v-if="entity" class="task-editor">
|
||||
<v-card-title class="d-flex justify-space-between align-center">
|
||||
<span>{{ entity.id ? entity.data?.label || 'Untitled Task' : 'New Task' }}</span>
|
||||
<div class="d-flex gap-2">
|
||||
<v-btn
|
||||
v-if="mode === 'view' && canEdit"
|
||||
icon="mdi-pencil"
|
||||
variant="text"
|
||||
size="small"
|
||||
@click="$emit('edit')"
|
||||
></v-btn>
|
||||
<v-btn
|
||||
icon="mdi-close"
|
||||
variant="text"
|
||||
size="small"
|
||||
@click="$emit('close')"
|
||||
></v-btn>
|
||||
</div>
|
||||
</v-card-title>
|
||||
<v-divider></v-divider>
|
||||
<v-card-text class="task-editor-content">
|
||||
<v-form ref="formRef">
|
||||
<v-text-field
|
||||
v-model="entity.data.label"
|
||||
label="Title"
|
||||
:rules="[v => !!v || 'Title is required']"
|
||||
:readonly="!isEditing"
|
||||
:variant="isEditing ? 'outlined' : 'plain'"
|
||||
class="mb-4"
|
||||
></v-text-field>
|
||||
|
||||
<v-select
|
||||
v-model="selectedCollectionId"
|
||||
:items="listOptions"
|
||||
label="Task List"
|
||||
:readonly="!isEditing || !canChangeCollection"
|
||||
:variant="isEditing && canChangeCollection ? 'outlined' : 'plain'"
|
||||
class="mb-4"
|
||||
></v-select>
|
||||
|
||||
<v-textarea
|
||||
v-model="entity.data.description"
|
||||
label="Description"
|
||||
:readonly="!isEditing"
|
||||
:variant="isEditing ? 'outlined' : 'plain'"
|
||||
rows="3"
|
||||
class="mb-4"
|
||||
></v-textarea>
|
||||
|
||||
<v-select
|
||||
v-model="entity.data.priority"
|
||||
:items="priorityOptions"
|
||||
label="Priority"
|
||||
:readonly="!isEditing"
|
||||
:variant="isEditing ? 'outlined' : 'plain'"
|
||||
class="mb-4"
|
||||
></v-select>
|
||||
|
||||
<v-text-field
|
||||
v-model="dueDate"
|
||||
label="Due Date"
|
||||
type="date"
|
||||
:readonly="!isEditing || !canEditDates"
|
||||
:variant="isEditing && canEditDates ? 'outlined' : 'plain'"
|
||||
clearable
|
||||
class="mb-4"
|
||||
></v-text-field>
|
||||
|
||||
<v-text-field
|
||||
v-model="startDate"
|
||||
label="Start Date"
|
||||
type="date"
|
||||
:readonly="!isEditing || !canEditDates"
|
||||
:variant="isEditing && canEditDates ? 'outlined' : 'plain'"
|
||||
clearable
|
||||
class="mb-4"
|
||||
></v-text-field>
|
||||
|
||||
<v-combobox
|
||||
v-model="entity.data.categories"
|
||||
label="Tags"
|
||||
multiple
|
||||
chips
|
||||
closable-chips
|
||||
:readonly="!isEditing"
|
||||
:variant="isEditing ? 'outlined' : 'plain'"
|
||||
class="mb-4"
|
||||
></v-combobox>
|
||||
|
||||
<div class="d-flex gap-4">
|
||||
<v-text-field
|
||||
v-model.number="entity.data.estimatedTime"
|
||||
label="Estimated Time (minutes)"
|
||||
type="number"
|
||||
:readonly="!isEditing"
|
||||
:variant="isEditing ? 'outlined' : 'plain'"
|
||||
class="flex-1-1"
|
||||
></v-text-field>
|
||||
<v-text-field
|
||||
v-model.number="entity.data.actualTime"
|
||||
label="Actual Time (minutes)"
|
||||
type="number"
|
||||
:readonly="!isEditing"
|
||||
:variant="isEditing ? 'outlined' : 'plain'"
|
||||
class="flex-1-1"
|
||||
></v-text-field>
|
||||
</div>
|
||||
|
||||
<!-- Metadata for view mode -->
|
||||
<div v-if="mode === 'view' && entity.data.created" class="mt-4">
|
||||
<v-divider class="mb-4"></v-divider>
|
||||
<div class="text-caption text-medium-emphasis">
|
||||
<div>Created: {{ new Date(entity.data.created).toLocaleString() }}</div>
|
||||
<div v-if="entity.data.modified">Modified: {{ new Date(entity.data.modified).toLocaleString() }}</div>
|
||||
<div v-if="entity.data.status === 'completed' && entity.data.completedOn">
|
||||
Completed: {{ new Date(entity.data.completedOn).toLocaleString() }}
|
||||
</div>
|
||||
<div v-if="isExternalTask" class="mt-2">
|
||||
<v-chip size="small" color="info" variant="tonal">
|
||||
<v-icon start size="small">mdi-account-arrow-right</v-icon>
|
||||
Assigned Task
|
||||
</v-chip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</v-form>
|
||||
</v-card-text>
|
||||
<v-divider></v-divider>
|
||||
<v-card-actions>
|
||||
<v-btn
|
||||
v-if="mode === 'edit' && entity.id && canDelete"
|
||||
color="error"
|
||||
variant="text"
|
||||
@click="handleDelete"
|
||||
>
|
||||
Delete
|
||||
</v-btn>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn
|
||||
v-if="mode === 'edit'"
|
||||
variant="text"
|
||||
@click="handleCancel"
|
||||
>
|
||||
Cancel
|
||||
</v-btn>
|
||||
<v-btn
|
||||
v-if="mode === 'edit'"
|
||||
color="primary"
|
||||
variant="flat"
|
||||
@click="handleSave"
|
||||
>
|
||||
Save
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
<v-card v-else class="task-editor">
|
||||
<v-card-text class="text-center pa-8">
|
||||
<v-icon size="64" color="grey-lighten-1">mdi-checkbox-marked-outline</v-icon>
|
||||
<div class="text-h6 mt-4 text-medium-emphasis">No task selected</div>
|
||||
<div class="text-body-2 text-medium-emphasis mt-2">Select a task to view or edit</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue';
|
||||
|
||||
const props = defineProps<{
|
||||
mode: 'view' | 'edit';
|
||||
entity: any | null;
|
||||
collection: any | null;
|
||||
lists: any[];
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
save: [entity: any, collection?: any];
|
||||
delete: [entity: any, collection?: any];
|
||||
edit: [];
|
||||
cancel: [];
|
||||
close: [];
|
||||
}>();
|
||||
|
||||
const formRef = ref();
|
||||
const selectedCollectionId = ref(props.entity?.in || props.collection?.id || props.lists[0]?.id || '');
|
||||
|
||||
// Determine permissions based on entity metadata
|
||||
const isExternalTask = computed(() => {
|
||||
// Check if task was assigned by another user
|
||||
return props.entity?.data?.external === true || props.entity?.data?.assignedBy;
|
||||
});
|
||||
|
||||
const canEdit = computed(() => {
|
||||
if (!props.entity) return false;
|
||||
if (props.entity.data?.readonly === true) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const canEditDates = computed(() => {
|
||||
// External/assigned tasks can't change dates
|
||||
return !isExternalTask.value;
|
||||
});
|
||||
|
||||
const canChangeCollection = computed(() => {
|
||||
// Can't move external tasks to different lists
|
||||
return !isExternalTask.value && !props.entity?.id;
|
||||
});
|
||||
|
||||
const canDelete = computed(() => {
|
||||
// Can't delete external/readonly tasks
|
||||
return !isExternalTask.value && props.entity?.data?.readonly !== true;
|
||||
});
|
||||
|
||||
const isEditing = computed(() => props.mode === 'edit');
|
||||
|
||||
const dueDate = ref('');
|
||||
const startDate = ref('');
|
||||
|
||||
// Watch for entity changes to update date fields
|
||||
watch(() => props.entity, (newEntity) => {
|
||||
if (newEntity?.data?.dueOn) {
|
||||
dueDate.value = formatDate(new Date(newEntity.data.dueOn));
|
||||
} else {
|
||||
dueDate.value = '';
|
||||
}
|
||||
|
||||
if (newEntity?.data?.startsOn) {
|
||||
startDate.value = formatDate(new Date(newEntity.data.startsOn));
|
||||
} else {
|
||||
startDate.value = '';
|
||||
}
|
||||
|
||||
selectedCollectionId.value = newEntity?.in || props.collection?.id || props.lists[0]?.id || '';
|
||||
}, { immediate: true });
|
||||
|
||||
const listOptions = computed(() =>
|
||||
props.lists.map(list => ({
|
||||
title: list.label || 'Unnamed List',
|
||||
value: list.id,
|
||||
}))
|
||||
);
|
||||
|
||||
const priorityOptions = [
|
||||
{ title: 'Low', value: 3 },
|
||||
{ title: 'Medium', value: 2 },
|
||||
{ title: 'High', value: 1 },
|
||||
];
|
||||
|
||||
watch(dueDate, (newVal) => {
|
||||
if (props.entity && isEditing.value) {
|
||||
props.entity.data.dueOn = newVal ? new Date(newVal).toISOString() : null;
|
||||
}
|
||||
});
|
||||
|
||||
watch(startDate, (newVal) => {
|
||||
if (props.entity && isEditing.value) {
|
||||
props.entity.data.startsOn = newVal ? new Date(newVal).toISOString() : null;
|
||||
}
|
||||
});
|
||||
|
||||
function formatDate(date: Date): string {
|
||||
return date.toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
const { valid } = await formRef.value.validate();
|
||||
if (!valid) return;
|
||||
|
||||
const targetCollection = props.lists.find(list => list.id === selectedCollectionId.value);
|
||||
emit('save', props.entity, targetCollection || props.collection);
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
emit('cancel');
|
||||
}
|
||||
|
||||
function handleDelete() {
|
||||
if (props.entity) {
|
||||
const targetCollection = props.lists.find(list => list.id === selectedCollectionId.value);
|
||||
emit('delete', props.entity, targetCollection || props.collection);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.gap-2 {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.gap-4 {
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.flex-1-1 {
|
||||
flex: 1 1 0;
|
||||
}
|
||||
|
||||
.task-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.v-card-title {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
background: rgb(var(--v-theme-surface));
|
||||
border-bottom: 1px solid rgb(var(--v-border-color));
|
||||
}
|
||||
|
||||
.task-editor-content {
|
||||
overflow-y: auto;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.v-card-actions {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
z-index: 2;
|
||||
background: rgb(var(--v-theme-surface));
|
||||
border-top: 1px solid rgb(var(--v-border-color));
|
||||
}
|
||||
</style>
|
||||
219
src/components/TaskView.vue
Normal file
219
src/components/TaskView.vue
Normal file
@@ -0,0 +1,219 @@
|
||||
<template>
|
||||
<div class="task-view">
|
||||
<v-tabs v-model="activeTab" class="mb-4">
|
||||
<v-tab value="all">All Tasks</v-tab>
|
||||
<v-tab value="active">Active</v-tab>
|
||||
<v-tab value="completed">Completed</v-tab>
|
||||
</v-tabs>
|
||||
|
||||
<div class="task-filters mb-4">
|
||||
<v-select
|
||||
v-model="filterPriority"
|
||||
:items="priorityOptions"
|
||||
label="Priority"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
clearable
|
||||
style="max-width: 200px"
|
||||
></v-select>
|
||||
</div>
|
||||
|
||||
<v-list>
|
||||
<v-list-item
|
||||
v-for="entity in filteredTasks"
|
||||
:key="entity.id"
|
||||
class="task-item"
|
||||
:class="{ 'completed': entity.data?.status === 'completed' }"
|
||||
@click="$emit('task-click', entity)"
|
||||
>
|
||||
<template #prepend>
|
||||
<v-checkbox-btn
|
||||
:model-value="entity.data?.status === 'completed'"
|
||||
hide-details
|
||||
@click.stop="$emit('toggle-complete', entity.id)"
|
||||
></v-checkbox-btn>
|
||||
</template>
|
||||
|
||||
<div class="task-content">
|
||||
<div class="task-title" :class="{ 'completed': entity.data?.status === 'completed' }">
|
||||
{{ entity.data?.label || 'Untitled Task' }}
|
||||
</div>
|
||||
<div v-if="entity.data?.description" class="task-description text-caption">
|
||||
{{ entity.data.description }}
|
||||
</div>
|
||||
<div class="task-meta">
|
||||
<span v-if="entity.data?.dueOn" class="due-date">
|
||||
<v-icon size="small">mdi-calendar</v-icon>
|
||||
{{ formatDueDate(new Date(entity.data.dueOn)) }}
|
||||
</span>
|
||||
<v-chip
|
||||
v-if="entity.data?.priority"
|
||||
size="x-small"
|
||||
:class="`priority-${entity.data.priority === 1 ? 'high' : entity.data.priority === 2 ? 'medium' : 'low'}`"
|
||||
class="priority-badge"
|
||||
>
|
||||
{{ entity.data.priority === 1 ? 'high' : entity.data.priority === 2 ? 'medium' : 'low' }}
|
||||
</v-chip>
|
||||
<v-chip
|
||||
v-for="tag in (entity.data?.categories || [])"
|
||||
:key="tag"
|
||||
size="x-small"
|
||||
variant="outlined"
|
||||
>
|
||||
{{ tag }}
|
||||
</v-chip>
|
||||
</div>
|
||||
</div>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
|
||||
<div v-if="filteredTasks.length === 0" class="text-center pa-8 text-disabled">
|
||||
No tasks found
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue';
|
||||
|
||||
const props = defineProps<{
|
||||
tasks: any[];
|
||||
lists: any[];
|
||||
}>();
|
||||
|
||||
defineEmits<{
|
||||
'task-click': [task: any];
|
||||
'toggle-complete': [taskId: string];
|
||||
}>();
|
||||
|
||||
const activeTab = ref('all');
|
||||
const filterPriority = ref<string | null>(null);
|
||||
|
||||
const priorityOptions = [
|
||||
{ title: 'High', value: 'high' },
|
||||
{ title: 'Medium', value: 'medium' },
|
||||
{ title: 'Low', value: 'low' },
|
||||
];
|
||||
|
||||
const filteredTasks = computed(() => {
|
||||
let filtered = [...props.tasks];
|
||||
|
||||
// Filter by tab
|
||||
if (activeTab.value === 'active') {
|
||||
filtered = filtered.filter(entity => entity.data?.status !== 'completed');
|
||||
} else if (activeTab.value === 'completed') {
|
||||
filtered = filtered.filter(entity => entity.data?.status === 'completed');
|
||||
}
|
||||
|
||||
// Filter by priority
|
||||
if (filterPriority.value) {
|
||||
const priorityMap = { high: 1, medium: 2, low: 3 };
|
||||
const targetPriority = priorityMap[filterPriority.value];
|
||||
filtered = filtered.filter(entity => entity.data?.priority === targetPriority);
|
||||
}
|
||||
|
||||
// Sort: incomplete first, then by due date, then by priority
|
||||
return filtered.sort((a, b) => {
|
||||
const aCompleted = a.data?.status === 'completed';
|
||||
const bCompleted = b.data?.status === 'completed';
|
||||
if (aCompleted !== bCompleted) {
|
||||
return aCompleted ? 1 : -1;
|
||||
}
|
||||
|
||||
if (a.data?.dueOn && b.data?.dueOn) {
|
||||
return new Date(a.data.dueOn).getTime() - new Date(b.data.dueOn).getTime();
|
||||
}
|
||||
|
||||
if (a.data?.dueOn) return -1;
|
||||
if (b.data?.dueOn) return 1;
|
||||
|
||||
return (a.data?.priority || 4) - (b.data?.priority || 4);
|
||||
});
|
||||
});
|
||||
|
||||
function formatDueDate(date: Date): string {
|
||||
const today = new Date();
|
||||
const tomorrow = new Date(today);
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
|
||||
if (date.toDateString() === today.toDateString()) {
|
||||
return 'Today';
|
||||
} else if (date.toDateString() === tomorrow.toDateString()) {
|
||||
return 'Tomorrow';
|
||||
} else if (date < today) {
|
||||
return `Overdue (${date.toLocaleDateString()})`;
|
||||
} else {
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.task-view {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.task-filters {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.task-item {
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.12);
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.task-item.completed {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.task-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.task-title {
|
||||
font-weight: 500;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.task-title.completed {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.task-description {
|
||||
color: rgba(0, 0, 0, 0.6);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.task-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.due-date {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.priority-badge {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.priority-high {
|
||||
background-color: #ffebee !important;
|
||||
color: #c62828 !important;
|
||||
}
|
||||
|
||||
.priority-medium {
|
||||
background-color: #fff3e0 !important;
|
||||
color: #ef6c00 !important;
|
||||
}
|
||||
|
||||
.priority-low {
|
||||
background-color: #e8f5e9 !important;
|
||||
color: #2e7d32 !important;
|
||||
}
|
||||
</style>
|
||||
190
src/components/editors/EventEditorDates.vue
Normal file
190
src/components/editors/EventEditorDates.vue
Normal file
@@ -0,0 +1,190 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
|
||||
interface Props {
|
||||
mode: 'edit' | 'view'
|
||||
startsOn?: string | null
|
||||
endsOn?: string | null
|
||||
timeless?: boolean | null
|
||||
timeZone?: string | null
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:startsOn': [date: string | null]
|
||||
'update:endsOn': [date: string | null]
|
||||
'update:timeless': [timeless: boolean | null]
|
||||
'update:timeZone': [timeZone: string | null]
|
||||
}>()
|
||||
|
||||
const timelessValue = computed({
|
||||
get: () => props.timeless ?? false,
|
||||
set: (value) => emit('update:timeless', value)
|
||||
})
|
||||
|
||||
const timeZoneValue = computed({
|
||||
get: () => props.timeZone ?? '',
|
||||
set: (value) => emit('update:timeZone', value || null)
|
||||
})
|
||||
|
||||
// Format helpers
|
||||
const formatDate = (date: Date): string => {
|
||||
return date.toISOString().split('T')[0]
|
||||
}
|
||||
|
||||
const formatTime = (date: Date): string => {
|
||||
return date.toTimeString().slice(0, 5)
|
||||
}
|
||||
|
||||
const formatDateTime = (isoString: string | null | undefined): string => {
|
||||
if (!isoString) return 'Not set'
|
||||
const date = new Date(isoString)
|
||||
return date.toLocaleString()
|
||||
}
|
||||
|
||||
const parseDateTime = (date: string, time: string): Date => {
|
||||
if (timelessValue.value) {
|
||||
return new Date(date)
|
||||
}
|
||||
return new Date(`${date}T${time}`)
|
||||
}
|
||||
|
||||
// Date/time fields
|
||||
const startDate = ref('')
|
||||
const startTime = ref('')
|
||||
const endDate = ref('')
|
||||
const endTime = ref('')
|
||||
|
||||
// Initialize date/time from props
|
||||
watch(() => props.startsOn, (newValue) => {
|
||||
if (newValue) {
|
||||
const start = new Date(newValue)
|
||||
startDate.value = formatDate(start)
|
||||
startTime.value = formatTime(start)
|
||||
} else {
|
||||
const now = new Date()
|
||||
startDate.value = formatDate(now)
|
||||
startTime.value = formatTime(now)
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
watch(() => props.endsOn, (newValue) => {
|
||||
if (newValue) {
|
||||
const end = new Date(newValue)
|
||||
endDate.value = formatDate(end)
|
||||
endTime.value = formatTime(end)
|
||||
} else {
|
||||
const later = new Date(Date.now() + 60 * 60 * 1000)
|
||||
endDate.value = formatDate(later)
|
||||
endTime.value = formatTime(later)
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
// Update parent when date/time changes
|
||||
watch([startDate, startTime], () => {
|
||||
if (props.mode === 'edit') {
|
||||
emit('update:startsOn', parseDateTime(startDate.value, startTime.value).toISOString())
|
||||
}
|
||||
})
|
||||
|
||||
watch([endDate, endTime], () => {
|
||||
if (props.mode === 'edit') {
|
||||
emit('update:endsOn', parseDateTime(endDate.value, endTime.value).toISOString())
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="event-editor-section">
|
||||
<div class="text-subtitle-1 mb-2">Date & Time</div>
|
||||
|
||||
<!-- Read-only view -->
|
||||
<div v-if="mode === 'view'">
|
||||
<div class="mb-2 pa-2">
|
||||
<div class="mb-1"><strong>Starts:</strong> {{ formatDateTime(startsOn) }}</div>
|
||||
<div class="mb-1"><strong>Ends:</strong> {{ formatDateTime(endsOn) }}</div>
|
||||
<div v-if="timelessValue" class="mb-1">
|
||||
<v-chip size="small" color="info" variant="tonal">
|
||||
<v-icon start size="small">mdi-calendar-blank</v-icon>
|
||||
All Day Event
|
||||
</v-chip>
|
||||
</div>
|
||||
<div v-if="timeZoneValue" class="text-caption text-grey">Time Zone: {{ timeZoneValue }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit view -->
|
||||
<div v-else>
|
||||
<v-checkbox
|
||||
v-model="timelessValue"
|
||||
label="All day event"
|
||||
density="compact"
|
||||
class="mb-2"
|
||||
/>
|
||||
|
||||
<div class="mb-3">
|
||||
<div class="text-subtitle-2 mb-2">Start</div>
|
||||
<v-row dense>
|
||||
<v-col cols="12" md="6">
|
||||
<v-text-field
|
||||
v-model="startDate"
|
||||
label="Start Date"
|
||||
type="date"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col v-if="!timelessValue" cols="12" md="6">
|
||||
<v-text-field
|
||||
v-model="startTime"
|
||||
label="Start Time"
|
||||
type="time"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<div class="text-subtitle-2 mb-2">End</div>
|
||||
<v-row dense>
|
||||
<v-col cols="12" md="6">
|
||||
<v-text-field
|
||||
v-model="endDate"
|
||||
label="End Date"
|
||||
type="date"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col v-if="!timelessValue" cols="12" md="6">
|
||||
<v-text-field
|
||||
v-model="endTime"
|
||||
label="End Time"
|
||||
type="time"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</div>
|
||||
|
||||
<v-text-field
|
||||
v-model="timeZoneValue"
|
||||
label="Time Zone"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hint="e.g., America/New_York"
|
||||
persistent-hint
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.event-editor-section {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
</style>
|
||||
51
src/components/editors/EventEditorDescription.vue
Normal file
51
src/components/editors/EventEditorDescription.vue
Normal file
@@ -0,0 +1,51 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
interface Props {
|
||||
mode: 'edit' | 'view'
|
||||
description?: string | null
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:description': [description: string | null]
|
||||
}>()
|
||||
|
||||
const descriptionValue = computed({
|
||||
get: () => props.description ?? '',
|
||||
set: (value) => emit('update:description', value || null)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="descriptionValue || mode === 'edit'" class="event-editor-section">
|
||||
<div class="text-subtitle-1 mb-2">Description</div>
|
||||
<!-- Read-only view -->
|
||||
<div v-if="mode === 'view'" class="mb-4">
|
||||
<div class="mb-2 pa-2 white-space-pre-wrap">{{ descriptionValue || 'No description' }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit view -->
|
||||
<div v-else>
|
||||
<v-textarea
|
||||
v-model="descriptionValue"
|
||||
label="Description"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
rows="3"
|
||||
auto-grow
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.event-editor-section {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.white-space-pre-wrap {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
</style>
|
||||
46
src/components/editors/EventEditorLabel.vue
Normal file
46
src/components/editors/EventEditorLabel.vue
Normal file
@@ -0,0 +1,46 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
interface Props {
|
||||
mode: 'edit' | 'view'
|
||||
label?: string | null
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:label': [label: string | null]
|
||||
}>()
|
||||
|
||||
const labelValue = computed({
|
||||
get: () => props.label ?? '',
|
||||
set: (value) => emit('update:label', value || null)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="event-editor-section">
|
||||
<div class="text-subtitle-1 mb-2">Title</div>
|
||||
<!-- Read-only view -->
|
||||
<div v-if="mode === 'view'" class="mb-4">
|
||||
<div class="mb-2 pa-2">{{ labelValue || 'Untitled Event' }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit view -->
|
||||
<div v-else>
|
||||
<v-text-field
|
||||
v-model="labelValue"
|
||||
label="Title"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
:rules="[v => !!v || 'Title is required']"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.event-editor-section {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
</style>
|
||||
174
src/components/editors/EventEditorLocations.vue
Normal file
174
src/components/editors/EventEditorLocations.vue
Normal file
@@ -0,0 +1,174 @@
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
mode: 'edit' | 'view'
|
||||
locationsPhysical: Record<string, any>
|
||||
locationsVirtual: Record<string, any>
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'add-location-physical': []
|
||||
'remove-location-physical': [key: string]
|
||||
'add-location-virtual': []
|
||||
'remove-location-virtual': [key: string]
|
||||
}>()
|
||||
|
||||
const hasLocations = () => {
|
||||
return Object.keys(props.locationsPhysical || {}).length > 0 ||
|
||||
Object.keys(props.locationsVirtual || {}).length > 0
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="hasLocations() || mode === 'edit'" class="event-editor-section">
|
||||
<div class="text-subtitle-1 mb-2">Locations</div>
|
||||
|
||||
<!-- Physical Locations -->
|
||||
<div v-if="Object.keys(locationsPhysical || {}).length > 0 || mode === 'edit'" class="mb-4">
|
||||
<div class="d-flex align-center justify-space-between mb-2">
|
||||
<div class="text-subtitle-2">Physical Locations</div>
|
||||
<v-btn v-if="mode === 'edit'"
|
||||
size="small"
|
||||
variant="outlined"
|
||||
@click="$emit('add-location-physical')">
|
||||
<v-icon left>mdi-plus</v-icon>
|
||||
Add
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<!-- Read-only view -->
|
||||
<div v-if="mode === 'view'">
|
||||
<div v-for="(location, key) in locationsPhysical" :key="key" class="mb-2 pa-2 border rounded">
|
||||
<div class="mb-1"><strong>{{ location.label || 'Location' }}</strong></div>
|
||||
<div v-if="location.timeZone" class="text-caption text-grey">Time Zone: {{ location.timeZone }}</div>
|
||||
<div v-if="location.description" class="text-caption">{{ location.description }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit view -->
|
||||
<div v-else>
|
||||
<div v-for="(location, key) in locationsPhysical" :key="key" class="mb-3 pa-3 border rounded">
|
||||
<div class="d-flex align-center justify-space-between mb-2">
|
||||
<strong>{{ location.label || 'Physical Location' }}</strong>
|
||||
<v-btn
|
||||
icon="mdi-delete"
|
||||
size="small"
|
||||
color="error"
|
||||
variant="text"
|
||||
@click="$emit('remove-location-physical', key)">
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<v-row dense>
|
||||
<v-col cols="12" md="6">
|
||||
<v-text-field
|
||||
v-model="location.label"
|
||||
label="Label"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" md="6">
|
||||
<v-text-field
|
||||
v-model="location.timeZone"
|
||||
label="Time Zone"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-textarea
|
||||
v-model="location.description"
|
||||
label="Description"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
rows="2"
|
||||
/>
|
||||
|
||||
<v-select
|
||||
v-model="location.relation"
|
||||
:items="['start', 'end']"
|
||||
label="Relation"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
clearable
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Virtual Locations -->
|
||||
<div v-if="Object.keys(locationsVirtual || {}).length > 0 || mode === 'edit'">
|
||||
<div class="d-flex align-center justify-space-between mb-2">
|
||||
<div class="text-subtitle-2">Virtual Locations</div>
|
||||
<v-btn v-if="mode === 'edit'"
|
||||
size="small"
|
||||
variant="outlined"
|
||||
@click="$emit('add-location-virtual')">
|
||||
<v-icon left>mdi-plus</v-icon>
|
||||
Add
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<!-- Read-only view -->
|
||||
<div v-if="mode === 'view'">
|
||||
<div v-for="(location, key) in locationsVirtual" :key="key" class="mb-2 pa-2 border rounded">
|
||||
<div class="mb-1"><strong>{{ location.label || 'Virtual Location' }}</strong></div>
|
||||
<div class="text-caption">
|
||||
<a :href="location.location" target="_blank" class="text-primary">{{ location.location }}</a>
|
||||
</div>
|
||||
<div v-if="location.description" class="text-caption text-grey mt-1">{{ location.description }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit view -->
|
||||
<div v-else>
|
||||
<div v-for="(location, key) in locationsVirtual" :key="key" class="mb-3 pa-3 border rounded">
|
||||
<div class="d-flex align-center justify-space-between mb-2">
|
||||
<strong>{{ location.label || 'Virtual Location' }}</strong>
|
||||
<v-btn
|
||||
icon="mdi-delete"
|
||||
size="small"
|
||||
color="error"
|
||||
variant="text"
|
||||
@click="$emit('remove-location-virtual', key)">
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<v-text-field
|
||||
v-model="location.label"
|
||||
label="Label"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
class="mb-2"
|
||||
/>
|
||||
|
||||
<v-text-field
|
||||
v-model="location.location"
|
||||
label="URL"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
class="mb-2"
|
||||
:rules="[v => !!v || 'URL is required']"
|
||||
/>
|
||||
|
||||
<v-textarea
|
||||
v-model="location.description"
|
||||
label="Description"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
rows="2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.event-editor-section {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
</style>
|
||||
149
src/components/editors/EventEditorNotifications.vue
Normal file
149
src/components/editors/EventEditorNotifications.vue
Normal file
@@ -0,0 +1,149 @@
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
mode: 'edit' | 'view'
|
||||
notifications: Record<string, any>
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'add-notification': []
|
||||
'remove-notification': [key: string]
|
||||
}>()
|
||||
|
||||
const typeOptions = [
|
||||
{ title: 'Email', value: 'email' },
|
||||
{ title: 'Visual (Display)', value: 'visual' },
|
||||
{ title: 'Audible (Sound)', value: 'audible' }
|
||||
]
|
||||
|
||||
const patternOptions = [
|
||||
{ title: 'Absolute (Specific Time)', value: 'absolute' },
|
||||
{ title: 'Relative (Before Event)', value: 'relative' },
|
||||
{ title: 'Unknown', value: 'unknown' }
|
||||
]
|
||||
|
||||
const anchorOptions = [
|
||||
{ title: 'Start Time', value: 'start' },
|
||||
{ title: 'End Time', value: 'end' }
|
||||
]
|
||||
|
||||
const formatNotification = (notification: any) => {
|
||||
if (notification.pattern === 'absolute' && notification.when) {
|
||||
return `At ${new Date(notification.when).toLocaleString()}`
|
||||
} else if (notification.pattern === 'relative' && notification.offset) {
|
||||
const anchor = notification.anchor === 'end' ? 'end' : 'start'
|
||||
return `${notification.offset} before ${anchor}`
|
||||
}
|
||||
return 'Custom notification'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="Object.keys(notifications || {}).length > 0 || mode === 'edit'" class="event-editor-section">
|
||||
<div class="d-flex align-center justify-space-between mb-2">
|
||||
<div class="text-subtitle-1">Notifications</div>
|
||||
<v-btn v-if="mode === 'edit'"
|
||||
size="small"
|
||||
variant="outlined"
|
||||
@click="$emit('add-notification')">
|
||||
<v-icon left>mdi-plus</v-icon>
|
||||
Add Notification
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<!-- Read-only view -->
|
||||
<div v-if="mode === 'view'">
|
||||
<div v-for="(notification, key) in notifications" :key="key" class="mb-2 pa-2 border rounded">
|
||||
<div class="d-flex align-center justify-space-between">
|
||||
<div>
|
||||
<div class="mb-1">
|
||||
<v-icon :icon="notification.type === 'email' ? 'mdi-email' : notification.type === 'audible' ? 'mdi-bell-ring' : 'mdi-bell'" size="small" class="mr-2" />
|
||||
<strong>{{ notification.type || 'Notification' }}</strong>
|
||||
</div>
|
||||
<div class="text-caption text-grey">{{ formatNotification(notification) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit view -->
|
||||
<div v-else>
|
||||
<div v-for="(notification, key) in notifications" :key="key" class="mb-3 pa-3 border rounded">
|
||||
<div class="d-flex align-center justify-space-between mb-2">
|
||||
<strong>Notification</strong>
|
||||
<v-btn
|
||||
icon="mdi-delete"
|
||||
size="small"
|
||||
color="error"
|
||||
variant="text"
|
||||
@click="$emit('remove-notification', key)">
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<v-row dense class="mb-2">
|
||||
<v-col cols="12" md="6">
|
||||
<v-select
|
||||
v-model="notification.type"
|
||||
:items="typeOptions"
|
||||
label="Type"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
:rules="[v => !!v || 'Type is required']"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" md="6">
|
||||
<v-select
|
||||
v-model="notification.pattern"
|
||||
:items="patternOptions"
|
||||
label="Pattern"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
:rules="[v => !!v || 'Pattern is required']"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<div v-if="notification.pattern === 'absolute'">
|
||||
<v-text-field
|
||||
v-model="notification.when"
|
||||
label="When (ISO Date)"
|
||||
type="datetime-local"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hint="Specific date and time for notification"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-else-if="notification.pattern === 'relative'">
|
||||
<v-row dense>
|
||||
<v-col cols="12" md="6">
|
||||
<v-text-field
|
||||
v-model="notification.offset"
|
||||
label="Offset (e.g., PT15M)"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hint="ISO 8601 duration before event"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" md="6">
|
||||
<v-select
|
||||
v-model="notification.anchor"
|
||||
:items="anchorOptions"
|
||||
label="Anchor"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.event-editor-section {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
</style>
|
||||
143
src/components/editors/EventEditorOccurrence.vue
Normal file
143
src/components/editors/EventEditorOccurrence.vue
Normal file
@@ -0,0 +1,143 @@
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
mode: 'edit' | 'view'
|
||||
pattern: any | null
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:pattern': [pattern: any | null]
|
||||
}>()
|
||||
|
||||
const precisionOptions = [
|
||||
{ title: 'Yearly', value: 'yearly' },
|
||||
{ title: 'Monthly', value: 'monthly' },
|
||||
{ title: 'Weekly', value: 'weekly' },
|
||||
{ title: 'Daily', value: 'daily' },
|
||||
{ title: 'Hourly', value: 'hourly' },
|
||||
{ title: 'Minutely', value: 'minutely' },
|
||||
{ title: 'Secondly', value: 'secondly' }
|
||||
]
|
||||
|
||||
const patternTypeOptions = [
|
||||
{ title: 'Absolute', value: 'absolute' },
|
||||
{ title: 'Relative', value: 'relative' }
|
||||
]
|
||||
|
||||
const formatRecurrence = (pattern: any) => {
|
||||
if (!pattern) return 'No recurrence'
|
||||
|
||||
const parts = []
|
||||
parts.push(`Every ${pattern.interval || 1}`)
|
||||
parts.push(pattern.precision || 'day')
|
||||
|
||||
if (pattern.iterations) {
|
||||
parts.push(`(${pattern.iterations} times)`)
|
||||
} else if (pattern.concludes) {
|
||||
parts.push(`until ${new Date(pattern.concludes).toLocaleDateString()}`)
|
||||
}
|
||||
|
||||
return parts.join(' ')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="pattern || mode === 'edit'" class="event-editor-section">
|
||||
<div class="d-flex align-center justify-space-between mb-2">
|
||||
<div class="text-subtitle-1">Recurrence</div>
|
||||
<v-btn v-if="mode === 'edit' && !pattern"
|
||||
size="small"
|
||||
variant="outlined"
|
||||
@click="$emit('update:pattern', { pattern: 'absolute', precision: 'daily', interval: 1 })">
|
||||
<v-icon left>mdi-plus</v-icon>
|
||||
Add Recurrence
|
||||
</v-btn>
|
||||
<v-btn v-else-if="mode === 'edit' && pattern"
|
||||
size="small"
|
||||
variant="text"
|
||||
color="error"
|
||||
@click="$emit('update:pattern', null)">
|
||||
Remove
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<!-- Read-only view -->
|
||||
<div v-if="mode === 'view' && pattern" class="mb-4">
|
||||
<div class="pa-2 border rounded">
|
||||
<div class="mb-1"><strong>{{ formatRecurrence(pattern) }}</strong></div>
|
||||
<div v-if="pattern.onDayOfWeek && pattern.onDayOfWeek.length > 0" class="text-caption text-grey">
|
||||
Days: {{ pattern.onDayOfWeek.join(', ') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit view -->
|
||||
<div v-else-if="mode === 'edit' && pattern" class="pa-3 border rounded">
|
||||
<v-row dense class="mb-2">
|
||||
<v-col cols="12" md="6">
|
||||
<v-select
|
||||
v-model="pattern.pattern"
|
||||
:items="patternTypeOptions"
|
||||
label="Pattern Type"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" md="6">
|
||||
<v-select
|
||||
v-model="pattern.precision"
|
||||
:items="precisionOptions"
|
||||
label="Frequency"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-row dense class="mb-2">
|
||||
<v-col cols="12" md="4">
|
||||
<v-text-field
|
||||
v-model.number="pattern.interval"
|
||||
label="Interval"
|
||||
type="number"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
:rules="[v => v > 0 || 'Must be greater than 0']"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" md="4">
|
||||
<v-text-field
|
||||
v-model.number="pattern.iterations"
|
||||
label="Iterations"
|
||||
type="number"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hint="Leave empty for infinite"
|
||||
clearable
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" md="4">
|
||||
<v-text-field
|
||||
v-model="pattern.concludes"
|
||||
label="End Date"
|
||||
type="date"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
clearable
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<div class="text-caption text-grey mt-2">
|
||||
Advanced recurrence rules (like specific days of week) can be added to the pattern object
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.event-editor-section {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
</style>
|
||||
206
src/components/editors/EventEditorParticipants.vue
Normal file
206
src/components/editors/EventEditorParticipants.vue
Normal file
@@ -0,0 +1,206 @@
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
mode: 'edit' | 'view'
|
||||
organizer: any | null
|
||||
participants: Record<string, any>
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:organizer': [organizer: any | null]
|
||||
'add-participant': []
|
||||
'remove-participant': [key: string]
|
||||
}>()
|
||||
|
||||
const roleOptions = [
|
||||
{ title: 'Owner', value: 'owner' },
|
||||
{ title: 'Chair', value: 'chair' },
|
||||
{ title: 'Attendee', value: 'attendee' },
|
||||
{ title: 'Optional', value: 'optional' },
|
||||
{ title: 'Informational', value: 'informational' },
|
||||
{ title: 'Contact', value: 'contact' }
|
||||
]
|
||||
|
||||
const statusOptions = [
|
||||
{ title: 'None', value: 'none' },
|
||||
{ title: 'Accepted', value: 'accepted' },
|
||||
{ title: 'Declined', value: 'declined' },
|
||||
{ title: 'Tentative', value: 'tentative' },
|
||||
{ title: 'Delegated', value: 'delegated' }
|
||||
]
|
||||
|
||||
const typeOptions = [
|
||||
{ title: 'Unknown', value: 'unknown' },
|
||||
{ title: 'Individual', value: 'individual' },
|
||||
{ title: 'Group', value: 'group' },
|
||||
{ title: 'Resource', value: 'resource' },
|
||||
{ title: 'Location', value: 'location' }
|
||||
]
|
||||
|
||||
const hasParticipants = () => {
|
||||
return props.organizer || Object.keys(props.participants || {}).length > 0
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="hasParticipants() || mode === 'edit'" class="event-editor-section">
|
||||
<div class="text-subtitle-1 mb-2">Participants</div>
|
||||
|
||||
<!-- Organizer -->
|
||||
<div v-if="organizer || mode === 'edit'" class="mb-4">
|
||||
<div class="text-subtitle-2 mb-2">Organizer</div>
|
||||
|
||||
<!-- Read-only view -->
|
||||
<div v-if="mode === 'view' && organizer" class="pa-2 border rounded">
|
||||
<div class="mb-1"><strong>{{ organizer.name || organizer.address }}</strong></div>
|
||||
<div class="text-caption text-grey">{{ organizer.address }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit view -->
|
||||
<div v-else-if="mode === 'edit'" class="pa-3 border rounded">
|
||||
<v-row dense>
|
||||
<v-col cols="12" md="6">
|
||||
<v-text-field
|
||||
v-model="organizer.name"
|
||||
label="Name"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" md="6">
|
||||
<v-text-field
|
||||
v-model="organizer.address"
|
||||
label="Email/Address"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
:rules="[v => !!v || 'Address is required']"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Participants List -->
|
||||
<div v-if="Object.keys(participants || {}).length > 0 || mode === 'edit'">
|
||||
<div class="d-flex align-center justify-space-between mb-2">
|
||||
<div class="text-subtitle-2">Attendees</div>
|
||||
<v-btn v-if="mode === 'edit'"
|
||||
size="small"
|
||||
variant="outlined"
|
||||
@click="$emit('add-participant')">
|
||||
<v-icon left>mdi-plus</v-icon>
|
||||
Add Participant
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<!-- Read-only view -->
|
||||
<div v-if="mode === 'view'">
|
||||
<div v-for="(participant, key) in participants" :key="key" class="mb-2 pa-2 border rounded">
|
||||
<div class="d-flex align-center justify-space-between">
|
||||
<div>
|
||||
<div class="mb-1"><strong>{{ participant.name || participant.address }}</strong></div>
|
||||
<div class="text-caption text-grey">
|
||||
{{ participant.address }}
|
||||
<span v-if="participant.type"> | {{ participant.type }}</span>
|
||||
</div>
|
||||
<div v-if="participant.roles && participant.roles.length > 0" class="mt-1">
|
||||
<v-chip
|
||||
v-for="role in participant.roles"
|
||||
:key="role"
|
||||
size="x-small"
|
||||
class="mr-1"
|
||||
variant="tonal"
|
||||
>
|
||||
{{ role }}
|
||||
</v-chip>
|
||||
</div>
|
||||
</div>
|
||||
<v-chip
|
||||
v-if="participant.status && participant.status !== 'none'"
|
||||
:color="participant.status === 'accepted' ? 'success' : participant.status === 'declined' ? 'error' : 'warning'"
|
||||
size="small"
|
||||
variant="tonal"
|
||||
>
|
||||
{{ participant.status }}
|
||||
</v-chip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit view -->
|
||||
<div v-else>
|
||||
<div v-for="(participant, key) in participants" :key="key" class="mb-3 pa-3 border rounded">
|
||||
<div class="d-flex align-center justify-space-between mb-2">
|
||||
<strong>{{ participant.name || 'Participant' }}</strong>
|
||||
<v-btn
|
||||
icon="mdi-delete"
|
||||
size="small"
|
||||
color="error"
|
||||
variant="text"
|
||||
@click="$emit('remove-participant', key)">
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<v-row dense class="mb-2">
|
||||
<v-col cols="12" md="6">
|
||||
<v-text-field
|
||||
v-model="participant.name"
|
||||
label="Name"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" md="6">
|
||||
<v-text-field
|
||||
v-model="participant.address"
|
||||
label="Email/Address"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
:rules="[v => !!v || 'Address is required']"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-row dense class="mb-2">
|
||||
<v-col cols="12" md="6">
|
||||
<v-select
|
||||
v-model="participant.type"
|
||||
:items="typeOptions"
|
||||
label="Type"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" md="6">
|
||||
<v-select
|
||||
v-model="participant.status"
|
||||
:items="statusOptions"
|
||||
label="Status"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-select
|
||||
v-model="participant.roles"
|
||||
:items="roleOptions"
|
||||
label="Roles"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
multiple
|
||||
chips
|
||||
closable-chips
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.event-editor-section {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
</style>
|
||||
61
src/components/editors/EventEditorTags.vue
Normal file
61
src/components/editors/EventEditorTags.vue
Normal file
@@ -0,0 +1,61 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
interface Props {
|
||||
mode: 'edit' | 'view'
|
||||
tags?: string[]
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:tags': [tags: string[]]
|
||||
}>()
|
||||
|
||||
const tagsValue = computed({
|
||||
get: () => props.tags ?? [],
|
||||
set: (value) => emit('update:tags', value)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="tagsValue.length > 0 || mode === 'edit'" class="event-editor-section">
|
||||
<div class="text-subtitle-1 mb-2">Tags</div>
|
||||
|
||||
<!-- Read-only view -->
|
||||
<div v-if="mode === 'view'" class="mb-4">
|
||||
<div v-if="tagsValue.length > 0" class="pa-2">
|
||||
<v-chip
|
||||
v-for="(tag, index) in tagsValue"
|
||||
:key="index"
|
||||
size="small"
|
||||
class="mr-1 mb-1"
|
||||
variant="tonal"
|
||||
>
|
||||
{{ tag }}
|
||||
</v-chip>
|
||||
</div>
|
||||
<div v-else class="pa-2 text-grey">No tags</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit view -->
|
||||
<div v-else>
|
||||
<v-combobox
|
||||
v-model="tagsValue"
|
||||
label="Tags"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
multiple
|
||||
chips
|
||||
closable-chips
|
||||
hint="Press Enter to add tags"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.event-editor-section {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
</style>
|
||||
34
src/integrations.ts
Normal file
34
src/integrations.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { ModuleIntegrations } from "@KTXC/types/moduleTypes";
|
||||
|
||||
const integrations: ModuleIntegrations = {
|
||||
// Main application menu items
|
||||
app_menu: [
|
||||
{
|
||||
id: 'chrono-calendar',
|
||||
label: 'Calendar',
|
||||
path: '/calendar',
|
||||
icon: 'mdi-calendar',
|
||||
priority: 10,
|
||||
},
|
||||
{
|
||||
id: 'chrono-tasks',
|
||||
label: 'Tasks',
|
||||
path: '/tasks',
|
||||
icon: 'mdi-checkbox-marked-outline',
|
||||
priority: 20,
|
||||
},
|
||||
],
|
||||
|
||||
// Settings tab for chrono configuration
|
||||
// settings_tabs: [
|
||||
// {
|
||||
// id: 'chrono-settings',
|
||||
// label: 'Chrono Settings',
|
||||
// icon: 'mdi-clock-outline',
|
||||
// priority: 50,
|
||||
// component: () => import('@/views/ChronoSettings.vue'),
|
||||
// },
|
||||
// ],
|
||||
};
|
||||
|
||||
export default integrations;
|
||||
8
src/main.ts
Normal file
8
src/main.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import routes from '@/routes'
|
||||
import integrations from '@/integrations'
|
||||
|
||||
// CSS filename is injected by the vite plugin at build time
|
||||
export const css = ['__CSS_FILENAME_PLACEHOLDER__']
|
||||
|
||||
export { routes, integrations }
|
||||
|
||||
641
src/pages/ChronoPage.vue
Normal file
641
src/pages/ChronoPage.vue
Normal file
@@ -0,0 +1,641 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useDisplay } from 'vuetify';
|
||||
import { useModuleStore } from '@KTXC/stores/moduleStore';
|
||||
import { useCollectionsStore } from '@ChronoManager/stores/collectionsStore';
|
||||
import { useEntitiesStore } from '@ChronoManager/stores/entitiesStore';
|
||||
import { useServicesStore } from '@ChronoManager/stores/servicesStore';
|
||||
import { CollectionObject } from '@ChronoManager/models/collection';
|
||||
import { EntityObject } from '@ChronoManager/models/entity';
|
||||
import { ServiceObject } from '@ChronoManager/models/service';
|
||||
import type { CalendarView as CalendarViewType } from '@/types';
|
||||
import CollectionList from '@/components/CollectionList.vue';
|
||||
import CollectionEditor from '@/components/CollectionEditor.vue';
|
||||
import CalendarView from '@/components/CalendarView.vue';
|
||||
import TaskView from '@/components/TaskView.vue';
|
||||
import EventEditor from '@/components/EventEditor.vue';
|
||||
import TaskEditor from '@/components/TaskEditor.vue';
|
||||
import MiniCalendar from '@/components/MiniCalendar.vue';
|
||||
|
||||
// Vuetify display
|
||||
const display = useDisplay();
|
||||
|
||||
// Check if chrono manager is available
|
||||
const moduleStore = useModuleStore();
|
||||
const isChronoManagerAvailable = computed(() => {
|
||||
return moduleStore.has('chrono_manager') || moduleStore.has('ChronoManager')
|
||||
});
|
||||
|
||||
// Stores
|
||||
const collectionsStore = useCollectionsStore();
|
||||
const entitiesStore = useEntitiesStore();
|
||||
const servicesStore = useServicesStore();
|
||||
|
||||
// View state
|
||||
const viewMode = ref<'calendar' | 'tasks'>('calendar');
|
||||
const calendarView = ref<CalendarViewType>('days');
|
||||
const currentDate = ref(new Date());
|
||||
const sidebarVisible = ref(true);
|
||||
|
||||
// Data - using manager objects directly
|
||||
const collections = ref<CollectionObject[]>([]);
|
||||
const entities = ref<EntityObject[]>([]);
|
||||
const selectedCollection = ref<CollectionObject | null>(null);
|
||||
|
||||
// Computed - filter collections and entities
|
||||
const calendars = computed(() => {
|
||||
return collections.value.filter(col => col.contents?.event);
|
||||
});
|
||||
|
||||
const taskLists = computed(() => {
|
||||
return collections.value.filter(col => col.contents?.task);
|
||||
});
|
||||
|
||||
const events = computed(() => {
|
||||
return entities.value.filter(entity => entity.data && (entity.data as any).type === 'event');
|
||||
});
|
||||
|
||||
const tasks = computed(() => {
|
||||
return entities.value.filter(entity => entity.data && (entity.data as any).type === 'task');
|
||||
});
|
||||
|
||||
// Dialog state
|
||||
const showEventEditor = ref(false);
|
||||
const showTaskEditor = ref(false);
|
||||
const showCollectionEditor = ref(false);
|
||||
const selectedEntity = ref<EntityObject | null>(null);
|
||||
const entityEditorMode = ref<'edit' | 'view'>('view');
|
||||
const editingCollection = ref<CollectionObject | null>(null);
|
||||
const collectionEditorMode = ref<'create' | 'edit'>('create');
|
||||
const collectionEditorType = ref<'calendar' | 'tasklist'>('calendar');
|
||||
|
||||
// Computed
|
||||
const isTaskView = computed(() => viewMode.value === 'tasks');
|
||||
|
||||
const filteredEvents = computed(() => {
|
||||
const visibleCalendarIds = calendars.value
|
||||
.filter(cal => cal.enabled !== false)
|
||||
.map(cal => cal.id);
|
||||
|
||||
return events.value.filter(event =>
|
||||
visibleCalendarIds.includes(event.in)
|
||||
);
|
||||
});
|
||||
|
||||
const filteredTasks = computed(() => {
|
||||
return tasks.value;
|
||||
});
|
||||
|
||||
// Methods
|
||||
function selectCalendar(calendar: CollectionObject) {
|
||||
selectedCollection.value = calendar;
|
||||
console.log('[Chrono] - Selected calendar:', calendar);
|
||||
}
|
||||
|
||||
function createCalendar() {
|
||||
editingCollection.value = collectionsStore.fresh();
|
||||
editingCollection.value.contents = { event: true };
|
||||
collectionEditorMode.value = 'create';
|
||||
collectionEditorType.value = 'calendar';
|
||||
showCollectionEditor.value = true;
|
||||
}
|
||||
|
||||
function editCalendar(collection: CollectionObject) {
|
||||
editingCollection.value = collection;
|
||||
collectionEditorMode.value = 'edit';
|
||||
collectionEditorType.value = 'calendar';
|
||||
showCollectionEditor.value = true;
|
||||
}
|
||||
|
||||
async function toggleCalendarVisibility(collection: CollectionObject) {
|
||||
try {
|
||||
await collectionsStore.modify(collection);
|
||||
console.log('[Chrono] - Toggled calendar visibility:', collection);
|
||||
} catch (error) {
|
||||
console.error('[Chrono] - Failed to toggle calendar visibility:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function selectTaskList(list: CollectionObject) {
|
||||
selectedCollection.value = list;
|
||||
console.log('[Chrono] - Selected task list:', list);
|
||||
}
|
||||
|
||||
function createTaskList() {
|
||||
editingCollection.value = collectionsStore.fresh();
|
||||
editingCollection.value.contents = { task: true };
|
||||
collectionEditorMode.value = 'create';
|
||||
collectionEditorType.value = 'tasklist';
|
||||
showCollectionEditor.value = true;
|
||||
}
|
||||
|
||||
function editTaskList(collection: CollectionObject) {
|
||||
editingCollection.value = collection;
|
||||
collectionEditorMode.value = 'edit';
|
||||
collectionEditorType.value = 'tasklist';
|
||||
showCollectionEditor.value = true;
|
||||
}
|
||||
|
||||
function createEvent() {
|
||||
// Select first calendar collection or use selected
|
||||
if (!selectedCollection.value && calendars.value.length > 0) {
|
||||
selectedCollection.value = calendars.value[0];
|
||||
}
|
||||
|
||||
if (!selectedCollection.value) {
|
||||
console.warn('[Chrono] - No calendar collection available');
|
||||
return;
|
||||
}
|
||||
|
||||
// Create fresh event entity
|
||||
selectedEntity.value = entitiesStore.fresh('event');
|
||||
selectedEntity.value.in = selectedCollection.value.id;
|
||||
entityEditorMode.value = 'edit';
|
||||
showEventEditor.value = true;
|
||||
}
|
||||
|
||||
function editEvent(entity: EntityObject) {
|
||||
selectedEntity.value = entity;
|
||||
entityEditorMode.value = 'view';
|
||||
showEventEditor.value = true;
|
||||
}
|
||||
|
||||
async function saveEvent(entity: EntityObject, collection?: CollectionObject | null) {
|
||||
try {
|
||||
if (!(collection instanceof CollectionObject)) {
|
||||
collection = selectedCollection.value;
|
||||
}
|
||||
if (!collection) {
|
||||
console.error('[Chrono] - No collection selected');
|
||||
return;
|
||||
}
|
||||
|
||||
if (entity.data) {
|
||||
entity.data.modified = new Date();
|
||||
}
|
||||
|
||||
if (entity.id === null) {
|
||||
entity.data.created = new Date();
|
||||
selectedEntity.value = await entitiesStore.create(collection, entity);
|
||||
} else {
|
||||
selectedEntity.value = await entitiesStore.modify(collection, entity);
|
||||
}
|
||||
|
||||
entityEditorMode.value = 'view';
|
||||
} catch (error) {
|
||||
console.error('[Chrono] - Failed to save event:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteEvent(entity: EntityObject, collection?: CollectionObject | null) {
|
||||
try {
|
||||
if (!(collection instanceof CollectionObject)) {
|
||||
collection = collections.value.find(c => c.id === entity.in);
|
||||
}
|
||||
if (!collection) {
|
||||
console.error('[Chrono] - No collection found');
|
||||
return;
|
||||
}
|
||||
|
||||
await entitiesStore.destroy(collection, entity);
|
||||
selectedEntity.value = null;
|
||||
entityEditorMode.value = 'view';
|
||||
showEventEditor.value = false;
|
||||
} catch (error) {
|
||||
console.error('[Chrono] - Failed to delete event:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function handleDateClick(date: Date) {
|
||||
// Select first calendar collection or use selected
|
||||
if (!selectedCollection.value && calendars.value.length > 0) {
|
||||
selectedCollection.value = calendars.value[0];
|
||||
}
|
||||
|
||||
if (!selectedCollection.value) {
|
||||
console.warn('[Chrono] - No calendar collection available');
|
||||
return;
|
||||
}
|
||||
|
||||
selectedEntity.value = entitiesStore.fresh('event');
|
||||
selectedEntity.value.in = selectedCollection.value.id;
|
||||
if (selectedEntity.value.data) {
|
||||
selectedEntity.value.data.startsOn = date;
|
||||
selectedEntity.value.data.endsOn = new Date(date.getTime() + 60 * 60 * 1000);
|
||||
}
|
||||
entityEditorMode.value = 'edit';
|
||||
showEventEditor.value = true;
|
||||
}
|
||||
|
||||
function handleEditorEdit() {
|
||||
console.log('[Chrono] - Editor editing started');
|
||||
entityEditorMode.value = 'edit';
|
||||
}
|
||||
|
||||
function handleEditorCancel() {
|
||||
console.log('[Chrono] - Editor editing cancelled');
|
||||
entityEditorMode.value = 'view';
|
||||
}
|
||||
|
||||
function handleEditorClose() {
|
||||
console.log('[Chrono] - Editor closed');
|
||||
selectedEntity.value = null;
|
||||
entityEditorMode.value = 'view';
|
||||
showEventEditor.value = false;
|
||||
showTaskEditor.value = false;
|
||||
}
|
||||
|
||||
function createTask() {
|
||||
// Select first task list collection or use selected
|
||||
if (!selectedCollection.value && taskLists.value.length > 0) {
|
||||
selectedCollection.value = taskLists.value[0];
|
||||
}
|
||||
|
||||
if (!selectedCollection.value) {
|
||||
console.warn('[Chrono] - No task list available');
|
||||
return;
|
||||
}
|
||||
|
||||
// Create fresh task entity
|
||||
selectedEntity.value = entitiesStore.fresh('task');
|
||||
selectedEntity.value.in = selectedCollection.value.id;
|
||||
entityEditorMode.value = 'edit';
|
||||
showTaskEditor.value = true;
|
||||
}
|
||||
|
||||
function editTask(entity: EntityObject) {
|
||||
selectedEntity.value = entity;
|
||||
entityEditorMode.value = 'view';
|
||||
showTaskEditor.value = true;
|
||||
}
|
||||
|
||||
async function saveTask(entity: EntityObject, collection?: CollectionObject | null) {
|
||||
try {
|
||||
if (!(collection instanceof CollectionObject)) {
|
||||
collection = selectedCollection.value;
|
||||
}
|
||||
if (!collection) {
|
||||
console.error('[Chrono] - No collection selected');
|
||||
return;
|
||||
}
|
||||
|
||||
if (entity.data) {
|
||||
entity.data.modified = new Date();
|
||||
}
|
||||
|
||||
if (entity.id === null) {
|
||||
entity.data.created = new Date();
|
||||
selectedEntity.value = await entitiesStore.create(collection, entity);
|
||||
} else {
|
||||
selectedEntity.value = await entitiesStore.modify(collection, entity);
|
||||
}
|
||||
|
||||
entityEditorMode.value = 'view';
|
||||
} catch (error) {
|
||||
console.error('[Chrono] - Failed to save task:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteTask(entity: EntityObject, collection?: CollectionObject | null) {
|
||||
try {
|
||||
if (!(collection instanceof CollectionObject)) {
|
||||
collection = collections.value.find(c => c.id === entity.in);
|
||||
}
|
||||
if (!collection) {
|
||||
console.error('[Chrono] - No collection found');
|
||||
return;
|
||||
}
|
||||
|
||||
await entitiesStore.destroy(collection, entity);
|
||||
selectedEntity.value = null;
|
||||
entityEditorMode.value = 'view';
|
||||
showTaskEditor.value = false;
|
||||
} catch (error) {
|
||||
console.error('[Chrono] - Failed to delete task:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleTaskComplete(taskId: string | number) {
|
||||
try {
|
||||
const entity = entities.value.find(e => e.id === taskId);
|
||||
if (!entity || !entity.data) return;
|
||||
|
||||
const collection = collections.value.find(c => c.id === entity.in);
|
||||
if (!collection) return;
|
||||
|
||||
const taskData = entity.data as any;
|
||||
const isCompleted = taskData.status === 'completed';
|
||||
|
||||
taskData.status = isCompleted ? 'needs-action' : 'completed';
|
||||
taskData.completedOn = isCompleted ? null : new Date();
|
||||
taskData.progress = isCompleted ? null : 100;
|
||||
taskData.modified = new Date();
|
||||
|
||||
await entitiesStore.modify(collection, entity);
|
||||
} catch (error) {
|
||||
console.error('[Chrono] - Failed to toggle task completion:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveCollection(collection: CollectionObject, service: ServiceObject) {
|
||||
try {
|
||||
if (collectionEditorMode.value === 'create') {
|
||||
await collectionsStore.create(service, collection);
|
||||
console.log('[Chrono] - Created collection:', collection);
|
||||
} else {
|
||||
await collectionsStore.modify(collection);
|
||||
console.log('[Chrono] - Modified collection:', collection);
|
||||
}
|
||||
// Reload collections
|
||||
collections.value = await collectionsStore.list();
|
||||
} catch (error) {
|
||||
console.error('[Chrono] - Failed to save collection:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteCollection(collection: CollectionObject) {
|
||||
try {
|
||||
await collectionsStore.destroy(collection);
|
||||
console.log('[Chrono] - Deleted collection:', collection);
|
||||
// Reload collections
|
||||
collections.value = await collectionsStore.list();
|
||||
if (selectedCollection.value?.id === collection.id) {
|
||||
selectedCollection.value = null;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Chrono] - Failed to delete collection:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize data from stores
|
||||
onMounted(async () => {
|
||||
try {
|
||||
// Load collections (calendars and task lists)
|
||||
collections.value = await collectionsStore.list();
|
||||
|
||||
// Load entities (events and tasks)
|
||||
entities.value = await entitiesStore.list(null, null, null);
|
||||
|
||||
console.log('[Chrono] - Loaded data from ChronoManager:', {
|
||||
collections: collections.value.length,
|
||||
calendars: calendars.value.length,
|
||||
events: events.value.length,
|
||||
tasks: tasks.value.length,
|
||||
taskLists: taskLists.value.length,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Chrono] - Failed to load data from ChronoManager:', error);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="chrono-container">
|
||||
<!-- Top Navigation Bar -->
|
||||
<v-app-bar elevation="0" class="chrono-toolbar border-b">
|
||||
<template #prepend>
|
||||
<v-btn
|
||||
icon="mdi-menu"
|
||||
variant="text"
|
||||
@click="sidebarVisible = !sidebarVisible"
|
||||
></v-btn>
|
||||
</template>
|
||||
|
||||
<v-app-bar-title class="d-flex align-center">
|
||||
<v-icon size="28" color="primary" class="mr-2">mdi-calendar-month</v-icon>
|
||||
<span class="text-h6 font-weight-bold">Chrono</span>
|
||||
</v-app-bar-title>
|
||||
|
||||
<v-spacer></v-spacer>
|
||||
|
||||
<!-- View Toggle -->
|
||||
<v-btn-toggle
|
||||
v-model="viewMode"
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
class="mr-2"
|
||||
>
|
||||
<v-btn value="calendar" size="small">
|
||||
<v-icon>mdi-calendar</v-icon>
|
||||
<span class="ml-1 d-none d-sm-inline">Calendar</span>
|
||||
</v-btn>
|
||||
<v-btn value="tasks" size="small">
|
||||
<v-icon>mdi-checkbox-marked-outline</v-icon>
|
||||
<span class="ml-1 d-none d-sm-inline">Tasks</span>
|
||||
</v-btn>
|
||||
</v-btn-toggle>
|
||||
|
||||
<!-- View Switcher for Calendar -->
|
||||
<v-btn-toggle
|
||||
v-if="!isTaskView"
|
||||
v-model="calendarView"
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
mandatory
|
||||
class="mr-2"
|
||||
>
|
||||
<v-btn value="days" size="small">Days</v-btn>
|
||||
<v-btn value="month" size="small">Month</v-btn>
|
||||
<v-btn value="agenda" size="small">Agenda</v-btn>
|
||||
</v-btn-toggle>
|
||||
</v-app-bar>
|
||||
|
||||
<!-- Main Content Area -->
|
||||
<div class="chrono-content">
|
||||
<!-- Sidebar Navigation Drawer -->
|
||||
<v-navigation-drawer
|
||||
v-model="sidebarVisible"
|
||||
:permanent="display.mdAndUp.value"
|
||||
:temporary="display.smAndDown.value"
|
||||
width="280"
|
||||
class="chrono-sidebar"
|
||||
>
|
||||
<div class="pa-4">
|
||||
<CollectionList
|
||||
v-if="!isTaskView"
|
||||
:selected-collection="selectedCollection"
|
||||
type="calendar"
|
||||
@select="selectCalendar"
|
||||
@edit="editCalendar"
|
||||
@toggle-visibility="toggleCalendarVisibility"
|
||||
/>
|
||||
<CollectionList
|
||||
v-else
|
||||
:selected-collection="selectedCollection"
|
||||
type="tasklist"
|
||||
@select="selectTaskList"
|
||||
@edit="editTaskList"
|
||||
/>
|
||||
|
||||
<v-btn
|
||||
v-if="!isTaskView"
|
||||
variant="tonal"
|
||||
color="primary"
|
||||
block
|
||||
class="mt-3"
|
||||
@click="createCalendar"
|
||||
>
|
||||
<v-icon start>mdi-plus</v-icon>
|
||||
New Calendar
|
||||
</v-btn>
|
||||
<v-btn
|
||||
v-else
|
||||
variant="tonal"
|
||||
color="primary"
|
||||
block
|
||||
class="mt-3"
|
||||
@click="createTaskList"
|
||||
>
|
||||
<v-icon start>mdi-plus</v-icon>
|
||||
New Task List
|
||||
</v-btn>
|
||||
|
||||
<!-- Mini Calendar Widget -->
|
||||
<v-card v-if="!isTaskView" class="mt-4" variant="outlined">
|
||||
<v-card-text class="pa-2">
|
||||
<MiniCalendar v-model="currentDate" />
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</div>
|
||||
</v-navigation-drawer>
|
||||
|
||||
<!-- Main Calendar/Task View -->
|
||||
<div class="chrono-main">
|
||||
<v-alert
|
||||
v-if="!isChronoManagerAvailable"
|
||||
type="warning"
|
||||
variant="tonal"
|
||||
closable
|
||||
class="mb-4"
|
||||
>
|
||||
<v-alert-title class="d-flex align-center">
|
||||
<v-icon icon="mdi-alert-circle" class="mr-2" />
|
||||
Chrono Manager Not Available
|
||||
</v-alert-title>
|
||||
<div class="mt-2">
|
||||
<p>
|
||||
The Chrono Manager module is not installed or enabled.
|
||||
This module requires the <strong>chrono_manager</strong> module to function properly.
|
||||
</p>
|
||||
<p class="mt-2 mb-0">
|
||||
Please contact your system administrator to install and enable the
|
||||
<code>chrono_manager</code> module.
|
||||
</p>
|
||||
</div>
|
||||
</v-alert>
|
||||
|
||||
<CalendarView
|
||||
v-if="!isTaskView"
|
||||
:view="calendarView"
|
||||
:current-date="currentDate"
|
||||
:events="filteredEvents"
|
||||
:calendars="calendars"
|
||||
@event-click="editEvent"
|
||||
@date-click="handleDateClick"
|
||||
/>
|
||||
|
||||
<TaskView
|
||||
v-else
|
||||
:tasks="filteredTasks"
|
||||
:lists="taskLists"
|
||||
@task-click="editTask"
|
||||
@toggle-complete="toggleTaskComplete"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Event Editor Dialog -->
|
||||
<v-dialog v-model="showEventEditor" max-width="800px" scrollable>
|
||||
<EventEditor
|
||||
v-if="showEventEditor"
|
||||
:mode="entityEditorMode"
|
||||
:entity="selectedEntity"
|
||||
:collection="selectedCollection"
|
||||
:calendars="calendars"
|
||||
@save="saveEvent"
|
||||
@delete="deleteEvent"
|
||||
@edit="handleEditorEdit"
|
||||
@cancel="handleEditorCancel"
|
||||
@close="handleEditorClose"
|
||||
/>
|
||||
</v-dialog>
|
||||
|
||||
<!-- Task Editor Dialog -->
|
||||
<v-dialog v-model="showTaskEditor" max-width="700px" scrollable>
|
||||
<TaskEditor
|
||||
v-if="showTaskEditor"
|
||||
:mode="entityEditorMode"
|
||||
:entity="selectedEntity"
|
||||
:collection="selectedCollection"
|
||||
:lists="taskLists"
|
||||
@save="saveTask"
|
||||
@delete="deleteTask"
|
||||
@edit="handleEditorEdit"
|
||||
@cancel="handleEditorCancel"
|
||||
@close="handleEditorClose"
|
||||
/>
|
||||
</v-dialog>
|
||||
|
||||
<!-- Collection Editor Dialog -->
|
||||
<CollectionEditor
|
||||
v-model="showCollectionEditor"
|
||||
:collection="editingCollection"
|
||||
:mode="collectionEditorMode"
|
||||
:type="collectionEditorType"
|
||||
@save="saveCollection"
|
||||
@delete="deleteCollection"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.chrono-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
isolation: isolate; /* Create stacking context to prevent style leakage */
|
||||
}
|
||||
|
||||
.chrono-toolbar {
|
||||
border-bottom: 1px solid rgb(var(--v-border-color)) !important;
|
||||
}
|
||||
|
||||
.chrono-content {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.chrono-sidebar {
|
||||
border-right: 1px solid rgb(var(--v-border-color)) !important;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.chrono-main {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.border-b {
|
||||
border-bottom: 1px solid rgb(var(--v-border-color));
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 960px) {
|
||||
.chrono-main {
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.chrono-main {
|
||||
padding: 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
19
src/routes.ts
Normal file
19
src/routes.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
const routes = [
|
||||
{
|
||||
name: 'index',
|
||||
path: '/',
|
||||
redirect: { name: 'calendar' }
|
||||
},
|
||||
{
|
||||
name: 'calendar',
|
||||
path: '/calendar',
|
||||
component: () => import('@/pages/ChronoPage.vue'),
|
||||
},
|
||||
{
|
||||
name: 'tasks',
|
||||
path: '/tasks',
|
||||
component: () => import('@/pages/ChronoPage.vue'),
|
||||
},
|
||||
]
|
||||
|
||||
export default routes
|
||||
201
src/style.css
Normal file
201
src/style.css
Normal file
@@ -0,0 +1,201 @@
|
||||
/* Chrono Module Styles - All scoped to .chrono-container and using theme colors */
|
||||
|
||||
/* Ensure grids display properly within Chrono module only */
|
||||
.chrono-container .calendar-grid,
|
||||
.chrono-container .mini-calendar {
|
||||
display: grid !important;
|
||||
}
|
||||
|
||||
.chrono-container .calendar-grid {
|
||||
grid-template-columns: repeat(7, 1fr) !important;
|
||||
grid-template-rows: auto repeat(6, 1fr) !important;
|
||||
}
|
||||
|
||||
.chrono-container .mini-calendar {
|
||||
grid-template-columns: repeat(7, 1fr) !important;
|
||||
}
|
||||
|
||||
.chrono-container .week-days,
|
||||
.chrono-container .week-view,
|
||||
.chrono-container .day-view {
|
||||
display: flex !important;
|
||||
flex-direction: row !important;
|
||||
}
|
||||
|
||||
.chrono-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.chrono-container .chrono-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid rgb(var(--v-border-color));
|
||||
}
|
||||
|
||||
.chrono-container .chrono-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chrono-container .chrono-sidebar {
|
||||
width: 280px;
|
||||
border-right: 1px solid rgb(var(--v-border-color));
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.chrono-container .chrono-main {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
/* Calendar specific styles - scoped to chrono-container */
|
||||
.chrono-container .calendar-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
gap: 1px;
|
||||
background-color: rgb(var(--v-border-color));
|
||||
border: 1px solid rgb(var(--v-border-color));
|
||||
}
|
||||
|
||||
.chrono-container .calendar-cell {
|
||||
background-color: rgb(var(--v-theme-surface));
|
||||
min-height: 100px;
|
||||
padding: 8px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.chrono-container .calendar-cell:hover {
|
||||
background-color: rgb(var(--v-theme-surface-variant));
|
||||
}
|
||||
|
||||
.chrono-container .calendar-cell.today {
|
||||
background-color: rgb(var(--v-theme-primary), 0.08);
|
||||
}
|
||||
|
||||
.chrono-container .calendar-event {
|
||||
padding: 2px 6px;
|
||||
margin: 2px 0;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Task specific styles - scoped to chrono-container */
|
||||
.chrono-container .task-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid rgb(var(--v-border-color));
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.chrono-container .task-item:hover {
|
||||
background-color: rgb(var(--v-theme-surface-variant));
|
||||
}
|
||||
|
||||
.chrono-container .task-item.completed {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.chrono-container .task-checkbox {
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.chrono-container .task-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.chrono-container .task-title {
|
||||
font-weight: 500;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.chrono-container .task-title.completed {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.chrono-container .task-meta {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
font-size: 12px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.chrono-container .priority-badge {
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.chrono-container .priority-high {
|
||||
background-color: rgb(var(--v-theme-error), 0.1);
|
||||
color: rgb(var(--v-theme-error));
|
||||
}
|
||||
|
||||
.chrono-container .priority-medium {
|
||||
background-color: rgb(var(--v-theme-warning), 0.1);
|
||||
color: rgb(var(--v-theme-warning));
|
||||
}
|
||||
|
||||
.chrono-container .priority-low {
|
||||
background-color: rgb(var(--v-theme-success), 0.1);
|
||||
color: rgb(var(--v-theme-success));
|
||||
}
|
||||
|
||||
/* Event Viewer Popup - applied globally since popup is rendered at fixed position */
|
||||
.event-popup {
|
||||
position: fixed;
|
||||
z-index: 9999;
|
||||
pointer-events: auto;
|
||||
transform: translate(-50%, -100%);
|
||||
margin-top: -10px;
|
||||
width: 320px;
|
||||
min-height: 150px;
|
||||
}
|
||||
|
||||
.event-popup-card {
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.event-popup-card:hover {
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.event-popup-details {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.event-popup .text-truncate {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 250px;
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.event-popup .border-t {
|
||||
border-top: 1px solid rgba(var(--v-border-color), 0.5);
|
||||
}
|
||||
15
src/types/index.ts
Normal file
15
src/types/index.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Chrono Module Types
|
||||
*
|
||||
* Local UI types for the Chrono module
|
||||
*/
|
||||
|
||||
// Local UI-specific types
|
||||
export type CalendarView = 'days' | 'month' | 'agenda';
|
||||
|
||||
export interface ViewState {
|
||||
currentView: CalendarView;
|
||||
currentDate: Date;
|
||||
selectedCalendars: string[];
|
||||
showTasks: boolean;
|
||||
}
|
||||
193
src/utils/calendarHelpers.ts
Normal file
193
src/utils/calendarHelpers.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* Calendar Helper Utilities
|
||||
* Shared logic for multi-day event handling across calendar views
|
||||
*/
|
||||
|
||||
export interface MultiDaySegment {
|
||||
entity: any;
|
||||
startCol: number;
|
||||
span: number;
|
||||
lane: number;
|
||||
isStart: boolean;
|
||||
isEnd: boolean;
|
||||
}
|
||||
|
||||
export interface LaneAssignment {
|
||||
segments: MultiDaySegment[];
|
||||
laneCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the start of day (midnight) for a given date
|
||||
*/
|
||||
export function startOfDay(date: Date): Date {
|
||||
const d = new Date(date);
|
||||
d.setHours(0, 0, 0, 0);
|
||||
return d;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the difference in days between two dates
|
||||
*/
|
||||
export function daysDiff(a: Date, b: Date): number {
|
||||
const msPerDay = 24 * 60 * 60 * 1000;
|
||||
return Math.round((startOfDay(b).getTime() - startOfDay(a).getTime()) / msPerDay);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an event spans multiple days
|
||||
*/
|
||||
export function isMultiDay(entity: any): boolean {
|
||||
if (!entity.data?.startsOn) return false;
|
||||
const start = startOfDay(new Date(entity.data.startsOn));
|
||||
const end = entity.data?.endsOn ? startOfDay(new Date(entity.data.endsOn)) : start;
|
||||
return daysDiff(start, end) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an event is an all-day event (no specific time, or spans full day)
|
||||
*/
|
||||
export function isAllDay(entity: any): boolean {
|
||||
if (!entity.data?.startsOn) return false;
|
||||
|
||||
// Explicit all-day flag
|
||||
if (entity.data?.allDay === true) return true;
|
||||
|
||||
// Multi-day events are treated as all-day in the days view
|
||||
if (isMultiDay(entity)) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an event overlaps with a date range
|
||||
*/
|
||||
export function eventOverlapsRange(entity: any, rangeStart: Date, rangeEnd: Date): boolean {
|
||||
if (!entity.data?.startsOn) return false;
|
||||
const eventStart = startOfDay(new Date(entity.data.startsOn));
|
||||
const eventEnd = entity.data?.endsOn ? startOfDay(new Date(entity.data.endsOn)) : eventStart;
|
||||
return eventStart <= rangeEnd && eventEnd >= rangeStart;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an event occurs on a specific date
|
||||
*/
|
||||
export function eventOnDate(entity: any, date: Date): boolean {
|
||||
if (!entity.data?.startsOn) return false;
|
||||
const eventStart = startOfDay(new Date(entity.data.startsOn));
|
||||
const eventEnd = entity.data?.endsOn ? startOfDay(new Date(entity.data.endsOn)) : eventStart;
|
||||
const targetDate = startOfDay(date);
|
||||
return eventStart <= targetDate && eventEnd >= targetDate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get multi-day event segments for a given date range with lane assignments
|
||||
* Used for rendering spanning events across columns
|
||||
*/
|
||||
export function getMultiDaySegments(
|
||||
events: any[],
|
||||
rangeStart: Date,
|
||||
rangeEnd: Date,
|
||||
columnCount: number,
|
||||
getColumnIndex: (date: Date) => number
|
||||
): LaneAssignment {
|
||||
const segments: MultiDaySegment[] = [];
|
||||
|
||||
// Filter multi-day/all-day events that overlap this range
|
||||
const multiDayEvents = events.filter(entity => {
|
||||
if (!entity.data?.startsOn) return false;
|
||||
if (!isAllDay(entity)) return false;
|
||||
return eventOverlapsRange(entity, rangeStart, rangeEnd);
|
||||
});
|
||||
|
||||
// Sort: longer events first, then by start date
|
||||
multiDayEvents.sort((a, b) => {
|
||||
const aStart = new Date(a.data.startsOn);
|
||||
const aEnd = new Date(a.data.endsOn || a.data.startsOn);
|
||||
const bStart = new Date(b.data.startsOn);
|
||||
const bEnd = new Date(b.data.endsOn || b.data.startsOn);
|
||||
const aDuration = daysDiff(aStart, aEnd);
|
||||
const bDuration = daysDiff(bStart, bEnd);
|
||||
if (bDuration !== aDuration) return bDuration - aDuration;
|
||||
return aStart.getTime() - bStart.getTime();
|
||||
});
|
||||
|
||||
// Lane assignment - track which days are occupied in each lane
|
||||
const lanes: boolean[][] = [];
|
||||
|
||||
for (const entity of multiDayEvents) {
|
||||
const eventStart = startOfDay(new Date(entity.data.startsOn));
|
||||
const eventEnd = entity.data?.endsOn ? startOfDay(new Date(entity.data.endsOn)) : eventStart;
|
||||
|
||||
// Clamp to visible range
|
||||
const segStart = eventStart < rangeStart ? rangeStart : eventStart;
|
||||
const segEnd = eventEnd > rangeEnd ? rangeEnd : eventEnd;
|
||||
|
||||
const startCol = getColumnIndex(segStart);
|
||||
const endCol = getColumnIndex(segEnd);
|
||||
|
||||
// Skip if outside visible columns
|
||||
if (startCol < 0 || startCol >= columnCount) continue;
|
||||
|
||||
const span = Math.min(endCol, columnCount - 1) - startCol + 1;
|
||||
|
||||
// Find available lane
|
||||
let lane = 0;
|
||||
while (true) {
|
||||
if (!lanes[lane]) {
|
||||
lanes[lane] = new Array(columnCount).fill(false);
|
||||
}
|
||||
|
||||
let isFree = true;
|
||||
for (let d = startCol; d < startCol + span; d++) {
|
||||
if (lanes[lane][d]) {
|
||||
isFree = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (isFree) {
|
||||
for (let d = startCol; d < startCol + span; d++) {
|
||||
lanes[lane][d] = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
lane++;
|
||||
}
|
||||
|
||||
segments.push({
|
||||
entity,
|
||||
startCol,
|
||||
span,
|
||||
lane,
|
||||
isStart: eventStart >= rangeStart,
|
||||
isEnd: eventEnd <= rangeEnd,
|
||||
});
|
||||
}
|
||||
|
||||
return { segments, laneCount: lanes.length };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get timed (non-all-day) events for a specific date
|
||||
*/
|
||||
export function getTimedEventsForDate(events: any[], date: Date): any[] {
|
||||
return events.filter(entity => {
|
||||
if (!entity.data?.startsOn) return false;
|
||||
if (isAllDay(entity)) return false;
|
||||
const eventDate = startOfDay(new Date(entity.data.startsOn));
|
||||
return eventDate.toDateString() === date.toDateString();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get single-day events for a specific date (used in month view)
|
||||
*/
|
||||
export function getSingleDayEventsForDate(events: any[], date: Date): any[] {
|
||||
return events.filter(entity => {
|
||||
if (!entity.data?.startsOn) return false;
|
||||
if (isMultiDay(entity)) return false;
|
||||
const eventDate = startOfDay(new Date(entity.data.startsOn));
|
||||
return eventDate.toDateString() === date.toDateString();
|
||||
});
|
||||
}
|
||||
1
src/vite-env.d.ts
vendored
Normal file
1
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
32
tsconfig.app.json
Normal file
32
tsconfig.app.json
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "preserve",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true,
|
||||
"paths": {
|
||||
"@/*": ["./src/*"],
|
||||
"@KTXC/*": ["../../core/src/*"],
|
||||
"@ChronoManager/*": ["../chrono_manager/src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
|
||||
}
|
||||
7
tsconfig.json
Normal file
7
tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
25
tsconfig.node.json
Normal file
25
tsconfig.node.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
60
vite.config.ts
Normal file
60
vite.config.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import path from 'path'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
vue(),
|
||||
{
|
||||
name: 'inject-css-filename',
|
||||
enforce: 'post',
|
||||
generateBundle(_options, bundle) {
|
||||
const cssFile = Object.keys(bundle).find(name => name.endsWith('.css'))
|
||||
if (!cssFile) return
|
||||
|
||||
for (const fileName of Object.keys(bundle)) {
|
||||
const chunk = bundle[fileName]
|
||||
if (chunk.type === 'chunk' && chunk.code.includes('__CSS_FILENAME_PLACEHOLDER__')) {
|
||||
chunk.code = chunk.code.replace(/__CSS_FILENAME_PLACEHOLDER__/g, `static/${cssFile}`)
|
||||
console.log(`Injected CSS filename "static/${cssFile}" into ${fileName}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
'@KTXC': path.resolve(__dirname, '../../core/src'),
|
||||
'@ChronoManager': path.resolve(__dirname, '../chrono_manager/src'),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: 'static',
|
||||
emptyOutDir: true,
|
||||
sourcemap: true,
|
||||
cssCodeSplit: false,
|
||||
lib: {
|
||||
entry: path.resolve(__dirname, 'src/main.ts'),
|
||||
formats: ['es'],
|
||||
fileName: () => 'module.mjs',
|
||||
},
|
||||
rollupOptions: {
|
||||
external: [
|
||||
'vue',
|
||||
'vue-router',
|
||||
'pinia',
|
||||
],
|
||||
output: {
|
||||
assetFileNames: (assetInfo) => {
|
||||
if (assetInfo.name?.endsWith('.css')) {
|
||||
return 'chrono-[hash].css'
|
||||
}
|
||||
return '[name]-[hash][extname]'
|
||||
},
|
||||
manualChunks: undefined,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user