@eeplatform/nuxt-layer-common 1.2.11 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,531 @@
1
+ <template>
2
+ <div class="calendar day-view" :style="{ height: calendarHeight }">
3
+ <div class="day-header">
4
+ <h3>{{ formatDayHeader(current) }}</h3>
5
+ </div>
6
+
7
+ <!-- Table-based Day View with Minute Grid -->
8
+ <div class="day-table-container">
9
+ <table class="day-table">
10
+ <colgroup>
11
+ <col style="width: 80px" />
12
+ <col style="width: calc(100% - 80px)" />
13
+ </colgroup>
14
+ <tbody>
15
+ <!-- Hour rows with minute subdivisions -->
16
+ <tr
17
+ v-for="timeSlot in dayTimeSlots"
18
+ :key="`${timeSlot.hour}-${timeSlot.minute}`"
19
+ class="time-row"
20
+ :class="{
21
+ 'hour-boundary': timeSlot.minute === 0,
22
+ 'quarter-hour':
23
+ timeSlot.minute % 15 === 0 && timeSlot.minute !== 0,
24
+ 'five-minute':
25
+ timeSlot.minute % 5 === 0 && timeSlot.minute % 15 !== 0,
26
+ }"
27
+ >
28
+ <!-- Time cell - only show hour label on hour boundaries -->
29
+ <td
30
+ v-if="timeSlot.minute === 0"
31
+ class="time-cell hour-cell"
32
+ :rowspan="60"
33
+ @click="onHourClick(current, timeSlot.display)"
34
+ >
35
+ <div class="hour-label">{{ timeSlot.display }}</div>
36
+ </td>
37
+
38
+ <!-- Event cell -->
39
+ <td
40
+ class="event-cell minute-cell"
41
+ :class="{
42
+ 'has-events': getEventForTimeSlot(
43
+ timeSlot.hour,
44
+ timeSlot.minute
45
+ ),
46
+ 'event-start': isEventStartAtTime(
47
+ timeSlot.hour,
48
+ timeSlot.minute
49
+ ),
50
+ 'event-continuation': isEventContinuationAtTime(
51
+ timeSlot.hour,
52
+ timeSlot.minute
53
+ ),
54
+ }"
55
+ @click="onMinuteClick(current, timeSlot.hour, timeSlot.minute)"
56
+ >
57
+ <!-- Show events horizontally stacked at their start time -->
58
+ <div
59
+ v-if="isEventStartAtTime(timeSlot.hour, timeSlot.minute)"
60
+ class="events-horizontal-container"
61
+ >
62
+ <div
63
+ v-for="(event, eventIndex) in getEventsStartingAt(
64
+ timeSlot.hour,
65
+ timeSlot.minute
66
+ )"
67
+ :key="`${timeSlot.hour}-${timeSlot.minute}-${event.id}`"
68
+ class="table-event horizontal-event"
69
+ :style="{
70
+ backgroundColor: event.color || '#2196f3',
71
+ height: `${getEventHeightInPixels(event)}px`,
72
+ width: '150px',
73
+ marginRight:
74
+ eventIndex <
75
+ getEventsStartingAt(timeSlot.hour, timeSlot.minute)
76
+ .length -
77
+ 1
78
+ ? '8px'
79
+ : '0',
80
+ zIndex: 10 + eventIndex,
81
+ }"
82
+ @click.stop="onEventClick(event)"
83
+ >
84
+ <div class="table-event-title">
85
+ {{ event.title }}
86
+ </div>
87
+ <div class="table-event-time">
88
+ {{ formatEventTime(event) }}
89
+ </div>
90
+ </div>
91
+ </div>
92
+ </td>
93
+ </tr>
94
+ </tbody>
95
+ </table>
96
+ </div>
97
+ </div>
98
+ </template>
99
+
100
+ <script setup>
101
+ import { computed } from "vue";
102
+
103
+ const props = defineProps({
104
+ current: {
105
+ type: Date,
106
+ required: true,
107
+ },
108
+ events: {
109
+ type: Array,
110
+ default: () => [],
111
+ },
112
+ calendarHeight: {
113
+ type: String,
114
+ default: "calc(100vh - 100px)",
115
+ },
116
+ });
117
+
118
+ const emit = defineEmits(["hourClick", "minuteClick", "eventClick"]);
119
+
120
+ // Helper functions for event calculations
121
+ const isSameDay = (date1, date2) => {
122
+ return date1.toDateString() === date2.toDateString();
123
+ };
124
+
125
+ const isDateInRange = (date, startDate, endDate) => {
126
+ const checkDate = new Date(date);
127
+ checkDate.setHours(0, 0, 0, 0);
128
+ const start = new Date(startDate);
129
+ start.setHours(0, 0, 0, 0);
130
+ const end = new Date(endDate);
131
+ end.setHours(0, 0, 0, 0);
132
+
133
+ return checkDate >= start && checkDate <= end;
134
+ };
135
+
136
+ const getEventsForDate = (date) => {
137
+ return props.events.filter((event) =>
138
+ isDateInRange(date, event.startDate, event.endDate)
139
+ );
140
+ };
141
+
142
+ // Generate time slots for minute-based day view (6 AM to 5 AM, 12*60 = 720 minutes)
143
+ const dayTimeSlots = computed(() => {
144
+ const timeSlots = [];
145
+
146
+ // 6 AM to 11:59 PM (current day)
147
+ for (let hour = 6; hour <= 23; hour++) {
148
+ for (let minute = 0; minute < 60; minute++) {
149
+ const display =
150
+ hour === 12 ? "12 PM" : hour > 12 ? `${hour - 12} PM` : `${hour} AM`;
151
+ timeSlots.push({
152
+ hour: hour,
153
+ minute: minute,
154
+ display: display,
155
+ time24: hour * 60 + minute,
156
+ timeString: `${hour.toString().padStart(2, "0")}:${minute
157
+ .toString()
158
+ .padStart(2, "0")}`,
159
+ });
160
+ }
161
+ }
162
+
163
+ // 12 AM to 5:59 AM (next day)
164
+ for (let hour = 0; hour <= 5; hour++) {
165
+ for (let minute = 0; minute < 60; minute++) {
166
+ const display = hour === 0 ? "12 AM" : `${hour} AM`;
167
+ timeSlots.push({
168
+ hour: hour + 24, // Use 24+ to represent next day hours
169
+ minute: minute,
170
+ display: display,
171
+ time24: (hour + 24) * 60 + minute,
172
+ timeString: `${hour.toString().padStart(2, "0")}:${minute
173
+ .toString()
174
+ .padStart(2, "0")}`,
175
+ });
176
+ }
177
+ }
178
+
179
+ return timeSlots;
180
+ });
181
+
182
+ // Helper functions for table-based day view
183
+ const getEventDurationInMinutes = (event) => {
184
+ if (!event.startTime || !event.endTime) return 60; // Default 1 hour
185
+
186
+ const startMinutes = parseTimeToMinutes(event.startTime);
187
+ const endMinutes = parseTimeToMinutes(event.endTime);
188
+
189
+ return Math.max(15, endMinutes - startMinutes); // Minimum 15 minutes
190
+ };
191
+
192
+ const parseTimeToMinutes = (timeStr) => {
193
+ if (!timeStr) return 0;
194
+
195
+ if (typeof timeStr === "string") {
196
+ // Handle 24-hour format (e.g., "08:30", "14:45")
197
+ const timeMatch = timeStr.match(/^(\d{1,2}):(\d{2})$/);
198
+ if (timeMatch) {
199
+ return parseInt(timeMatch[1]) * 60 + parseInt(timeMatch[2]);
200
+ }
201
+
202
+ // Handle 12-hour format with AM/PM (e.g., "9:30 AM", "2:45 PM")
203
+ const ampmMatch = timeStr.match(/(\d+):(\d+)\s*(AM|PM)/i);
204
+ if (ampmMatch) {
205
+ let hour = parseInt(ampmMatch[1]);
206
+ const minute = parseInt(ampmMatch[2]);
207
+ const period = ampmMatch[3].toUpperCase();
208
+
209
+ if (period === "PM" && hour !== 12) {
210
+ hour += 12;
211
+ } else if (period === "AM" && hour === 12) {
212
+ hour = 0;
213
+ }
214
+
215
+ return hour * 60 + minute;
216
+ }
217
+
218
+ // Handle hour only format (e.g., "9 AM", "2 PM")
219
+ const hourMatch = timeStr.match(/(\d+)\s*(AM|PM)/i);
220
+ if (hourMatch) {
221
+ let hour = parseInt(hourMatch[1]);
222
+ const period = hourMatch[2].toUpperCase();
223
+
224
+ if (period === "PM" && hour !== 12) {
225
+ hour += 12;
226
+ } else if (period === "AM" && hour === 12) {
227
+ hour = 0;
228
+ }
229
+
230
+ return hour * 60;
231
+ }
232
+ }
233
+
234
+ if (timeStr instanceof Date) {
235
+ return timeStr.getHours() * 60 + timeStr.getMinutes();
236
+ }
237
+
238
+ return 0;
239
+ };
240
+
241
+ // Check if an event starts at a specific time
242
+ const isEventStartAtTime = (hour, minute) => {
243
+ const eventsForDay = getEventsForDate(props.current);
244
+ const slotTime = hour * 60 + minute;
245
+
246
+ return eventsForDay.some((event) => {
247
+ const startTime = parseTimeToMinutes(event.startTime);
248
+
249
+ // Handle next day slots
250
+ if (hour >= 24) {
251
+ // For slots after midnight, check if event starts at this time on next day
252
+ const nextDayTime = (hour - 24) * 60 + minute;
253
+ return (
254
+ startTime === nextDayTime &&
255
+ parseTimeToMinutes(event.endTime) < startTime
256
+ ); // Event spans midnight
257
+ } else {
258
+ return startTime === slotTime;
259
+ }
260
+ });
261
+ };
262
+
263
+ // Check if this time slot is a continuation of an event (not the start)
264
+ const isEventContinuationAtTime = (hour, minute) => {
265
+ const event = getEventForTimeSlot(hour, minute);
266
+ return event && !isEventStartAtTime(hour, minute);
267
+ };
268
+
269
+ // Get all events that start at a specific hour and minute
270
+ const getEventsStartingAt = (hour, minute) => {
271
+ const eventsForDay = getEventsForDate(props.current);
272
+ const slotTime = hour * 60 + minute;
273
+
274
+ return eventsForDay.filter((event) => {
275
+ const startTime = parseTimeToMinutes(event.startTime);
276
+
277
+ // Handle next day slots
278
+ if (hour >= 24) {
279
+ const nextDayTime = (hour - 24) * 60 + minute;
280
+ return (
281
+ startTime === nextDayTime &&
282
+ parseTimeToMinutes(event.endTime) < startTime
283
+ );
284
+ } else {
285
+ return startTime === slotTime;
286
+ }
287
+ });
288
+ };
289
+
290
+ // Calculate event height in pixels based on duration
291
+ const getEventHeightInPixels = (event) => {
292
+ const durationMinutes = getEventDurationInMinutes(event);
293
+ const pixelsPerMinute = 2; // Each minute row is 2px high
294
+ return Math.max(20, durationMinutes * pixelsPerMinute); // Minimum 20px height
295
+ };
296
+
297
+ const getEventForTimeSlot = (hour, minute) => {
298
+ const eventsForDay = getEventsForDate(props.current);
299
+ const slotTime = hour * 60 + minute;
300
+
301
+ // Find event that starts exactly at this time
302
+ return eventsForDay.find((event) => {
303
+ const eventStartMinutes = parseTimeToMinutes(event.startTime);
304
+ return eventStartMinutes === slotTime;
305
+ });
306
+ };
307
+
308
+ // Format event time display
309
+ const formatEventTime = (event) => {
310
+ if (!event.startTime && !event.endTime) return "";
311
+
312
+ const formatTime = (timeStr) => {
313
+ if (!timeStr) return "";
314
+
315
+ // If it's already a formatted 12-hour string (contains AM/PM), return it as-is
316
+ if (typeof timeStr === "string" && /AM|PM/i.test(timeStr)) {
317
+ return timeStr;
318
+ }
319
+
320
+ // If it's a 24-hour format string, convert to 12-hour
321
+ if (typeof timeStr === "string" && /^\d{1,2}:\d{2}$/.test(timeStr)) {
322
+ const [hours, minutes] = timeStr.split(":");
323
+ const hour24 = parseInt(hours);
324
+ const hour12 = hour24 === 0 ? 12 : hour24 > 12 ? hour24 - 12 : hour24;
325
+ const period = hour24 >= 12 ? "PM" : "AM";
326
+ return `${hour12}:${minutes} ${period}`;
327
+ }
328
+
329
+ // If it's a Date object, format it to 12-hour
330
+ if (timeStr instanceof Date) {
331
+ return timeStr.toLocaleTimeString([], {
332
+ hour: "numeric",
333
+ minute: "2-digit",
334
+ hour12: true,
335
+ });
336
+ }
337
+
338
+ return timeStr;
339
+ };
340
+
341
+ const start = formatTime(event.startTime);
342
+ const end = formatTime(event.endTime);
343
+
344
+ if (start && end) {
345
+ return `${start} - ${end}`;
346
+ } else if (start) {
347
+ return start;
348
+ } else if (end) {
349
+ return `Until ${end}`;
350
+ }
351
+
352
+ return "";
353
+ };
354
+
355
+ // Format day header for day view
356
+ const formatDayHeader = (date) => {
357
+ return date.toLocaleDateString("en-US", {
358
+ weekday: "long",
359
+ year: "numeric",
360
+ month: "long",
361
+ day: "numeric",
362
+ });
363
+ };
364
+
365
+ // Handle hour click for day views
366
+ const onHourClick = (date, hour) => {
367
+ emit("hourClick", {
368
+ date: date,
369
+ hour: hour,
370
+ });
371
+ };
372
+
373
+ // Handle minute click for table-based day view
374
+ const onMinuteClick = (date, hour, minute) => {
375
+ emit("minuteClick", {
376
+ date: date,
377
+ hour: hour,
378
+ minute: minute,
379
+ time: `${hour}:${minute.toString().padStart(2, "0")}`,
380
+ });
381
+ };
382
+
383
+ // Handle event click
384
+ const onEventClick = (event) => {
385
+ emit("eventClick", event);
386
+ };
387
+ </script>
388
+
389
+ <style scoped>
390
+ /* Day View Styles */
391
+ .day-view {
392
+ display: flex;
393
+ flex-direction: column;
394
+ height: 100%;
395
+ }
396
+
397
+ .day-header {
398
+ padding: 16px;
399
+ border-bottom: 1px solid #e0e0e0;
400
+ background-color: #f5f5f5;
401
+ text-align: center;
402
+ }
403
+
404
+ /* Table-based Day View Styles */
405
+ .day-table-container {
406
+ flex: 1;
407
+ overflow: auto;
408
+ background: white;
409
+ }
410
+
411
+ .day-table {
412
+ width: 100%;
413
+ border-collapse: collapse;
414
+ table-layout: fixed; /* Fixed layout for better control */
415
+ }
416
+
417
+ .day-table colgroup col:first-child {
418
+ width: 80px; /* Fixed width for time column */
419
+ }
420
+
421
+ .day-table colgroup col:last-child {
422
+ width: calc(100% - 80px); /* Remaining width for events */
423
+ }
424
+
425
+ .time-row {
426
+ height: 2px; /* Each minute row is 2px high */
427
+ }
428
+
429
+ .time-row.hour-boundary {
430
+ height: 2px;
431
+ border-top: 2px solid #d0d0d0; /* Strong border for hour boundaries */
432
+ }
433
+
434
+ .time-cell {
435
+ border: 1px solid #e0e0e0;
436
+ vertical-align: top;
437
+ position: relative;
438
+ background-color: #fafafa;
439
+ }
440
+
441
+ .hour-cell {
442
+ padding: 4px 6px;
443
+ text-align: center;
444
+ font-weight: 500;
445
+ border-right: 2px solid #d0d0d0;
446
+ white-space: nowrap;
447
+ width: 80px; /* Fixed width for time column */
448
+ min-width: 80px;
449
+ vertical-align: middle; /* Center the hour label vertically */
450
+ position: sticky;
451
+ left: 0;
452
+ z-index: 10;
453
+ background-color: #fafafa;
454
+ cursor: pointer;
455
+ }
456
+
457
+ .hour-label {
458
+ font-size: 0.75em;
459
+ color: #666;
460
+ font-weight: 600;
461
+ }
462
+
463
+ .event-cell {
464
+ border: 1px solid #f8f8f8;
465
+ position: relative;
466
+ cursor: default; /* Default cursor for empty cells */
467
+ transition: background-color 0.2s;
468
+ width: auto; /* Let the cell expand to fit content */
469
+ min-width: 200px; /* Minimum width for events */
470
+ padding: 0; /* Remove padding to allow full control */
471
+ }
472
+
473
+ .minute-cell {
474
+ border-top: none;
475
+ border-bottom: none;
476
+ border-left: 1px solid #f8f8f8;
477
+ padding: 0;
478
+ margin: 0;
479
+ }
480
+
481
+ .events-horizontal-container {
482
+ display: flex;
483
+ align-items: flex-start;
484
+ gap: 8px;
485
+ position: absolute;
486
+ top: 0;
487
+ left: 2px;
488
+ right: 2px;
489
+ min-height: 2px; /* Match the minute row height */
490
+ overflow: visible; /* Allow events to overflow for long durations */
491
+ }
492
+
493
+ .table-event {
494
+ border-radius: 4px;
495
+ padding: 4px 6px;
496
+ color: white;
497
+ font-size: 0.75em;
498
+ cursor: pointer;
499
+ overflow: hidden;
500
+ display: flex;
501
+ flex-direction: column;
502
+ justify-content: flex-start;
503
+ box-sizing: border-box;
504
+ border-bottom: 1px solid rgba(255, 255, 255, 0.3);
505
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
506
+ transition: transform 0.1s, box-shadow 0.1s;
507
+ min-height: 20px; /* Minimum height for very short events */
508
+ flex-shrink: 0; /* Prevent events from shrinking */
509
+ }
510
+
511
+ .table-event.horizontal-event {
512
+ position: relative; /* Changed from absolute to relative for horizontal stacking */
513
+ }
514
+
515
+ .table-event-title {
516
+ font-weight: 600;
517
+ white-space: nowrap;
518
+ overflow: hidden;
519
+ text-overflow: ellipsis;
520
+ line-height: 1.2;
521
+ margin-bottom: 2px;
522
+ }
523
+
524
+ .table-event-time {
525
+ font-size: 0.85em;
526
+ opacity: 0.9;
527
+ white-space: nowrap;
528
+ overflow: hidden;
529
+ text-overflow: ellipsis;
530
+ }
531
+ </style>