Files
Nitro-V3/src/components/housekeeping/views/rooms/HousekeepingRoomsTab.tsx
T
simoleo89 eeab548917 feat(housekeeping): in-client admin panel
Adds the Housekeeping in-client admin panel — a Modtools-adjacent
surface that runs entirely inside the React client, talking to the
emulator over the existing wire instead of a separate REST/CMS layer.

Surface:
- `src/components/housekeeping/` — panel shell + 5 tabs (Dashboard,
  Users, Rooms, Economy, Audit). Each tab drives one domain of the
  matching emulator handlers (find/sanction/admin/economy/catalog/
  hotel-wide).
- `src/api/housekeeping/` — composer/parser orchestration:
  `HousekeepingApi.ts` exposes 30+ typed actions, each one running
  through `runHkAction()` which awaits the shared
  `HousekeepingActionResultEvent` correlated by action key.
- `src/hooks/housekeeping/` — `useHousekeeping` (the public hook),
  `useHousekeepingStore` (useBetween singleton: shared selection +
  audit polling + sanction templates), `useHousekeepingActions`,
  `useHousekeepingConfirm`.
- `src/api/nitro/awaitMessageEvent.ts` — Promise adapter over
  `CommunicationManager.subscribeMessage` with a sync `select`
  callback that snapshots the parser INSIDE the subscribe handler
  before the renderer recycles the parser instance after the
  Promise resolves.
- `public/configuration/housekeeping-texts-{en,it}.example` —
  149 EN + 149 IT i18n keys under `housekeeping.*` for every panel
  string + every server-side error slug the emulator may emit.

Wiring (additive only):
- `src/components/MainView.tsx` — `<HousekeepingView />` mounted
  alongside `<ModToolsView />`.
- `src/api/index.ts`, `src/hooks/index.ts`, `src/api/nitro/index.ts`
  — added the `housekeeping` and `awaitMessageEvent` re-exports.

Wire contract: pairs against the Arcturus PR (#120 on
duckietm/Arcturus-Morningstar-Extended) and the renderer PR (#77 on
duckietm/Nitro_Render_V3). Incoming events 9100..9129, outgoing
composers 9200..9207. Permission gate `acc_housekeeping` enforced
server-side; the panel is hidden client-side via
`housekeeping.enabled` in the runtime ui-config.
2026-05-24 16:38:16 +02:00

199 lines
12 KiB
TypeScript

import { FC, useState } from 'react';
import { FaCrown, FaDoorOpen, FaExchangeAlt, FaHome, FaLock, FaMapMarkerAlt, FaSearch, FaTimes, FaTrash, FaUserSlash, FaUsers, FaVolumeMute } from 'react-icons/fa';
import { LocalizeText } from '../../../../api';
import { Button } from '../../../../common';
import { useHousekeeping, useHousekeepingConfirm, useRoom } from '../../../../hooks';
const DEFAULT_MUTE_MINUTES = 10;
export const HousekeepingRoomsTab: FC = () =>
{
const {
selectedRoom, setSelectedRoom, lookupRoomById, isRoomLoading, isActionPending,
openRoom, closeRoom, muteRoom, kickAllFromRoom, transferRoomOwnership, deleteRoom
} = useHousekeeping();
const { roomSession = null } = useRoom();
const [ query, setQuery ] = useState('');
const [ muteMinutes, setMuteMinutes ] = useState<number>(DEFAULT_MUTE_MINUTES);
const [ newOwnerId, setNewOwnerId ] = useState<number>(0);
const confirm = useHousekeepingConfirm();
const currentRoomId = roomSession && roomSession.roomId > 0 ? roomSession.roomId : 0;
// Empty query + Cerca → fall back to the room the operator is
// currently standing in. Saves a copy-paste of the room id from
// navigator just to inspect "this room". Mirrors how /ban / /kick
// in chat default to the active room.
const submitLookup = () =>
{
const trimmed = query.trim();
const idFromQuery = parseInt(trimmed);
const id = (Number.isFinite(idFromQuery) && idFromQuery > 0) ? idFromQuery : currentRoomId;
if(id <= 0) return;
lookupRoomById(id);
};
const useCurrentRoom = () =>
{
if(currentRoomId <= 0) return;
setQuery(String(currentRoomId));
lookupRoomById(currentRoomId);
};
const disableActions = !selectedRoom || isActionPending;
const confirmAndRun = (key: string, fn: () => void) => confirm(LocalizeText(key), fn);
const occupancyPct = selectedRoom && selectedRoom.maxUsers > 0
? Math.min(100, Math.round((selectedRoom.userCount / selectedRoom.maxUsers) * 100))
: 0;
return (
<div className="flex flex-col gap-2">
{ /* Lookup bar */ }
<div className="flex gap-1.5 items-center">
<div className="flex items-center gap-1 grow rounded-md border border-zinc-300 bg-white px-2 py-1 shadow-sm focus-within:ring-1 focus-within:ring-sky-300 focus-within:border-sky-400 transition-colors">
<FaSearch className="text-zinc-400 shrink-0" size={ 11 } />
<input
type="number"
min={ 1 }
className="grow text-sm bg-transparent outline-none placeholder:text-zinc-400"
placeholder={ currentRoomId > 0
? `${ LocalizeText('housekeeping.room.search.placeholder') } · empty → current #${ currentRoomId }`
: LocalizeText('housekeeping.room.search.placeholder') }
value={ query }
onChange={ event => setQuery(event.target.value) }
onKeyDown={ event => { if(event.key === 'Enter') submitLookup(); } } />
</div>
{ currentRoomId > 0 && currentRoomId !== selectedRoom?.id &&
<Button
gap={ 1 }
variant="secondary"
disabled={ isRoomLoading }
title={ `Lookup current room #${ currentRoomId }` }
onClick={ useCurrentRoom }>
<FaMapMarkerAlt size={ 10 } className="text-sky-500" />
<span>here</span>
</Button> }
<Button gap={ 1 } disabled={ isRoomLoading } onClick={ submitLookup }>
<FaSearch size={ 10 } className={ isRoomLoading ? 'animate-pulse' : '' } />
<span>{ LocalizeText('housekeeping.room.search.button') }</span>
</Button>
</div>
{ /* Selected room hero card */ }
{ selectedRoom
? (
<div className="relative overflow-hidden rounded-lg border border-sky-200 bg-gradient-to-br from-sky-50 via-white to-violet-50 p-3 shadow-sm">
<div className="flex items-start gap-3">
<div className="rounded-full bg-sky-100 p-2 shrink-0 flex items-center justify-center">
<span className="nitro-icon nitro-icon-hk-hero icon-rooms" />
</div>
<div className="grow min-w-0">
<div className="flex items-center gap-1.5 flex-wrap">
<span className="font-bold text-base truncate">{ selectedRoom.name }</span>
<span className="text-[10px] text-zinc-500 tabular-nums">#{ selectedRoom.id }</span>
{ selectedRoom.isPublic &&
<span className="text-[9px] uppercase font-semibold px-1.5 py-0.5 rounded-full bg-emerald-100 border border-emerald-200 text-emerald-800">public</span> }
{ selectedRoom.isLocked &&
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded-full text-[10px] font-semibold bg-rose-100 border border-rose-200 text-rose-700"><FaLock size={ 8 } /> closed</span> }
{ selectedRoom.isMuted &&
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded-full text-[10px] font-semibold bg-amber-100 border border-amber-200 text-amber-700"><FaVolumeMute size={ 8 } /> muted</span> }
</div>
<div className="text-xs text-zinc-600 truncate mt-0.5">{ selectedRoom.description || '—' }</div>
<div className="flex items-center gap-3 text-[11px] text-zinc-700 mt-1.5">
<span className="inline-flex items-center gap-1" title={ `${ selectedRoom.userCount } / ${ selectedRoom.maxUsers }` }>
<FaUsers size={ 10 } className="text-sky-600" />
<span className="tabular-nums font-semibold">{ selectedRoom.userCount }</span>
<span className="text-zinc-400">/</span>
<span className="tabular-nums">{ selectedRoom.maxUsers }</span>
</span>
<span className="inline-flex items-center gap-1 truncate" title={ selectedRoom.ownerName }>
<FaCrown size={ 10 } className="text-amber-500" />
<span className="truncate">{ selectedRoom.ownerName }</span>
<span className="text-zinc-400 tabular-nums">#{ selectedRoom.ownerId }</span>
</span>
</div>
{ selectedRoom.maxUsers > 0 &&
<div className="h-1 mt-1.5 rounded-full bg-zinc-100 overflow-hidden">
<div
className={ `h-full transition-all ${ occupancyPct > 85 ? 'bg-rose-500' : occupancyPct > 60 ? 'bg-amber-500' : 'bg-emerald-500' }` }
style={ { width: `${ occupancyPct }%` } } />
</div> }
</div>
<button
className="text-zinc-400 hover:text-rose-600 transition-colors p-1"
onClick={ () => setSelectedRoom(null) }
title={ LocalizeText('housekeeping.room.clear') }>
<FaTimes size={ 12 } />
</button>
</div>
</div>
)
: (
<div className="flex items-center gap-2 rounded-lg border border-dashed border-zinc-300 bg-zinc-50/50 p-3 text-xs text-zinc-500 italic">
<FaHome size={ 14 } />
{ LocalizeText('housekeeping.room.none') }
</div>
) }
{ /* Open / Close + Mute */ }
<div className="grid grid-cols-2 gap-1.5">
<Button variant="success" disabled={ disableActions || !selectedRoom?.isLocked } onClick={ () => openRoom(selectedRoom.id) }>
<FaDoorOpen size={ 10 } />
<span className="ml-1">{ LocalizeText('housekeeping.room.open') }</span>
</Button>
<Button variant="danger" disabled={ disableActions || selectedRoom?.isLocked } onClick={ () => closeRoom(selectedRoom.id) }>
<FaLock size={ 10 } />
<span className="ml-1">{ LocalizeText('housekeeping.room.close') }</span>
</Button>
<div className="col-span-2 flex items-center gap-1.5 rounded-md border border-amber-200 bg-amber-50/40 px-2 py-1.5">
<FaVolumeMute size={ 11 } className="text-amber-600" />
<input
type="number"
min={ 1 }
className="w-14 px-1.5 py-0.5 rounded border border-amber-200 bg-white text-xs tabular-nums focus:outline-none focus:ring-1 focus:ring-amber-400"
value={ muteMinutes }
onChange={ event => setMuteMinutes(parseInt(event.target.value) || 0) } />
<span className="text-[11px] text-zinc-600">min</span>
<Button variant="warning" disabled={ disableActions } className="ml-auto" onClick={ () => muteRoom(selectedRoom.id, muteMinutes) }>
<span>{ LocalizeText('housekeeping.room.mute_min', [ 'm' ], [ String(muteMinutes) ]) }</span>
</Button>
</div>
<Button variant="warning" disabled={ disableActions } onClick={ () => confirmAndRun('housekeeping.room.kick_all.confirm', () => kickAllFromRoom(selectedRoom.id)) }>
<FaUserSlash size={ 10 } />
<span className="ml-1">{ LocalizeText('housekeeping.room.kick_all') }</span>
</Button>
<Button variant="danger" disabled={ disableActions } onClick={ () => confirmAndRun('housekeeping.room.delete.confirm', () => deleteRoom(selectedRoom.id)) }>
<FaTrash size={ 10 } />
<span className="ml-1">{ LocalizeText('housekeeping.room.delete') }</span>
</Button>
</div>
{ /* Transfer ownership card */ }
<div className="flex flex-col gap-1.5 rounded-md border border-violet-200 bg-violet-50/40 p-2">
<label className="text-[10px] uppercase tracking-wider font-semibold opacity-60 flex items-center gap-1">
<FaExchangeAlt size={ 8 } className="text-violet-500" />
{ LocalizeText('housekeeping.room.transfer.label') }
</label>
<div className="flex items-center gap-1.5">
<input
type="number"
min={ 1 }
className="w-24 px-1.5 py-1 rounded border border-violet-200 bg-white text-xs tabular-nums focus:outline-none focus:ring-1 focus:ring-violet-400"
placeholder={ LocalizeText('housekeeping.room.transfer.new_owner') }
value={ newOwnerId || '' }
onChange={ event => setNewOwnerId(parseInt(event.target.value) || 0) } />
<Button variant="primary" disabled={ disableActions || !newOwnerId } className="grow" onClick={ () => transferRoomOwnership(selectedRoom.id, newOwnerId) }>
<FaExchangeAlt size={ 10 } />
<span className="ml-1">{ LocalizeText('housekeeping.room.transfer') }</span>
</Button>
</div>
</div>
</div>
);
};