Files
yourpart3/frontend/src/views/personal/CalendarView.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">&lt;</button>
<button @click="navigateNext" class="btn-nav">&gt;</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">&times;</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>