1390 lines
42 KiB
Vue
1390 lines
42 KiB
Vue
<template>
|
|
<div class="calendar-view">
|
|
<h2>{{ $t('personal.calendar.title') }}</h2>
|
|
|
|
<!-- Toolbar -->
|
|
<div class="calendar-toolbar">
|
|
<div class="nav-buttons">
|
|
<button @click="openNewEventDialog()" class="btn-new-event">
|
|
+ {{ $t('personal.calendar.newEntry') }}
|
|
</button>
|
|
<button @click="goToToday" class="btn-today">{{ $t('personal.calendar.today') }}</button>
|
|
<button @click="navigatePrev" class="btn-nav"><</button>
|
|
<button @click="navigateNext" class="btn-nav">></button>
|
|
<span class="current-period">{{ currentPeriodLabel }}</span>
|
|
</div>
|
|
|
|
<div class="view-tabs">
|
|
<button
|
|
v-for="view in views"
|
|
:key="view.id"
|
|
:class="['view-tab', { active: currentView === view.id }]"
|
|
@click="currentView = view.id"
|
|
>
|
|
{{ $t(`personal.calendar.views.${view.id}`) }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Selection info -->
|
|
<div v-if="selectedDates.length > 1" class="selection-info">
|
|
{{ $t('personal.calendar.selectedDays', { count: selectedDates.length }) }}
|
|
<button @click="createEventFromSelection" class="btn-create-from-selection">
|
|
{{ $t('personal.calendar.createEventForSelection') }}
|
|
</button>
|
|
<button @click="clearSelection" class="btn-clear-selection">
|
|
{{ $t('personal.calendar.clearSelection') }}
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Month View -->
|
|
<div v-if="currentView === 'month'" class="calendar-grid month-view">
|
|
<div class="weekday-headers">
|
|
<div v-for="day in weekDays" :key="day" class="weekday-header">
|
|
{{ $t(`personal.calendar.weekdays.${day}`) }}
|
|
</div>
|
|
</div>
|
|
<div class="month-days">
|
|
<div
|
|
v-for="(day, index) in monthDays"
|
|
:key="index"
|
|
:class="['day-cell', {
|
|
'other-month': !day.currentMonth,
|
|
'today': day.isToday,
|
|
'weekend': day.isWeekend,
|
|
'selected': isDateSelected(day.date)
|
|
}]"
|
|
@click="handleDayClick(day.date, $event)"
|
|
@mousedown="startDragSelection(day.date)"
|
|
@mouseenter="continueDragSelection(day.date)"
|
|
@mouseup="endDragSelection"
|
|
>
|
|
<span class="day-number">{{ day.dayNumber }}</span>
|
|
<div class="day-events">
|
|
<div
|
|
v-for="event in getEventsForDate(day.date)"
|
|
:key="event.id"
|
|
class="event-item"
|
|
:style="{ backgroundColor: getCategoryColor(event.categoryId) }"
|
|
:title="event.title"
|
|
@click.stop="editEvent(event)"
|
|
>
|
|
{{ event.title }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Week View -->
|
|
<div v-if="currentView === 'week'" class="calendar-grid week-view">
|
|
<div class="time-grid">
|
|
<div class="time-header-spacer"></div>
|
|
<div v-for="day in weekDaysData" :key="day.date" class="day-column-header">
|
|
<div :class="['day-name', { today: day.isToday }]">
|
|
{{ $t(`personal.calendar.weekdays.${day.weekday}`) }}
|
|
</div>
|
|
<div :class="['day-date', { today: day.isToday }]">{{ day.dayNumber }}</div>
|
|
</div>
|
|
</div>
|
|
<div class="time-slots-container">
|
|
<div class="time-labels">
|
|
<div v-for="hour in hours" :key="hour" class="time-label">
|
|
{{ formatHour(hour) }}
|
|
</div>
|
|
</div>
|
|
<div class="day-columns">
|
|
<div v-for="day in weekDaysData" :key="day.date" class="day-column">
|
|
<div
|
|
v-for="hour in hours"
|
|
:key="hour"
|
|
:class="['time-slot', { 'current-hour': isCurrentHour(day.date, hour) }]"
|
|
@click="openNewEventDialog(day.date, hour)"
|
|
>
|
|
<div
|
|
v-for="event in getEventsForDateTime(day.date, hour)"
|
|
:key="event.id"
|
|
class="time-event"
|
|
:style="{ backgroundColor: getCategoryColor(event.categoryId) }"
|
|
@click.stop="editEvent(event)"
|
|
>
|
|
{{ event.title }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Work Week View -->
|
|
<div v-if="currentView === 'workweek'" class="calendar-grid week-view">
|
|
<div class="time-grid">
|
|
<div class="time-header-spacer"></div>
|
|
<div v-for="day in workWeekDaysData" :key="day.date" class="day-column-header">
|
|
<div :class="['day-name', { today: day.isToday }]">
|
|
{{ $t(`personal.calendar.weekdays.${day.weekday}`) }}
|
|
</div>
|
|
<div :class="['day-date', { today: day.isToday }]">{{ day.dayNumber }}</div>
|
|
</div>
|
|
</div>
|
|
<div class="time-slots-container">
|
|
<div class="time-labels">
|
|
<div v-for="hour in workHours" :key="hour" class="time-label">
|
|
{{ formatHour(hour) }}
|
|
</div>
|
|
</div>
|
|
<div class="day-columns">
|
|
<div v-for="day in workWeekDaysData" :key="day.date" class="day-column">
|
|
<div
|
|
v-for="hour in workHours"
|
|
:key="hour"
|
|
:class="['time-slot', { 'current-hour': isCurrentHour(day.date, hour) }]"
|
|
@click="openNewEventDialog(day.date, hour)"
|
|
>
|
|
<div
|
|
v-for="event in getEventsForDateTime(day.date, hour)"
|
|
:key="event.id"
|
|
class="time-event"
|
|
:style="{ backgroundColor: getCategoryColor(event.categoryId) }"
|
|
@click.stop="editEvent(event)"
|
|
>
|
|
{{ event.title }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Day View -->
|
|
<div v-if="currentView === 'day'" class="calendar-grid day-view">
|
|
<div class="day-header">
|
|
<div :class="['day-title', { today: isDayToday }]">
|
|
{{ $t(`personal.calendar.weekdaysFull.${currentDayData.weekday}`) }},
|
|
{{ currentDayData.dayNumber }}. {{ $t(`personal.calendar.months.${currentDayData.month}`) }} {{ currentDayData.year }}
|
|
</div>
|
|
</div>
|
|
<div class="time-slots-container single-day">
|
|
<div class="time-labels">
|
|
<div v-for="hour in hours" :key="hour" class="time-label">
|
|
{{ formatHour(hour) }}
|
|
</div>
|
|
</div>
|
|
<div class="day-columns">
|
|
<div class="day-column full-width">
|
|
<div
|
|
v-for="hour in hours"
|
|
:key="hour"
|
|
:class="['time-slot', { 'current-hour': isCurrentHour(currentDateStr, hour) }]"
|
|
@click="openNewEventDialog(currentDateStr, hour)"
|
|
>
|
|
<div
|
|
v-for="event in getEventsForDateTime(currentDateStr, hour)"
|
|
:key="event.id"
|
|
class="time-event full-width"
|
|
:style="{ backgroundColor: getCategoryColor(event.categoryId) }"
|
|
@click.stop="editEvent(event)"
|
|
>
|
|
<span class="event-time">{{ formatEventTime(event) }}</span>
|
|
{{ event.title }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Event Dialog -->
|
|
<div v-if="showEventDialog" class="dialog-overlay" @click.self="closeEventDialog">
|
|
<div class="event-dialog">
|
|
<div class="dialog-header">
|
|
<h3>{{ editingEvent ? $t('personal.calendar.editEntry') : $t('personal.calendar.newEntry') }}</h3>
|
|
<button @click="closeEventDialog" class="btn-close">×</button>
|
|
</div>
|
|
<div class="dialog-body">
|
|
<div class="form-group">
|
|
<label>{{ $t('personal.calendar.form.title') }}</label>
|
|
<input
|
|
v-model="eventForm.title"
|
|
type="text"
|
|
:placeholder="$t('personal.calendar.form.titlePlaceholder')"
|
|
ref="eventTitleInput"
|
|
/>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label>{{ $t('personal.calendar.form.category') }}</label>
|
|
<div class="category-selector">
|
|
<button
|
|
v-for="cat in categories"
|
|
:key="cat.id"
|
|
:class="['category-btn', { selected: eventForm.categoryId === cat.id }]"
|
|
:style="{
|
|
backgroundColor: eventForm.categoryId === cat.id ? cat.color : 'transparent',
|
|
borderColor: cat.color,
|
|
color: eventForm.categoryId === cat.id ? getContrastColor(cat.color) : cat.color
|
|
}"
|
|
@click="eventForm.categoryId = cat.id"
|
|
>
|
|
{{ $t(`personal.calendar.categories.${cat.id}`) }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="form-row">
|
|
<div class="form-group">
|
|
<label>{{ $t('personal.calendar.form.startDate') }}</label>
|
|
<input v-model="eventForm.startDate" type="date" />
|
|
</div>
|
|
<div class="form-group">
|
|
<label>{{ $t('personal.calendar.form.startTime') }}</label>
|
|
<input v-model="eventForm.startTime" type="time" />
|
|
</div>
|
|
</div>
|
|
|
|
<div class="form-row">
|
|
<div class="form-group">
|
|
<label>{{ $t('personal.calendar.form.endDate') }}</label>
|
|
<input v-model="eventForm.endDate" type="date" />
|
|
</div>
|
|
<div class="form-group">
|
|
<label>{{ $t('personal.calendar.form.endTime') }}</label>
|
|
<input v-model="eventForm.endTime" type="time" />
|
|
</div>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label class="checkbox-label">
|
|
<input v-model="eventForm.allDay" type="checkbox" />
|
|
{{ $t('personal.calendar.form.allDay') }}
|
|
</label>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label>{{ $t('personal.calendar.form.description') }}</label>
|
|
<textarea
|
|
v-model="eventForm.description"
|
|
rows="3"
|
|
:placeholder="$t('personal.calendar.form.descriptionPlaceholder')"
|
|
></textarea>
|
|
</div>
|
|
</div>
|
|
<div class="dialog-footer">
|
|
<button v-if="editingEvent" @click="deleteEvent" class="btn-delete" :disabled="saving">
|
|
{{ $t('personal.calendar.form.delete') }}
|
|
</button>
|
|
<div class="spacer"></div>
|
|
<button @click="closeEventDialog" class="btn-cancel">
|
|
{{ $t('personal.calendar.form.cancel') }}
|
|
</button>
|
|
<button @click="saveEvent" class="btn-save" :disabled="!eventForm.title || saving">
|
|
{{ $t('personal.calendar.form.save') }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script>
|
|
import apiClient from '@/utils/axios.js';
|
|
|
|
export default {
|
|
name: 'CalendarView',
|
|
data() {
|
|
return {
|
|
currentView: 'month',
|
|
currentDate: new Date(),
|
|
views: [
|
|
{ id: 'month' },
|
|
{ id: 'week' },
|
|
{ id: 'workweek' },
|
|
{ id: 'day' }
|
|
],
|
|
weekDays: ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'],
|
|
hours: Array.from({ length: 24 }, (_, i) => i),
|
|
workHours: Array.from({ length: 12 }, (_, i) => i + 7),
|
|
|
|
// Categories with colors
|
|
categories: [
|
|
{ id: 'personal', color: '#4CAF50' }, // Green
|
|
{ id: 'work', color: '#2196F3' }, // Blue
|
|
{ id: 'family', color: '#9C27B0' }, // Purple
|
|
{ id: 'health', color: '#F44336' }, // Red
|
|
{ id: 'birthday', color: '#FF9800' }, // Orange
|
|
{ id: 'holiday', color: '#00BCD4' }, // Cyan
|
|
{ id: 'reminder', color: '#795548' }, // Brown
|
|
{ id: 'other', color: '#607D8B' } // Gray-Blue
|
|
],
|
|
|
|
// Events storage
|
|
events: [],
|
|
loading: false,
|
|
saving: false,
|
|
|
|
// Selection
|
|
selectedDates: [],
|
|
isDragging: false,
|
|
dragStartDate: null,
|
|
|
|
// Dialog
|
|
showEventDialog: false,
|
|
editingEvent: null,
|
|
eventForm: {
|
|
title: '',
|
|
categoryId: 'personal',
|
|
startDate: '',
|
|
startTime: '09:00',
|
|
endDate: '',
|
|
endTime: '10:00',
|
|
allDay: false,
|
|
description: ''
|
|
}
|
|
};
|
|
},
|
|
computed: {
|
|
currentPeriodLabel() {
|
|
const options = { month: 'long', year: 'numeric' };
|
|
if (this.currentView === 'day') {
|
|
options.day = 'numeric';
|
|
}
|
|
return this.currentDate.toLocaleDateString(this.$i18n.locale, options);
|
|
},
|
|
currentDateStr() {
|
|
// Local timezone formatting to avoid UTC conversion issues
|
|
const d = this.currentDate;
|
|
const year = d.getFullYear();
|
|
const month = String(d.getMonth() + 1).padStart(2, '0');
|
|
const day = String(d.getDate()).padStart(2, '0');
|
|
return `${year}-${month}-${day}`;
|
|
},
|
|
monthDays() {
|
|
const year = this.currentDate.getFullYear();
|
|
const month = this.currentDate.getMonth();
|
|
const firstDay = new Date(year, month, 1);
|
|
|
|
let startDate = new Date(firstDay);
|
|
const dayOfWeek = firstDay.getDay();
|
|
const diff = dayOfWeek === 0 ? -6 : 1 - dayOfWeek;
|
|
startDate.setDate(startDate.getDate() + diff);
|
|
|
|
const days = [];
|
|
const today = new Date();
|
|
today.setHours(0, 0, 0, 0);
|
|
|
|
for (let i = 0; i < 42; i++) {
|
|
const date = new Date(startDate);
|
|
date.setDate(startDate.getDate() + i);
|
|
const dayOfWeekNum = date.getDay();
|
|
|
|
// Local timezone formatting
|
|
const dateStr = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
|
days.push({
|
|
date: dateStr,
|
|
dayNumber: date.getDate(),
|
|
currentMonth: date.getMonth() === month,
|
|
isToday: date.getTime() === today.getTime(),
|
|
isWeekend: dayOfWeekNum === 0 || dayOfWeekNum === 6
|
|
});
|
|
}
|
|
|
|
return days;
|
|
},
|
|
weekDaysData() {
|
|
const days = [];
|
|
const today = new Date();
|
|
today.setHours(0, 0, 0, 0);
|
|
|
|
const monday = this.getMonday(this.currentDate);
|
|
|
|
for (let i = 0; i < 7; i++) {
|
|
const date = new Date(monday);
|
|
date.setDate(monday.getDate() + i);
|
|
const dayOfWeek = date.getDay();
|
|
|
|
// Local timezone formatting
|
|
const dateStr = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
|
days.push({
|
|
date: dateStr,
|
|
dayNumber: date.getDate(),
|
|
weekday: this.weekDays[i],
|
|
isToday: date.getTime() === today.getTime(),
|
|
isWeekend: dayOfWeek === 0 || dayOfWeek === 6
|
|
});
|
|
}
|
|
|
|
return days;
|
|
},
|
|
workWeekDaysData() {
|
|
return this.weekDaysData.slice(0, 5);
|
|
},
|
|
currentDayData() {
|
|
const date = this.currentDate;
|
|
const dayIndex = date.getDay();
|
|
const weekdayIndex = dayIndex === 0 ? 6 : dayIndex - 1;
|
|
|
|
// Local timezone formatting
|
|
const dateStr = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
|
return {
|
|
date: dateStr,
|
|
dayNumber: date.getDate(),
|
|
month: this.getMonthKey(date.getMonth()),
|
|
year: date.getFullYear(),
|
|
weekday: this.weekDays[weekdayIndex]
|
|
};
|
|
},
|
|
isDayToday() {
|
|
const today = new Date();
|
|
return this.currentDate.toDateString() === today.toDateString();
|
|
}
|
|
},
|
|
mounted() {
|
|
this.loadEvents();
|
|
document.addEventListener('mouseup', this.endDragSelection);
|
|
},
|
|
beforeUnmount() {
|
|
document.removeEventListener('mouseup', this.endDragSelection);
|
|
},
|
|
methods: {
|
|
getMonday(date) {
|
|
const d = new Date(date);
|
|
const day = d.getDay();
|
|
const diff = d.getDate() - day + (day === 0 ? -6 : 1);
|
|
return new Date(d.setDate(diff));
|
|
},
|
|
getMonthKey(monthIndex) {
|
|
const months = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec'];
|
|
return months[monthIndex];
|
|
},
|
|
// Helper: Format date as YYYY-MM-DD in local timezone (not UTC!)
|
|
formatDateLocal(date) {
|
|
const year = date.getFullYear();
|
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
|
const day = String(date.getDate()).padStart(2, '0');
|
|
return `${year}-${month}-${day}`;
|
|
},
|
|
navigatePrev() {
|
|
const newDate = new Date(this.currentDate);
|
|
switch (this.currentView) {
|
|
case 'month':
|
|
// Set day to 1 first to avoid month overflow (e.g., Jan 31 - 1 month = Dec 31, not Dec 1)
|
|
newDate.setDate(1);
|
|
newDate.setMonth(newDate.getMonth() - 1);
|
|
break;
|
|
case 'week':
|
|
case 'workweek':
|
|
newDate.setDate(newDate.getDate() - 7);
|
|
break;
|
|
case 'day':
|
|
newDate.setDate(newDate.getDate() - 1);
|
|
break;
|
|
}
|
|
this.currentDate = newDate;
|
|
},
|
|
navigateNext() {
|
|
const newDate = new Date(this.currentDate);
|
|
switch (this.currentView) {
|
|
case 'month':
|
|
// Set day to 1 first to avoid month overflow (e.g., Jan 30 + 1 month would skip Feb)
|
|
newDate.setDate(1);
|
|
newDate.setMonth(newDate.getMonth() + 1);
|
|
break;
|
|
case 'week':
|
|
case 'workweek':
|
|
newDate.setDate(newDate.getDate() + 7);
|
|
break;
|
|
case 'day':
|
|
newDate.setDate(newDate.getDate() + 1);
|
|
break;
|
|
}
|
|
this.currentDate = newDate;
|
|
},
|
|
goToToday() {
|
|
this.currentDate = new Date();
|
|
},
|
|
formatHour(hour) {
|
|
return `${hour.toString().padStart(2, '0')}:00`;
|
|
},
|
|
isCurrentHour(dateStr, hour) {
|
|
const now = new Date();
|
|
const date = new Date(dateStr);
|
|
return date.toDateString() === now.toDateString() && now.getHours() === hour;
|
|
},
|
|
|
|
// Selection methods
|
|
isDateSelected(dateStr) {
|
|
return this.selectedDates.includes(dateStr);
|
|
},
|
|
handleDayClick(dateStr, event) {
|
|
if (event.shiftKey && this.selectedDates.length > 0) {
|
|
// Shift-click: select range
|
|
const lastSelected = this.selectedDates[this.selectedDates.length - 1];
|
|
this.selectDateRange(lastSelected, dateStr);
|
|
} else if (event.ctrlKey || event.metaKey) {
|
|
// Ctrl/Cmd-click: toggle selection
|
|
const index = this.selectedDates.indexOf(dateStr);
|
|
if (index > -1) {
|
|
this.selectedDates.splice(index, 1);
|
|
} else {
|
|
this.selectedDates.push(dateStr);
|
|
}
|
|
} else if (!this.isDragging) {
|
|
// Normal click: open event dialog for this day
|
|
this.openNewEventDialog(dateStr);
|
|
}
|
|
},
|
|
startDragSelection(dateStr) {
|
|
this.isDragging = true;
|
|
this.dragStartDate = dateStr;
|
|
this.selectedDates = [dateStr];
|
|
},
|
|
continueDragSelection(dateStr) {
|
|
if (this.isDragging && this.dragStartDate) {
|
|
this.selectDateRange(this.dragStartDate, dateStr);
|
|
}
|
|
},
|
|
endDragSelection() {
|
|
if (this.isDragging && this.selectedDates.length === 1) {
|
|
// Single day was clicked, not dragged
|
|
this.selectedDates = [];
|
|
}
|
|
this.isDragging = false;
|
|
this.dragStartDate = null;
|
|
},
|
|
selectDateRange(startStr, endStr) {
|
|
const start = new Date(startStr);
|
|
const end = new Date(endStr);
|
|
|
|
if (start > end) {
|
|
[start, end] = [end, start];
|
|
}
|
|
|
|
this.selectedDates = [];
|
|
const current = new Date(start);
|
|
while (current <= end) {
|
|
this.selectedDates.push(this.formatDateLocal(current));
|
|
current.setDate(current.getDate() + 1);
|
|
}
|
|
},
|
|
clearSelection() {
|
|
this.selectedDates = [];
|
|
},
|
|
createEventFromSelection() {
|
|
if (this.selectedDates.length > 0) {
|
|
const sorted = [...this.selectedDates].sort();
|
|
this.openNewEventDialog(sorted[0], null, sorted[sorted.length - 1]);
|
|
}
|
|
},
|
|
|
|
// Event methods
|
|
getCategoryColor(categoryId) {
|
|
const cat = this.categories.find(c => c.id === categoryId);
|
|
return cat ? cat.color : '#607D8B';
|
|
},
|
|
getContrastColor(hexColor) {
|
|
const r = parseInt(hexColor.slice(1, 3), 16);
|
|
const g = parseInt(hexColor.slice(3, 5), 16);
|
|
const b = parseInt(hexColor.slice(5, 7), 16);
|
|
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
|
|
return luminance > 0.5 ? '#000000' : '#ffffff';
|
|
},
|
|
getEventsForDate(dateStr) {
|
|
return this.events.filter(event => {
|
|
const eventStart = event.startDate;
|
|
const eventEnd = event.endDate || event.startDate;
|
|
return dateStr >= eventStart && dateStr <= eventEnd;
|
|
});
|
|
},
|
|
getEventsForDateTime(dateStr, hour) {
|
|
return this.events.filter(event => {
|
|
if (event.allDay) return false;
|
|
const eventStart = event.startDate;
|
|
const eventEnd = event.endDate || event.startDate;
|
|
if (dateStr < eventStart || dateStr > eventEnd) return false;
|
|
|
|
const startHour = parseInt(event.startTime?.split(':')[0] || '0');
|
|
const endHour = parseInt(event.endTime?.split(':')[0] || '23');
|
|
|
|
if (dateStr === eventStart && dateStr === eventEnd) {
|
|
return hour >= startHour && hour < endHour;
|
|
} else if (dateStr === eventStart) {
|
|
return hour >= startHour;
|
|
} else if (dateStr === eventEnd) {
|
|
return hour < endHour;
|
|
}
|
|
return true;
|
|
});
|
|
},
|
|
formatEventTime(event) {
|
|
if (event.allDay) return '';
|
|
return event.startTime || '';
|
|
},
|
|
|
|
// Dialog methods
|
|
openNewEventDialog(dateStr = null, hour = null, endDateStr = null) {
|
|
this.editingEvent = null;
|
|
const today = this.formatDateLocal(new Date());
|
|
|
|
this.eventForm = {
|
|
title: '',
|
|
categoryId: 'personal',
|
|
startDate: dateStr || today,
|
|
startTime: hour !== null ? `${hour.toString().padStart(2, '0')}:00` : '09:00',
|
|
endDate: endDateStr || dateStr || today,
|
|
endTime: hour !== null ? `${(hour + 1).toString().padStart(2, '0')}:00` : '10:00',
|
|
allDay: endDateStr !== null && endDateStr !== dateStr,
|
|
description: ''
|
|
};
|
|
|
|
this.showEventDialog = true;
|
|
this.selectedDates = [];
|
|
|
|
this.$nextTick(() => {
|
|
this.$refs.eventTitleInput?.focus();
|
|
});
|
|
},
|
|
editEvent(event) {
|
|
this.editingEvent = event;
|
|
this.eventForm = {
|
|
title: event.title,
|
|
categoryId: event.categoryId,
|
|
startDate: event.startDate,
|
|
startTime: event.startTime || '09:00',
|
|
endDate: event.endDate || event.startDate,
|
|
endTime: event.endTime || '10:00',
|
|
allDay: event.allDay || false,
|
|
description: event.description || ''
|
|
};
|
|
this.showEventDialog = true;
|
|
},
|
|
closeEventDialog() {
|
|
this.showEventDialog = false;
|
|
this.editingEvent = null;
|
|
},
|
|
async saveEvent() {
|
|
if (!this.eventForm.title || this.saving) return;
|
|
|
|
this.saving = true;
|
|
|
|
const eventData = {
|
|
title: this.eventForm.title,
|
|
categoryId: this.eventForm.categoryId,
|
|
startDate: this.eventForm.startDate,
|
|
startTime: this.eventForm.allDay ? null : this.eventForm.startTime,
|
|
endDate: this.eventForm.endDate,
|
|
endTime: this.eventForm.allDay ? null : this.eventForm.endTime,
|
|
allDay: this.eventForm.allDay,
|
|
description: this.eventForm.description
|
|
};
|
|
|
|
try {
|
|
if (this.editingEvent) {
|
|
// Update existing event
|
|
const response = await apiClient.put(`/api/calendar/events/${this.editingEvent.id}`, eventData);
|
|
const index = this.events.findIndex(e => e.id === this.editingEvent.id);
|
|
if (index > -1) {
|
|
this.events[index] = response.data;
|
|
}
|
|
} else {
|
|
// Create new event
|
|
const response = await apiClient.post('/api/calendar/events', eventData);
|
|
this.events.push(response.data);
|
|
}
|
|
this.closeEventDialog();
|
|
} catch (error) {
|
|
console.error('Error saving event:', error);
|
|
alert(this.$t('personal.calendar.form.saveError') || 'Error saving event');
|
|
} finally {
|
|
this.saving = false;
|
|
}
|
|
},
|
|
async deleteEvent() {
|
|
if (!this.editingEvent || this.saving) return;
|
|
|
|
this.saving = true;
|
|
|
|
try {
|
|
await apiClient.delete(`/api/calendar/events/${this.editingEvent.id}`);
|
|
const index = this.events.findIndex(e => e.id === this.editingEvent.id);
|
|
if (index > -1) {
|
|
this.events.splice(index, 1);
|
|
}
|
|
this.closeEventDialog();
|
|
} catch (error) {
|
|
console.error('Error deleting event:', error);
|
|
alert(this.$t('personal.calendar.form.deleteError') || 'Error deleting event');
|
|
} finally {
|
|
this.saving = false;
|
|
}
|
|
},
|
|
|
|
// API methods
|
|
async loadEvents() {
|
|
this.loading = true;
|
|
try {
|
|
const response = await apiClient.get('/api/calendar/events');
|
|
this.events = response.data;
|
|
} catch (error) {
|
|
console.error('Error loading events:', error);
|
|
this.events = [];
|
|
} finally {
|
|
this.loading = false;
|
|
}
|
|
}
|
|
}
|
|
};
|
|
</script>
|
|
|
|
<style scoped lang="scss">
|
|
.calendar-view {
|
|
padding: 20px;
|
|
max-width: 1200px;
|
|
margin: 0 auto;
|
|
}
|
|
|
|
h2 {
|
|
margin-bottom: 20px;
|
|
color: var(--color-text-primary);
|
|
}
|
|
|
|
// Toolbar
|
|
.calendar-toolbar {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 20px;
|
|
flex-wrap: wrap;
|
|
gap: 10px;
|
|
}
|
|
|
|
.nav-buttons {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
}
|
|
|
|
.btn-new-event {
|
|
padding: 8px 16px;
|
|
background: var(--color-primary-green);
|
|
color: #000;
|
|
border: none;
|
|
border-radius: 4px;
|
|
cursor: pointer;
|
|
font-weight: 600;
|
|
|
|
&:hover {
|
|
background: var(--color-primary-green-hover);
|
|
}
|
|
}
|
|
|
|
.btn-today {
|
|
padding: 8px 16px;
|
|
background: var(--color-primary-orange);
|
|
color: #000;
|
|
border: none;
|
|
border-radius: 4px;
|
|
cursor: pointer;
|
|
font-weight: 500;
|
|
|
|
&:hover {
|
|
background: var(--color-primary-orange-hover);
|
|
}
|
|
}
|
|
|
|
.btn-nav {
|
|
padding: 8px 12px;
|
|
background: #fff;
|
|
border: 1px solid #ddd;
|
|
border-radius: 4px;
|
|
cursor: pointer;
|
|
font-size: 1.1em;
|
|
|
|
&:hover {
|
|
background: #f5f5f5;
|
|
}
|
|
}
|
|
|
|
.current-period {
|
|
font-size: 1.2em;
|
|
font-weight: 600;
|
|
margin-left: 10px;
|
|
color: var(--color-text-primary);
|
|
}
|
|
|
|
.view-tabs {
|
|
display: flex;
|
|
gap: 4px;
|
|
background: #f0f0f0;
|
|
padding: 4px;
|
|
border-radius: 6px;
|
|
}
|
|
|
|
.view-tab {
|
|
padding: 8px 16px;
|
|
background: transparent;
|
|
border: none;
|
|
border-radius: 4px;
|
|
cursor: pointer;
|
|
color: #666;
|
|
transition: all 0.2s;
|
|
|
|
&:hover {
|
|
background: #e0e0e0;
|
|
}
|
|
|
|
&.active {
|
|
background: var(--color-primary-orange);
|
|
color: #000;
|
|
font-weight: 500;
|
|
}
|
|
}
|
|
|
|
// Selection info bar
|
|
.selection-info {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
padding: 10px 16px;
|
|
background: #e3f2fd;
|
|
border: 1px solid #90caf9;
|
|
border-radius: 6px;
|
|
margin-bottom: 16px;
|
|
}
|
|
|
|
.btn-create-from-selection {
|
|
padding: 6px 12px;
|
|
background: var(--color-primary-green);
|
|
color: #000;
|
|
border: none;
|
|
border-radius: 4px;
|
|
cursor: pointer;
|
|
font-size: 0.9em;
|
|
|
|
&:hover {
|
|
background: var(--color-primary-green-hover);
|
|
}
|
|
}
|
|
|
|
.btn-clear-selection {
|
|
padding: 6px 12px;
|
|
background: #fff;
|
|
color: #666;
|
|
border: 1px solid #ddd;
|
|
border-radius: 4px;
|
|
cursor: pointer;
|
|
font-size: 0.9em;
|
|
|
|
&:hover {
|
|
background: #f5f5f5;
|
|
}
|
|
}
|
|
|
|
// Month View
|
|
.month-view {
|
|
background: #fff;
|
|
border: 1px solid #ddd;
|
|
border-radius: 8px;
|
|
overflow: hidden;
|
|
user-select: none;
|
|
}
|
|
|
|
.weekday-headers {
|
|
display: grid;
|
|
grid-template-columns: repeat(7, 1fr);
|
|
background: #f8f8f8;
|
|
border-bottom: 1px solid #ddd;
|
|
}
|
|
|
|
.weekday-header {
|
|
padding: 12px 8px;
|
|
text-align: center;
|
|
font-weight: 600;
|
|
color: #666;
|
|
font-size: 0.9em;
|
|
}
|
|
|
|
.month-days {
|
|
display: grid;
|
|
grid-template-columns: repeat(7, 1fr);
|
|
}
|
|
|
|
.day-cell {
|
|
min-height: 90px;
|
|
padding: 8px;
|
|
border-right: 1px solid #eee;
|
|
border-bottom: 1px solid #eee;
|
|
cursor: pointer;
|
|
transition: background 0.2s;
|
|
|
|
&:nth-child(7n) {
|
|
border-right: none;
|
|
}
|
|
|
|
&:hover {
|
|
background: var(--color-primary-orange-light);
|
|
}
|
|
|
|
&.other-month {
|
|
background: #fafafa;
|
|
|
|
.day-number {
|
|
color: #bbb;
|
|
}
|
|
}
|
|
|
|
&.today {
|
|
background: #fff8e1;
|
|
|
|
.day-number {
|
|
background: var(--color-primary-orange);
|
|
color: #000;
|
|
border-radius: 50%;
|
|
width: 28px;
|
|
height: 28px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
}
|
|
|
|
&.weekend {
|
|
background: #f9f9f9;
|
|
}
|
|
|
|
&.selected {
|
|
background: #bbdefb !important;
|
|
|
|
&:hover {
|
|
background: #90caf9 !important;
|
|
}
|
|
}
|
|
}
|
|
|
|
.day-number {
|
|
font-weight: 500;
|
|
font-size: 0.95em;
|
|
color: var(--color-text-primary);
|
|
}
|
|
|
|
.day-events {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 2px;
|
|
margin-top: 4px;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.event-item {
|
|
padding: 2px 6px;
|
|
border-radius: 3px;
|
|
font-size: 0.75em;
|
|
color: #fff;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
cursor: pointer;
|
|
|
|
&:hover {
|
|
opacity: 0.9;
|
|
}
|
|
}
|
|
|
|
// Week & Day View
|
|
.week-view, .day-view {
|
|
background: #fff;
|
|
border: 1px solid #ddd;
|
|
border-radius: 8px;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.time-grid {
|
|
display: flex;
|
|
border-bottom: 1px solid #ddd;
|
|
background: #f8f8f8;
|
|
}
|
|
|
|
.time-header-spacer {
|
|
width: 60px;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.day-column-header {
|
|
flex: 1;
|
|
padding: 12px 8px;
|
|
text-align: center;
|
|
border-left: 1px solid #eee;
|
|
|
|
&:first-of-type {
|
|
border-left: none;
|
|
}
|
|
}
|
|
|
|
.day-name {
|
|
font-weight: 600;
|
|
color: #666;
|
|
font-size: 0.85em;
|
|
|
|
&.today {
|
|
color: var(--color-primary-orange);
|
|
}
|
|
}
|
|
|
|
.day-date {
|
|
font-size: 1.4em;
|
|
font-weight: 600;
|
|
color: var(--color-text-primary);
|
|
|
|
&.today {
|
|
background: var(--color-primary-orange);
|
|
color: #000;
|
|
border-radius: 50%;
|
|
width: 36px;
|
|
height: 36px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
margin: 4px auto 0;
|
|
}
|
|
}
|
|
|
|
.time-slots-container {
|
|
display: flex;
|
|
max-height: 600px;
|
|
overflow-y: auto;
|
|
|
|
&.single-day {
|
|
.day-column {
|
|
flex: 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
.time-labels {
|
|
width: 60px;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.time-label {
|
|
height: 48px;
|
|
padding: 4px 8px;
|
|
font-size: 0.8em;
|
|
color: #888;
|
|
text-align: right;
|
|
border-bottom: 1px solid #f0f0f0;
|
|
}
|
|
|
|
.day-columns {
|
|
display: flex;
|
|
flex: 1;
|
|
}
|
|
|
|
.day-column {
|
|
flex: 1;
|
|
border-left: 1px solid #eee;
|
|
|
|
&:first-child {
|
|
border-left: none;
|
|
}
|
|
|
|
&.full-width {
|
|
border-left: none;
|
|
}
|
|
}
|
|
|
|
.time-slot {
|
|
height: 48px;
|
|
border-bottom: 1px solid #f0f0f0;
|
|
cursor: pointer;
|
|
transition: background 0.2s;
|
|
position: relative;
|
|
|
|
&:hover {
|
|
background: var(--color-primary-orange-light);
|
|
}
|
|
|
|
&.current-hour {
|
|
background: rgba(255, 184, 77, 0.2);
|
|
border-left: 3px solid var(--color-primary-orange);
|
|
}
|
|
}
|
|
|
|
.time-event {
|
|
position: absolute;
|
|
left: 2px;
|
|
right: 2px;
|
|
top: 2px;
|
|
padding: 2px 6px;
|
|
border-radius: 3px;
|
|
font-size: 0.75em;
|
|
color: #fff;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
cursor: pointer;
|
|
z-index: 1;
|
|
|
|
&:hover {
|
|
opacity: 0.9;
|
|
}
|
|
|
|
&.full-width {
|
|
right: 2px;
|
|
}
|
|
}
|
|
|
|
.event-time {
|
|
font-weight: 600;
|
|
margin-right: 4px;
|
|
}
|
|
|
|
// Day View specifics
|
|
.day-header {
|
|
padding: 20px;
|
|
background: #f8f8f8;
|
|
border-bottom: 1px solid #ddd;
|
|
}
|
|
|
|
.day-title {
|
|
font-size: 1.3em;
|
|
font-weight: 600;
|
|
color: var(--color-text-primary);
|
|
|
|
&.today {
|
|
color: var(--color-primary-orange);
|
|
}
|
|
}
|
|
|
|
// Dialog
|
|
.dialog-overlay {
|
|
position: fixed;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
background: rgba(0, 0, 0, 0.5);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
z-index: 1000;
|
|
}
|
|
|
|
.event-dialog {
|
|
background: #fff;
|
|
border-radius: 12px;
|
|
width: 90%;
|
|
max-width: 500px;
|
|
max-height: 90vh;
|
|
overflow: hidden;
|
|
display: flex;
|
|
flex-direction: column;
|
|
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
|
|
}
|
|
|
|
.dialog-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
padding: 16px 20px;
|
|
border-bottom: 1px solid #eee;
|
|
background: var(--color-primary-orange);
|
|
|
|
h3 {
|
|
margin: 0;
|
|
font-size: 1.2em;
|
|
color: #000;
|
|
}
|
|
}
|
|
|
|
.btn-close {
|
|
background: transparent;
|
|
border: none;
|
|
font-size: 1.5em;
|
|
cursor: pointer;
|
|
color: #000;
|
|
padding: 0;
|
|
line-height: 1;
|
|
|
|
&:hover {
|
|
opacity: 0.7;
|
|
}
|
|
}
|
|
|
|
.dialog-body {
|
|
padding: 20px;
|
|
overflow-y: auto;
|
|
flex: 1;
|
|
}
|
|
|
|
.form-group {
|
|
margin-bottom: 16px;
|
|
|
|
label {
|
|
display: block;
|
|
margin-bottom: 6px;
|
|
font-weight: 500;
|
|
color: var(--color-text-primary);
|
|
}
|
|
|
|
input[type="text"],
|
|
input[type="date"],
|
|
input[type="time"],
|
|
textarea {
|
|
width: 100%;
|
|
padding: 10px 12px;
|
|
border: 1px solid #ddd;
|
|
border-radius: 6px;
|
|
font-size: 1em;
|
|
|
|
&:focus {
|
|
outline: none;
|
|
border-color: var(--color-primary-orange);
|
|
}
|
|
}
|
|
|
|
textarea {
|
|
resize: vertical;
|
|
}
|
|
}
|
|
|
|
.form-row {
|
|
display: flex;
|
|
gap: 16px;
|
|
|
|
.form-group {
|
|
flex: 1;
|
|
}
|
|
}
|
|
|
|
.checkbox-label {
|
|
display: flex !important;
|
|
align-items: center;
|
|
gap: 8px;
|
|
cursor: pointer;
|
|
|
|
input[type="checkbox"] {
|
|
width: 18px;
|
|
height: 18px;
|
|
cursor: pointer;
|
|
}
|
|
}
|
|
|
|
.category-selector {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 8px;
|
|
}
|
|
|
|
.category-btn {
|
|
padding: 6px 12px;
|
|
border: 2px solid;
|
|
border-radius: 20px;
|
|
cursor: pointer;
|
|
font-size: 0.85em;
|
|
font-weight: 500;
|
|
transition: all 0.2s;
|
|
|
|
&:hover {
|
|
opacity: 0.8;
|
|
}
|
|
|
|
&.selected {
|
|
font-weight: 600;
|
|
}
|
|
}
|
|
|
|
.dialog-footer {
|
|
display: flex;
|
|
align-items: center;
|
|
padding: 16px 20px;
|
|
border-top: 1px solid #eee;
|
|
gap: 10px;
|
|
}
|
|
|
|
.spacer {
|
|
flex: 1;
|
|
}
|
|
|
|
.btn-delete {
|
|
padding: 10px 16px;
|
|
background: #f44336;
|
|
color: #fff;
|
|
border: none;
|
|
border-radius: 6px;
|
|
cursor: pointer;
|
|
font-weight: 500;
|
|
|
|
&:hover {
|
|
background: #d32f2f;
|
|
}
|
|
}
|
|
|
|
.btn-cancel {
|
|
padding: 10px 16px;
|
|
background: #f5f5f5;
|
|
color: #666;
|
|
border: 1px solid #ddd;
|
|
border-radius: 6px;
|
|
cursor: pointer;
|
|
|
|
&:hover {
|
|
background: #e0e0e0;
|
|
}
|
|
}
|
|
|
|
.btn-save {
|
|
padding: 10px 20px;
|
|
background: var(--color-primary-green);
|
|
color: #000;
|
|
border: none;
|
|
border-radius: 6px;
|
|
cursor: pointer;
|
|
font-weight: 600;
|
|
|
|
&:hover:not(:disabled) {
|
|
background: var(--color-primary-green-hover);
|
|
}
|
|
|
|
&:disabled {
|
|
opacity: 0.5;
|
|
cursor: not-allowed;
|
|
}
|
|
}
|
|
|
|
// Responsive
|
|
@media (max-width: 768px) {
|
|
.calendar-toolbar {
|
|
flex-direction: column;
|
|
align-items: stretch;
|
|
}
|
|
|
|
.nav-buttons {
|
|
justify-content: center;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.view-tabs {
|
|
justify-content: center;
|
|
}
|
|
|
|
.day-cell {
|
|
min-height: 60px;
|
|
padding: 4px;
|
|
}
|
|
|
|
.weekday-header {
|
|
padding: 8px 4px;
|
|
font-size: 0.8em;
|
|
}
|
|
|
|
.form-row {
|
|
flex-direction: column;
|
|
gap: 0;
|
|
}
|
|
|
|
.selection-info {
|
|
flex-direction: column;
|
|
text-align: center;
|
|
}
|
|
}
|
|
</style>
|