From 00e5e684fa16abb3ffa370aced0d970424bfe111 Mon Sep 17 00:00:00 2001 From: jtricerolph Date: Sat, 4 Jul 2026 10:02:44 +0000 Subject: [PATCH] Room planner: rewrite card layout to match original Gantt design Replace CSS grid with the original plugin's vertical list + 23px margin bracket system. Each card has a 2px border coloured by booking status; data-previous-status / data-next-status drive C-bracket pseudo-elements in the 23px gutter; data-spans-previous/next remove the corresponding border and collapse the margin to extend the card across days. Card interior is restructured: header row (room number, flow badge, lock, nights counter, site-status pill), guest info row, and stats row (time pill, bed-type pill, task-status bordered pill). Category headers now match the original's sticky bordered style with edge-extending pseudo-elements. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/components/RoomCard.tsx | 291 +++++++++++++++------- frontend/src/index.css | 357 ++++++++++++++++++--------- 2 files changed, 439 insertions(+), 209 deletions(-) diff --git a/frontend/src/components/RoomCard.tsx b/frontend/src/components/RoomCard.tsx index 7d522d3..4551c5f 100644 --- a/frontend/src/components/RoomCard.tsx +++ b/frontend/src/components/RoomCard.tsx @@ -1,9 +1,10 @@ import { memo } from 'react' -import type { RoomData, AppConfig } from '../types' import { - roomCardColor, bookingStatusColor, FLOW_TYPE_LABEL, sliverColor, spanDataAttrs, - hasOutstandingTasks, isDirty, -} from '../lib/booking-flow' + Lock, Moon, Clock, BedDouble, BedSingle, + ClipboardX, ClipboardCheck, Clipboard, + ArrowDown, ArrowUp, ArrowUpDown, Package, +} from 'lucide-react' +import type { RoomData, AppConfig } from '../types' import { detectTwin } from '../lib/twin-detect' interface Props { @@ -13,113 +14,225 @@ interface Props { onClick: (room: RoomData) => void } +const I = { strokeWidth: 1.75 } + +function nightsInfo(arrival: string, departure: string, viewDate: string) { + const msDay = 86400000 + const total = Math.round((Date.parse(departure) - Date.parse(arrival)) / msDay) + const current = Math.round((Date.parse(viewDate) - Date.parse(arrival)) / msDay) + 1 + return { current: Math.max(1, Math.min(current, total)), total: Math.max(1, total) } +} + +function etaTime(eta: string | null | undefined): string | null { + if (!eta) return null + const t = eta.slice(11, 16) + return t && t !== '00:00' ? t : null +} + +function statusCls(status: string): string { + const s = (status || '').toLowerCase() + if (s === 'clean') return 'site-clean' + if (s === 'inspected') return 'site-inspected' + if (s === 'dirty') return 'site-dirty' + return 'site-unknown' +} + +const FLOW: Record = { + arrive: { label: 'Arrive', cls: 'flow-arrive' }, + depart: { label: 'Depart', cls: 'flow-depart' }, + stopover: { label: 'Stay', cls: 'flow-stopover' }, + 'back-to-back': { label: 'B2B', cls: 'flow-b2b' }, + vacant: { label: 'Vacant', cls: 'flow-vacant' }, + blocked: { label: 'Blocked', cls: '' }, +} + +function FlowIcon({ type }: { type: string }) { + if (type === 'arrive') return + if (type === 'depart') return + if (type === 'back-to-back') return + return null +} + function RoomCard({ room, viewDate, config, onClick }: Props) { - const booking = room.booking - const twinType = detectTwin(booking, config) - const taskDisplay = config?.task_display ?? {} + const booking = room.booking + const twinType = detectTwin(booking, config) + const taskDisp = config?.task_display ?? {} const isBlocked = room.flow_type === 'blocked' + const isVacant = room.flow_type === 'vacant' - const stripColor = booking ? bookingStatusColor(booking.booking_status) : 'transparent' - const prevColor = sliverColor(room.previous_status) - const nextColor = sliverColor(room.next_status) + // Booking status (lowercased) drives the card border via CSS attribute selector + const bookingStatus = booking?.booking_status?.toLowerCase() ?? null - // Tasks relevant for today + // Tasks in scope for today const todayTasks = room.tasks.filter(t => { const d = t.task_when_date || t.task_period_from - return !d || d <= viewDate && (t.task_period_to ? t.task_period_to >= viewDate : d === viewDate) + return !d || (d <= viewDate && (!t.task_period_to || t.task_period_to >= viewDate)) + }) + const outstanding = todayTasks.filter(t => !t.completed_on) + const hasRollover = outstanding.some(t => { + const d = t.task_when_date || t.task_period_from + return !!d && d < viewDate }) - const outstanding = todayTasks.filter(t => !t.completed_on) - const dirty = isDirty(room) + // Night counter (current night / total nights) + const nights = booking?.booking_arrival && booking?.booking_departure + ? nightsInfo(booking.booking_arrival, booking.booking_departure, viewDate) + : null - const spanAttrs = spanDataAttrs(room) - const cssVars: Record = {} - if (room.spans_previous) cssVars['--prev-color'] = prevColor - if (room.spans_next) cssVars['--next-color'] = nextColor + // Time pills + const arriveTime = (room.flow_type === 'arrive' || room.flow_type === 'back-to-back') + ? etaTime(booking?.booking_eta) + : null + const departTime = (room.flow_type === 'depart' || room.flow_type === 'back-to-back') + ? (room.departing_time?.slice(0, 5) ?? null) + : null + + // CSS data attributes for the Gantt bracket system + const dataAttrs: Record = {} + if (bookingStatus) dataAttrs['data-booking-status'] = bookingStatus + if (room.spans_previous) { + dataAttrs['data-spans-previous'] = 'true' + } else if (room.previous_status) { + dataAttrs['data-previous-status'] = room.previous_status.toLowerCase() + } + if (room.spans_next) { + dataAttrs['data-spans-next'] = 'true' + } else if (room.next_status) { + dataAttrs['data-next-status'] = room.next_status.toLowerCase() + } + + // Task status pill + let taskCls: string + let TaskIcon: typeof Clipboard + if (todayTasks.length === 0) { + taskCls = 'no-tasks'; TaskIcon = Clipboard + } else if (outstanding.length === 0) { + taskCls = 'all-done'; TaskIcon = ClipboardCheck + } else if (hasRollover) { + taskCls = 'rollover'; TaskIcon = ClipboardX + } else { + taskCls = 'outstanding'; TaskIcon = ClipboardX + } + + const flow = FLOW[room.flow_type] ?? FLOW.vacant return (
)} + className={`room-card${isBlocked ? ' room-blocked' : ''}`} + {...(dataAttrs as React.HTMLAttributes)} onClick={() => onClick(room)} > - {!isBlocked && ( -
- )} - -
-
- {room.site_name} -
- {twinType === 'twin' && Twin} - {twinType === 'extra-bed' && +Bed} - {booking?.pax != null && booking.pax > 0 && ( - {booking.pax}p - )} - {room.departing_time && ( - {room.departing_time.slice(0,5)} - )} -
-
+ {/* ── Header row ─────────────────────────────── */} +
+ {room.site_name} {!isBlocked && ( -
- {booking?.guest_name ? ( - {booking.guest_name} - ) : ( - - {room.flow_type === 'vacant' ? 'Vacant' : '—'} - - )} - {booking?.rate_plan_name && ( - {booking.rate_plan_name} - )} -
- )} - -
- {!isBlocked && ( - - {FLOW_TYPE_LABEL[room.flow_type]} - - )} - - - {dirty ? 'Dirty' : room.site_status} + + + {flow.label} + )} - {outstanding.length > 0 && ( - - {outstanding.length} task{outstanding.length > 1 ? 's' : ''} + {isBlocked && ( + + + + )} + + {booking?.booking_locked && ( + + )} + +
+ {nights && !isVacant && ( + + + {nights.current}/{nights.total} )} + + {room.site_status || 'Unknown'} +
- - {/* Task dots for show_on_card tasks */} - {todayTasks.length > 0 && ( -
- {todayTasks.map(t => { - const display = taskDisplay[t.task_type_id] - if (!display?.show_on_card) return null - return ( -
- ) - })} -
- )}
+ + {/* ── Guest info row ──────────────────────────── */} + {!isBlocked && ( +
+ {isVacant ? ( + Vacant + ) : booking?.guest_name ? ( + {booking.guest_name} + ) : ( + + )} + + {!isVacant && booking?.pax != null && booking.pax > 0 && ( + {booking.pax}p + )} + {twinType === 'twin' && Twin} + {twinType === 'extra-bed' && +Bed} +
+ )} + + {/* ── Stats row ───────────────────────────────── */} + {!isBlocked && !isVacant && ( +
+ + {/* Arrival ETA */} + {arriveTime && ( + + {arriveTime} + + )} + + {/* Departure time */} + {departTime && ( + + {departTime} + + )} + + {/* Bed type */} + {twinType === 'twin' ? ( + + + + ) : twinType === 'extra-bed' ? ( + + + + ) : ( + + + + )} + + {/* Task status */} + + + {outstanding.length > 0 && {outstanding.length}} + +
+ )} + + {/* ── show_on_card task dots ──────────────────── */} + {todayTasks.some(t => taskDisp[t.task_type_id]?.show_on_card) && ( +
+ {todayTasks.map(t => { + const d = taskDisp[t.task_type_id] + if (!d?.show_on_card) return null + return ( +
+ ) + })} +
+ )}
) } diff --git a/frontend/src/index.css b/frontend/src/index.css index 48d356b..1cd6337 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -299,28 +299,52 @@ html, body, #root { /* ── Category group ──────────────────────────────────────────── */ .category-group { - margin-bottom: 2px; + margin-bottom: 4px; } .category-header { display: flex; align-items: center; gap: 8px; - padding: 6px 12px 5px; - background: var(--body-bg); - border-bottom: 1px solid var(--border); + background: #e5e7eb; + border: 2px solid #d1d5db; + border-radius: 8px; + margin: 8px 23px 5px; + padding: 6px 12px; cursor: pointer; user-select: none; position: sticky; top: 0; z-index: 10; } +.category-header::before, +.category-header::after { + content: ''; + position: absolute; + top: -2px; + bottom: -2px; + width: 16px; + background: #e5e7eb; + border-top: 2px solid #d1d5db; + border-bottom: 2px solid #d1d5db; + pointer-events: none; +} +.category-header::before { + left: -16px; + border-right: 2px solid #d1d5db; + border-radius: 0 6px 6px 0; +} +.category-header::after { + right: -16px; + border-left: 2px solid #d1d5db; + border-radius: 6px 0 0 6px; +} .category-name { flex: 1; font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: .06em; - color: var(--text-muted); + color: #4b5563; } .category-badge { display: inline-flex; @@ -333,163 +357,256 @@ html, body, #root { } .badge-tasks { background: #fef3c7; color: #92400e; } .badge-dirty { background: #fee2e2; color: #991b1b; } -.category-chevron { color: var(--text-muted); transition: transform .15s; } -.category-chevron.open { transform: rotate(0deg); } +.category-chevron { color: #9ca3af; transition: transform .15s; } +.category-chevron.open { transform: rotate(0deg); } .category-chevron.closed { transform: rotate(-90deg); } -.category-rooms { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); - gap: 1px; - background: var(--border); -} -/* ── Room card ───────────────────────────────────────────────── */ +.category-rooms { padding: 4px 0 2px; } + +/* ── Room card — Gantt bracket system ────────────────────────── */ .room-card { - position: relative; - background: var(--card-bg); - min-height: var(--card-h); - cursor: pointer; - overflow: hidden; - transition: box-shadow .12s; - padding-left: var(--sliver-w); - padding-right: var(--sliver-w); -} -.room-card:hover { box-shadow: var(--shadow-md); z-index: 1; } - -/* Left sliver (previous day status) */ -.room-card[data-spans-previous="true"]::before { - content: ''; - position: absolute; - left: 0; - top: 0; - bottom: 0; - width: var(--sliver-w); - background: var(--prev-color, transparent); - opacity: .7; -} -/* Right sliver (next day status) */ -.room-card[data-spans-next="true"]::after { - content: ''; - position: absolute; - right: 0; - top: 0; - bottom: 0; - width: var(--sliver-w); - background: var(--next-color, transparent); - opacity: .7; -} - -/* Blocked rooms — fill entire card */ -.room-card.blocked-room { - background: var(--col-blocked); -} -.room-card.blocked-room .card-room-name, -.room-card.blocked-room .card-status-dot { color: rgba(255,255,255,.85); } - -/* Status indicator strip at top of card */ -.card-status-strip { - position: absolute; - top: 0; - left: var(--sliver-w); - right: var(--sliver-w); - height: 3px; -} - -/* Card inner */ -.card-inner { + background: #ffffff; + border: 2px solid #d1d5db; + border-radius: 8px; + margin: 0 23px 5px; padding: 7px 10px; - display: flex; - flex-direction: column; - gap: 3px; - height: 100%; + cursor: pointer; + position: relative; + transition: box-shadow .15s; } -.card-top { +.room-card:hover { box-shadow: 0 2px 8px rgba(0,0,0,.15); } + +/* Border colour by booking status */ +.room-card[data-booking-status="arrived"] { border-color: #3b82f6; } +.room-card[data-booking-status="confirmed"] { border-color: #10b981; } +.room-card[data-booking-status="unconfirmed"] { border-color: #f59e0b; } +.room-card[data-booking-status="departed"] { border-color: #a855f7; } + +/* Span to previous day: card extends left into the margin */ +.room-card[data-spans-previous="true"] { + border-left: none; + border-top-left-radius: 0; + border-bottom-left-radius: 0; + margin-left: 0; + padding-left: 30px; +} +/* Span to next day: card extends right into the margin */ +.room-card[data-spans-next="true"] { + border-right: none; + border-top-right-radius: 0; + border-bottom-right-radius: 0; + margin-right: 0; + padding-right: 30px; +} +/* Both: fully square on both sides */ +.room-card[data-spans-previous="true"][data-spans-next="true"] { + border-radius: 0; +} + +/* ── Gantt bracket pseudo-elements ───────────────────────────── */ +/* Left bracket: previous adjacent booking status. + Extends 23px left (to the left edge of the margin area), width 15px, + leaving an 8px visual gap to the card border — matches original plugin. */ +.room-card[data-previous-status]::before { + content: ''; + position: absolute; + left: -23px; + top: -2px; + bottom: -2px; + width: 15px; + border-top: 2px solid #d1d5db; + border-bottom: 2px solid #d1d5db; + border-right: 2px solid #d1d5db; + border-radius: 0 8px 8px 0; + pointer-events: none; +} +.room-card[data-previous-status="arrived"]::before { border-color: #3b82f6; } +.room-card[data-previous-status="confirmed"]::before { border-color: #10b981; } +.room-card[data-previous-status="unconfirmed"]::before { border-color: #f59e0b; } +.room-card[data-previous-status="departed"]::before { border-color: #a855f7; } + +/* Right bracket: next adjacent booking status. */ +.room-card[data-next-status]::after { + content: ''; + position: absolute; + right: -23px; + top: -2px; + bottom: -2px; + width: 15px; + border-top: 2px solid #d1d5db; + border-bottom: 2px solid #d1d5db; + border-left: 2px solid #d1d5db; + border-radius: 8px 0 0 8px; + pointer-events: none; +} +.room-card[data-next-status="arrived"]::after { border-color: #3b82f6; } +.room-card[data-next-status="confirmed"]::after { border-color: #10b981; } +.room-card[data-next-status="unconfirmed"]::after { border-color: #f59e0b; } +.room-card[data-next-status="departed"]::after { border-color: #a855f7; } + +/* Blocked room */ +.room-card.room-blocked { + background: #4b5563; + border-color: #374151; + cursor: default; +} + +/* ── Card content layout ──────────────────────────────────────── */ +/* Header row: room number, flow badge, lock icon, [spacer], nights+status */ +.card-header { display: flex; align-items: center; gap: 6px; } -.card-room-name { - font-size: 15px; +.card-room-number { + font-size: 14px; font-weight: 700; - color: var(--text-primary); - flex: 1; + color: #111827; + flex-shrink: 0; } -.card-badges { - display: flex; +.room-blocked .card-room-number { color: rgba(255,255,255,.9); } + +.card-flow-badge { + display: inline-flex; + align-items: center; gap: 3px; - flex-wrap: wrap; -} -.card-badge { - font-size: 9px; - font-weight: 600; - padding: 2px 5px; + padding: 2px 6px 2px 4px; border-radius: 4px; + font-size: 10px; + font-weight: 700; text-transform: uppercase; letter-spacing: .04em; + flex-shrink: 0; + white-space: nowrap; } -.badge-twin { background: #e0e7ff; color: #3730a3; } -.badge-extra { background: #fce7f3; color: #9d174d; } -.badge-pax { background: #f3f4f6; color: #374151; } -.badge-depart-time { background: #f5f3ff; color: #6d28d9; } +.flow-arrive { background: #d1fae5; color: #065f46; } +.flow-depart { background: #ede9fe; color: #5b21b6; } +.flow-stopover { background: #dbeafe; color: #1e40af; } +.flow-b2b { background: #fef3c7; color: #92400e; } +.flow-vacant { background: #f3f4f6; color: #6b7280; } -.card-mid { +.card-locked-icon { color: #9ca3af; display: flex; flex-shrink: 0; } + +/* Right-aligned header group: nights counter + site status pill */ +.card-header-right { + margin-left: auto; display: flex; align-items: center; gap: 6px; + flex-shrink: 0; +} +.card-nights { + display: inline-flex; + align-items: center; + gap: 2px; + font-size: 11px; + color: #6b7280; +} +.card-site-status { + display: inline-block; + padding: 2px 6px; + border-radius: 4px; + font-size: 10px; + font-weight: 600; +} +.site-clean { background: #d1fae5; color: #065f46; } +.site-dirty { background: #fee2e2; color: #991b1b; } +.site-inspected { background: #ede9fe; color: #5b21b6; } +.site-unknown { background: #e5e7eb; color: #6b7280; } + +/* Guest info row */ +.card-guest-row { + display: flex; + align-items: center; + gap: 6px; + margin-top: 4px; } .card-guest-name { font-size: 12px; - color: var(--text-muted); + color: #374151; flex: 1; + min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.card-rate { +.card-vacant-label { + font-size: 12px; + color: #9ca3af; + font-style: italic; + flex: 1; +} +.card-pax-badge { font-size: 10px; - color: var(--text-muted); - background: #f9fafb; + color: #6b7280; + background: #f3f4f6; border-radius: 4px; padding: 1px 5px; -} - -.card-bottom { - display: flex; - align-items: center; - gap: 6px; - margin-top: auto; -} -.card-flow-label { - font-size: 10px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: .05em; - padding: 2px 6px; - border-radius: 4px; flex-shrink: 0; } -.card-clean-status { +.card-type-badge { + font-size: 10px; + border-radius: 4px; + padding: 1px 5px; + flex-shrink: 0; + font-weight: 600; +} +.type-twin { background: #e0e7ff; color: #3730a3; } +.type-extra { background: #fce7f3; color: #9d174d; } + +/* Stats row: time pill, bed type pill, task status pill */ +.card-stats-row { + display: flex; + align-items: center; + gap: 4px; + margin-top: 5px; +} +.card-stat-pill { + display: inline-flex; + align-items: center; + gap: 3px; + padding: 2px 5px; + border-radius: 4px; + border: 1.5px solid #d1d5db; + font-size: 10px; + color: #6b7280; + background: #fff; + white-space: nowrap; + flex-shrink: 0; +} +.card-stat-pill.time-arrive { border-color: #10b981; color: #065f46; } +.card-stat-pill.time-depart { border-color: #a855f7; color: #7c3aed; } +.card-stat-pill.bed-twin { border-color: #c084fc; color: #7e22ce; } +.card-stat-pill.bed-double { border-color: #93c5fd; color: #1d4ed8; } +.card-stat-pill.bed-extra { border-color: #f9a8d4; color: #9d174d; } + +/* Task status pill (right-aligned) */ +.card-task-pill { + display: inline-flex; + align-items: center; + gap: 3px; + padding: 2px 5px; + border-radius: 4px; + border: 1.5px solid; font-size: 10px; font-weight: 600; - padding: 2px 6px; - border-radius: 4px; + background: #fff; + margin-left: auto; + flex-shrink: 0; } -.clean-dot { background: #d1fae5; color: #065f46; } -.dirty-dot { background: #fee2e2; color: #991b1b; } +.card-task-pill.no-tasks { border-color: #d1d5db; color: #d1d5db; } +.card-task-pill.all-done { border-color: #10b981; color: #10b981; } +.card-task-pill.outstanding { border-color: #dc2626; color: #dc2626; } +.card-task-pill.rollover { border-color: #f59e0b; color: #f59e0b; } -/* Task dots row */ +/* show_on_card task dots */ .card-task-dots { display: flex; gap: 3px; flex-wrap: wrap; - margin-top: 2px; -} -.task-dot { - width: 8px; - height: 8px; - border-radius: 50%; - flex-shrink: 0; + margin-top: 4px; } +.task-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; } .task-dot.complete { opacity: .3; } /* ── Room modal ──────────────────────────────────────────────── */