Просмотр исходного кода

Add CalendarGrid.vue component; wire into calendar.html

CalendarGrid owns the DOW header, week-row loop, day-cell container,
date-number, and banner overlay positioning. App-specific content
(price row, alert dots) goes through #cell and #day-header slots;
banner active-state via bannerClass prop; day state classes (booked,
closed, selected) via cellClass prop.

Adds @vitejs/plugin-vue to vite.config for SFC support.
Component CSS is emitted as a separate chunk; app retains only the
state-specific rules (booked, closed, selected, banner.res, etc.).
ZiB 4 месяцев назад
Родитель
Сommit
2bca61ff5b

+ 34 - 123
room_cal/vue/calendar.html

@@ -134,52 +134,8 @@
     }
 
     /* ── DOW row ─────────────────────────────────────────────── */
-    .dow-row {
-      display: grid;
-      grid-template-columns: repeat(7, 1fr);
-      gap: 4px;
-      margin-bottom: 5px;
-    }
-    .dow-cell {
-      text-align: center;
-      font-size: 10.5px;
-      font-weight: 700;
-      color: var(--text-muted);
-      text-transform: uppercase;
-      letter-spacing: 0.1em;
-      padding: 4px 0 6px;
-    }
-
-    /* ── Week rows ───────────────────────────────────────────── */
-    .week-row {
-      position: relative;
-      display: grid;
-      grid-template-columns: repeat(7, 1fr);
-      gap: 4px;
-      margin-bottom: 4px;
-    }
-
-    /* ── Day cells ───────────────────────────────────────────── */
-    .day-cell {
-      background: var(--surface);
-      border: 1px solid var(--border-cell);
-      border-radius: var(--radius);
-      min-height: 96px;
-      padding: 8px 8px 7px;
-      display: flex;
-      flex-direction: column;
-      justify-content: space-between;
-      transition: border-color 0.12s, box-shadow 0.12s;
-      cursor: pointer;
-    }
-    .day-cell:hover { border-color: var(--border); }
+    /* ── Day cell state (app-specific; layout rules live in CalendarGrid.vue) ── */
     .day-cell.selected { box-shadow: inset 0 0 0 2px var(--selected); }
-    .day-cell.outside {
-      background: transparent;
-      border-color: transparent;
-      visibility: hidden;
-      cursor: default;
-    }
     .day-cell.closed { background: #f0ede8; }
     .day-cell.closed .day-num { color: var(--text-muted); }
     .day-cell.closed .day-price-row,
@@ -188,20 +144,6 @@
     .day-cell.booked .day-price-row,
     .day-cell.booked .day-no-price { opacity: 0.45; }
 
-    .day-top {
-      display: flex;
-      align-items: flex-start;
-      justify-content: space-between;
-    }
-    .day-num-wrap {
-      width: 22px; height: 22px;
-      display: flex; align-items: center; justify-content: center;
-      flex-shrink: 0;
-    }
-    .day-num { font-size: 12.5px; font-weight: 600; color: var(--text); line-height: 1; }
-    .day-cell.today .day-num-wrap { background: var(--today-color); border-radius: 50%; }
-    .day-cell.today .day-num { color: #fff; font-weight: 700; }
-
     .day-alerts {
       display: flex;
       gap: 2px;
@@ -227,18 +169,7 @@
     .day-discount { font-size: 10px; font-weight: 700; letter-spacing: 0.01em; color: var(--disc-flat); }
     .day-no-price { font-size: 11px; color: var(--text-muted); opacity: 0.35; }
 
-    /* ── Banners layer ───────────────────────────────────────── */
-    .banners-layer { position: absolute; inset: 0; pointer-events: none; z-index: 10; }
-
-    .banner {
-      position: absolute;
-      display: flex; align-items: center;
-      padding: 0 7px;
-      font-size: 10.5px; font-weight: 700;
-      white-space: nowrap; overflow: hidden;
-      border-radius: 4px;
-      letter-spacing: 0.015em;
-    }
+    /* ── Banner state (base banner layout lives in CalendarGrid.vue) ─── */
     .banner.res {
       top: 30px; height: 21px;
       background: var(--res-bg);
@@ -688,33 +619,28 @@
 
       <!-- ── Calendar grid ── -->
       <div class="calendar-col">
-        <div class="dow-row">
-          <div class="dow-cell" v-for="d in DAYS" :key="d">{{ d }}</div>
-        </div>
-
-        <div class="week-row" v-for="(week, wi) in weeks" :key="wi">
-          <div
-            v-for="(day, di) in week"
-            :key="di"
-            class="day-cell"
-            :class="{
-              outside:  !inMonth(day),
-              today:    isToday(day),
-              booked:   inMonth(day) && dayStatus(day) === 'Booked',
-              closed:   inMonth(day) && isClosed(day),
-              selected: bulkEnabled ? bulkDays.has(fmtDate(day)) : (selection?.type === 'day' && selection.key === fmtDate(day)),
-            }"
-            @click="selectDay(day)"
-          >
-            <div class="day-top">
-              <div class="day-num-wrap">
-                <span class="day-num">{{ day.getDate() }}</span>
-              </div>
-              <div class="day-alerts" v-if="syncEnabled && inMonth(day) && dayCategories(fmtDate(day)).length > 0">
-                <span v-for="cat in dayCategories(fmtDate(day))" :key="cat" class="day-dot"></span>
-              </div>
+        <calendar-grid
+          :year="year"
+          :month="month"
+          :spans="[...reservationSpans, ...closureSpans]"
+          :today="todayKey"
+          :cell-class="d => ({
+            booked:   inMonth(d) && dayStatus(d) === 'Booked',
+            closed:   inMonth(d) && isClosed(d),
+            selected: bulkEnabled ? bulkDays.has(fmtDate(d)) : (selection?.type === 'day' && selection.key === fmtDate(d)),
+          })"
+          @day-click="selectDay"
+          :banner-class="b => b.cls === 'res' ? { active: selection?.type === 'res' && selection.key === b.resKey } : null"
+          @banner-click="b => !bulkEnabled && b.cls === 'res' && selectReservation(b)"
+        >
+          <template #day-header="{ dateKey, inMonth }">
+            <div class="day-alerts" v-if="syncEnabled && inMonth && dayCategories(dateKey).length > 0">
+              <span v-for="cat in dayCategories(dateKey)" :key="cat" class="day-dot"></span>
             </div>
-            <template v-if="inMonth(day)">
+          </template>
+
+          <template #cell="{ day, dateKey, inMonth }">
+            <template v-if="inMonth">
               <div v-if="priceFor(day) !== null" class="day-price-row">
                 <span class="day-price">${{ priceFor(day) }}</span>
                 <span v-if="discountFor(day)" class="day-discount">
@@ -725,20 +651,8 @@
               </div>
               <div v-else class="day-no-price">—</div>
             </template>
-          </div>
-
-          <!-- Spanning banners overlay -->
-          <div class="banners-layer">
-            <div
-              v-for="(b, bi) in bannersForWeek(week)"
-              :key="bi"
-              class="banner"
-              :class="[b.cls, { active: selection?.type === 'res' && selection.key === b.spanKey }]"
-              :style="{ left: b.left, width: b.width }"
-              @click="!bulkEnabled && b.cls === 'res' && selectReservation(b.span)"
-            >{{ b.label }}</div>
-          </div>
-        </div>
+          </template>
+        </calendar-grid>
       </div>
 
       <!-- ── Detail panel ── -->
@@ -1325,10 +1239,10 @@
 <script type="module">
 import * as Vue from 'vue'
 import '@iconify/iconify'
-import { fmtDate, parseLocal, addDays, daysBetween, buildWeeks, bannersForWeek as _bannersForWeek } from './src/lib/calendar-grid/index.js'
+import { fmtDate, parseLocal, addDays, daysBetween } from './src/lib/calendar-grid/index.js'
+import CalendarGrid from './src/lib/calendar-grid/CalendarGrid.vue'
 const API_BASE = window.API_BASE
 const { createApp, ref, computed, watch, onMounted, nextTick, reactive } = Vue;
-const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
 
 const CAT = {
   prices:            { label: 'Prices',          color: '#b45309' },
@@ -1802,7 +1716,7 @@ createApp({
     }
 
     // ── Calendar grid ──────────────────────────────────────
-    const weeks = computed(() => buildWeeks(year.value, month.value));
+    // weeks, inMonth, isToday, bannersForWeek handled by CalendarGrid component
 
     const monthName = computed(() =>
       new Date(year.value, month.value, 1).toLocaleString('default', { month: 'long' })
@@ -1833,10 +1747,10 @@ createApp({
       return `${b} for ${n}${r}`;
     });
 
+    // inMonth / isToday live in CalendarGrid; keep a local inMonth for monthStats
     function inMonth(d) {
       return d.getMonth() === month.value && d.getFullYear() === year.value;
     }
-    function isToday(d) { return fmtDate(d) === todayKey; }
 
     // ── Data accessors ─────────────────────────────────────
     function priceFor(d) {
@@ -2055,11 +1969,6 @@ createApp({
       return spans;
     });
 
-    function bannersForWeek(week) {
-      return _bannersForWeek(week, [...reservationSpans.value, ...closureSpans.value])
-        .map(b => ({ ...b, spanKey: b.resKey ?? null, span: b }));
-    }
-
     // ── Fetch ──────────────────────────────────────────────
     async function fetchData() {
       const lastDay = new Date(year.value, month.value + 1, 0).getDate();
@@ -2503,16 +2412,18 @@ createApp({
       toggleClosed, commitPrice, switchDiscountType, commitDiscount, commitResField,
       editingDoorCode, editingWifiKey, editingWifiSsid,
       resDoorState, resKeyState, resSsidState,
-      weeks, monthName, monthStatsLabel,
-      inMonth, isToday, isClosed,
+      monthName, monthStatsLabel,
+      todayKey, inMonth, isClosed,
       priceFor, discountFor, priceDataFor, dayServiceKeys, dayStatus,
       fmtDate, fmtLong, fmtShort, fmtDiscount, fmtField, capitalize, serviceName,
-      bannersForWeek, selectDay, selectReservation, fmtBulkDates,
+      selectDay, selectReservation, fmtBulkDates,
+      reservationSpans, closureSpans,
       prevMonth, nextMonth,
       byDay, resChanges, dayCategories, totalDaysWithIssues,
       selMismatches, selCategories, hasMissingLocalPrices,
     };
   },
+  components: { CalendarGrid },
 }).mount('#app');
 </script>
 </body>

+ 25 - 0
room_cal/vue/package-lock.json

@@ -10,6 +10,7 @@
         "vue": "^3.5.13"
       },
       "devDependencies": {
+        "@vitejs/plugin-vue": "^6.0.7",
         "vite": "^6.3.5"
       }
     },
@@ -526,6 +527,13 @@
       "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
       "license": "MIT"
     },
+    "node_modules/@rolldown/pluginutils": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
+      "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
+      "dev": true,
+      "license": "MIT"
+    },
     "node_modules/@rollup/rollup-android-arm-eabi": {
       "version": "4.62.2",
       "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz",
@@ -922,6 +930,23 @@
       "dev": true,
       "license": "MIT"
     },
+    "node_modules/@vitejs/plugin-vue": {
+      "version": "6.0.7",
+      "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.7.tgz",
+      "integrity": "sha512-km+p+XdSz9Sxm5rqUbqcSfZYaAniKxWBj1KURl+Jr7UaPvvX7BmaWMdP69I5rrFDeQGyxAG7NXdc57vz+snhWg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@rolldown/pluginutils": "^1.0.1"
+      },
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      },
+      "peerDependencies": {
+        "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0",
+        "vue": "^3.2.25"
+      }
+    },
     "node_modules/@vue/compiler-core": {
       "version": "3.5.39",
       "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.39.tgz",

+ 1 - 0
room_cal/vue/package.json

@@ -10,6 +10,7 @@
     "vue": "^3.5.13"
   },
   "devDependencies": {
+    "@vitejs/plugin-vue": "^6.0.7",
     "vite": "^6.3.5"
   }
 }

+ 158 - 0
room_cal/vue/src/lib/calendar-grid/CalendarGrid.vue

@@ -0,0 +1,158 @@
+<template>
+  <div class="cal-grid">
+    <div class="dow-row">
+      <div class="dow-cell" v-for="d in DAYS" :key="d">{{ d }}</div>
+    </div>
+
+    <div class="week-row" v-for="(week, wi) in weeks" :key="wi">
+      <div
+        v-for="(day, di) in week"
+        :key="di"
+        class="day-cell"
+        :class="[
+          { outside: !_inMonth(day), today: _isToday(day) },
+          cellClass ? cellClass(day) : null,
+        ]"
+        @click="$emit('day-click', day)"
+      >
+        <div class="day-top">
+          <div class="day-num-wrap">
+            <span class="day-num">{{ day.getDate() }}</span>
+          </div>
+          <slot name="day-header" :day="day" :dateKey="fmtDate(day)" :inMonth="_inMonth(day)" />
+        </div>
+        <slot name="cell" :day="day" :dateKey="fmtDate(day)" :inMonth="_inMonth(day)" />
+      </div>
+
+      <div class="banners-layer">
+        <div
+          v-for="(banner, bi) in _bannersForWeek(week)"
+          :key="bi"
+          class="banner"
+          :class="[banner.cls, bannerClass ? bannerClass(banner) : null]"
+          :style="{ left: banner.left, width: banner.width }"
+          @click.stop="$emit('banner-click', banner)"
+        >
+          <slot name="banner" :banner="banner">{{ banner.label }}</slot>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script>
+import { computed } from 'vue'
+import { fmtDate, buildWeeks, bannersForWeek } from './index.js'
+
+const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
+
+export default {
+  name: 'CalendarGrid',
+
+  props: {
+    year:      { type: Number, required: true },
+    month:     { type: Number, required: true },   // 0-indexed
+    spans:     { type: Array,  default: () => [] },
+    today:       { type: String, default: () => fmtDate(new Date()) },
+    cellClass:   { type: Function, default: null },
+    bannerClass: { type: Function, default: null },
+  },
+
+  emits: ['day-click', 'banner-click'],
+
+  setup(props) {
+    const weeks = computed(() => buildWeeks(props.year, props.month))
+
+    function _inMonth(d) {
+      return d.getMonth() === props.month && d.getFullYear() === props.year
+    }
+
+    function _isToday(d) {
+      return fmtDate(d) === props.today
+    }
+
+    function _bannersForWeek(week) {
+      return bannersForWeek(week, props.spans)
+    }
+
+    return { DAYS, weeks, fmtDate, _inMonth, _isToday, _bannersForWeek }
+
+  },
+}
+</script>
+
+<style>
+/* Layout — unscoped so host can target .day-cell.{state}, .banner.{cls} freely */
+
+.cal-grid {}
+
+.dow-row {
+  display: grid;
+  grid-template-columns: repeat(7, 1fr);
+  gap: 4px;
+  margin-bottom: 5px;
+}
+.dow-cell {
+  text-align: center;
+  font-size: 10.5px;
+  font-weight: 700;
+  color: var(--text-muted, #9b9087);
+  text-transform: uppercase;
+  letter-spacing: 0.1em;
+  padding: 4px 0 6px;
+}
+
+.week-row {
+  position: relative;
+  display: grid;
+  grid-template-columns: repeat(7, 1fr);
+  gap: 4px;
+  margin-bottom: 4px;
+}
+
+.day-cell {
+  background: var(--surface, #fff);
+  border: 1px solid var(--border-cell, #f0ebe2);
+  border-radius: var(--radius, 10px);
+  min-height: 96px;
+  padding: 8px 8px 7px;
+  display: flex;
+  flex-direction: column;
+  justify-content: space-between;
+  transition: border-color 0.12s, box-shadow 0.12s;
+  cursor: pointer;
+}
+.day-cell:hover { border-color: var(--border, #ede7dd); }
+.day-cell.outside {
+  background: transparent;
+  border-color: transparent;
+  visibility: hidden;
+  cursor: default;
+}
+
+.day-top {
+  display: flex;
+  align-items: flex-start;
+  justify-content: space-between;
+}
+.day-num-wrap {
+  width: 22px; height: 22px;
+  display: flex; align-items: center; justify-content: center;
+  flex-shrink: 0;
+}
+.day-num { font-size: 12.5px; font-weight: 600; color: var(--text, #1c1a18); line-height: 1; }
+.day-cell.today .day-num-wrap { background: var(--today-color, #b8450f); border-radius: 50%; }
+.day-cell.today .day-num { color: #fff; font-weight: 700; }
+
+.banners-layer { position: absolute; inset: 0; pointer-events: none; z-index: 10; }
+
+.banner {
+  position: absolute;
+  display: flex; align-items: center;
+  padding: 0 7px;
+  font-size: 10.5px; font-weight: 700;
+  white-space: nowrap; overflow: hidden;
+  border-radius: 4px;
+  letter-spacing: 0.015em;
+}
+</style>

+ 2 - 0
room_cal/vue/vite.config.js

@@ -1,7 +1,9 @@
 import { defineConfig } from 'vite'
 import path from 'path'
+import vue from '@vitejs/plugin-vue'
 
 export default defineConfig({
+  plugins: [vue()],
   resolve: {
     alias: {
       // Include the template compiler so in-DOM templates render correctly