App & Dashboardappdashboardsaasstatusboardgridhoteloperations
Housekeeping Board
Room status board by floor — clean, dirty, inspecting, occupied, out of order — with filters and quick status changes.
Live Preview
Housekeeping
Floor 1
Floor 2
Usage
Copy and paste this code into your React component:
tsx
import { HousekeepingBoard } from 'react-component-kit-ui/app';
<HousekeepingBoard rooms={[{ number: '101', floor: 'Floor 1', status: 'dirty', assignee: 'Rosa' }]} />Manual Installation
If you are not using the npm package, create a new file and paste the following code: src/components/HousekeepingBoard.tsx + HousekeepingBoard.css:
HousekeepingBoard.tsx
'use client';
import { useEffect, useRef } from 'react';
import './HousekeepingBoard.css';
/** class:list → className */
function cx(...args: unknown[]): string {
const out: string[] = [];
const walk = (v: unknown) => {
if (!v) return;
if (typeof v === 'string' || typeof v === 'number') out.push(String(v));
else if (Array.isArray(v)) v.forEach(walk);
else if (typeof v === 'object') for (const [k, on] of Object.entries(v as Record<string, unknown>)) if (on) out.push(k);
};
args.forEach(walk);
return out.join(' ');
}
type Status = 'clean' | 'dirty' | 'inspecting' | 'occupied' | 'ooo';
interface Room { number: string; floor: string; type?: string; status: Status; guest?: string; note?: string; assignee?: string }
/**
* HousekeepingBoard — Room status board grouped by floor: clean, dirty, inspecting, occupied and
* out of order, with live counters, status filter and a quick "next status" action per room.
*
* @param {Array<{number: string, floor: string, type?: string, status: Status, guest?: string, note?: string, assignee?: string}>} rooms - Rooms to show.
* @param {string} title - Card title. Default 'Housekeeping'.
* @param {boolean} interactive - Clicking a tile advances dirty → inspecting → clean (demo/workflow). Default true.
* @param {string} variant - Style (card|plain). Default 'card'.
* @param {string} size - Tile size (sm|md). Default 'md'.
* @param {string} className - Extra class.
*
* Statuses: clean · dirty · inspecting · occupied · ooo (out of order).
* Emits `app:room-status` with `{ room, status }` on change.
*
* Accessibility: status is written in text on every tile (not colour only); tiles are buttons
* with full labels; counters live in a polite region; the filter is a set of toggle buttons.
*/
export interface HousekeepingBoardProps {
rooms?: Room[];
title?: string;
interactive?: boolean;
variant?: 'card' | 'plain';
size?: 'sm' | 'md';
className?: string;
}
export function HousekeepingBoard({
rooms = [
{ number: '101', floor: 'Floor 1', type: 'Standard', status: 'clean' },
{ number: '102', floor: 'Floor 1', type: 'Standard', status: 'occupied', guest: 'A. Tanaka' },
{ number: '103', floor: 'Floor 1', type: 'Standard', status: 'dirty', assignee: 'Rosa' },
{ number: '104', floor: 'Floor 1', type: 'Twin', status: 'inspecting', assignee: 'Luis' },
{ number: '201', floor: 'Floor 2', type: 'Deluxe', status: 'ooo', note: 'A/C repair' },
{ number: '202', floor: 'Floor 2', type: 'Deluxe', status: 'occupied', guest: 'J. Whitfield' },
{ number: '203', floor: 'Floor 2', type: 'Deluxe', status: 'dirty', assignee: 'Rosa' },
{ number: '204', floor: 'Floor 2', type: 'Deluxe', status: 'clean' },
],
title = 'Housekeeping',
interactive = true,
variant = 'card',
size = 'md',
className = '',
}: HousekeepingBoardProps) {
const labels: Record<Status, string> = { clean: 'Clean', dirty: 'Dirty', inspecting: 'Inspecting', occupied: 'Occupied', ooo: 'Out of order' };
const order: Status[] = ['clean', 'dirty', 'inspecting', 'occupied', 'ooo'];
const floors = Array.from(new Set(rooms.map((r) => r.floor)));
const counts = Object.fromEntries(order.map((s) => [s, rooms.filter((r) => r.status === s).length]));
// Ported from the Astro <script>: runs once after mount (DOM logic kept as in the Astro kit).
const mounted = useRef(false);
useEffect(() => {
if (mounted.current) return;
mounted.current = true;
const LABELS: Record<string, string> = { clean: 'Clean', dirty: 'Dirty', inspecting: 'Inspecting', occupied: 'Occupied', ooo: 'Out of order' };
const NEXT: Record<string, string> = { dirty: 'inspecting', inspecting: 'clean' };
document.querySelectorAll<HTMLElement>('[data-hk]:not([data-bound])').forEach((hk) => {
hk.dataset.bound = '';
const filters = Array.from(hk.querySelectorAll<HTMLButtonElement>('[data-f]'));
const announce = hk.querySelector<HTMLElement>('[data-announce]')!;
let active = '';
const recount = () => {
const items = Array.from(hk.querySelectorAll<HTMLElement>('li[data-status]'));
hk.querySelectorAll<HTMLElement>('[data-c]').forEach((c) => {
const s = c.dataset.c;
c.textContent = String(s ? items.filter((i) => i.dataset.status === s).length : items.length);
});
items.forEach((i) => { i.hidden = Boolean(active) && i.dataset.status !== active; });
hk.querySelectorAll<HTMLElement>('.hk__floor').forEach((f) => { f.hidden = !f.querySelector('li:not([hidden])'); });
};
filters.forEach((f) => f.addEventListener('click', () => {
active = f.dataset.f || '';
filters.forEach((x) => x.setAttribute('aria-pressed', String(x === f)));
recount();
}));
if (hk.hasAttribute('data-interactive')) {
hk.querySelectorAll<HTMLButtonElement>('.hk__room').forEach((btn) => btn.addEventListener('click', () => {
const next = NEXT[btn.dataset.status || ''];
if (!next) return;
btn.classList.remove(`hk__room--${btn.dataset.status}`);
btn.classList.add(`hk__room--${next}`);
btn.dataset.status = next;
btn.closest('li')!.dataset.status = next;
btn.querySelector('[data-label]')!.textContent = LABELS[next];
btn.setAttribute('aria-label', btn.getAttribute('aria-label')!.replace(/, (Dirty|Inspecting|Clean)/, `, ${LABELS[next]}`));
announce.textContent = `Room ${btn.dataset.room} is now ${LABELS[next].toLowerCase()}.`;
hk.dispatchEvent(new CustomEvent('app:room-status', { bubbles: true, detail: { room: btn.dataset.room, status: next } }));
recount();
}));
}
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<>
<section className={cx(['hk', `hk--${variant}`, `hk--${size}`, className])} data-hk data-interactive={interactive ? '' : undefined} aria-labelledby="hk-title" data-rk-66fdf4="">
<header className="hk__head" data-rk-66fdf4="">
<h2 className="hk__title" id="hk-title" data-rk-66fdf4="">{title}</h2>
<div className="hk__filters" role="group" aria-label="Filter rooms by status" data-rk-66fdf4="">
<button type="button" className="hk__filter" aria-pressed="true" data-f="" data-rk-66fdf4="">All <span data-c="" data-rk-66fdf4="">{rooms.length}</span></button>
{order.map((s, __i: number) => (
<button key={__i} type="button" className={cx(['hk__filter', `hk__filter--${s}`])} aria-pressed="false" data-f={s} data-rk-66fdf4="">
<i aria-hidden="true" data-rk-66fdf4=""></i>{labels[s]} <span data-c={s} data-rk-66fdf4="">{counts[s]}</span>
</button>
))}
</div>
</header>
<p className="sr-only" aria-live="polite" data-announce data-rk-66fdf4=""></p>
{floors.map((floor, __i: number) => (
<div key={__i} className="hk__floor" data-rk-66fdf4="">
<h3 className="hk__floor-title" data-rk-66fdf4="">{floor}</h3>
<ul className="hk__grid" data-rk-66fdf4="">
{rooms.filter((r) => r.floor === floor).map((r, __i: number) => (
<li key={__i} data-status={r.status} data-rk-66fdf4="">
<button type="button" className={cx(['hk__room', `hk__room--${r.status}`])} data-room={r.number} data-status={r.status} aria-label={`Room ${r.number}, ${labels[r.status]}${r.guest ? `, ${r.guest}` : ''}${r.assignee ? `, assigned to ${r.assignee}` : ''}${interactive && (r.status === 'dirty' || r.status === 'inspecting') ? '. Activate to advance status' : ''}`} data-rk-66fdf4="">
<span className="hk__num" data-rk-66fdf4="">{r.number}</span>
{r.type && <span className="hk__type" data-rk-66fdf4="">{r.type}</span>}
<span className="hk__state" data-rk-66fdf4=""><i aria-hidden="true" data-rk-66fdf4=""></i><span data-label data-rk-66fdf4="">{labels[r.status]}</span></span>
{(r.guest || r.note || r.assignee) && <span className="hk__meta" data-rk-66fdf4="">{r.guest ?? r.note ?? `→ ${r.assignee}`}</span>}
</button>
</li>
))}
</ul>
</div>
))}
</section>
</>
);
}
export default HousekeepingBoard;HousekeepingBoard.css
/* HousekeepingBoard — ported from astro-component-kit (app/HousekeepingBoard.astro) */
.hk:where([data-rk-66fdf4]) { container-type: inline-size; display: grid; grid-template-columns: minmax(0, 1fr); gap: var(--sp-4, 1rem); min-width: 0; }
.hk--card:where([data-rk-66fdf4]) { padding: var(--sp-5, 1.25rem); border-radius: var(--r-lg, 16px); border: 1px solid var(--c-border, rgba(255, 255, 255, 0.08)); background: var(--c-bg-1, #0a0a0a); }
.hk__head:where([data-rk-66fdf4]) { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: var(--sp-3, 0.75rem); }
.hk__title:where([data-rk-66fdf4]) { margin: 0; font-size: 1rem; font-weight: 700; color: var(--c-text-1, #fff); }
.hk__filters:where([data-rk-66fdf4]) { display: flex; gap: 6px; min-width: 0; max-width: 100%; overflow-x: auto; scrollbar-width: none; }
.hk__filter:where([data-rk-66fdf4]) { display: inline-flex; align-items: center; gap: 6px; min-height: 32px; padding: 0 0.7rem; border-radius: 999px; border: 1px solid var(--c-border, rgba(255, 255, 255, 0.1)); background: transparent; color: var(--c-text-2, #a3a3a3); font: inherit; font-size: 0.76rem; font-weight: 600; white-space: nowrap; cursor: pointer; }
.hk__filter:where([data-rk-66fdf4]) i:where([data-rk-66fdf4]) { width: 8px; height: 8px; border-radius: 50%; background: var(--tone); }
.hk__filter:where([data-rk-66fdf4]) span:where([data-rk-66fdf4]) { color: var(--c-text-3, #737373); font-variant-numeric: tabular-nums; }
.hk__filter[aria-pressed='true']:where([data-rk-66fdf4]) { background: var(--c-text-1, #fff); color: var(--c-bg, #050505); border-color: var(--c-text-1, #fff); }
.hk__filter[aria-pressed='true']:where([data-rk-66fdf4]) span:where([data-rk-66fdf4]) { color: inherit; opacity: 0.7; }
.hk__floor[hidden]:where([data-rk-66fdf4]) { display: none; }
.hk__floor-title:where([data-rk-66fdf4]) { margin: 0 0 var(--sp-2, 0.5rem); font-size: 0.72rem; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; color: var(--c-text-3, #737373); }
.hk__grid:where([data-rk-66fdf4]) { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: var(--sp-2, 0.5rem); margin: 0; padding: 0; list-style: none; }
.hk--sm:where([data-rk-66fdf4]) .hk__grid:where([data-rk-66fdf4]) { grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); }
.hk__grid:where([data-rk-66fdf4]) li[hidden]:where([data-rk-66fdf4]) { display: none; }
.hk__room:where([data-rk-66fdf4]) {
--tone: var(--c-text-3, #737373);
display: grid;
gap: 2px;
width: 100%;
min-height: 96px;
padding: var(--sp-3, 0.75rem);
border: 1px solid color-mix(in srgb, var(--tone) 35%, transparent);
border-top: 3px solid var(--tone);
border-radius: 10px;
background: color-mix(in srgb, var(--tone) 7%, var(--c-bg-1, #0a0a0a));
color: var(--c-text-1, #fff);
font: inherit;
text-align: left;
cursor: pointer;
transition: transform 0.15s ease, box-shadow 0.15s ease;
}
.hk--sm:where([data-rk-66fdf4]) .hk__room:where([data-rk-66fdf4]) { min-height: 76px; }
.hk__room:hover:where([data-rk-66fdf4]) { transform: translateY(-2px); box-shadow: 0 10px 24px -14px color-mix(in srgb, var(--tone) 70%, transparent); }
.hk__room:focus-visible:where([data-rk-66fdf4]) { outline: 2px solid var(--app-accent, var(--c-primary-light, #d4a017)); outline-offset: 2px; }
.hk__num:where([data-rk-66fdf4]) { font-size: 1.2rem; font-weight: 800; letter-spacing: -0.02em; font-variant-numeric: tabular-nums; }
.hk__type:where([data-rk-66fdf4]) { font-size: 0.72rem; color: var(--c-text-3, #737373); }
.hk__state:where([data-rk-66fdf4]) { display: inline-flex; align-items: center; gap: 6px; margin-top: 4px; font-size: 0.75rem; font-weight: 700; color: var(--tone); }
.hk__state:where([data-rk-66fdf4]) i:where([data-rk-66fdf4]) { width: 7px; height: 7px; border-radius: 50%; background: currentColor; }
.hk__meta:where([data-rk-66fdf4]) { font-size: 0.72rem; color: var(--c-text-2, #a3a3a3); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.hk__room--clean:where([data-rk-66fdf4]), .hk__filter--clean:where([data-rk-66fdf4]) { --tone: var(--c-success, #34d399); }
.hk__room--dirty:where([data-rk-66fdf4]), .hk__filter--dirty:where([data-rk-66fdf4]) { --tone: var(--c-danger, #f87171); }
.hk__room--inspecting:where([data-rk-66fdf4]), .hk__filter--inspecting:where([data-rk-66fdf4]) { --tone: var(--c-warning, #fbbf24); }
.hk__room--occupied:where([data-rk-66fdf4]), .hk__filter--occupied:where([data-rk-66fdf4]) { --tone: var(--app-accent, var(--c-primary-light, #d4a017)); }
.hk__room--ooo:where([data-rk-66fdf4]), .hk__filter--ooo:where([data-rk-66fdf4]) { --tone: var(--c-text-3, #737373); }
.hk__room--ooo:where([data-rk-66fdf4]) { background: repeating-linear-gradient(135deg, rgb(var(--ink-rgb, 255 255 255) / 0.04) 0 8px, transparent 8px 16px), var(--c-bg-1, #0a0a0a); }
.hk__filter:focus-visible:where([data-rk-66fdf4]) { outline: 2px solid var(--app-accent, var(--c-primary-light, #d4a017)); outline-offset: 2px; }
.sr-only:where([data-rk-66fdf4]) { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
@media (prefers-reduced-motion: reduce) { .hk__room:where([data-rk-66fdf4]) { transition: none; } }Quick Info
- Category
- App & Dashboard
- Filename
HousekeepingBoard.tsx- Dependencies
- None — pure React + CSS
- Tags
- appdashboardsaasstatusboardgridhoteloperations