|
@@ -134,52 +134,8 @@
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/* ── DOW row ─────────────────────────────────────────────── */
|
|
/* ── 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.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 { background: #f0ede8; }
|
|
|
.day-cell.closed .day-num { color: var(--text-muted); }
|
|
.day-cell.closed .day-num { color: var(--text-muted); }
|
|
|
.day-cell.closed .day-price-row,
|
|
.day-cell.closed .day-price-row,
|
|
@@ -188,20 +144,6 @@
|
|
|
.day-cell.booked .day-price-row,
|
|
.day-cell.booked .day-price-row,
|
|
|
.day-cell.booked .day-no-price { opacity: 0.45; }
|
|
.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 {
|
|
.day-alerts {
|
|
|
display: flex;
|
|
display: flex;
|
|
|
gap: 2px;
|
|
gap: 2px;
|
|
@@ -227,18 +169,7 @@
|
|
|
.day-discount { font-size: 10px; font-weight: 700; letter-spacing: 0.01em; color: var(--disc-flat); }
|
|
.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; }
|
|
.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 {
|
|
.banner.res {
|
|
|
top: 30px; height: 21px;
|
|
top: 30px; height: 21px;
|
|
|
background: var(--res-bg);
|
|
background: var(--res-bg);
|
|
@@ -688,33 +619,28 @@
|
|
|
|
|
|
|
|
<!-- ── Calendar grid ── -->
|
|
<!-- ── Calendar grid ── -->
|
|
|
<div class="calendar-col">
|
|
<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>
|
|
</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">
|
|
<div v-if="priceFor(day) !== null" class="day-price-row">
|
|
|
<span class="day-price">${{ priceFor(day) }}</span>
|
|
<span class="day-price">${{ priceFor(day) }}</span>
|
|
|
<span v-if="discountFor(day)" class="day-discount">
|
|
<span v-if="discountFor(day)" class="day-discount">
|
|
@@ -725,20 +651,8 @@
|
|
|
</div>
|
|
</div>
|
|
|
<div v-else class="day-no-price">—</div>
|
|
<div v-else class="day-no-price">—</div>
|
|
|
</template>
|
|
</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>
|
|
</div>
|
|
|
|
|
|
|
|
<!-- ── Detail panel ── -->
|
|
<!-- ── Detail panel ── -->
|
|
@@ -1325,10 +1239,10 @@
|
|
|
<script type="module">
|
|
<script type="module">
|
|
|
import * as Vue from 'vue'
|
|
import * as Vue from 'vue'
|
|
|
import '@iconify/iconify'
|
|
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 API_BASE = window.API_BASE
|
|
|
const { createApp, ref, computed, watch, onMounted, nextTick, reactive } = Vue;
|
|
const { createApp, ref, computed, watch, onMounted, nextTick, reactive } = Vue;
|
|
|
-const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
|
|
|
|
|
|
|
|
|
const CAT = {
|
|
const CAT = {
|
|
|
prices: { label: 'Prices', color: '#b45309' },
|
|
prices: { label: 'Prices', color: '#b45309' },
|
|
@@ -1802,7 +1716,7 @@ createApp({
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// ── Calendar grid ──────────────────────────────────────
|
|
// ── Calendar grid ──────────────────────────────────────
|
|
|
- const weeks = computed(() => buildWeeks(year.value, month.value));
|
|
|
|
|
|
|
+ // weeks, inMonth, isToday, bannersForWeek handled by CalendarGrid component
|
|
|
|
|
|
|
|
const monthName = computed(() =>
|
|
const monthName = computed(() =>
|
|
|
new Date(year.value, month.value, 1).toLocaleString('default', { month: 'long' })
|
|
new Date(year.value, month.value, 1).toLocaleString('default', { month: 'long' })
|
|
@@ -1833,10 +1747,10 @@ createApp({
|
|
|
return `${b} for ${n}${r}`;
|
|
return `${b} for ${n}${r}`;
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
|
|
+ // inMonth / isToday live in CalendarGrid; keep a local inMonth for monthStats
|
|
|
function inMonth(d) {
|
|
function inMonth(d) {
|
|
|
return d.getMonth() === month.value && d.getFullYear() === year.value;
|
|
return d.getMonth() === month.value && d.getFullYear() === year.value;
|
|
|
}
|
|
}
|
|
|
- function isToday(d) { return fmtDate(d) === todayKey; }
|
|
|
|
|
|
|
|
|
|
// ── Data accessors ─────────────────────────────────────
|
|
// ── Data accessors ─────────────────────────────────────
|
|
|
function priceFor(d) {
|
|
function priceFor(d) {
|
|
@@ -2055,11 +1969,6 @@ createApp({
|
|
|
return spans;
|
|
return spans;
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
- function bannersForWeek(week) {
|
|
|
|
|
- return _bannersForWeek(week, [...reservationSpans.value, ...closureSpans.value])
|
|
|
|
|
- .map(b => ({ ...b, spanKey: b.resKey ?? null, span: b }));
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
// ── Fetch ──────────────────────────────────────────────
|
|
// ── Fetch ──────────────────────────────────────────────
|
|
|
async function fetchData() {
|
|
async function fetchData() {
|
|
|
const lastDay = new Date(year.value, month.value + 1, 0).getDate();
|
|
const lastDay = new Date(year.value, month.value + 1, 0).getDate();
|
|
@@ -2503,16 +2412,18 @@ createApp({
|
|
|
toggleClosed, commitPrice, switchDiscountType, commitDiscount, commitResField,
|
|
toggleClosed, commitPrice, switchDiscountType, commitDiscount, commitResField,
|
|
|
editingDoorCode, editingWifiKey, editingWifiSsid,
|
|
editingDoorCode, editingWifiKey, editingWifiSsid,
|
|
|
resDoorState, resKeyState, resSsidState,
|
|
resDoorState, resKeyState, resSsidState,
|
|
|
- weeks, monthName, monthStatsLabel,
|
|
|
|
|
- inMonth, isToday, isClosed,
|
|
|
|
|
|
|
+ monthName, monthStatsLabel,
|
|
|
|
|
+ todayKey, inMonth, isClosed,
|
|
|
priceFor, discountFor, priceDataFor, dayServiceKeys, dayStatus,
|
|
priceFor, discountFor, priceDataFor, dayServiceKeys, dayStatus,
|
|
|
fmtDate, fmtLong, fmtShort, fmtDiscount, fmtField, capitalize, serviceName,
|
|
fmtDate, fmtLong, fmtShort, fmtDiscount, fmtField, capitalize, serviceName,
|
|
|
- bannersForWeek, selectDay, selectReservation, fmtBulkDates,
|
|
|
|
|
|
|
+ selectDay, selectReservation, fmtBulkDates,
|
|
|
|
|
+ reservationSpans, closureSpans,
|
|
|
prevMonth, nextMonth,
|
|
prevMonth, nextMonth,
|
|
|
byDay, resChanges, dayCategories, totalDaysWithIssues,
|
|
byDay, resChanges, dayCategories, totalDaysWithIssues,
|
|
|
selMismatches, selCategories, hasMissingLocalPrices,
|
|
selMismatches, selCategories, hasMissingLocalPrices,
|
|
|
};
|
|
};
|
|
|
},
|
|
},
|
|
|
|
|
+ components: { CalendarGrid },
|
|
|
}).mount('#app');
|
|
}).mount('#app');
|
|
|
</script>
|
|
</script>
|
|
|
</body>
|
|
</body>
|