@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,545 @@
1
+ <template>
2
+ <div class="calendar week-view" :style="{ height: calendarHeight }">
3
+ <div class="week-header">
4
+ <div class="time-column header-time-column"></div>
5
+ <div v-for="day in weekDays" :key="day.date" class="week-day-header">
6
+ <div class="day-name">{{ day.dayName }}</div>
7
+ <div class="day-date" :class="{ today: day.isToday }">
8
+ {{ day.dayNumber }}
9
+ </div>
10
+ </div>
11
+ </div>
12
+ <div class="week-body">
13
+ <div class="week-table-container">
14
+ <table class="week-table">
15
+ <colgroup>
16
+ <col style="width: 80px" />
17
+ <col
18
+ v-for="day in weekDays"
19
+ :key="day.date"
20
+ style="width: calc((100% - 80px) / 7)"
21
+ />
22
+ </colgroup>
23
+ <tbody>
24
+ <!-- Generate 732 rows: 12 hours × 61 rows each (60 minutes + 1 border) -->
25
+ <tr
26
+ v-for="(timeSlot, rowIndex) in getWeekTimeSlots()"
27
+ :key="`${timeSlot.hour}-${timeSlot.minute}`"
28
+ class="week-time-row"
29
+ :class="{
30
+ 'hour-boundary': timeSlot.isBoundary,
31
+ 'minute-row': !timeSlot.isBoundary,
32
+ }"
33
+ >
34
+ <!-- Hour column - only show hour label on boundary rows -->
35
+ <td v-if="timeSlot.isBoundary" class="hour-cell" :rowspan="61">
36
+ <div class="hour-label">{{ timeSlot.display }}</div>
37
+ </td>
38
+
39
+ <!-- Event columns for each day -->
40
+ <td
41
+ v-for="day in weekDays"
42
+ :key="day.date"
43
+ v-if="
44
+ shouldRenderEventCell(
45
+ day.date,
46
+ timeSlot.hour,
47
+ timeSlot.minute,
48
+ rowIndex
49
+ )
50
+ "
51
+ class="event-cell"
52
+ :rowspan="
53
+ getEventRowspan(day.date, timeSlot.hour, timeSlot.minute)
54
+ "
55
+ @click="onMinuteClick(day.date, timeSlot.hour, timeSlot.minute)"
56
+ >
57
+ <!-- Render event or empty cell -->
58
+ <div
59
+ v-if="
60
+ getEventForTimeSlot(
61
+ day.date,
62
+ timeSlot.hour,
63
+ timeSlot.minute
64
+ )
65
+ "
66
+ class="week-event-content"
67
+ :style="{
68
+ backgroundColor:
69
+ getEventForTimeSlot(
70
+ day.date,
71
+ timeSlot.hour,
72
+ timeSlot.minute
73
+ ).color || '#2196f3',
74
+ height: '100%',
75
+ }"
76
+ @click.stop="
77
+ onEventClick(
78
+ getEventForTimeSlot(
79
+ day.date,
80
+ timeSlot.hour,
81
+ timeSlot.minute
82
+ )
83
+ )
84
+ "
85
+ >
86
+ <div class="event-title">
87
+ {{
88
+ getEventForTimeSlot(
89
+ day.date,
90
+ timeSlot.hour,
91
+ timeSlot.minute
92
+ ).title
93
+ }}
94
+ </div>
95
+ <div class="event-time">
96
+ {{
97
+ formatEventTime(
98
+ getEventForTimeSlot(
99
+ day.date,
100
+ timeSlot.hour,
101
+ timeSlot.minute
102
+ )
103
+ )
104
+ }}
105
+ </div>
106
+ </div>
107
+ <!-- Empty cell -->
108
+ <div v-else class="empty-cell"></div>
109
+ </td>
110
+ </tr>
111
+ </tbody>
112
+ </table>
113
+ </div>
114
+ </div>
115
+ </div>
116
+ </template>
117
+
118
+ <script setup>
119
+ import { computed } from "vue";
120
+
121
+ const props = defineProps({
122
+ current: {
123
+ type: Date,
124
+ required: true,
125
+ },
126
+ events: {
127
+ type: Array,
128
+ default: () => [],
129
+ },
130
+ calendarHeight: {
131
+ type: String,
132
+ default: "calc(100vh - 100px)",
133
+ },
134
+ });
135
+
136
+ const emit = defineEmits(["minuteClick", "eventClick"]);
137
+
138
+ const weekdays = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
139
+
140
+ // Helper functions for event calculations
141
+ const isSameDay = (date1, date2) => {
142
+ return date1.toDateString() === date2.toDateString();
143
+ };
144
+
145
+ const isDateInRange = (date, startDate, endDate) => {
146
+ const checkDate = new Date(date);
147
+ checkDate.setHours(0, 0, 0, 0);
148
+ const start = new Date(startDate);
149
+ start.setHours(0, 0, 0, 0);
150
+ const end = new Date(endDate);
151
+ end.setHours(0, 0, 0, 0);
152
+
153
+ return checkDate >= start && checkDate <= end;
154
+ };
155
+
156
+ const getEventsForDate = (date) => {
157
+ return props.events.filter((event) =>
158
+ isDateInRange(date, event.startDate, event.endDate)
159
+ );
160
+ };
161
+
162
+ // Week view computed properties
163
+ const weekDays = computed(() => {
164
+ const days = [];
165
+ const startOfWeek = new Date(props.current);
166
+ startOfWeek.setDate(props.current.getDate() - props.current.getDay());
167
+
168
+ for (let i = 0; i < 7; i++) {
169
+ const day = new Date(startOfWeek);
170
+ day.setDate(startOfWeek.getDate() + i);
171
+ days.push({
172
+ date: day,
173
+ dayName: weekdays[i],
174
+ dayNumber: day.getDate(),
175
+ isToday: isSameDay(day, new Date()),
176
+ });
177
+ }
178
+
179
+ return days;
180
+ });
181
+
182
+ // Week view table-based functions
183
+ const getWeekTimeSlots = () => {
184
+ const slots = [];
185
+ for (let hour = 6; hour < 18; hour++) {
186
+ // 6 AM to 6 PM = 12 hours
187
+ // Add boundary row for hour
188
+ slots.push({
189
+ hour,
190
+ minute: 0,
191
+ isBoundary: true,
192
+ display: formatHour(hour),
193
+ });
194
+
195
+ // Add 60 minute rows
196
+ for (let minute = 0; minute < 60; minute++) {
197
+ slots.push({
198
+ hour,
199
+ minute,
200
+ isBoundary: false,
201
+ display: `${hour}:${minute.toString().padStart(2, "0")}`,
202
+ });
203
+ }
204
+ }
205
+ return slots;
206
+ };
207
+
208
+ const shouldRenderEventCell = (date, hour, minute, rowIndex) => {
209
+ // Don't render cells for boundary rows (they're handled by hour column)
210
+ const timeSlot = getWeekTimeSlots()[rowIndex];
211
+ if (timeSlot.isBoundary) return false;
212
+
213
+ // Check if this cell is already covered by a previous event's rowspan
214
+ return !isRowCoveredByPreviousEvent(date, hour, minute);
215
+ };
216
+
217
+ const isRowCoveredByPreviousEvent = (date, hour, minute) => {
218
+ const eventsForDate = getEventsForDate(date);
219
+ const currentTimeInMinutes = hour * 60 + minute;
220
+
221
+ // Check if any event that started before this time is still spanning this row
222
+ return eventsForDate.some((event) => {
223
+ const eventStartMinutes = parseTimeToMinutesEnhanced(event.startTime);
224
+ const eventEndMinutes = parseTimeToMinutesEnhanced(event.endTime);
225
+
226
+ return (
227
+ eventStartMinutes < currentTimeInMinutes &&
228
+ currentTimeInMinutes < eventEndMinutes
229
+ );
230
+ });
231
+ };
232
+
233
+ const getEventForTimeSlot = (date, hour, minute) => {
234
+ const eventsForDate = getEventsForDate(date);
235
+ const currentTimeInMinutes = hour * 60 + minute;
236
+
237
+ // Find event that starts exactly at this time
238
+ return eventsForDate.find((event) => {
239
+ const eventStartMinutes = parseTimeToMinutesEnhanced(event.startTime);
240
+ return eventStartMinutes === currentTimeInMinutes;
241
+ });
242
+ };
243
+
244
+ const getEventRowspan = (date, hour, minute) => {
245
+ const event = getEventForTimeSlot(date, hour, minute);
246
+
247
+ if (event) {
248
+ // Calculate duration in minutes for the event
249
+ const startMinutes = parseTimeToMinutesEnhanced(event.startTime);
250
+ const endMinutes = parseTimeToMinutesEnhanced(event.endTime);
251
+ return endMinutes - startMinutes; // Each minute = 1 row
252
+ }
253
+
254
+ // Check if we need to fill empty space until next event or end of hour
255
+ const currentTimeInMinutes = hour * 60 + minute;
256
+ const nextHourStart = (hour + 1) * 60;
257
+
258
+ // Find next event in this hour
259
+ const eventsForDate = getEventsForDate(date);
260
+ const nextEvent = eventsForDate.find((event) => {
261
+ const eventStartMinutes = parseTimeToMinutesEnhanced(event.startTime);
262
+ return (
263
+ eventStartMinutes > currentTimeInMinutes &&
264
+ eventStartMinutes < nextHourStart
265
+ );
266
+ });
267
+
268
+ if (nextEvent) {
269
+ const nextEventStart = parseTimeToMinutesEnhanced(nextEvent.startTime);
270
+ return nextEventStart - currentTimeInMinutes;
271
+ }
272
+
273
+ // Fill until end of hour
274
+ return nextHourStart - currentTimeInMinutes;
275
+ };
276
+
277
+ // Enhanced parseTimeToMinutes to handle next-day events better
278
+ const parseTimeToMinutesEnhanced = (timeStr) => {
279
+ if (!timeStr) return 0;
280
+
281
+ if (typeof timeStr === "string") {
282
+ // Handle 24-hour format (e.g., "08:30", "14:45")
283
+ const timeMatch = timeStr.match(/^(\d{1,2}):(\d{2})$/);
284
+ if (timeMatch) {
285
+ return parseInt(timeMatch[1]) * 60 + parseInt(timeMatch[2]);
286
+ }
287
+
288
+ // Handle 12-hour format with AM/PM (e.g., "9:30 AM", "2:45 PM")
289
+ const ampmMatch = timeStr.match(/(\d+):(\d+)\s*(AM|PM)/i);
290
+ if (ampmMatch) {
291
+ let hour = parseInt(ampmMatch[1]);
292
+ const minute = parseInt(ampmMatch[2]);
293
+ const period = ampmMatch[3].toUpperCase();
294
+
295
+ if (period === "PM" && hour !== 12) {
296
+ hour += 12;
297
+ } else if (period === "AM" && hour === 12) {
298
+ hour = 0;
299
+ }
300
+
301
+ return hour * 60 + minute;
302
+ }
303
+
304
+ // Handle hour only format (e.g., "9 AM", "2 PM")
305
+ const hourMatch = timeStr.match(/(\d+)\s*(AM|PM)/i);
306
+ if (hourMatch) {
307
+ let hour = parseInt(hourMatch[1]);
308
+ const period = hourMatch[2].toUpperCase();
309
+
310
+ if (period === "PM" && hour !== 12) {
311
+ hour += 12;
312
+ } else if (period === "AM" && hour === 12) {
313
+ hour = 0;
314
+ }
315
+
316
+ return hour * 60;
317
+ }
318
+ }
319
+
320
+ if (timeStr instanceof Date) {
321
+ return timeStr.getHours() * 60 + timeStr.getMinutes();
322
+ }
323
+
324
+ return 0;
325
+ };
326
+
327
+ // Format event time display
328
+ const formatEventTime = (event) => {
329
+ if (!event.startTime && !event.endTime) return "";
330
+
331
+ const formatTime = (timeStr) => {
332
+ if (!timeStr) return "";
333
+
334
+ // If it's already a formatted 12-hour string (contains AM/PM), return it as-is
335
+ if (typeof timeStr === "string" && /AM|PM/i.test(timeStr)) {
336
+ return timeStr;
337
+ }
338
+
339
+ // If it's a 24-hour format string, convert to 12-hour
340
+ if (typeof timeStr === "string" && /^\d{1,2}:\d{2}$/.test(timeStr)) {
341
+ const [hours, minutes] = timeStr.split(":");
342
+ const hour24 = parseInt(hours);
343
+ const hour12 = hour24 === 0 ? 12 : hour24 > 12 ? hour24 - 12 : hour24;
344
+ const period = hour24 >= 12 ? "PM" : "AM";
345
+ return `${hour12}:${minutes} ${period}`;
346
+ }
347
+
348
+ // If it's a Date object, format it to 12-hour
349
+ if (timeStr instanceof Date) {
350
+ return timeStr.toLocaleTimeString([], {
351
+ hour: "numeric",
352
+ minute: "2-digit",
353
+ hour12: true,
354
+ });
355
+ }
356
+
357
+ return timeStr;
358
+ };
359
+
360
+ const start = formatTime(event.startTime);
361
+ const end = formatTime(event.endTime);
362
+
363
+ if (start && end) {
364
+ return `${start} - ${end}`;
365
+ } else if (start) {
366
+ return start;
367
+ } else if (end) {
368
+ return `Until ${end}`;
369
+ }
370
+
371
+ return "";
372
+ };
373
+
374
+ // Format hour number to display string (e.g., 9 -> "9:00 AM", 13 -> "1:00 PM")
375
+ const formatHour = (hour) => {
376
+ if (hour === 0) return "12:00 AM";
377
+ if (hour === 12) return "12:00 PM";
378
+ if (hour < 12) return `${hour}:00 AM`;
379
+ return `${hour - 12}:00 PM`;
380
+ };
381
+
382
+ // Handle minute click for week view
383
+ const onMinuteClick = (date, hour, minute) => {
384
+ emit("minuteClick", {
385
+ date: date,
386
+ hour: hour,
387
+ minute: minute,
388
+ time: `${hour}:${minute.toString().padStart(2, "0")}`,
389
+ });
390
+ };
391
+
392
+ // Handle event click
393
+ const onEventClick = (event) => {
394
+ emit("eventClick", event);
395
+ };
396
+ </script>
397
+
398
+ <style scoped>
399
+ /* Week View Styles */
400
+ .week-view {
401
+ display: flex;
402
+ flex-direction: column;
403
+ height: 100%;
404
+ }
405
+
406
+ .week-header {
407
+ display: grid;
408
+ grid-template-columns: 60px repeat(7, 1fr);
409
+ border-bottom: 1px solid #e0e0e0;
410
+ background-color: #f5f5f5;
411
+ overflow-x: auto;
412
+ }
413
+
414
+ .time-column {
415
+ display: flex;
416
+ flex-direction: column;
417
+ border-right: 1px solid #e0e0e0;
418
+ position: sticky;
419
+ left: 0;
420
+ z-index: 10;
421
+ background-color: #f9f9f9;
422
+ }
423
+
424
+ .header-time-column {
425
+ background-color: #f5f5f5;
426
+ }
427
+
428
+ .week-day-header {
429
+ text-align: center;
430
+ padding: 8px 4px;
431
+ border-right: 1px solid #e0e0e0;
432
+ }
433
+
434
+ .day-name {
435
+ font-size: 0.8em;
436
+ color: #666;
437
+ margin-bottom: 2px;
438
+ }
439
+
440
+ .day-date {
441
+ font-size: 1.2em;
442
+ font-weight: 500;
443
+ }
444
+
445
+ .day-date.today {
446
+ color: #1976d2;
447
+ font-weight: 700;
448
+ }
449
+
450
+ .week-body {
451
+ display: flex;
452
+ flex-direction: column;
453
+ flex: 1;
454
+ overflow: hidden;
455
+ }
456
+
457
+ .week-table-container {
458
+ flex: 1;
459
+ overflow-y: auto;
460
+ overflow-x: auto;
461
+ }
462
+
463
+ .week-table {
464
+ width: 100%;
465
+ border-collapse: collapse;
466
+ table-layout: fixed;
467
+ }
468
+
469
+ .week-time-row.hour-boundary {
470
+ height: 3px;
471
+ border-bottom: 2px solid #e0e0e0;
472
+ }
473
+
474
+ .week-time-row.minute-row {
475
+ height: 2px;
476
+ border-bottom: 1px solid transparent;
477
+ }
478
+
479
+ .hour-cell {
480
+ background-color: #f9f9f9;
481
+ border-right: 1px solid #e0e0e0;
482
+ vertical-align: top;
483
+ padding: 4px;
484
+ text-align: center;
485
+ }
486
+
487
+ .hour-label {
488
+ font-size: 0.8em;
489
+ color: #666;
490
+ font-weight: 500;
491
+ writing-mode: horizontal-tb;
492
+ }
493
+
494
+ .event-cell {
495
+ border-right: 1px solid #e0e0e0;
496
+ border-bottom: 1px solid transparent;
497
+ padding: 0;
498
+ margin: 0;
499
+ vertical-align: top;
500
+ position: relative;
501
+ cursor: pointer;
502
+ }
503
+
504
+ .event-cell:hover {
505
+ background-color: rgba(25, 118, 210, 0.05);
506
+ }
507
+
508
+ .week-event-content {
509
+ display: flex;
510
+ flex-direction: column;
511
+ justify-content: flex-start;
512
+ padding: 2px 4px;
513
+ color: white;
514
+ font-size: 0.7em;
515
+ border-radius: 3px;
516
+ margin: 1px;
517
+ cursor: pointer;
518
+ overflow: hidden;
519
+ box-sizing: border-box;
520
+ border: 1px solid rgba(255, 255, 255, 0.3);
521
+ }
522
+
523
+ .week-event-content .event-title {
524
+ font-weight: 600;
525
+ line-height: 1.1;
526
+ margin-bottom: 1px;
527
+ overflow: hidden;
528
+ text-overflow: ellipsis;
529
+ white-space: nowrap;
530
+ }
531
+
532
+ .week-event-content .event-time {
533
+ font-size: 0.8em;
534
+ opacity: 0.9;
535
+ line-height: 1;
536
+ overflow: hidden;
537
+ text-overflow: ellipsis;
538
+ white-space: nowrap;
539
+ }
540
+
541
+ .empty-cell {
542
+ height: 100%;
543
+ width: 100%;
544
+ }
545
+ </style>