feat: admin booking panel — Vite, React, shadcn, MSW, dashboard and routes
Made-with: Cursor
This commit is contained in:
parent
a8fd9fd4a5
commit
b8aecf31e3
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
18
components.json
Normal file
18
components.json
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "new-york",
|
||||||
|
"rsc": false,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "",
|
||||||
|
"css": "src/styles/globals.css",
|
||||||
|
"baseColor": "slate",
|
||||||
|
"cssVariables": true
|
||||||
|
},
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils",
|
||||||
|
"ui": "@/components/ui"
|
||||||
|
},
|
||||||
|
"iconLibrary": "lucide"
|
||||||
|
}
|
||||||
32
eslint.config.js
Normal file
32
eslint.config.js
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
import js from "@eslint/js";
|
||||||
|
import globals from "globals";
|
||||||
|
import reactHooks from "eslint-plugin-react-hooks";
|
||||||
|
import reactRefresh from "eslint-plugin-react-refresh";
|
||||||
|
import tseslint from "typescript-eslint";
|
||||||
|
|
||||||
|
export default tseslint.config(
|
||||||
|
{ ignores: ["dist"] },
|
||||||
|
{
|
||||||
|
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||||
|
files: ["**/*.{ts,tsx}"],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
globals: globals.browser,
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
"react-hooks": reactHooks,
|
||||||
|
"react-refresh": reactRefresh,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
...reactHooks.configs.recommended.rules,
|
||||||
|
"react-refresh/only-export-components": [
|
||||||
|
"warn",
|
||||||
|
{ allowConstantExport: true },
|
||||||
|
],
|
||||||
|
"@typescript-eslint/no-unused-vars": [
|
||||||
|
"warn",
|
||||||
|
{ argsIgnorePattern: "^_" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
13
index.html
Normal file
13
index.html
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Yaltopia Hotels — Admin</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
6947
package-lock.json
generated
Normal file
6947
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
59
package.json
Normal file
59
package.json
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
{
|
||||||
|
"name": "yaltopia-hotels-admin",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.1",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@fontsource/inter": "^5.2.5",
|
||||||
|
"@radix-ui/react-avatar": "^1.1.3",
|
||||||
|
"@radix-ui/react-dialog": "^1.1.6",
|
||||||
|
"@radix-ui/react-dropdown-menu": "^2.1.6",
|
||||||
|
"@radix-ui/react-label": "^2.1.2",
|
||||||
|
"@radix-ui/react-popover": "^1.1.6",
|
||||||
|
"@radix-ui/react-progress": "^1.1.2",
|
||||||
|
"@radix-ui/react-scroll-area": "^1.2.3",
|
||||||
|
"@radix-ui/react-select": "^2.1.6",
|
||||||
|
"@radix-ui/react-separator": "^1.1.2",
|
||||||
|
"@radix-ui/react-slot": "^1.1.2",
|
||||||
|
"@radix-ui/react-tabs": "^1.1.3",
|
||||||
|
"@radix-ui/react-tooltip": "^1.1.8",
|
||||||
|
"@tanstack/react-table": "^8.21.2",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
|
"lucide-react": "^0.483.0",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-day-picker": "^9.6.4",
|
||||||
|
"react-dom": "^19.0.0",
|
||||||
|
"react-router-dom": "^7.4.0",
|
||||||
|
"recharts": "^2.15.1",
|
||||||
|
"tailwind-merge": "^3.0.2",
|
||||||
|
"tw-animate-css": "^1.2.4"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.21.0",
|
||||||
|
"@tailwindcss/vite": "^4.0.14",
|
||||||
|
"@types/node": "^22.13.10",
|
||||||
|
"@types/react": "^19.0.10",
|
||||||
|
"@types/react-dom": "^19.0.4",
|
||||||
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"eslint": "^9.21.0",
|
||||||
|
"eslint-plugin-react-hooks": "^5.1.0",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.19",
|
||||||
|
"globals": "^15.15.0",
|
||||||
|
"msw": "^2.7.3",
|
||||||
|
"tailwindcss": "^4.0.14",
|
||||||
|
"typescript": "~5.7.2",
|
||||||
|
"typescript-eslint": "^8.24.1",
|
||||||
|
"vite": "^6.2.0"
|
||||||
|
},
|
||||||
|
"msw": {
|
||||||
|
"workerDirectory": ["public"]
|
||||||
|
}
|
||||||
|
}
|
||||||
349
public/mockServiceWorker.js
Normal file
349
public/mockServiceWorker.js
Normal file
|
|
@ -0,0 +1,349 @@
|
||||||
|
/* eslint-disable */
|
||||||
|
/* tslint:disable */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mock Service Worker.
|
||||||
|
* @see https://github.com/mswjs/msw
|
||||||
|
* - Please do NOT modify this file.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const PACKAGE_VERSION = '2.12.14'
|
||||||
|
const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82'
|
||||||
|
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
|
||||||
|
const activeClientIds = new Set()
|
||||||
|
|
||||||
|
addEventListener('install', function () {
|
||||||
|
self.skipWaiting()
|
||||||
|
})
|
||||||
|
|
||||||
|
addEventListener('activate', function (event) {
|
||||||
|
event.waitUntil(self.clients.claim())
|
||||||
|
})
|
||||||
|
|
||||||
|
addEventListener('message', async function (event) {
|
||||||
|
const clientId = Reflect.get(event.source || {}, 'id')
|
||||||
|
|
||||||
|
if (!clientId || !self.clients) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = await self.clients.get(clientId)
|
||||||
|
|
||||||
|
if (!client) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const allClients = await self.clients.matchAll({
|
||||||
|
type: 'window',
|
||||||
|
})
|
||||||
|
|
||||||
|
switch (event.data) {
|
||||||
|
case 'KEEPALIVE_REQUEST': {
|
||||||
|
sendToClient(client, {
|
||||||
|
type: 'KEEPALIVE_RESPONSE',
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'INTEGRITY_CHECK_REQUEST': {
|
||||||
|
sendToClient(client, {
|
||||||
|
type: 'INTEGRITY_CHECK_RESPONSE',
|
||||||
|
payload: {
|
||||||
|
packageVersion: PACKAGE_VERSION,
|
||||||
|
checksum: INTEGRITY_CHECKSUM,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'MOCK_ACTIVATE': {
|
||||||
|
activeClientIds.add(clientId)
|
||||||
|
|
||||||
|
sendToClient(client, {
|
||||||
|
type: 'MOCKING_ENABLED',
|
||||||
|
payload: {
|
||||||
|
client: {
|
||||||
|
id: client.id,
|
||||||
|
frameType: client.frameType,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'CLIENT_CLOSED': {
|
||||||
|
activeClientIds.delete(clientId)
|
||||||
|
|
||||||
|
const remainingClients = allClients.filter((client) => {
|
||||||
|
return client.id !== clientId
|
||||||
|
})
|
||||||
|
|
||||||
|
// Unregister itself when there are no more clients
|
||||||
|
if (remainingClients.length === 0) {
|
||||||
|
self.registration.unregister()
|
||||||
|
}
|
||||||
|
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
addEventListener('fetch', function (event) {
|
||||||
|
const requestInterceptedAt = Date.now()
|
||||||
|
|
||||||
|
// Bypass navigation requests.
|
||||||
|
if (event.request.mode === 'navigate') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Opening the DevTools triggers the "only-if-cached" request
|
||||||
|
// that cannot be handled by the worker. Bypass such requests.
|
||||||
|
if (
|
||||||
|
event.request.cache === 'only-if-cached' &&
|
||||||
|
event.request.mode !== 'same-origin'
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bypass all requests when there are no active clients.
|
||||||
|
// Prevents the self-unregistered worked from handling requests
|
||||||
|
// after it's been terminated (still remains active until the next reload).
|
||||||
|
if (activeClientIds.size === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestId = crypto.randomUUID()
|
||||||
|
event.respondWith(handleRequest(event, requestId, requestInterceptedAt))
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {FetchEvent} event
|
||||||
|
* @param {string} requestId
|
||||||
|
* @param {number} requestInterceptedAt
|
||||||
|
*/
|
||||||
|
async function handleRequest(event, requestId, requestInterceptedAt) {
|
||||||
|
const client = await resolveMainClient(event)
|
||||||
|
const requestCloneForEvents = event.request.clone()
|
||||||
|
const response = await getResponse(
|
||||||
|
event,
|
||||||
|
client,
|
||||||
|
requestId,
|
||||||
|
requestInterceptedAt,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Send back the response clone for the "response:*" life-cycle events.
|
||||||
|
// Ensure MSW is active and ready to handle the message, otherwise
|
||||||
|
// this message will pend indefinitely.
|
||||||
|
if (client && activeClientIds.has(client.id)) {
|
||||||
|
const serializedRequest = await serializeRequest(requestCloneForEvents)
|
||||||
|
|
||||||
|
// Clone the response so both the client and the library could consume it.
|
||||||
|
const responseClone = response.clone()
|
||||||
|
|
||||||
|
sendToClient(
|
||||||
|
client,
|
||||||
|
{
|
||||||
|
type: 'RESPONSE',
|
||||||
|
payload: {
|
||||||
|
isMockedResponse: IS_MOCKED_RESPONSE in response,
|
||||||
|
request: {
|
||||||
|
id: requestId,
|
||||||
|
...serializedRequest,
|
||||||
|
},
|
||||||
|
response: {
|
||||||
|
type: responseClone.type,
|
||||||
|
status: responseClone.status,
|
||||||
|
statusText: responseClone.statusText,
|
||||||
|
headers: Object.fromEntries(responseClone.headers.entries()),
|
||||||
|
body: responseClone.body,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
responseClone.body ? [serializedRequest.body, responseClone.body] : [],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the main client for the given event.
|
||||||
|
* Client that issues a request doesn't necessarily equal the client
|
||||||
|
* that registered the worker. It's with the latter the worker should
|
||||||
|
* communicate with during the response resolving phase.
|
||||||
|
* @param {FetchEvent} event
|
||||||
|
* @returns {Promise<Client | undefined>}
|
||||||
|
*/
|
||||||
|
async function resolveMainClient(event) {
|
||||||
|
const client = await self.clients.get(event.clientId)
|
||||||
|
|
||||||
|
if (activeClientIds.has(event.clientId)) {
|
||||||
|
return client
|
||||||
|
}
|
||||||
|
|
||||||
|
if (client?.frameType === 'top-level') {
|
||||||
|
return client
|
||||||
|
}
|
||||||
|
|
||||||
|
const allClients = await self.clients.matchAll({
|
||||||
|
type: 'window',
|
||||||
|
})
|
||||||
|
|
||||||
|
return allClients
|
||||||
|
.filter((client) => {
|
||||||
|
// Get only those clients that are currently visible.
|
||||||
|
return client.visibilityState === 'visible'
|
||||||
|
})
|
||||||
|
.find((client) => {
|
||||||
|
// Find the client ID that's recorded in the
|
||||||
|
// set of clients that have registered the worker.
|
||||||
|
return activeClientIds.has(client.id)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {FetchEvent} event
|
||||||
|
* @param {Client | undefined} client
|
||||||
|
* @param {string} requestId
|
||||||
|
* @param {number} requestInterceptedAt
|
||||||
|
* @returns {Promise<Response>}
|
||||||
|
*/
|
||||||
|
async function getResponse(event, client, requestId, requestInterceptedAt) {
|
||||||
|
// Clone the request because it might've been already used
|
||||||
|
// (i.e. its body has been read and sent to the client).
|
||||||
|
const requestClone = event.request.clone()
|
||||||
|
|
||||||
|
function passthrough() {
|
||||||
|
// Cast the request headers to a new Headers instance
|
||||||
|
// so the headers can be manipulated with.
|
||||||
|
const headers = new Headers(requestClone.headers)
|
||||||
|
|
||||||
|
// Remove the "accept" header value that marked this request as passthrough.
|
||||||
|
// This prevents request alteration and also keeps it compliant with the
|
||||||
|
// user-defined CORS policies.
|
||||||
|
const acceptHeader = headers.get('accept')
|
||||||
|
if (acceptHeader) {
|
||||||
|
const values = acceptHeader.split(',').map((value) => value.trim())
|
||||||
|
const filteredValues = values.filter(
|
||||||
|
(value) => value !== 'msw/passthrough',
|
||||||
|
)
|
||||||
|
|
||||||
|
if (filteredValues.length > 0) {
|
||||||
|
headers.set('accept', filteredValues.join(', '))
|
||||||
|
} else {
|
||||||
|
headers.delete('accept')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fetch(requestClone, { headers })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bypass mocking when the client is not active.
|
||||||
|
if (!client) {
|
||||||
|
return passthrough()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bypass initial page load requests (i.e. static assets).
|
||||||
|
// The absence of the immediate/parent client in the map of the active clients
|
||||||
|
// means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet
|
||||||
|
// and is not ready to handle requests.
|
||||||
|
if (!activeClientIds.has(client.id)) {
|
||||||
|
return passthrough()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify the client that a request has been intercepted.
|
||||||
|
const serializedRequest = await serializeRequest(event.request)
|
||||||
|
const clientMessage = await sendToClient(
|
||||||
|
client,
|
||||||
|
{
|
||||||
|
type: 'REQUEST',
|
||||||
|
payload: {
|
||||||
|
id: requestId,
|
||||||
|
interceptedAt: requestInterceptedAt,
|
||||||
|
...serializedRequest,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
[serializedRequest.body],
|
||||||
|
)
|
||||||
|
|
||||||
|
switch (clientMessage.type) {
|
||||||
|
case 'MOCK_RESPONSE': {
|
||||||
|
return respondWithMock(clientMessage.data)
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'PASSTHROUGH': {
|
||||||
|
return passthrough()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return passthrough()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Client} client
|
||||||
|
* @param {any} message
|
||||||
|
* @param {Array<Transferable>} transferrables
|
||||||
|
* @returns {Promise<any>}
|
||||||
|
*/
|
||||||
|
function sendToClient(client, message, transferrables = []) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const channel = new MessageChannel()
|
||||||
|
|
||||||
|
channel.port1.onmessage = (event) => {
|
||||||
|
if (event.data && event.data.error) {
|
||||||
|
return reject(event.data.error)
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve(event.data)
|
||||||
|
}
|
||||||
|
|
||||||
|
client.postMessage(message, [
|
||||||
|
channel.port2,
|
||||||
|
...transferrables.filter(Boolean),
|
||||||
|
])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Response} response
|
||||||
|
* @returns {Response}
|
||||||
|
*/
|
||||||
|
function respondWithMock(response) {
|
||||||
|
// Setting response status code to 0 is a no-op.
|
||||||
|
// However, when responding with a "Response.error()", the produced Response
|
||||||
|
// instance will have status code set to 0. Since it's not possible to create
|
||||||
|
// a Response instance with status code 0, handle that use-case separately.
|
||||||
|
if (response.status === 0) {
|
||||||
|
return Response.error()
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockedResponse = new Response(response.body, response)
|
||||||
|
|
||||||
|
Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, {
|
||||||
|
value: true,
|
||||||
|
enumerable: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
return mockedResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Request} request
|
||||||
|
*/
|
||||||
|
async function serializeRequest(request) {
|
||||||
|
return {
|
||||||
|
url: request.url,
|
||||||
|
mode: request.mode,
|
||||||
|
method: request.method,
|
||||||
|
headers: Object.fromEntries(request.headers.entries()),
|
||||||
|
cache: request.cache,
|
||||||
|
credentials: request.credentials,
|
||||||
|
destination: request.destination,
|
||||||
|
integrity: request.integrity,
|
||||||
|
redirect: request.redirect,
|
||||||
|
referrer: request.referrer,
|
||||||
|
referrerPolicy: request.referrerPolicy,
|
||||||
|
body: await request.arrayBuffer(),
|
||||||
|
keepalive: request.keepalive,
|
||||||
|
}
|
||||||
|
}
|
||||||
4
public/vite.svg
Normal file
4
public/vite.svg
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
|
||||||
|
<rect width="32" height="32" rx="8" fill="#2563eb"/>
|
||||||
|
<path d="M8 22V10l8 6 8-6v12" stroke="white" stroke-width="2" fill="none"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 213 B |
51
src/App.tsx
Normal file
51
src/App.tsx
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
import { Navigate, Route, Routes } from "react-router-dom";
|
||||||
|
|
||||||
|
import { AppLayout } from "@/components/layout/AppLayout";
|
||||||
|
import { useAuth } from "@/context/AuthContext";
|
||||||
|
import { BookingDetailPage } from "@/pages/BookingDetailPage";
|
||||||
|
import { BookingsPage } from "@/pages/BookingsPage";
|
||||||
|
import { CalendarPage } from "@/pages/CalendarPage";
|
||||||
|
import { CustomersPage } from "@/pages/CustomersPage";
|
||||||
|
import { DashboardPage } from "@/pages/DashboardPage";
|
||||||
|
import { DiscountCodesPage } from "@/pages/DiscountCodesPage";
|
||||||
|
import { LoginPage } from "@/pages/LoginPage";
|
||||||
|
import { NewBookingPage } from "@/pages/NewBookingPage";
|
||||||
|
import { PaymentsPage } from "@/pages/PaymentsPage";
|
||||||
|
import { ReferralCodesPage } from "@/pages/ReferralCodesPage";
|
||||||
|
import { ReservationsPage } from "@/pages/ReservationsPage";
|
||||||
|
import { RoomsPage } from "@/pages/RoomsPage";
|
||||||
|
import { SettingsPage } from "@/pages/SettingsPage";
|
||||||
|
import { TransactionsPage } from "@/pages/TransactionsPage";
|
||||||
|
import { VisitsPage } from "@/pages/VisitsPage";
|
||||||
|
|
||||||
|
function ProtectedLayout() {
|
||||||
|
const { role } = useAuth();
|
||||||
|
if (!role) return <Navigate to="/login" replace />;
|
||||||
|
return <AppLayout />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route path="/login" element={<LoginPage />} />
|
||||||
|
<Route element={<ProtectedLayout />}>
|
||||||
|
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||||
|
<Route path="/dashboard" element={<DashboardPage />} />
|
||||||
|
<Route path="/reservations" element={<ReservationsPage />} />
|
||||||
|
<Route path="/bookings" element={<BookingsPage />} />
|
||||||
|
<Route path="/bookings/new" element={<NewBookingPage />} />
|
||||||
|
<Route path="/bookings/:id" element={<BookingDetailPage />} />
|
||||||
|
<Route path="/calendar" element={<CalendarPage />} />
|
||||||
|
<Route path="/rooms" element={<RoomsPage />} />
|
||||||
|
<Route path="/customers" element={<CustomersPage />} />
|
||||||
|
<Route path="/transactions" element={<TransactionsPage />} />
|
||||||
|
<Route path="/payments" element={<PaymentsPage />} />
|
||||||
|
<Route path="/marketing/visits" element={<VisitsPage />} />
|
||||||
|
<Route path="/marketing/discount-codes" element={<DiscountCodesPage />} />
|
||||||
|
<Route path="/marketing/referral-codes" element={<ReferralCodesPage />} />
|
||||||
|
<Route path="/settings" element={<SettingsPage />} />
|
||||||
|
</Route>
|
||||||
|
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
|
}
|
||||||
89
src/components/layout/AppHeader.tsx
Normal file
89
src/components/layout/AppHeader.tsx
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
import { format } from "date-fns";
|
||||||
|
import { LogOut, Sparkles } from "lucide-react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
|
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { useAuth } from "@/context/AuthContext";
|
||||||
|
|
||||||
|
export function AppHeader() {
|
||||||
|
const { name, property, setProperty, logout, role } = useAuth();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="sticky top-0 z-40 flex h-14 shrink-0 items-center gap-3 border-b bg-card/95 px-4 backdrop-blur supports-[backdrop-filter]:bg-card/80 lg:px-6">
|
||||||
|
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||||
|
<Select value={property} onValueChange={setProperty}>
|
||||||
|
<SelectTrigger className="h-9 w-[min(100%,220px)] lg:w-56">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="Shitaye Suite Hotel">
|
||||||
|
Shitaye Suite Hotel
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="Serenity Cove (demo)">
|
||||||
|
Serenity Cove (demo)
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="hidden text-sm text-muted-foreground md:block">
|
||||||
|
{format(new Date(), "MMMM d, yyyy")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="outline" size="sm" className="hidden sm:inline-flex">
|
||||||
|
<Sparkles className="size-4" />
|
||||||
|
AI assistant
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" className="hidden sm:inline-flex" asChild>
|
||||||
|
<Link to="/bookings/new">+ New reservation</Link>
|
||||||
|
</Button>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex items-center gap-2 rounded-xl border border-transparent px-1 py-1 hover:bg-muted"
|
||||||
|
>
|
||||||
|
<Avatar className="size-8">
|
||||||
|
<AvatarFallback className="bg-primary/15 text-xs font-medium text-primary">
|
||||||
|
{name
|
||||||
|
.split(" ")
|
||||||
|
.map((n) => n[0])
|
||||||
|
.join("")}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div className="hidden text-left text-sm lg:block">
|
||||||
|
<p className="font-medium leading-none">{name}</p>
|
||||||
|
<p className="text-xs text-muted-foreground capitalize">
|
||||||
|
{role}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-52">
|
||||||
|
<DropdownMenuLabel>Account</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem onClick={() => logout()}>
|
||||||
|
<LogOut className="size-4" />
|
||||||
|
Log out
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
22
src/components/layout/AppLayout.tsx
Normal file
22
src/components/layout/AppLayout.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
import { Outlet } from "react-router-dom";
|
||||||
|
|
||||||
|
import { AppHeader } from "@/components/layout/AppHeader";
|
||||||
|
import { AppSidebar } from "@/components/layout/AppSidebar";
|
||||||
|
import { MobileBottomNav } from "@/components/layout/MobileBottomNav";
|
||||||
|
|
||||||
|
export function AppLayout() {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen bg-background">
|
||||||
|
<AppSidebar />
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col">
|
||||||
|
<AppHeader />
|
||||||
|
<main className="flex-1 overflow-auto pb-nav lg:pb-6">
|
||||||
|
<div className="mx-auto max-w-[1600px] p-4 lg:p-6">
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
<MobileBottomNav />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
73
src/components/layout/AppSidebar.tsx
Normal file
73
src/components/layout/AppSidebar.tsx
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
import type { ElementType } from "react";
|
||||||
|
import { NavLink } from "react-router-dom";
|
||||||
|
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import {
|
||||||
|
navFinance,
|
||||||
|
navMain,
|
||||||
|
navMarketing,
|
||||||
|
navOther,
|
||||||
|
} from "@/components/layout/nav-config";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function NavSection({
|
||||||
|
title,
|
||||||
|
items,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
items: { to: string; label: string; icon: ElementType }[];
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="px-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
|
{title}
|
||||||
|
</p>
|
||||||
|
{items.map(({ to, label, icon: Icon }) => (
|
||||||
|
<NavLink
|
||||||
|
key={to}
|
||||||
|
to={to}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
cn(
|
||||||
|
"flex items-center gap-3 rounded-xl px-3 py-2 text-sm font-medium transition-colors",
|
||||||
|
isActive
|
||||||
|
? "bg-accent text-accent-foreground"
|
||||||
|
: "text-foreground/80 hover:bg-muted"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Icon className="size-4 shrink-0 opacity-70" />
|
||||||
|
{label}
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AppSidebar() {
|
||||||
|
return (
|
||||||
|
<aside className="hidden w-60 shrink-0 border-r bg-card lg:flex lg:flex-col">
|
||||||
|
<div className="flex h-16 items-center gap-2 border-b px-4">
|
||||||
|
<div className="flex size-9 items-center justify-center rounded-xl bg-primary text-primary-foreground font-bold text-sm">
|
||||||
|
Y
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold leading-tight">Yaltopia Hotels</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Admin</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ScrollArea className="flex-1 px-3 py-4">
|
||||||
|
<nav className="space-y-6">
|
||||||
|
<NavSection title="Main" items={navMain} />
|
||||||
|
<NavSection title="Marketing" items={navMarketing} />
|
||||||
|
<NavSection title="Finance" items={navFinance} />
|
||||||
|
<NavSection title="Other" items={navOther} />
|
||||||
|
</nav>
|
||||||
|
</ScrollArea>
|
||||||
|
<Separator />
|
||||||
|
<div className="p-3 text-xs text-muted-foreground">
|
||||||
|
<p>Help & support</p>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
119
src/components/layout/MobileBottomNav.tsx
Normal file
119
src/components/layout/MobileBottomNav.tsx
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
import { Menu } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { NavLink } from "react-router-dom";
|
||||||
|
|
||||||
|
import { bottomNav } from "@/components/layout/nav-config";
|
||||||
|
import {
|
||||||
|
navFinance,
|
||||||
|
navMain,
|
||||||
|
navMarketing,
|
||||||
|
navOther,
|
||||||
|
} from "@/components/layout/nav-config";
|
||||||
|
import {
|
||||||
|
Sheet,
|
||||||
|
SheetContent,
|
||||||
|
SheetHeader,
|
||||||
|
SheetTitle,
|
||||||
|
SheetTrigger,
|
||||||
|
} from "@/components/ui/sheet";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function SheetLinks({
|
||||||
|
items,
|
||||||
|
onNavigate,
|
||||||
|
}: {
|
||||||
|
items: { to: string; label: string; icon: React.ElementType }[];
|
||||||
|
onNavigate: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{items.map(({ to, label, icon: Icon }) => (
|
||||||
|
<NavLink
|
||||||
|
key={to}
|
||||||
|
to={to}
|
||||||
|
onClick={onNavigate}
|
||||||
|
className="flex items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium hover:bg-muted"
|
||||||
|
>
|
||||||
|
<Icon className="size-4 opacity-70" />
|
||||||
|
{label}
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MobileBottomNav() {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const close = () => setOpen(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav className="fixed bottom-0 left-0 right-0 z-50 border-t bg-card pb-[env(safe-area-inset-bottom)] lg:hidden">
|
||||||
|
<div className="flex h-14 items-stretch justify-around">
|
||||||
|
{bottomNav.map(({ to, label, icon: Icon }) => (
|
||||||
|
<NavLink
|
||||||
|
key={to}
|
||||||
|
to={to}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
cn(
|
||||||
|
"flex flex-1 flex-col items-center justify-center gap-0.5 text-[10px] font-medium",
|
||||||
|
isActive ? "text-primary" : "text-muted-foreground"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Icon className="size-5" />
|
||||||
|
{label}
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
<Sheet open={open} onOpenChange={setOpen}>
|
||||||
|
<SheetTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex flex-1 flex-col items-center justify-center gap-0.5 text-[10px] font-medium text-muted-foreground"
|
||||||
|
>
|
||||||
|
<Menu className="size-5" />
|
||||||
|
More
|
||||||
|
</button>
|
||||||
|
</SheetTrigger>
|
||||||
|
<SheetContent side="bottom" className="h-[70vh] rounded-t-2xl">
|
||||||
|
<SheetHeader>
|
||||||
|
<SheetTitle>Menu</SheetTitle>
|
||||||
|
</SheetHeader>
|
||||||
|
<ScrollArea className="mt-4 h-[calc(70vh-5rem)] pr-4">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<p className="mb-2 text-xs font-semibold text-muted-foreground">
|
||||||
|
Main
|
||||||
|
</p>
|
||||||
|
<SheetLinks items={navMain} onNavigate={close} />
|
||||||
|
</div>
|
||||||
|
<Separator />
|
||||||
|
<div>
|
||||||
|
<p className="mb-2 text-xs font-semibold text-muted-foreground">
|
||||||
|
Marketing
|
||||||
|
</p>
|
||||||
|
<SheetLinks items={navMarketing} onNavigate={close} />
|
||||||
|
</div>
|
||||||
|
<Separator />
|
||||||
|
<div>
|
||||||
|
<p className="mb-2 text-xs font-semibold text-muted-foreground">
|
||||||
|
Finance
|
||||||
|
</p>
|
||||||
|
<SheetLinks items={navFinance} onNavigate={close} />
|
||||||
|
</div>
|
||||||
|
<Separator />
|
||||||
|
<div>
|
||||||
|
<p className="mb-2 text-xs font-semibold text-muted-foreground">
|
||||||
|
Other
|
||||||
|
</p>
|
||||||
|
<SheetLinks items={navOther} onNavigate={close} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
43
src/components/layout/nav-config.ts
Normal file
43
src/components/layout/nav-config.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
import {
|
||||||
|
BedDouble,
|
||||||
|
CalendarDays,
|
||||||
|
CreditCard,
|
||||||
|
LayoutDashboard,
|
||||||
|
Percent,
|
||||||
|
Settings,
|
||||||
|
Share2,
|
||||||
|
Users,
|
||||||
|
Building2,
|
||||||
|
LineChart,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
export const navMain = [
|
||||||
|
{ to: "/dashboard", label: "Dashboard", icon: LayoutDashboard },
|
||||||
|
{ to: "/reservations", label: "Reservations", icon: CalendarDays },
|
||||||
|
{ to: "/bookings", label: "Bookings", icon: BedDouble },
|
||||||
|
{ to: "/calendar", label: "Calendar", icon: CalendarDays },
|
||||||
|
{ to: "/rooms", label: "Rooms", icon: Building2 },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const navMarketing = [
|
||||||
|
{ to: "/marketing/visits", label: "Site visits", icon: LineChart },
|
||||||
|
{ to: "/marketing/discount-codes", label: "Discount codes", icon: Percent },
|
||||||
|
{ to: "/marketing/referral-codes", label: "Referral codes", icon: Share2 },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const navFinance = [
|
||||||
|
{ to: "/transactions", label: "Transactions", icon: CreditCard },
|
||||||
|
{ to: "/payments", label: "Payments", icon: CreditCard },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const navOther = [
|
||||||
|
{ to: "/customers", label: "Customers", icon: Users },
|
||||||
|
{ to: "/settings", label: "Settings", icon: Settings },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const bottomNav = [
|
||||||
|
{ to: "/dashboard", label: "Home", icon: LayoutDashboard },
|
||||||
|
{ to: "/bookings", label: "Bookings", icon: BedDouble },
|
||||||
|
{ to: "/calendar", label: "Calendar", icon: CalendarDays },
|
||||||
|
{ to: "/transactions", label: "Money", icon: CreditCard },
|
||||||
|
];
|
||||||
48
src/components/ui/avatar.tsx
Normal file
48
src/components/ui/avatar.tsx
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
import * as AvatarPrimitive from "@radix-ui/react-avatar";
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const Avatar = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AvatarPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AvatarPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
Avatar.displayName = AvatarPrimitive.Root.displayName;
|
||||||
|
|
||||||
|
const AvatarImage = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AvatarPrimitive.Image>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AvatarPrimitive.Image
|
||||||
|
ref={ref}
|
||||||
|
className={cn("aspect-square h-full w-full", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
AvatarImage.displayName = AvatarPrimitive.Image.displayName;
|
||||||
|
|
||||||
|
const AvatarFallback = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AvatarPrimitive.Fallback>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AvatarPrimitive.Fallback
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex h-full w-full items-center justify-center rounded-full bg-muted text-sm font-medium",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
|
||||||
|
|
||||||
|
export { Avatar, AvatarImage, AvatarFallback };
|
||||||
40
src/components/ui/badge.tsx
Normal file
40
src/components/ui/badge.tsx
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const badgeVariants = cva(
|
||||||
|
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default:
|
||||||
|
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
|
||||||
|
secondary:
|
||||||
|
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
|
destructive:
|
||||||
|
"border-transparent bg-destructive text-white shadow hover:bg-destructive/80",
|
||||||
|
outline: "text-foreground",
|
||||||
|
success:
|
||||||
|
"border-transparent bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-200",
|
||||||
|
warning:
|
||||||
|
"border-transparent bg-amber-100 text-amber-900 dark:bg-amber-900/40 dark:text-amber-100",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface BadgeProps
|
||||||
|
extends React.HTMLAttributes<HTMLDivElement>,
|
||||||
|
VariantProps<typeof badgeVariants> {}
|
||||||
|
|
||||||
|
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||||
|
return (
|
||||||
|
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge, badgeVariants };
|
||||||
111
src/components/ui/breadcrumb.tsx
Normal file
111
src/components/ui/breadcrumb.tsx
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
import { ChevronRight, MoreHorizontal } from "lucide-react";
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const Breadcrumb = React.forwardRef<
|
||||||
|
HTMLElement,
|
||||||
|
React.ComponentPropsWithoutRef<"nav"> & {
|
||||||
|
separator?: React.ReactNode;
|
||||||
|
}
|
||||||
|
>(({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />);
|
||||||
|
Breadcrumb.displayName = "Breadcrumb";
|
||||||
|
|
||||||
|
const BreadcrumbList = React.forwardRef<
|
||||||
|
HTMLOListElement,
|
||||||
|
React.ComponentPropsWithoutRef<"ol">
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ol
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
BreadcrumbList.displayName = "BreadcrumbList";
|
||||||
|
|
||||||
|
const BreadcrumbItem = React.forwardRef<
|
||||||
|
HTMLLIElement,
|
||||||
|
React.ComponentPropsWithoutRef<"li">
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<li
|
||||||
|
ref={ref}
|
||||||
|
className={cn("inline-flex items-center gap-1.5", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
BreadcrumbItem.displayName = "BreadcrumbItem";
|
||||||
|
|
||||||
|
const BreadcrumbLink = React.forwardRef<
|
||||||
|
HTMLAnchorElement,
|
||||||
|
React.ComponentPropsWithoutRef<"a"> & { asChild?: boolean }
|
||||||
|
>(({ asChild: _asChild, className, ...props }, ref) => {
|
||||||
|
const Comp = "a";
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
ref={ref}
|
||||||
|
className={cn("transition-colors hover:text-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
BreadcrumbLink.displayName = "BreadcrumbLink";
|
||||||
|
|
||||||
|
const BreadcrumbPage = React.forwardRef<
|
||||||
|
HTMLSpanElement,
|
||||||
|
React.ComponentPropsWithoutRef<"span">
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<span
|
||||||
|
ref={ref}
|
||||||
|
role="link"
|
||||||
|
aria-disabled="true"
|
||||||
|
aria-current="page"
|
||||||
|
className={cn("font-normal text-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
BreadcrumbPage.displayName = "BreadcrumbPage";
|
||||||
|
|
||||||
|
const BreadcrumbSeparator = ({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"li">) => (
|
||||||
|
<li
|
||||||
|
role="presentation"
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cn("[&>svg]:size-3.5", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children ?? <ChevronRight />}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
BreadcrumbSeparator.displayName = "BreadcrumbSeparator";
|
||||||
|
|
||||||
|
const BreadcrumbEllipsis = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span">) => (
|
||||||
|
<span
|
||||||
|
role="presentation"
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cn("flex h-9 w-9 items-center justify-center", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
<span className="sr-only">More</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
BreadcrumbEllipsis.displayName = "BreadcrumbElipssis";
|
||||||
|
|
||||||
|
export {
|
||||||
|
Breadcrumb,
|
||||||
|
BreadcrumbList,
|
||||||
|
BreadcrumbItem,
|
||||||
|
BreadcrumbLink,
|
||||||
|
BreadcrumbPage,
|
||||||
|
BreadcrumbSeparator,
|
||||||
|
BreadcrumbEllipsis,
|
||||||
|
};
|
||||||
56
src/components/ui/button.tsx
Normal file
56
src/components/ui/button.tsx
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
import { Slot } from "@radix-ui/react-slot";
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-xl text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-primary text-primary-foreground shadow-sm hover:bg-primary/90",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive text-white shadow-sm hover:bg-destructive/90",
|
||||||
|
outline:
|
||||||
|
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
|
||||||
|
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-10 px-4 py-2",
|
||||||
|
sm: "h-9 rounded-lg px-3",
|
||||||
|
lg: "h-11 rounded-xl px-8",
|
||||||
|
icon: "h-10 w-10",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface ButtonProps
|
||||||
|
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
|
VariantProps<typeof buttonVariants> {
|
||||||
|
asChild?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||||
|
const Comp = asChild ? Slot : "button";
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
Button.displayName = "Button";
|
||||||
|
|
||||||
|
export { Button, buttonVariants };
|
||||||
19
src/components/ui/calendar.tsx
Normal file
19
src/components/ui/calendar.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
import * as React from "react";
|
||||||
|
import { DayPicker } from "react-day-picker";
|
||||||
|
import "react-day-picker/style.css";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export type CalendarProps = React.ComponentProps<typeof DayPicker>;
|
||||||
|
|
||||||
|
function Calendar({ className, ...props }: CalendarProps) {
|
||||||
|
return (
|
||||||
|
<DayPicker
|
||||||
|
className={cn("rounded-xl border border-border p-3", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Calendar.displayName = "Calendar";
|
||||||
|
|
||||||
|
export { Calendar };
|
||||||
76
src/components/ui/card.tsx
Normal file
76
src/components/ui/card.tsx
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const Card = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"rounded-2xl border bg-card text-card-foreground shadow-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
Card.displayName = "Card";
|
||||||
|
|
||||||
|
const CardHeader = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("flex flex-col space-y-1.5 p-5", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
CardHeader.displayName = "CardHeader";
|
||||||
|
|
||||||
|
const CardTitle = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLHeadingElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<h3
|
||||||
|
ref={ref}
|
||||||
|
className={cn("font-semibold leading-none tracking-tight", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
CardTitle.displayName = "CardTitle";
|
||||||
|
|
||||||
|
const CardDescription = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLParagraphElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<p
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
CardDescription.displayName = "CardDescription";
|
||||||
|
|
||||||
|
const CardContent = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn("p-5 pt-0", className)} {...props} />
|
||||||
|
));
|
||||||
|
CardContent.displayName = "CardContent";
|
||||||
|
|
||||||
|
const CardFooter = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("flex items-center p-5 pt-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
CardFooter.displayName = "CardFooter";
|
||||||
|
|
||||||
|
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
|
||||||
102
src/components/ui/dialog.tsx
Normal file
102
src/components/ui/dialog.tsx
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||||
|
import { X } from "lucide-react";
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const Dialog = DialogPrimitive.Root;
|
||||||
|
const DialogTrigger = DialogPrimitive.Trigger;
|
||||||
|
const DialogPortal = DialogPrimitive.Portal;
|
||||||
|
const DialogClose = DialogPrimitive.Close;
|
||||||
|
|
||||||
|
const DialogOverlay = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Overlay
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-0 z-50 bg-black/40 data-[state=open]:animate-in data-[state=closed]:animate-out",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||||
|
|
||||||
|
const DialogContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<DialogPortal>
|
||||||
|
<DialogOverlay />
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out sm:rounded-2xl",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring disabled:pointer-events-none">
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</DialogPortal>
|
||||||
|
));
|
||||||
|
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
const DialogHeader = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col space-y-1.5 text-center sm:text-left",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
DialogHeader.displayName = "DialogHeader";
|
||||||
|
|
||||||
|
const DialogTitle = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"text-lg font-semibold leading-none tracking-tight",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DialogTitle.displayName = DialogPrimitive.Title.displayName;
|
||||||
|
|
||||||
|
const DialogDescription = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Description
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
||||||
|
|
||||||
|
export {
|
||||||
|
Dialog,
|
||||||
|
DialogPortal,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogTrigger,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
};
|
||||||
180
src/components/ui/dropdown-menu.tsx
Normal file
180
src/components/ui/dropdown-menu.tsx
Normal file
|
|
@ -0,0 +1,180 @@
|
||||||
|
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||||
|
import { Check, ChevronRight, Circle } from "lucide-react";
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const DropdownMenu = DropdownMenuPrimitive.Root;
|
||||||
|
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
|
||||||
|
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
|
||||||
|
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
|
||||||
|
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
|
||||||
|
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
|
||||||
|
|
||||||
|
const DropdownMenuSubTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||||
|
inset?: boolean;
|
||||||
|
}
|
||||||
|
>(({ className, inset, children, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.SubTrigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default select-none items-center gap-2 rounded-lg px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||||
|
inset && "pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronRight className="ml-auto" />
|
||||||
|
</DropdownMenuPrimitive.SubTrigger>
|
||||||
|
));
|
||||||
|
DropdownMenuSubTrigger.displayName =
|
||||||
|
DropdownMenuPrimitive.SubTrigger.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuSubContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.SubContent
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"z-50 min-w-[8rem] overflow-hidden rounded-xl border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DropdownMenuSubContent.displayName =
|
||||||
|
DropdownMenuPrimitive.SubContent.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||||
|
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Portal>
|
||||||
|
<DropdownMenuPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-xl border bg-popover p-1 text-popover-foreground shadow-md",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</DropdownMenuPrimitive.Portal>
|
||||||
|
));
|
||||||
|
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||||
|
inset?: boolean;
|
||||||
|
}
|
||||||
|
>(({ className, inset, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default select-none items-center gap-2 rounded-lg px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:size-4 [&_svg]:shrink-0",
|
||||||
|
inset && "pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||||
|
>(({ className, children, checked, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.CheckboxItem
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default select-none items-center rounded-lg py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
checked={checked}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.CheckboxItem>
|
||||||
|
));
|
||||||
|
DropdownMenuCheckboxItem.displayName =
|
||||||
|
DropdownMenuPrimitive.CheckboxItem.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuRadioItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.RadioItem
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default select-none items-center rounded-lg py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<Circle className="h-2 w-2 fill-current" />
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.RadioItem>
|
||||||
|
));
|
||||||
|
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuLabel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||||
|
inset?: boolean;
|
||||||
|
}
|
||||||
|
>(({ className, inset, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Label
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"px-2 py-1.5 text-sm font-semibold",
|
||||||
|
inset && "pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuSeparator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Separator
|
||||||
|
ref={ref}
|
||||||
|
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DropdownMenuSeparator.displayName =
|
||||||
|
DropdownMenuPrimitive.Separator.displayName;
|
||||||
|
|
||||||
|
export {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
|
DropdownMenuRadioItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuPortal,
|
||||||
|
DropdownMenuSub,
|
||||||
|
DropdownMenuSubContent,
|
||||||
|
DropdownMenuSubTrigger,
|
||||||
|
DropdownMenuRadioGroup,
|
||||||
|
};
|
||||||
22
src/components/ui/input.tsx
Normal file
22
src/components/ui/input.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
|
||||||
|
({ className, type, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
className={cn(
|
||||||
|
"flex h-10 w-full rounded-xl border border-input bg-background px-3 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
Input.displayName = "Input";
|
||||||
|
|
||||||
|
export { Input };
|
||||||
24
src/components/ui/label.tsx
Normal file
24
src/components/ui/label.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const labelVariants = cva(
|
||||||
|
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||||
|
);
|
||||||
|
|
||||||
|
const Label = React.forwardRef<
|
||||||
|
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||||
|
VariantProps<typeof labelVariants>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<LabelPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(labelVariants(), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
Label.displayName = LabelPrimitive.Root.displayName;
|
||||||
|
|
||||||
|
export { Label };
|
||||||
29
src/components/ui/popover.tsx
Normal file
29
src/components/ui/popover.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
import * as PopoverPrimitive from "@radix-ui/react-popover";
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const Popover = PopoverPrimitive.Root;
|
||||||
|
const PopoverTrigger = PopoverPrimitive.Trigger;
|
||||||
|
const PopoverAnchor = PopoverPrimitive.Anchor;
|
||||||
|
|
||||||
|
const PopoverContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof PopoverPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||||
|
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
|
||||||
|
<PopoverPrimitive.Portal>
|
||||||
|
<PopoverPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
align={align}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"z-50 w-72 rounded-xl border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</PopoverPrimitive.Portal>
|
||||||
|
));
|
||||||
|
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };
|
||||||
26
src/components/ui/progress.tsx
Normal file
26
src/components/ui/progress.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
import * as ProgressPrimitive from "@radix-ui/react-progress";
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const Progress = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ProgressPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
|
||||||
|
>(({ className, value, ...props }, ref) => (
|
||||||
|
<ProgressPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative h-2 w-full overflow-hidden rounded-full bg-secondary",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ProgressPrimitive.Indicator
|
||||||
|
className="h-full bg-[var(--navy)] transition-all"
|
||||||
|
style={{ width: `${value ?? 0}%` }}
|
||||||
|
/>
|
||||||
|
</ProgressPrimitive.Root>
|
||||||
|
));
|
||||||
|
Progress.displayName = ProgressPrimitive.Root.displayName;
|
||||||
|
|
||||||
|
export { Progress };
|
||||||
46
src/components/ui/scroll-area.tsx
Normal file
46
src/components/ui/scroll-area.tsx
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const ScrollArea = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<ScrollAreaPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn("relative overflow-hidden", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
|
||||||
|
{children}
|
||||||
|
</ScrollAreaPrimitive.Viewport>
|
||||||
|
<ScrollBar />
|
||||||
|
<ScrollAreaPrimitive.Corner />
|
||||||
|
</ScrollAreaPrimitive.Root>
|
||||||
|
));
|
||||||
|
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
|
||||||
|
|
||||||
|
const ScrollBar = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||||
|
>(({ className, orientation = "vertical", ...props }, ref) => (
|
||||||
|
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||||
|
ref={ref}
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"flex touch-none select-none transition-colors",
|
||||||
|
orientation === "vertical" &&
|
||||||
|
"h-full w-2.5 border-l border-l-transparent p-[1px]",
|
||||||
|
orientation === "horizontal" &&
|
||||||
|
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
|
||||||
|
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||||
|
));
|
||||||
|
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;
|
||||||
|
|
||||||
|
export { ScrollArea, ScrollBar };
|
||||||
153
src/components/ui/select.tsx
Normal file
153
src/components/ui/select.tsx
Normal file
|
|
@ -0,0 +1,153 @@
|
||||||
|
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||||
|
import { Check, ChevronDown, ChevronUp } from "lucide-react";
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const Select = SelectPrimitive.Root;
|
||||||
|
const SelectGroup = SelectPrimitive.Group;
|
||||||
|
const SelectValue = SelectPrimitive.Value;
|
||||||
|
|
||||||
|
const SelectTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Trigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex h-10 w-full items-center justify-between rounded-xl border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<SelectPrimitive.Icon asChild>
|
||||||
|
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||||
|
</SelectPrimitive.Icon>
|
||||||
|
</SelectPrimitive.Trigger>
|
||||||
|
));
|
||||||
|
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
|
||||||
|
|
||||||
|
const SelectScrollUpButton = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.ScrollUpButton
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default items-center justify-center py-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronUp className="h-4 w-4" />
|
||||||
|
</SelectPrimitive.ScrollUpButton>
|
||||||
|
));
|
||||||
|
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
|
||||||
|
|
||||||
|
const SelectScrollDownButton = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.ScrollDownButton
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default items-center justify-center py-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronDown className="h-4 w-4" />
|
||||||
|
</SelectPrimitive.ScrollDownButton>
|
||||||
|
));
|
||||||
|
SelectScrollDownButton.displayName =
|
||||||
|
SelectPrimitive.ScrollDownButton.displayName;
|
||||||
|
|
||||||
|
const SelectContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||||
|
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Portal>
|
||||||
|
<SelectPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-xl border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out",
|
||||||
|
position === "popper" &&
|
||||||
|
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
position={position}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SelectScrollUpButton />
|
||||||
|
<SelectPrimitive.Viewport
|
||||||
|
className={cn(
|
||||||
|
"p-1",
|
||||||
|
position === "popper" &&
|
||||||
|
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</SelectPrimitive.Viewport>
|
||||||
|
<SelectScrollDownButton />
|
||||||
|
</SelectPrimitive.Content>
|
||||||
|
</SelectPrimitive.Portal>
|
||||||
|
));
|
||||||
|
SelectContent.displayName = SelectPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
const SelectLabel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Label
|
||||||
|
ref={ref}
|
||||||
|
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
SelectLabel.displayName = SelectPrimitive.Label.displayName;
|
||||||
|
|
||||||
|
const SelectItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex w-full cursor-default select-none items-center rounded-lg py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<SelectPrimitive.ItemIndicator>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</SelectPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||||
|
</SelectPrimitive.Item>
|
||||||
|
));
|
||||||
|
SelectItem.displayName = SelectPrimitive.Item.displayName;
|
||||||
|
|
||||||
|
const SelectSeparator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Separator
|
||||||
|
ref={ref}
|
||||||
|
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
|
||||||
|
|
||||||
|
export {
|
||||||
|
Select,
|
||||||
|
SelectGroup,
|
||||||
|
SelectValue,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectContent,
|
||||||
|
SelectLabel,
|
||||||
|
SelectItem,
|
||||||
|
SelectSeparator,
|
||||||
|
};
|
||||||
29
src/components/ui/separator.tsx
Normal file
29
src/components/ui/separator.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
import * as SeparatorPrimitive from "@radix-ui/react-separator";
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const Separator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
||||||
|
>(
|
||||||
|
(
|
||||||
|
{ className, orientation = "horizontal", decorative = true, ...props },
|
||||||
|
ref
|
||||||
|
) => (
|
||||||
|
<SeparatorPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
decorative={decorative}
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 bg-border",
|
||||||
|
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
Separator.displayName = SeparatorPrimitive.Root.displayName;
|
||||||
|
|
||||||
|
export { Separator };
|
||||||
97
src/components/ui/sheet.tsx
Normal file
97
src/components/ui/sheet.tsx
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
import * as SheetPrimitive from "@radix-ui/react-dialog";
|
||||||
|
import { X } from "lucide-react";
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const Sheet = SheetPrimitive.Root;
|
||||||
|
const SheetTrigger = SheetPrimitive.Trigger;
|
||||||
|
const SheetClose = SheetPrimitive.Close;
|
||||||
|
const SheetPortal = SheetPrimitive.Portal;
|
||||||
|
|
||||||
|
const SheetOverlay = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SheetPrimitive.Overlay>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SheetPrimitive.Overlay
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-0 z-50 bg-black/40 data-[state=open]:animate-in data-[state=closed]:animate-out",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
|
||||||
|
|
||||||
|
interface SheetContentProps
|
||||||
|
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content> {
|
||||||
|
side?: "top" | "right" | "bottom" | "left";
|
||||||
|
}
|
||||||
|
|
||||||
|
const SheetContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SheetPrimitive.Content>,
|
||||||
|
SheetContentProps
|
||||||
|
>(({ side = "right", className, children, ...props }, ref) => (
|
||||||
|
<SheetPortal>
|
||||||
|
<SheetOverlay />
|
||||||
|
<SheetPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out",
|
||||||
|
side === "right" &&
|
||||||
|
"inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right",
|
||||||
|
side === "left" &&
|
||||||
|
"inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left",
|
||||||
|
side === "bottom" &&
|
||||||
|
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring disabled:pointer-events-none">
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</SheetPrimitive.Close>
|
||||||
|
</SheetPrimitive.Content>
|
||||||
|
</SheetPortal>
|
||||||
|
));
|
||||||
|
SheetContent.displayName = SheetPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
const SheetHeader = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col space-y-2 text-center sm:text-left",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
SheetHeader.displayName = "SheetHeader";
|
||||||
|
|
||||||
|
const SheetTitle = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SheetPrimitive.Title>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SheetPrimitive.Title
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-lg font-semibold text-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
SheetTitle.displayName = SheetPrimitive.Title.displayName;
|
||||||
|
|
||||||
|
export {
|
||||||
|
Sheet,
|
||||||
|
SheetPortal,
|
||||||
|
SheetOverlay,
|
||||||
|
SheetTrigger,
|
||||||
|
SheetClose,
|
||||||
|
SheetContent,
|
||||||
|
SheetHeader,
|
||||||
|
SheetTitle,
|
||||||
|
};
|
||||||
84
src/components/ui/table.tsx
Normal file
84
src/components/ui/table.tsx
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const Table = React.forwardRef<
|
||||||
|
HTMLTableElement,
|
||||||
|
React.HTMLAttributes<HTMLTableElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div className="relative w-full overflow-auto">
|
||||||
|
<table
|
||||||
|
ref={ref}
|
||||||
|
className={cn("w-full caption-bottom text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
));
|
||||||
|
Table.displayName = "Table";
|
||||||
|
|
||||||
|
const TableHeader = React.forwardRef<
|
||||||
|
HTMLTableSectionElement,
|
||||||
|
React.HTMLAttributes<HTMLTableSectionElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
|
||||||
|
));
|
||||||
|
TableHeader.displayName = "TableHeader";
|
||||||
|
|
||||||
|
const TableBody = React.forwardRef<
|
||||||
|
HTMLTableSectionElement,
|
||||||
|
React.HTMLAttributes<HTMLTableSectionElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<tbody
|
||||||
|
ref={ref}
|
||||||
|
className={cn("[&_tr:last-child]:border-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TableBody.displayName = "TableBody";
|
||||||
|
|
||||||
|
const TableRow = React.forwardRef<
|
||||||
|
HTMLTableRowElement,
|
||||||
|
React.HTMLAttributes<HTMLTableRowElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<tr
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TableRow.displayName = "TableRow";
|
||||||
|
|
||||||
|
const TableHead = React.forwardRef<
|
||||||
|
HTMLTableCellElement,
|
||||||
|
React.ThHTMLAttributes<HTMLTableCellElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<th
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TableHead.displayName = "TableHead";
|
||||||
|
|
||||||
|
const TableCell = React.forwardRef<
|
||||||
|
HTMLTableCellElement,
|
||||||
|
React.TdHTMLAttributes<HTMLTableCellElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<td
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TableCell.displayName = "TableCell";
|
||||||
|
|
||||||
|
export { Table, TableHeader, TableBody, TableHead, TableRow, TableCell };
|
||||||
53
src/components/ui/tabs.tsx
Normal file
53
src/components/ui/tabs.tsx
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
import * as TabsPrimitive from "@radix-ui/react-tabs";
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const Tabs = TabsPrimitive.Root;
|
||||||
|
|
||||||
|
const TabsList = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TabsPrimitive.List>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.List
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex h-9 items-center justify-center rounded-xl bg-muted p-1 text-muted-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TabsList.displayName = TabsPrimitive.List.displayName;
|
||||||
|
|
||||||
|
const TabsTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.Trigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center justify-center whitespace-nowrap rounded-lg px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
|
||||||
|
|
||||||
|
const TabsContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TabsContent.displayName = TabsPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
export { Tabs, TabsList, TabsTrigger, TabsContent };
|
||||||
22
src/components/ui/textarea.tsx
Normal file
22
src/components/ui/textarea.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const Textarea = React.forwardRef<
|
||||||
|
HTMLTextAreaElement,
|
||||||
|
React.ComponentProps<"textarea">
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
className={cn(
|
||||||
|
"flex min-h-[80px] w-full rounded-xl border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
Textarea.displayName = "Textarea";
|
||||||
|
|
||||||
|
export { Textarea };
|
||||||
65
src/context/AuthContext.tsx
Normal file
65
src/context/AuthContext.tsx
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
type ReactNode,
|
||||||
|
} from "react";
|
||||||
|
|
||||||
|
import type { AdminRole } from "@/lib/types";
|
||||||
|
|
||||||
|
interface AuthState {
|
||||||
|
role: AdminRole | null;
|
||||||
|
name: string;
|
||||||
|
property: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthContextValue extends AuthState {
|
||||||
|
setRole: (r: AdminRole) => void;
|
||||||
|
setProperty: (p: string) => void;
|
||||||
|
logout: () => void;
|
||||||
|
canManageCodes: boolean;
|
||||||
|
canRefund: boolean;
|
||||||
|
canEditBookings: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [role, setRoleState] = useState<AdminRole | null>(null);
|
||||||
|
const [name] = useState("Sophia Mitchell");
|
||||||
|
const [property, setProperty] = useState("Shitaye Suite Hotel");
|
||||||
|
|
||||||
|
const setRole = useCallback((r: AdminRole) => setRoleState(r), []);
|
||||||
|
|
||||||
|
const logout = useCallback(() => setRoleState(null), []);
|
||||||
|
|
||||||
|
const value = useMemo<AuthContextValue>(
|
||||||
|
() => ({
|
||||||
|
role,
|
||||||
|
name,
|
||||||
|
property,
|
||||||
|
setRole,
|
||||||
|
setProperty,
|
||||||
|
logout,
|
||||||
|
canManageCodes: role === "finance" || role === "superadmin",
|
||||||
|
canRefund: role === "finance" || role === "superadmin",
|
||||||
|
canEditBookings:
|
||||||
|
role === "front_desk" ||
|
||||||
|
role === "finance" ||
|
||||||
|
role === "superadmin",
|
||||||
|
}),
|
||||||
|
[role, name, property, setRole, setProperty, logout]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider value={value}>{children}</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
const ctx = useContext(AuthContext);
|
||||||
|
if (!ctx) throw new Error("useAuth outside AuthProvider");
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
33
src/lib/api.ts
Normal file
33
src/lib/api.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
export async function apiGet<T>(path: string): Promise<T> {
|
||||||
|
const res = await fetch(`/api${path}`);
|
||||||
|
if (!res.ok) throw new Error(await res.text());
|
||||||
|
return res.json() as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiPost<T>(path: string, body: unknown): Promise<T> {
|
||||||
|
const res = await fetch(`/api${path}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const t = await res.text();
|
||||||
|
throw new Error(t);
|
||||||
|
}
|
||||||
|
return res.json() as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiPatch<T>(path: string, body: unknown): Promise<T> {
|
||||||
|
const res = await fetch(`/api${path}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(await res.text());
|
||||||
|
return res.json() as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiDelete(path: string): Promise<void> {
|
||||||
|
const res = await fetch(`/api${path}`, { method: "DELETE" });
|
||||||
|
if (!res.ok) throw new Error(await res.text());
|
||||||
|
}
|
||||||
34
src/lib/constants.ts
Normal file
34
src/lib/constants.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
import type { RoomTypeCatalog } from "@/lib/types";
|
||||||
|
|
||||||
|
export const TAX_RATE = 0.15;
|
||||||
|
|
||||||
|
export const ROOM_CATALOGUE: RoomTypeCatalog[] = [
|
||||||
|
{
|
||||||
|
slug: "penthouse",
|
||||||
|
name: "The 4 Bedroom Penthouse",
|
||||||
|
nightlyRate: 450,
|
||||||
|
maxGuests: 8,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: "standard",
|
||||||
|
name: "Standard Rooms",
|
||||||
|
nightlyRate: 120,
|
||||||
|
maxGuests: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: "connecting-suite",
|
||||||
|
name: "Connecting Suite",
|
||||||
|
nightlyRate: 280,
|
||||||
|
maxGuests: 6,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: "junior-studio",
|
||||||
|
name: "Junior Studios",
|
||||||
|
nightlyRate: 95,
|
||||||
|
maxGuests: 2,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function roomTypeLabel(slug: string): string {
|
||||||
|
return ROOM_CATALOGUE.find((r) => r.slug === slug)?.name ?? slug;
|
||||||
|
}
|
||||||
27
src/lib/format.ts
Normal file
27
src/lib/format.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
import { format, parseISO } from "date-fns";
|
||||||
|
|
||||||
|
/** Display dates in property context; production: Africa/Addis_Ababa */
|
||||||
|
export function formatDate(isoDate: string, pattern = "MMM d, yyyy") {
|
||||||
|
try {
|
||||||
|
return format(parseISO(isoDate), pattern);
|
||||||
|
} catch {
|
||||||
|
return isoDate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatMoney(amount: number, currency = "USD") {
|
||||||
|
return new Intl.NumberFormat("en-US", {
|
||||||
|
style: "currency",
|
||||||
|
currency,
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
}).format(amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDateTime(iso: string) {
|
||||||
|
try {
|
||||||
|
return format(parseISO(iso), "MMM d, yyyy HH:mm");
|
||||||
|
} catch {
|
||||||
|
return iso;
|
||||||
|
}
|
||||||
|
}
|
||||||
12
src/lib/room-utils.ts
Normal file
12
src/lib/room-utils.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
import { roomTypeLabel } from "@/lib/constants";
|
||||||
|
|
||||||
|
export function inferRoomTypeSlug(roomId: string): string {
|
||||||
|
if (roomId.includes("penthouse")) return "penthouse";
|
||||||
|
if (roomId.includes("suite")) return "connecting-suite";
|
||||||
|
if (roomId.includes("studio")) return "junior-studio";
|
||||||
|
return "standard";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function roomDisplayName(roomId: string) {
|
||||||
|
return roomTypeLabel(inferRoomTypeSlug(roomId));
|
||||||
|
}
|
||||||
198
src/lib/types/index.ts
Normal file
198
src/lib/types/index.ts
Normal file
|
|
@ -0,0 +1,198 @@
|
||||||
|
export type AdminRole =
|
||||||
|
| "viewer"
|
||||||
|
| "front_desk"
|
||||||
|
| "finance"
|
||||||
|
| "superadmin";
|
||||||
|
|
||||||
|
export type BookingStatus =
|
||||||
|
| "draft"
|
||||||
|
| "held"
|
||||||
|
| "payment_pending"
|
||||||
|
| "confirmed"
|
||||||
|
| "cancelled"
|
||||||
|
| "expired";
|
||||||
|
|
||||||
|
export type PaymentStatus = "paid" | "pending" | "failed" | "refunded";
|
||||||
|
|
||||||
|
export type TransactionType =
|
||||||
|
| "payment"
|
||||||
|
| "refund"
|
||||||
|
| "adjustment"
|
||||||
|
| "charge";
|
||||||
|
|
||||||
|
export type RoomInventoryStatus =
|
||||||
|
| "available"
|
||||||
|
| "occupied"
|
||||||
|
| "maintenance"
|
||||||
|
| "out_of_order";
|
||||||
|
|
||||||
|
export type RoomBlockReason =
|
||||||
|
| "maintenance"
|
||||||
|
| "owner_hold"
|
||||||
|
| "closed"
|
||||||
|
| "other";
|
||||||
|
|
||||||
|
export type DiscountType = "percent" | "fixed_amount";
|
||||||
|
|
||||||
|
export interface GuestDetails {
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
email: string;
|
||||||
|
phone: string;
|
||||||
|
flightBookingNumber: string;
|
||||||
|
arrivalTime: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PricingLine {
|
||||||
|
nightlySubtotal: number;
|
||||||
|
couponCode?: string;
|
||||||
|
discountCodeId?: string;
|
||||||
|
discountPercent: number;
|
||||||
|
discountAmount: number;
|
||||||
|
taxRate: number;
|
||||||
|
taxAmount: number;
|
||||||
|
total: number;
|
||||||
|
totalCents: number;
|
||||||
|
currency: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Booking {
|
||||||
|
id: string;
|
||||||
|
guest: GuestDetails;
|
||||||
|
checkIn: string;
|
||||||
|
checkOut: string;
|
||||||
|
guests: number;
|
||||||
|
roomId: string;
|
||||||
|
nights: number;
|
||||||
|
pricing: PricingLine;
|
||||||
|
status: BookingStatus;
|
||||||
|
holdReference?: string;
|
||||||
|
payLaterHold: boolean;
|
||||||
|
confirmationId?: string;
|
||||||
|
paidAt?: string;
|
||||||
|
referralCode?: string;
|
||||||
|
referralCodeId?: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
internalNotes?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Payment {
|
||||||
|
id: string;
|
||||||
|
bookingId: string;
|
||||||
|
provider: string;
|
||||||
|
amount: number;
|
||||||
|
currency: string;
|
||||||
|
status: PaymentStatus;
|
||||||
|
last4?: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Transaction {
|
||||||
|
id: string;
|
||||||
|
type: TransactionType;
|
||||||
|
amount: number;
|
||||||
|
currency: string;
|
||||||
|
status: PaymentStatus | "completed" | "pending";
|
||||||
|
bookingId?: string;
|
||||||
|
paymentRef?: string;
|
||||||
|
description: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RoomTypeCatalog {
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
nightlyRate: number;
|
||||||
|
maxGuests: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Room {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
roomTypeSlug: string;
|
||||||
|
floor?: string;
|
||||||
|
maxGuests: number;
|
||||||
|
baseRate: number;
|
||||||
|
status: RoomInventoryStatus;
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RoomBlock {
|
||||||
|
id: string;
|
||||||
|
roomId: string;
|
||||||
|
startDate: string;
|
||||||
|
endDate: string;
|
||||||
|
reason: RoomBlockReason;
|
||||||
|
title: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SiteVisit {
|
||||||
|
id: string;
|
||||||
|
occurredAt: string;
|
||||||
|
path: string;
|
||||||
|
referrer?: string;
|
||||||
|
utmSource?: string;
|
||||||
|
utmMedium?: string;
|
||||||
|
utmCampaign?: string;
|
||||||
|
sessionId?: string;
|
||||||
|
device?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DiscountCode {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
description?: string;
|
||||||
|
discountType: DiscountType;
|
||||||
|
value: number;
|
||||||
|
currency?: string;
|
||||||
|
validFrom: string;
|
||||||
|
validTo: string;
|
||||||
|
maxRedemptions: number | null;
|
||||||
|
redemptionCount: number;
|
||||||
|
isActive: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReferralCode {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
label: string;
|
||||||
|
attributedTo?: string;
|
||||||
|
validFrom: string;
|
||||||
|
validTo: string;
|
||||||
|
maxRedemptions: number | null;
|
||||||
|
redemptionCount: number;
|
||||||
|
isActive: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CustomerRow {
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
bookingCount: number;
|
||||||
|
lastStay?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TimelineSegment {
|
||||||
|
bookingId: string;
|
||||||
|
guestName: string;
|
||||||
|
roomId: string;
|
||||||
|
start: string;
|
||||||
|
end: string;
|
||||||
|
status: BookingStatus;
|
||||||
|
paymentLabel: string;
|
||||||
|
source: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardPayload {
|
||||||
|
bookingSeries: { date: string; total: number; online: number; cancelled: number }[];
|
||||||
|
visitsSeries: { date: string; views: number; sessions: number }[];
|
||||||
|
heatmap: { roomId: string; state: "vacant" | "not_ready" | "occupied" | "unavailable" }[];
|
||||||
|
revenueExtras: { label: string; current: number; target: number }[];
|
||||||
|
rating: { score: number; label: string; imageUrl?: string };
|
||||||
|
recentBookings: Booking[];
|
||||||
|
calendarEvents: { id: string; title: string; date: string; accent: "sky" | "pink" | "violet" }[];
|
||||||
|
codeStats: { discountRedemptions: number; referralRedemptions: number };
|
||||||
|
}
|
||||||
6
src/lib/utils.ts
Normal file
6
src/lib/utils.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
import { type ClassValue, clsx } from "clsx";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
30
src/main.tsx
Normal file
30
src/main.tsx
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
import "@fontsource/inter/latin-400.css";
|
||||||
|
import "@fontsource/inter/latin-500.css";
|
||||||
|
import "@fontsource/inter/latin-600.css";
|
||||||
|
import { StrictMode } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { BrowserRouter } from "react-router-dom";
|
||||||
|
|
||||||
|
import App from "@/App";
|
||||||
|
import { AuthProvider } from "@/context/AuthContext";
|
||||||
|
import "@/styles/globals.css";
|
||||||
|
|
||||||
|
async function enableMocking() {
|
||||||
|
if (import.meta.env.MODE !== "development") return;
|
||||||
|
const { worker } = await import("@/mocks/browser");
|
||||||
|
await worker.start({
|
||||||
|
onUnhandledRequest: "bypass",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void enableMocking().then(() => {
|
||||||
|
createRoot(document.getElementById("root")!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<BrowserRouter>
|
||||||
|
<AuthProvider>
|
||||||
|
<App />
|
||||||
|
</AuthProvider>
|
||||||
|
</BrowserRouter>
|
||||||
|
</StrictMode>
|
||||||
|
);
|
||||||
|
});
|
||||||
5
src/mocks/browser.ts
Normal file
5
src/mocks/browser.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
import { setupWorker } from "msw/browser";
|
||||||
|
|
||||||
|
import { handlers } from "@/mocks/handlers";
|
||||||
|
|
||||||
|
export const worker = setupWorker(...handlers);
|
||||||
120
src/mocks/db.ts
Normal file
120
src/mocks/db.ts
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
import type {
|
||||||
|
Booking,
|
||||||
|
BookingStatus,
|
||||||
|
DiscountCode,
|
||||||
|
Payment,
|
||||||
|
ReferralCode,
|
||||||
|
Room,
|
||||||
|
RoomBlock,
|
||||||
|
SiteVisit,
|
||||||
|
Transaction,
|
||||||
|
} from "@/lib/types";
|
||||||
|
import {
|
||||||
|
seedBlocks,
|
||||||
|
seedBookings,
|
||||||
|
seedDiscountCodes,
|
||||||
|
seedPayments,
|
||||||
|
seedReferralCodes,
|
||||||
|
seedRooms,
|
||||||
|
seedSiteVisits,
|
||||||
|
seedTransactions,
|
||||||
|
} from "@/mocks/seed";
|
||||||
|
|
||||||
|
function deepClone<T>(x: T): T {
|
||||||
|
return JSON.parse(JSON.stringify(x)) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MockDb {
|
||||||
|
bookings: Booking[];
|
||||||
|
rooms: Room[];
|
||||||
|
blocks: RoomBlock[];
|
||||||
|
payments: Payment[];
|
||||||
|
transactions: Transaction[];
|
||||||
|
discountCodes: DiscountCode[];
|
||||||
|
referralCodes: ReferralCode[];
|
||||||
|
siteVisits: SiteVisit[];
|
||||||
|
}
|
||||||
|
|
||||||
|
let db: MockDb = createFreshDb();
|
||||||
|
|
||||||
|
function createFreshDb(): MockDb {
|
||||||
|
return {
|
||||||
|
bookings: deepClone(seedBookings),
|
||||||
|
rooms: deepClone(seedRooms),
|
||||||
|
blocks: deepClone(seedBlocks),
|
||||||
|
payments: deepClone(seedPayments),
|
||||||
|
transactions: deepClone(seedTransactions),
|
||||||
|
discountCodes: deepClone(seedDiscountCodes),
|
||||||
|
referralCodes: deepClone(seedReferralCodes),
|
||||||
|
siteVisits: deepClone(seedSiteVisits),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetDb() {
|
||||||
|
db = createFreshDb();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDb(): MockDb {
|
||||||
|
return db;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Inclusive start, exclusive end for nights (hotel convention) */
|
||||||
|
export function parseYmd(s: string) {
|
||||||
|
const [y, m, d] = s.split("-").map(Number);
|
||||||
|
return new Date(y, m - 1, d);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rangesOverlap(
|
||||||
|
aStart: string,
|
||||||
|
aEnd: string,
|
||||||
|
bStart: string,
|
||||||
|
bEnd: string
|
||||||
|
) {
|
||||||
|
const as = parseYmd(aStart).getTime();
|
||||||
|
const ae = parseYmd(aEnd).getTime();
|
||||||
|
const bs = parseYmd(bStart).getTime();
|
||||||
|
const be = parseYmd(bEnd).getTime();
|
||||||
|
return as < be && bs < ae;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bookingConflicts(
|
||||||
|
roomId: string,
|
||||||
|
checkIn: string,
|
||||||
|
checkOut: string,
|
||||||
|
excludeId?: string
|
||||||
|
): boolean {
|
||||||
|
const active: BookingStatus[] = [
|
||||||
|
"held",
|
||||||
|
"payment_pending",
|
||||||
|
"confirmed",
|
||||||
|
];
|
||||||
|
return db.bookings.some(
|
||||||
|
(b) =>
|
||||||
|
b.roomId === roomId &&
|
||||||
|
active.includes(b.status) &&
|
||||||
|
b.id !== excludeId &&
|
||||||
|
rangesOverlap(checkIn, checkOut, b.checkIn, b.checkOut)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function blockConflicts(
|
||||||
|
roomId: string,
|
||||||
|
start: string,
|
||||||
|
end: string,
|
||||||
|
excludeId?: string
|
||||||
|
) {
|
||||||
|
return db.blocks.some(
|
||||||
|
(blk) =>
|
||||||
|
blk.roomId === roomId &&
|
||||||
|
blk.id !== excludeId &&
|
||||||
|
rangesOverlap(start, end, blk.startDate, blk.endDate)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateId(prefix: string) {
|
||||||
|
return `${prefix}-${Math.random().toString(36).slice(2, 10)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeCode(code: string) {
|
||||||
|
return code.trim().toUpperCase();
|
||||||
|
}
|
||||||
566
src/mocks/handlers.ts
Normal file
566
src/mocks/handlers.ts
Normal file
|
|
@ -0,0 +1,566 @@
|
||||||
|
import { addDays, format, parseISO, startOfMonth, endOfMonth, eachDayOfInterval } from "date-fns";
|
||||||
|
import { http, HttpResponse } from "msw";
|
||||||
|
|
||||||
|
import { TAX_RATE } from "@/lib/constants";
|
||||||
|
import type { Booking, CustomerRow, DashboardPayload } from "@/lib/types";
|
||||||
|
import {
|
||||||
|
bookingConflicts,
|
||||||
|
blockConflicts,
|
||||||
|
generateId,
|
||||||
|
getDb,
|
||||||
|
normalizeCode,
|
||||||
|
parseYmd,
|
||||||
|
rangesOverlap,
|
||||||
|
} from "@/mocks/db";
|
||||||
|
|
||||||
|
const API = "/api";
|
||||||
|
|
||||||
|
export const handlers = [
|
||||||
|
http.get(`${API}/bookings`, ({ request }) => {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const status = url.searchParams.get("status");
|
||||||
|
const roomId = url.searchParams.get("roomId");
|
||||||
|
const email = url.searchParams.get("email");
|
||||||
|
const q = url.searchParams.get("q")?.toLowerCase();
|
||||||
|
const discountCode = url.searchParams.get("discountCode");
|
||||||
|
const referralCode = url.searchParams.get("referralCode");
|
||||||
|
let list = [...getDb().bookings];
|
||||||
|
if (status) list = list.filter((b) => b.status === status);
|
||||||
|
if (roomId) list = list.filter((b) => b.roomId === roomId);
|
||||||
|
if (email) list = list.filter((b) => b.guest.email === email);
|
||||||
|
if (discountCode)
|
||||||
|
list = list.filter(
|
||||||
|
(b) =>
|
||||||
|
b.pricing.couponCode?.toUpperCase() === discountCode.toUpperCase()
|
||||||
|
);
|
||||||
|
if (referralCode)
|
||||||
|
list = list.filter(
|
||||||
|
(b) => b.referralCode?.toUpperCase() === referralCode.toUpperCase()
|
||||||
|
);
|
||||||
|
if (q) {
|
||||||
|
list = list.filter(
|
||||||
|
(b) =>
|
||||||
|
`${b.guest.firstName} ${b.guest.lastName}`
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(q) ||
|
||||||
|
b.guest.email.toLowerCase().includes(q) ||
|
||||||
|
b.holdReference?.toLowerCase().includes(q) ||
|
||||||
|
b.confirmationId?.toLowerCase().includes(q) ||
|
||||||
|
b.id.toLowerCase().includes(q)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return HttpResponse.json({ data: list });
|
||||||
|
}),
|
||||||
|
|
||||||
|
http.get(`${API}/bookings/:id`, ({ params }) => {
|
||||||
|
const b = getDb().bookings.find((x) => x.id === params.id);
|
||||||
|
if (!b) return HttpResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
return HttpResponse.json(b);
|
||||||
|
}),
|
||||||
|
|
||||||
|
http.patch(`${API}/bookings/:id`, async ({ params, request }) => {
|
||||||
|
const body = (await request.json()) as Partial<Booking>;
|
||||||
|
const db = getDb();
|
||||||
|
const idx = db.bookings.findIndex((x) => x.id === params.id);
|
||||||
|
if (idx === -1)
|
||||||
|
return HttpResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
const cur = db.bookings[idx];
|
||||||
|
const next = { ...cur, ...body, updatedAt: new Date().toISOString() };
|
||||||
|
if (body.internalNotes) {
|
||||||
|
next.internalNotes = [
|
||||||
|
...(cur.internalNotes ?? []),
|
||||||
|
...body.internalNotes,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
db.bookings[idx] = next;
|
||||||
|
return HttpResponse.json(next);
|
||||||
|
}),
|
||||||
|
|
||||||
|
http.post(`${API}/bookings`, async ({ request }) => {
|
||||||
|
const body = (await request.json()) as Partial<Booking> & {
|
||||||
|
guest: Booking["guest"];
|
||||||
|
checkIn: string;
|
||||||
|
checkOut: string;
|
||||||
|
roomId: string;
|
||||||
|
guests: number;
|
||||||
|
};
|
||||||
|
const db = getDb();
|
||||||
|
if (
|
||||||
|
bookingConflicts(body.roomId!, body.checkIn!, body.checkOut!)
|
||||||
|
) {
|
||||||
|
return HttpResponse.json(
|
||||||
|
{ error: "Room unavailable for these dates" },
|
||||||
|
{ status: 409 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const ci = parseYmd(body.checkIn!);
|
||||||
|
const co = parseYmd(body.checkOut!);
|
||||||
|
const nights = Math.max(
|
||||||
|
1,
|
||||||
|
Math.round((co.getTime() - ci.getTime()) / 86400000)
|
||||||
|
);
|
||||||
|
const room = db.rooms.find((r) => r.id === body.roomId);
|
||||||
|
const nightly = room?.baseRate ?? 120;
|
||||||
|
const sub = nightly * nights;
|
||||||
|
let discountPct = 0;
|
||||||
|
let coupon: string | undefined;
|
||||||
|
if (body.pricing?.couponCode) {
|
||||||
|
const dc = db.discountCodes.find(
|
||||||
|
(d) =>
|
||||||
|
normalizeCode(d.code) === normalizeCode(body.pricing!.couponCode!) &&
|
||||||
|
d.isActive
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
dc &&
|
||||||
|
dc.discountType === "percent" &&
|
||||||
|
(!dc.maxRedemptions || dc.redemptionCount < dc.maxRedemptions)
|
||||||
|
) {
|
||||||
|
discountPct = dc.value;
|
||||||
|
coupon = dc.code;
|
||||||
|
dc.redemptionCount += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const discountAmount = sub * (discountPct / 100);
|
||||||
|
const after = sub - discountAmount;
|
||||||
|
const taxAmount = after * TAX_RATE;
|
||||||
|
const total = after + taxAmount;
|
||||||
|
const id = generateId("b");
|
||||||
|
const booking: Booking = {
|
||||||
|
id,
|
||||||
|
guest: body.guest,
|
||||||
|
checkIn: body.checkIn!,
|
||||||
|
checkOut: body.checkOut!,
|
||||||
|
guests: body.guests ?? 2,
|
||||||
|
roomId: body.roomId!,
|
||||||
|
nights,
|
||||||
|
status: body.status ?? "confirmed",
|
||||||
|
payLaterHold: body.payLaterHold ?? false,
|
||||||
|
holdReference: `SHY-${id.slice(-6).toUpperCase()}`,
|
||||||
|
pricing: {
|
||||||
|
nightlySubtotal: sub,
|
||||||
|
couponCode: coupon,
|
||||||
|
discountPercent: discountPct,
|
||||||
|
discountAmount,
|
||||||
|
taxRate: TAX_RATE,
|
||||||
|
taxAmount,
|
||||||
|
total,
|
||||||
|
totalCents: Math.round(total * 100),
|
||||||
|
currency: "USD",
|
||||||
|
},
|
||||||
|
referralCode: body.referralCode,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
if (body.referralCode) {
|
||||||
|
const rc = db.referralCodes.find(
|
||||||
|
(r) => normalizeCode(r.code) === normalizeCode(body.referralCode!)
|
||||||
|
);
|
||||||
|
if (rc && rc.isActive) rc.redemptionCount += 1;
|
||||||
|
}
|
||||||
|
db.bookings.push(booking);
|
||||||
|
return HttpResponse.json(booking, { status: 201 });
|
||||||
|
}),
|
||||||
|
|
||||||
|
http.get(`${API}/payments`, () =>
|
||||||
|
HttpResponse.json({ data: getDb().payments })
|
||||||
|
),
|
||||||
|
|
||||||
|
http.get(`${API}/transactions`, ({ request }) => {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const bookingId = url.searchParams.get("bookingId");
|
||||||
|
let t = [...getDb().transactions];
|
||||||
|
if (bookingId) t = t.filter((x) => x.bookingId === bookingId);
|
||||||
|
t.sort(
|
||||||
|
(a, b) =>
|
||||||
|
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
||||||
|
);
|
||||||
|
return HttpResponse.json({ data: t });
|
||||||
|
}),
|
||||||
|
|
||||||
|
http.get(`${API}/rooms`, () =>
|
||||||
|
HttpResponse.json({ data: getDb().rooms })
|
||||||
|
),
|
||||||
|
|
||||||
|
http.post(`${API}/rooms`, async ({ request }) => {
|
||||||
|
const body = (await request.json()) as Omit<
|
||||||
|
import("@/lib/types").Room,
|
||||||
|
"id"
|
||||||
|
>;
|
||||||
|
const db = getDb();
|
||||||
|
const room = { ...body, id: generateId("r") };
|
||||||
|
db.rooms.push(room);
|
||||||
|
return HttpResponse.json(room, { status: 201 });
|
||||||
|
}),
|
||||||
|
|
||||||
|
http.patch(`${API}/rooms/:id`, async ({ params, request }) => {
|
||||||
|
const body = (await request.json()) as Partial<import("@/lib/types").Room>;
|
||||||
|
const db = getDb();
|
||||||
|
const idx = db.rooms.findIndex((r) => r.id === params.id);
|
||||||
|
if (idx === -1)
|
||||||
|
return HttpResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
db.rooms[idx] = { ...db.rooms[idx], ...body };
|
||||||
|
return HttpResponse.json(db.rooms[idx]);
|
||||||
|
}),
|
||||||
|
|
||||||
|
http.get(`${API}/rooms/:id/blocks`, ({ params }) => {
|
||||||
|
const list = getDb().blocks.filter((b) => b.roomId === params.id);
|
||||||
|
return HttpResponse.json({ data: list });
|
||||||
|
}),
|
||||||
|
|
||||||
|
http.post(`${API}/rooms/:id/blocks`, async ({ params, request }) => {
|
||||||
|
const body = (await request.json()) as Omit<
|
||||||
|
import("@/lib/types").RoomBlock,
|
||||||
|
"id" | "roomId" | "createdAt"
|
||||||
|
>;
|
||||||
|
const db = getDb();
|
||||||
|
if (blockConflicts(params.id as string, body.startDate, body.endDate)) {
|
||||||
|
return HttpResponse.json(
|
||||||
|
{ error: "Overlapping block exists" },
|
||||||
|
{ status: 409 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const blk: import("@/lib/types").RoomBlock = {
|
||||||
|
...body,
|
||||||
|
id: generateId("blk"),
|
||||||
|
roomId: params.id as string,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
db.blocks.push(blk);
|
||||||
|
return HttpResponse.json(blk, { status: 201 });
|
||||||
|
}),
|
||||||
|
|
||||||
|
http.delete(`${API}/rooms/:roomId/blocks/:blockId`, ({ params }) => {
|
||||||
|
const db = getDb();
|
||||||
|
const idx = db.blocks.findIndex(
|
||||||
|
(b) => b.id === params.blockId && b.roomId === params.roomId
|
||||||
|
);
|
||||||
|
if (idx === -1)
|
||||||
|
return HttpResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
db.blocks.splice(idx, 1);
|
||||||
|
return HttpResponse.json({ ok: true });
|
||||||
|
}),
|
||||||
|
|
||||||
|
http.get(`${API}/dashboard/summary`, () => {
|
||||||
|
const db = getDb();
|
||||||
|
const today = format(new Date(), "yyyy-MM-dd");
|
||||||
|
const arrivals = db.bookings.filter(
|
||||||
|
(b) => b.checkIn === today && ["confirmed", "held"].includes(b.status)
|
||||||
|
).length;
|
||||||
|
const departures = db.bookings.filter(
|
||||||
|
(b) => b.checkOut === today && b.status === "confirmed"
|
||||||
|
).length;
|
||||||
|
const unpaidHolds = db.bookings.filter(
|
||||||
|
(b) => b.status === "held" && b.payLaterHold
|
||||||
|
).length;
|
||||||
|
const revenue = db.bookings
|
||||||
|
.filter((b) => b.status === "confirmed")
|
||||||
|
.reduce((s, b) => s + b.pricing.total, 0);
|
||||||
|
return HttpResponse.json({
|
||||||
|
arrivals,
|
||||||
|
departures,
|
||||||
|
unpaidHolds,
|
||||||
|
revenueMonth: revenue,
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
|
||||||
|
http.get(`${API}/dashboard`, () => {
|
||||||
|
const db = getDb();
|
||||||
|
const payload: DashboardPayload = {
|
||||||
|
bookingSeries: Array.from({ length: 14 }, (_, i) => {
|
||||||
|
const d = addDays(new Date(), -13 + i);
|
||||||
|
return {
|
||||||
|
date: format(d, "MMM d"),
|
||||||
|
total: 20 + (i % 5) * 3,
|
||||||
|
online: 12 + (i % 4) * 2,
|
||||||
|
cancelled: i % 6 === 0 ? 2 : 0,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
visitsSeries: Array.from({ length: 14 }, (_, i) => {
|
||||||
|
const d = addDays(new Date(), -13 + i);
|
||||||
|
const key = format(d, "yyyy-MM-dd");
|
||||||
|
const dayVisits = db.siteVisits.filter((v) =>
|
||||||
|
v.occurredAt.startsWith(key)
|
||||||
|
);
|
||||||
|
const sessions = new Set(dayVisits.map((v) => v.sessionId)).size;
|
||||||
|
return {
|
||||||
|
date: format(d, "MMM d"),
|
||||||
|
views: dayVisits.length * 8,
|
||||||
|
sessions: sessions || dayVisits.length,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
heatmap: db.rooms.map((r) => ({
|
||||||
|
roomId: r.id,
|
||||||
|
state:
|
||||||
|
r.status === "maintenance"
|
||||||
|
? ("not_ready" as const)
|
||||||
|
: r.status === "occupied"
|
||||||
|
? ("occupied" as const)
|
||||||
|
: r.status === "out_of_order"
|
||||||
|
? ("unavailable" as const)
|
||||||
|
: ("vacant" as const),
|
||||||
|
})),
|
||||||
|
revenueExtras: [
|
||||||
|
{ label: "Restaurant", current: 12400, target: 18000 },
|
||||||
|
{ label: "Bar", current: 8200, target: 12000 },
|
||||||
|
{ label: "Spa", current: 5600, target: 9000 },
|
||||||
|
],
|
||||||
|
rating: {
|
||||||
|
score: 4.8,
|
||||||
|
label: "Impressive",
|
||||||
|
imageUrl:
|
||||||
|
"https://images.unsplash.com/photo-1571896349842-33c89424de2d?w=400&q=80",
|
||||||
|
},
|
||||||
|
recentBookings: [...db.bookings]
|
||||||
|
.sort(
|
||||||
|
(a, b) =>
|
||||||
|
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
||||||
|
)
|
||||||
|
.slice(0, 5),
|
||||||
|
calendarEvents: [
|
||||||
|
{
|
||||||
|
id: "e1",
|
||||||
|
title: "VIP arrival — Suite 201",
|
||||||
|
date: format(new Date(), "yyyy-MM-dd"),
|
||||||
|
accent: "sky",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "e2",
|
||||||
|
title: "Staff training",
|
||||||
|
date: format(addDays(new Date(), 2), "yyyy-MM-dd"),
|
||||||
|
accent: "pink",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
codeStats: {
|
||||||
|
discountRedemptions: db.discountCodes.reduce(
|
||||||
|
(s, d) => s + d.redemptionCount,
|
||||||
|
0
|
||||||
|
),
|
||||||
|
referralRedemptions: db.referralCodes.reduce(
|
||||||
|
(s, r) => s + r.redemptionCount,
|
||||||
|
0
|
||||||
|
),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return HttpResponse.json(payload);
|
||||||
|
}),
|
||||||
|
|
||||||
|
http.get(`${API}/reservations/timeline`, ({ request }) => {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const month = url.searchParams.get("month") ?? format(new Date(), "yyyy-MM");
|
||||||
|
const start = startOfMonth(parseISO(`${month}-01`));
|
||||||
|
const end = endOfMonth(start);
|
||||||
|
const days = eachDayOfInterval({ start, end }).map((d) =>
|
||||||
|
format(d, "yyyy-MM-dd")
|
||||||
|
);
|
||||||
|
const db = getDb();
|
||||||
|
const segments = db.bookings
|
||||||
|
.filter((b) =>
|
||||||
|
rangesOverlap(b.checkIn, b.checkOut, format(start, "yyyy-MM-dd"), format(end, "yyyy-MM-dd"))
|
||||||
|
)
|
||||||
|
.map((b) => ({
|
||||||
|
bookingId: b.id,
|
||||||
|
guestName: `${b.guest.firstName} ${b.guest.lastName}`,
|
||||||
|
roomId: b.roomId,
|
||||||
|
start: b.checkIn,
|
||||||
|
end: b.checkOut,
|
||||||
|
status: b.status,
|
||||||
|
paymentLabel:
|
||||||
|
b.status === "confirmed"
|
||||||
|
? "Paid"
|
||||||
|
: b.status === "payment_pending"
|
||||||
|
? "Part-paid"
|
||||||
|
: "Unpaid",
|
||||||
|
source: ["Direct", "Booking.com", "Direct"][b.id.length % 3],
|
||||||
|
}));
|
||||||
|
return HttpResponse.json({ days, rooms: db.rooms, segments });
|
||||||
|
}),
|
||||||
|
|
||||||
|
http.get(`${API}/export/bookings.csv`, () => {
|
||||||
|
const db = getDb();
|
||||||
|
const header =
|
||||||
|
"id,guest,email,checkIn,checkOut,room,status,total\n";
|
||||||
|
const rows = db.bookings
|
||||||
|
.map(
|
||||||
|
(b) =>
|
||||||
|
`${b.id},"${b.guest.firstName} ${b.guest.lastName}",${b.guest.email},${b.checkIn},${b.checkOut},${b.roomId},${b.status},${b.pricing.total}`
|
||||||
|
)
|
||||||
|
.join("\n");
|
||||||
|
return new HttpResponse(header + rows, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "text/csv",
|
||||||
|
"Content-Disposition": 'attachment; filename="bookings.csv"',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
|
||||||
|
http.get(`${API}/customers`, () => {
|
||||||
|
const map = new Map<string, CustomerRow>();
|
||||||
|
for (const b of getDb().bookings) {
|
||||||
|
const k = b.guest.email.toLowerCase();
|
||||||
|
const name = `${b.guest.firstName} ${b.guest.lastName}`;
|
||||||
|
const prev = map.get(k);
|
||||||
|
if (!prev) {
|
||||||
|
map.set(k, {
|
||||||
|
email: b.guest.email,
|
||||||
|
name,
|
||||||
|
bookingCount: 1,
|
||||||
|
lastStay: b.checkOut,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
prev.bookingCount += 1;
|
||||||
|
if (!prev.lastStay || b.checkOut > prev.lastStay)
|
||||||
|
prev.lastStay = b.checkOut;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return HttpResponse.json({ data: [...map.values()] });
|
||||||
|
}),
|
||||||
|
|
||||||
|
http.get(`${API}/analytics/visits/recent`, () => {
|
||||||
|
const visits = [...getDb().siteVisits]
|
||||||
|
.sort(
|
||||||
|
(a, b) =>
|
||||||
|
new Date(b.occurredAt).getTime() - new Date(a.occurredAt).getTime()
|
||||||
|
)
|
||||||
|
.slice(0, 40);
|
||||||
|
return HttpResponse.json({ data: visits });
|
||||||
|
}),
|
||||||
|
|
||||||
|
http.get(`${API}/analytics/visits`, ({ request }) => {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const from = url.searchParams.get("from");
|
||||||
|
const to = url.searchParams.get("to");
|
||||||
|
let visits = [...getDb().siteVisits];
|
||||||
|
if (from) visits = visits.filter((v) => v.occurredAt >= from);
|
||||||
|
if (to) visits = visits.filter((v) => v.occurredAt <= to);
|
||||||
|
const byDay = new Map<string, { views: number; sessions: Set<string> }>();
|
||||||
|
for (const v of visits) {
|
||||||
|
const day = v.occurredAt.slice(0, 10);
|
||||||
|
if (!byDay.has(day))
|
||||||
|
byDay.set(day, { views: 0, sessions: new Set() });
|
||||||
|
const g = byDay.get(day)!;
|
||||||
|
g.views += 1;
|
||||||
|
if (v.sessionId) g.sessions.add(v.sessionId);
|
||||||
|
}
|
||||||
|
const series = [...byDay.entries()]
|
||||||
|
.sort(([a], [b]) => a.localeCompare(b))
|
||||||
|
.map(([date, g]) => ({
|
||||||
|
date,
|
||||||
|
views: g.views,
|
||||||
|
sessions: g.sessions.size || g.views,
|
||||||
|
}));
|
||||||
|
return HttpResponse.json({
|
||||||
|
series,
|
||||||
|
totalViews: visits.length,
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
|
||||||
|
http.post(`${API}/analytics/visits`, async ({ request }) => {
|
||||||
|
const body = (await request.json()) as Partial<
|
||||||
|
import("@/lib/types").SiteVisit
|
||||||
|
>;
|
||||||
|
const db = getDb();
|
||||||
|
const v: import("@/lib/types").SiteVisit = {
|
||||||
|
id: generateId("v"),
|
||||||
|
occurredAt: body.occurredAt ?? new Date().toISOString(),
|
||||||
|
path: body.path ?? "/",
|
||||||
|
referrer: body.referrer,
|
||||||
|
utmSource: body.utmSource,
|
||||||
|
utmMedium: body.utmMedium,
|
||||||
|
utmCampaign: body.utmCampaign,
|
||||||
|
sessionId: body.sessionId ?? `s-${Date.now()}`,
|
||||||
|
device: body.device,
|
||||||
|
};
|
||||||
|
db.siteVisits.push(v);
|
||||||
|
return HttpResponse.json(v, { status: 201 });
|
||||||
|
}),
|
||||||
|
|
||||||
|
http.get(`${API}/discount-codes`, () =>
|
||||||
|
HttpResponse.json({ data: getDb().discountCodes })
|
||||||
|
),
|
||||||
|
|
||||||
|
http.post(`${API}/discount-codes`, async ({ request }) => {
|
||||||
|
const body = (await request.json()) as Partial<import("@/lib/types").DiscountCode> & {
|
||||||
|
generate?: boolean;
|
||||||
|
};
|
||||||
|
const db = getDb();
|
||||||
|
const code =
|
||||||
|
body.code && !body.generate
|
||||||
|
? normalizeCode(body.code)
|
||||||
|
: `SAVE${Math.random().toString(36).slice(2, 6).toUpperCase()}`;
|
||||||
|
if (db.discountCodes.some((d) => normalizeCode(d.code) === code)) {
|
||||||
|
return HttpResponse.json({ error: "Code exists" }, { status: 409 });
|
||||||
|
}
|
||||||
|
const dc: import("@/lib/types").DiscountCode = {
|
||||||
|
id: generateId("dc"),
|
||||||
|
code,
|
||||||
|
description: body.description,
|
||||||
|
discountType: body.discountType ?? "percent",
|
||||||
|
value: body.value ?? 10,
|
||||||
|
currency: body.currency,
|
||||||
|
validFrom: body.validFrom ?? format(new Date(), "yyyy-MM-dd"),
|
||||||
|
validTo: body.validTo ?? format(addDays(new Date(), 365), "yyyy-MM-dd"),
|
||||||
|
maxRedemptions: body.maxRedemptions ?? null,
|
||||||
|
redemptionCount: 0,
|
||||||
|
isActive: body.isActive ?? true,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
db.discountCodes.push(dc);
|
||||||
|
return HttpResponse.json(dc, { status: 201 });
|
||||||
|
}),
|
||||||
|
|
||||||
|
http.patch(`${API}/discount-codes/:id`, async ({ params, request }) => {
|
||||||
|
const body = (await request.json()) as Partial<
|
||||||
|
import("@/lib/types").DiscountCode
|
||||||
|
>;
|
||||||
|
const db = getDb();
|
||||||
|
const idx = db.discountCodes.findIndex((d) => d.id === params.id);
|
||||||
|
if (idx === -1)
|
||||||
|
return HttpResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
db.discountCodes[idx] = { ...db.discountCodes[idx], ...body };
|
||||||
|
return HttpResponse.json(db.discountCodes[idx]);
|
||||||
|
}),
|
||||||
|
|
||||||
|
http.get(`${API}/referral-codes`, () =>
|
||||||
|
HttpResponse.json({ data: getDb().referralCodes })
|
||||||
|
),
|
||||||
|
|
||||||
|
http.post(`${API}/referral-codes`, async ({ request }) => {
|
||||||
|
const body = (await request.json()) as Partial<
|
||||||
|
import("@/lib/types").ReferralCode
|
||||||
|
> & { generate?: boolean };
|
||||||
|
const db = getDb();
|
||||||
|
const code =
|
||||||
|
body.code && !body.generate
|
||||||
|
? normalizeCode(body.code)
|
||||||
|
: `REF${Math.random().toString(36).slice(2, 8).toUpperCase()}`;
|
||||||
|
if (db.referralCodes.some((r) => normalizeCode(r.code) === code)) {
|
||||||
|
return HttpResponse.json({ error: "Code exists" }, { status: 409 });
|
||||||
|
}
|
||||||
|
const rc: import("@/lib/types").ReferralCode = {
|
||||||
|
id: generateId("rf"),
|
||||||
|
code,
|
||||||
|
label: body.label ?? "Campaign",
|
||||||
|
attributedTo: body.attributedTo,
|
||||||
|
validFrom: body.validFrom ?? format(new Date(), "yyyy-MM-dd"),
|
||||||
|
validTo: body.validTo ?? format(addDays(new Date(), 365), "yyyy-MM-dd"),
|
||||||
|
maxRedemptions: body.maxRedemptions ?? null,
|
||||||
|
redemptionCount: 0,
|
||||||
|
isActive: body.isActive ?? true,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
db.referralCodes.push(rc);
|
||||||
|
return HttpResponse.json(rc, { status: 201 });
|
||||||
|
}),
|
||||||
|
|
||||||
|
http.patch(`${API}/referral-codes/:id`, async ({ params, request }) => {
|
||||||
|
const body = (await request.json()) as Partial<
|
||||||
|
import("@/lib/types").ReferralCode
|
||||||
|
>;
|
||||||
|
const db = getDb();
|
||||||
|
const idx = db.referralCodes.findIndex((r) => r.id === params.id);
|
||||||
|
if (idx === -1)
|
||||||
|
return HttpResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
db.referralCodes[idx] = { ...db.referralCodes[idx], ...body };
|
||||||
|
return HttpResponse.json(db.referralCodes[idx]);
|
||||||
|
}),
|
||||||
|
];
|
||||||
363
src/mocks/seed.ts
Normal file
363
src/mocks/seed.ts
Normal file
|
|
@ -0,0 +1,363 @@
|
||||||
|
import { TAX_RATE } from "@/lib/constants";
|
||||||
|
import type {
|
||||||
|
Booking,
|
||||||
|
DiscountCode,
|
||||||
|
Payment,
|
||||||
|
ReferralCode,
|
||||||
|
Room,
|
||||||
|
RoomBlock,
|
||||||
|
SiteVisit,
|
||||||
|
Transaction,
|
||||||
|
} from "@/lib/types";
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const iso = (d: Date) => d.toISOString();
|
||||||
|
|
||||||
|
function pricing(
|
||||||
|
nightly: number,
|
||||||
|
nights: number,
|
||||||
|
coupon?: string,
|
||||||
|
pct = 0
|
||||||
|
): Booking["pricing"] {
|
||||||
|
const sub = nightly * nights;
|
||||||
|
const discountAmount = sub * (pct / 100);
|
||||||
|
const after = sub - discountAmount;
|
||||||
|
const taxAmount = after * TAX_RATE;
|
||||||
|
const total = after + taxAmount;
|
||||||
|
return {
|
||||||
|
nightlySubtotal: sub,
|
||||||
|
couponCode: coupon,
|
||||||
|
discountPercent: pct,
|
||||||
|
discountAmount,
|
||||||
|
taxRate: TAX_RATE,
|
||||||
|
taxAmount,
|
||||||
|
total,
|
||||||
|
totalCents: Math.round(total * 100),
|
||||||
|
currency: "USD",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const seedBookings: Booking[] = [
|
||||||
|
{
|
||||||
|
id: "b1",
|
||||||
|
guest: {
|
||||||
|
firstName: "Amina",
|
||||||
|
lastName: "Tesfaye",
|
||||||
|
email: "amina@example.com",
|
||||||
|
phone: "+251911000001",
|
||||||
|
flightBookingNumber: "ETH708",
|
||||||
|
arrivalTime: "14:30",
|
||||||
|
},
|
||||||
|
checkIn: "2026-03-22",
|
||||||
|
checkOut: "2026-03-25",
|
||||||
|
guests: 2,
|
||||||
|
roomId: "r-standard-101",
|
||||||
|
nights: 3,
|
||||||
|
pricing: pricing(120, 3, "SHITAYE10", 10),
|
||||||
|
status: "confirmed",
|
||||||
|
holdReference: "SHY-H001",
|
||||||
|
payLaterHold: false,
|
||||||
|
confirmationId: "PAY-001",
|
||||||
|
paidAt: iso(new Date(now.getTime() - 86400000)),
|
||||||
|
referralCode: "PARTNER2024",
|
||||||
|
createdAt: iso(new Date(now.getTime() - 172800000)),
|
||||||
|
updatedAt: iso(now),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "b2",
|
||||||
|
guest: {
|
||||||
|
firstName: "James",
|
||||||
|
lastName: "Brown",
|
||||||
|
email: "james@example.com",
|
||||||
|
phone: "+447700900123",
|
||||||
|
flightBookingNumber: "BA123",
|
||||||
|
arrivalTime: "09:00",
|
||||||
|
},
|
||||||
|
checkIn: "2026-03-23",
|
||||||
|
checkOut: "2026-03-28",
|
||||||
|
guests: 4,
|
||||||
|
roomId: "r-suite-201",
|
||||||
|
nights: 5,
|
||||||
|
pricing: pricing(280, 5, undefined, 0),
|
||||||
|
status: "held",
|
||||||
|
holdReference: "SHY-H002",
|
||||||
|
payLaterHold: true,
|
||||||
|
createdAt: iso(new Date(now.getTime() - 3600000)),
|
||||||
|
updatedAt: iso(now),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "b3",
|
||||||
|
guest: {
|
||||||
|
firstName: "Sofia",
|
||||||
|
lastName: "Mitchell",
|
||||||
|
email: "sofia@example.com",
|
||||||
|
phone: "+12025550199",
|
||||||
|
flightBookingNumber: "DL44",
|
||||||
|
arrivalTime: "16:00",
|
||||||
|
},
|
||||||
|
checkIn: "2026-03-20",
|
||||||
|
checkOut: "2026-03-22",
|
||||||
|
guests: 2,
|
||||||
|
roomId: "r-studio-305",
|
||||||
|
nights: 2,
|
||||||
|
pricing: pricing(95, 2, "WELCOME5", 5),
|
||||||
|
status: "confirmed",
|
||||||
|
confirmationId: "PAY-003",
|
||||||
|
paidAt: iso(new Date(now.getTime() - 259200000)),
|
||||||
|
payLaterHold: false,
|
||||||
|
createdAt: iso(new Date(now.getTime() - 400000000)),
|
||||||
|
updatedAt: iso(now),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "b4",
|
||||||
|
guest: {
|
||||||
|
firstName: "Yonas",
|
||||||
|
lastName: "Kebede",
|
||||||
|
email: "yonas@example.com",
|
||||||
|
phone: "+251922000002",
|
||||||
|
flightBookingNumber: "ET302",
|
||||||
|
arrivalTime: "11:15",
|
||||||
|
},
|
||||||
|
checkIn: "2026-03-25",
|
||||||
|
checkOut: "2026-03-30",
|
||||||
|
guests: 6,
|
||||||
|
roomId: "r-penthouse-1",
|
||||||
|
nights: 5,
|
||||||
|
pricing: pricing(450, 5),
|
||||||
|
status: "payment_pending",
|
||||||
|
holdReference: "SHY-H004",
|
||||||
|
payLaterHold: false,
|
||||||
|
createdAt: iso(now),
|
||||||
|
updatedAt: iso(now),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "b5",
|
||||||
|
guest: {
|
||||||
|
firstName: "Elena",
|
||||||
|
lastName: "Rossi",
|
||||||
|
email: "elena@example.it",
|
||||||
|
phone: "+39333111222",
|
||||||
|
flightBookingNumber: "AZ784",
|
||||||
|
arrivalTime: "20:00",
|
||||||
|
},
|
||||||
|
checkIn: "2026-03-18",
|
||||||
|
checkOut: "2026-03-19",
|
||||||
|
guests: 2,
|
||||||
|
roomId: "r-standard-102",
|
||||||
|
nights: 1,
|
||||||
|
pricing: pricing(120, 1),
|
||||||
|
status: "cancelled",
|
||||||
|
holdReference: "SHY-H005",
|
||||||
|
payLaterHold: false,
|
||||||
|
createdAt: iso(new Date(now.getTime() - 500000000)),
|
||||||
|
updatedAt: iso(now),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const seedRooms: Room[] = [
|
||||||
|
{
|
||||||
|
id: "r-standard-101",
|
||||||
|
name: "Room 101",
|
||||||
|
roomTypeSlug: "standard",
|
||||||
|
floor: "1",
|
||||||
|
maxGuests: 2,
|
||||||
|
baseRate: 120,
|
||||||
|
status: "occupied",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "r-standard-102",
|
||||||
|
name: "Room 102",
|
||||||
|
roomTypeSlug: "standard",
|
||||||
|
floor: "1",
|
||||||
|
maxGuests: 2,
|
||||||
|
baseRate: 120,
|
||||||
|
status: "available",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "r-suite-201",
|
||||||
|
name: "Suite 201",
|
||||||
|
roomTypeSlug: "connecting-suite",
|
||||||
|
floor: "2",
|
||||||
|
maxGuests: 6,
|
||||||
|
baseRate: 280,
|
||||||
|
status: "occupied",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "r-studio-305",
|
||||||
|
name: "Studio 305",
|
||||||
|
roomTypeSlug: "junior-studio",
|
||||||
|
floor: "3",
|
||||||
|
maxGuests: 2,
|
||||||
|
baseRate: 95,
|
||||||
|
status: "available",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "r-penthouse-1",
|
||||||
|
name: "Penthouse",
|
||||||
|
roomTypeSlug: "penthouse",
|
||||||
|
floor: "PH",
|
||||||
|
maxGuests: 8,
|
||||||
|
baseRate: 450,
|
||||||
|
status: "occupied",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "r-standard-103",
|
||||||
|
name: "Room 103",
|
||||||
|
roomTypeSlug: "standard",
|
||||||
|
floor: "1",
|
||||||
|
maxGuests: 2,
|
||||||
|
baseRate: 120,
|
||||||
|
status: "maintenance",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const seedBlocks: RoomBlock[] = [
|
||||||
|
{
|
||||||
|
id: "blk1",
|
||||||
|
roomId: "r-standard-103",
|
||||||
|
startDate: "2026-03-21",
|
||||||
|
endDate: "2026-03-24",
|
||||||
|
reason: "maintenance",
|
||||||
|
title: "HVAC service",
|
||||||
|
createdAt: iso(now),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const seedPayments: Payment[] = [
|
||||||
|
{
|
||||||
|
id: "pay1",
|
||||||
|
bookingId: "b1",
|
||||||
|
provider: "mock",
|
||||||
|
amount: seedBookings[0].pricing.total,
|
||||||
|
currency: "USD",
|
||||||
|
status: "paid",
|
||||||
|
last4: "4242",
|
||||||
|
createdAt: seedBookings[0].paidAt!,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "pay3",
|
||||||
|
bookingId: "b3",
|
||||||
|
provider: "mock",
|
||||||
|
amount: seedBookings[2].pricing.total,
|
||||||
|
currency: "USD",
|
||||||
|
status: "paid",
|
||||||
|
last4: "1881",
|
||||||
|
createdAt: seedBookings[2].paidAt!,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const seedTransactions: Transaction[] = [
|
||||||
|
{
|
||||||
|
id: "t1",
|
||||||
|
type: "payment",
|
||||||
|
amount: seedBookings[0].pricing.total,
|
||||||
|
currency: "USD",
|
||||||
|
status: "completed",
|
||||||
|
bookingId: "b1",
|
||||||
|
paymentRef: "PAY-001",
|
||||||
|
description: "Card capture",
|
||||||
|
createdAt: seedBookings[0].paidAt!,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "t2",
|
||||||
|
type: "refund",
|
||||||
|
amount: 80,
|
||||||
|
currency: "USD",
|
||||||
|
status: "completed",
|
||||||
|
bookingId: "b5",
|
||||||
|
description: "Partial refund — cancellation policy",
|
||||||
|
createdAt: iso(new Date(now.getTime() - 86400000)),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const seedDiscountCodes: DiscountCode[] = [
|
||||||
|
{
|
||||||
|
id: "dc1",
|
||||||
|
code: "SHITAYE10",
|
||||||
|
description: "10% off public campaign",
|
||||||
|
discountType: "percent",
|
||||||
|
value: 10,
|
||||||
|
validFrom: "2026-01-01",
|
||||||
|
validTo: "2026-12-31",
|
||||||
|
maxRedemptions: 500,
|
||||||
|
redemptionCount: 42,
|
||||||
|
isActive: true,
|
||||||
|
createdAt: iso(new Date(now.getTime() - 86400000 * 60)),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "dc2",
|
||||||
|
code: "WELCOME5",
|
||||||
|
discountType: "percent",
|
||||||
|
value: 5,
|
||||||
|
validFrom: "2026-01-01",
|
||||||
|
validTo: "2026-06-30",
|
||||||
|
maxRedemptions: null,
|
||||||
|
redemptionCount: 18,
|
||||||
|
isActive: true,
|
||||||
|
createdAt: iso(new Date(now.getTime() - 86400000 * 30)),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "dc3",
|
||||||
|
code: "OLDPROMO",
|
||||||
|
discountType: "fixed_amount",
|
||||||
|
value: 2500,
|
||||||
|
currency: "USD",
|
||||||
|
validFrom: "2025-01-01",
|
||||||
|
validTo: "2025-12-31",
|
||||||
|
maxRedemptions: 100,
|
||||||
|
redemptionCount: 100,
|
||||||
|
isActive: false,
|
||||||
|
createdAt: iso(new Date(now.getTime() - 86400000 * 400)),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const seedReferralCodes: ReferralCode[] = [
|
||||||
|
{
|
||||||
|
id: "rf1",
|
||||||
|
code: "PARTNER2024",
|
||||||
|
label: "Travel partner Q1",
|
||||||
|
attributedTo: "partner-ethio-tours",
|
||||||
|
validFrom: "2026-01-01",
|
||||||
|
validTo: "2026-12-31",
|
||||||
|
maxRedemptions: null,
|
||||||
|
redemptionCount: 12,
|
||||||
|
isActive: true,
|
||||||
|
createdAt: iso(new Date(now.getTime() - 86400000 * 90)),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "rf2",
|
||||||
|
code: "VIPGUEST",
|
||||||
|
label: "VIP referrals",
|
||||||
|
validFrom: "2026-03-01",
|
||||||
|
validTo: "2026-12-31",
|
||||||
|
maxRedemptions: 50,
|
||||||
|
redemptionCount: 3,
|
||||||
|
isActive: true,
|
||||||
|
createdAt: iso(new Date(now.getTime() - 86400000 * 5)),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function seedVisits(): SiteVisit[] {
|
||||||
|
const visits: SiteVisit[] = [];
|
||||||
|
let id = 0;
|
||||||
|
for (let d = 29; d >= 0; d--) {
|
||||||
|
const day = new Date(now);
|
||||||
|
day.setDate(day.getDate() - d);
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
visits.push({
|
||||||
|
id: `v${++id}`,
|
||||||
|
occurredAt: new Date(
|
||||||
|
day.getTime() + i * 3600000 + 1000 * id
|
||||||
|
).toISOString(),
|
||||||
|
path: ["/", "/booking", "/rooms", "/contact"][i % 4],
|
||||||
|
referrer: i % 2 ? "https://google.com" : undefined,
|
||||||
|
utmSource: i === 0 ? "newsletter" : undefined,
|
||||||
|
sessionId: `s-${d}-${i}`,
|
||||||
|
device: ["mobile", "desktop"][id % 2],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return visits;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const seedSiteVisits = seedVisits();
|
||||||
136
src/pages/BookingDetailPage.tsx
Normal file
136
src/pages/BookingDetailPage.tsx
Normal file
|
|
@ -0,0 +1,136 @@
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Link, useParams } from "react-router-dom";
|
||||||
|
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { useAuth } from "@/context/AuthContext";
|
||||||
|
import { apiGet, apiPatch } from "@/lib/api";
|
||||||
|
import type { Booking } from "@/lib/types";
|
||||||
|
import { formatDate, formatDateTime, formatMoney } from "@/lib/format";
|
||||||
|
import { roomDisplayName } from "@/lib/room-utils";
|
||||||
|
|
||||||
|
export function BookingDetailPage() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const [b, setB] = useState<Booking | null>(null);
|
||||||
|
const [note, setNote] = useState("");
|
||||||
|
const { canEditBookings } = useAuth();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!id) return;
|
||||||
|
apiGet<Booking>(`/bookings/${id}`).then(setB).catch(console.error);
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
if (!b) return <p className="text-muted-foreground">Loading…</p>;
|
||||||
|
|
||||||
|
async function addNote() {
|
||||||
|
if (!b || !note.trim() || !canEditBookings) return;
|
||||||
|
const next = await apiPatch<Booking>(`/bookings/${b.id}`, {
|
||||||
|
internalNotes: [note.trim()],
|
||||||
|
});
|
||||||
|
setB(next);
|
||||||
|
setNote("");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<Button variant="ghost" size="sm" asChild>
|
||||||
|
<Link to="/bookings">← Back</Link>
|
||||||
|
</Button>
|
||||||
|
<h1 className="mt-2 text-2xl font-bold">
|
||||||
|
{b.guest.firstName} {b.guest.lastName}
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground text-sm">{b.id}</p>
|
||||||
|
</div>
|
||||||
|
<Badge>{b.status}</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
|
<Card className="rounded-2xl">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Guest</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-1 text-sm">
|
||||||
|
<p>
|
||||||
|
<span className="text-muted-foreground">Email:</span>{" "}
|
||||||
|
{b.guest.email}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<span className="text-muted-foreground">Phone:</span>{" "}
|
||||||
|
{b.guest.phone}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<span className="text-muted-foreground">PNR:</span>{" "}
|
||||||
|
{b.guest.flightBookingNumber}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<span className="text-muted-foreground">Arrival:</span>{" "}
|
||||||
|
{b.guest.arrivalTime}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="rounded-2xl">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Stay</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-1 text-sm">
|
||||||
|
<p>
|
||||||
|
{formatDate(b.checkIn)} → {formatDate(b.checkOut)} ({b.nights}{" "}
|
||||||
|
nights)
|
||||||
|
</p>
|
||||||
|
<p>Guests: {b.guests}</p>
|
||||||
|
<p>Room: {roomDisplayName(b.roomId)}</p>
|
||||||
|
{b.holdReference && <p>Hold: {b.holdReference}</p>}
|
||||||
|
{b.confirmationId && <p>Payment ref: {b.confirmationId}</p>}
|
||||||
|
{b.paidAt && <p>Paid: {formatDateTime(b.paidAt)}</p>}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="rounded-2xl lg:col-span-2">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Pricing</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="grid gap-2 text-sm sm:grid-cols-2">
|
||||||
|
<p>Nightly subtotal: {formatMoney(b.pricing.nightlySubtotal)}</p>
|
||||||
|
<p>Coupon: {b.pricing.couponCode ?? "—"}</p>
|
||||||
|
<p>Discount: {b.pricing.discountPercent}%</p>
|
||||||
|
<p>Tax: {formatMoney(b.pricing.taxAmount)}</p>
|
||||||
|
<p className="font-semibold sm:col-span-2">
|
||||||
|
Total: {formatMoney(b.pricing.total)}
|
||||||
|
</p>
|
||||||
|
<p>Referral: {b.referralCode ?? "—"}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="rounded-2xl lg:col-span-2">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Internal notes</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<ul className="list-inside list-disc text-sm text-muted-foreground">
|
||||||
|
{(b.internalNotes ?? []).map((n, i) => (
|
||||||
|
<li key={i}>{n}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
{canEditBookings && (
|
||||||
|
<>
|
||||||
|
<Textarea
|
||||||
|
placeholder="Add note…"
|
||||||
|
value={note}
|
||||||
|
onChange={(e) => setNote(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Button size="sm" onClick={addNote}>
|
||||||
|
Save note
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
203
src/pages/BookingsPage.tsx
Normal file
203
src/pages/BookingsPage.tsx
Normal file
|
|
@ -0,0 +1,203 @@
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Link, useSearchParams } from "react-router-dom";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Breadcrumb,
|
||||||
|
BreadcrumbItem,
|
||||||
|
BreadcrumbLink,
|
||||||
|
BreadcrumbList,
|
||||||
|
BreadcrumbPage,
|
||||||
|
BreadcrumbSeparator,
|
||||||
|
} from "@/components/ui/breadcrumb";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import { apiGet } from "@/lib/api";
|
||||||
|
import type { Booking } from "@/lib/types";
|
||||||
|
import { formatDate, formatMoney } from "@/lib/format";
|
||||||
|
import { roomDisplayName } from "@/lib/room-utils";
|
||||||
|
|
||||||
|
export function BookingsPage() {
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const ref = searchParams.get("referral") ?? "";
|
||||||
|
const [list, setList] = useState<Booking[]>([]);
|
||||||
|
const [status, setStatus] = useState<string>("all");
|
||||||
|
const [q, setQ] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (status !== "all") params.set("status", status);
|
||||||
|
if (q) params.set("q", q);
|
||||||
|
if (ref) params.set("referralCode", ref);
|
||||||
|
const t = setTimeout(() => {
|
||||||
|
apiGet<{ data: Booking[] }>(`/bookings?${params}`)
|
||||||
|
.then((r) => setList(r.data))
|
||||||
|
.catch(console.error);
|
||||||
|
}, 200);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, [status, q, ref]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Breadcrumb>
|
||||||
|
<BreadcrumbList>
|
||||||
|
<BreadcrumbItem>
|
||||||
|
<BreadcrumbLink href="/dashboard">Home</BreadcrumbLink>
|
||||||
|
</BreadcrumbItem>
|
||||||
|
<BreadcrumbSeparator />
|
||||||
|
<BreadcrumbItem>
|
||||||
|
<BreadcrumbPage>Bookings</BreadcrumbPage>
|
||||||
|
</BreadcrumbItem>
|
||||||
|
</BreadcrumbList>
|
||||||
|
</Breadcrumb>
|
||||||
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">Bookings</h1>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
Search, filter, export
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button asChild>
|
||||||
|
<Link to="/bookings/new">+ New booking</Link>
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" asChild>
|
||||||
|
<a href="/api/export/bookings.csv" download>
|
||||||
|
Export CSV
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
{["Total", "Confirmed", "Held", "Pending"].map((label, i) => (
|
||||||
|
<Card key={label} className="rounded-2xl">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||||
|
{label}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-2xl font-bold">
|
||||||
|
{i === 0
|
||||||
|
? list.length
|
||||||
|
: list.filter((b) =>
|
||||||
|
label === "Confirmed"
|
||||||
|
? b.status === "confirmed"
|
||||||
|
: label === "Held"
|
||||||
|
? b.status === "held"
|
||||||
|
: b.status === "payment_pending"
|
||||||
|
).length}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="rounded-2xl">
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="mb-4 flex flex-col gap-3 sm:flex-row">
|
||||||
|
<Input
|
||||||
|
placeholder="Search guest, ref…"
|
||||||
|
value={q}
|
||||||
|
onChange={(e) => setQ(e.target.value)}
|
||||||
|
className="max-w-md"
|
||||||
|
/>
|
||||||
|
<Select value={status} onValueChange={setStatus}>
|
||||||
|
<SelectTrigger className="w-full sm:w-44">
|
||||||
|
<SelectValue placeholder="Status" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All status</SelectItem>
|
||||||
|
<SelectItem value="confirmed">Confirmed</SelectItem>
|
||||||
|
<SelectItem value="held">Held</SelectItem>
|
||||||
|
<SelectItem value="payment_pending">Payment pending</SelectItem>
|
||||||
|
<SelectItem value="cancelled">Cancelled</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="hidden md:block overflow-x-auto">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Guest</TableHead>
|
||||||
|
<TableHead>Room</TableHead>
|
||||||
|
<TableHead>Dates</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead className="text-right">Total</TableHead>
|
||||||
|
<TableHead />
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{list.map((b) => (
|
||||||
|
<TableRow key={b.id}>
|
||||||
|
<TableCell>
|
||||||
|
<p className="font-medium">
|
||||||
|
{b.guest.firstName} {b.guest.lastName}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{b.guest.email}
|
||||||
|
</p>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm">
|
||||||
|
{roomDisplayName(b.roomId)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-xs text-muted-foreground">
|
||||||
|
{formatDate(b.checkIn)} → {formatDate(b.checkOut)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="secondary">{b.status}</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right font-medium">
|
||||||
|
{formatMoney(b.pricing.total)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Button variant="outline" size="sm" asChild>
|
||||||
|
<Link to={`/bookings/${b.id}`}>Details</Link>
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3 md:hidden">
|
||||||
|
{list.map((b) => (
|
||||||
|
<Link
|
||||||
|
key={b.id}
|
||||||
|
to={`/bookings/${b.id}`}
|
||||||
|
className="block rounded-xl border p-4"
|
||||||
|
>
|
||||||
|
<p className="font-medium">
|
||||||
|
{b.guest.firstName} {b.guest.lastName}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{formatDate(b.checkIn)} · {b.status}
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 font-semibold">
|
||||||
|
{formatMoney(b.pricing.total)}
|
||||||
|
</p>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
199
src/pages/CalendarPage.tsx
Normal file
199
src/pages/CalendarPage.tsx
Normal file
|
|
@ -0,0 +1,199 @@
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import { apiDelete, apiGet, apiPost } from "@/lib/api";
|
||||||
|
import type { Room, RoomBlock } from "@/lib/types";
|
||||||
|
import { formatDate } from "@/lib/format";
|
||||||
|
|
||||||
|
export function CalendarPage() {
|
||||||
|
const [rooms, setRooms] = useState<Room[]>([]);
|
||||||
|
const [roomId, setRoomId] = useState("");
|
||||||
|
const [blocks, setBlocks] = useState<RoomBlock[]>([]);
|
||||||
|
const [start, setStart] = useState("");
|
||||||
|
const [end, setEnd] = useState("");
|
||||||
|
const [title, setTitle] = useState("");
|
||||||
|
const [reason, setReason] = useState<RoomBlock["reason"]>("maintenance");
|
||||||
|
|
||||||
|
const loadRooms = useCallback(() => {
|
||||||
|
apiGet<{ data: Room[] }>("/rooms").then((r) => {
|
||||||
|
setRooms(r.data);
|
||||||
|
setRoomId((cur) => cur || (r.data[0]?.id ?? ""));
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadBlocks = useCallback(() => {
|
||||||
|
if (!roomId) return;
|
||||||
|
apiGet<{ data: RoomBlock[] }>(`/rooms/${roomId}/blocks`).then((r) =>
|
||||||
|
setBlocks(r.data)
|
||||||
|
);
|
||||||
|
}, [roomId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadRooms();
|
||||||
|
}, [loadRooms]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadBlocks();
|
||||||
|
}, [loadBlocks]);
|
||||||
|
|
||||||
|
async function addBlock(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
await apiPost(`/rooms/${roomId}/blocks`, {
|
||||||
|
startDate: start,
|
||||||
|
endDate: end,
|
||||||
|
title,
|
||||||
|
reason,
|
||||||
|
});
|
||||||
|
setTitle("");
|
||||||
|
loadBlocks();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h1 className="text-2xl font-bold">Calendar & blocks</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Block rooms for maintenance or holds (mock).
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Card className="rounded-2xl">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Room</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Select value={roomId} onValueChange={setRoomId}>
|
||||||
|
<SelectTrigger className="max-w-md">
|
||||||
|
<SelectValue placeholder="Select room" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{rooms.map((r) => (
|
||||||
|
<SelectItem key={r.id} value={r.id}>
|
||||||
|
{r.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="rounded-2xl">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">New block</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form
|
||||||
|
onSubmit={addBlock}
|
||||||
|
className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4"
|
||||||
|
>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Start</Label>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
required
|
||||||
|
value={start}
|
||||||
|
onChange={(e) => setStart(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>End</Label>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
required
|
||||||
|
value={end}
|
||||||
|
onChange={(e) => setEnd(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2 sm:col-span-2">
|
||||||
|
<Label>Title</Label>
|
||||||
|
<Input
|
||||||
|
required
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Reason</Label>
|
||||||
|
<Select
|
||||||
|
value={reason}
|
||||||
|
onValueChange={(v) => setReason(v as RoomBlock["reason"])}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="maintenance">Maintenance</SelectItem>
|
||||||
|
<SelectItem value="owner_hold">Owner hold</SelectItem>
|
||||||
|
<SelectItem value="closed">Closed</SelectItem>
|
||||||
|
<SelectItem value="other">Other</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-end">
|
||||||
|
<Button type="submit">Add block</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="rounded-2xl">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Blocks for selected room</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Title</TableHead>
|
||||||
|
<TableHead>Dates</TableHead>
|
||||||
|
<TableHead>Reason</TableHead>
|
||||||
|
<TableHead />
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{blocks.map((b) => (
|
||||||
|
<TableRow key={b.id}>
|
||||||
|
<TableCell>{b.title}</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground">
|
||||||
|
{formatDate(b.startDate)} → {formatDate(b.endDate)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{b.reason}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
apiDelete(`/rooms/${roomId}/blocks/${b.id}`).then(
|
||||||
|
loadBlocks
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
75
src/pages/CustomersPage.tsx
Normal file
75
src/pages/CustomersPage.tsx
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import { apiGet } from "@/lib/api";
|
||||||
|
import type { CustomerRow } from "@/lib/types";
|
||||||
|
import { formatDate } from "@/lib/format";
|
||||||
|
|
||||||
|
export function CustomersPage() {
|
||||||
|
const [rows, setRows] = useState<CustomerRow[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
apiGet<{ data: CustomerRow[] }>("/customers").then((r) =>
|
||||||
|
setRows(r.data)
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h1 className="text-2xl font-bold">Customers</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Aggregated from bookings (read-only mock).
|
||||||
|
</p>
|
||||||
|
<Card className="rounded-2xl">
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="hidden md:block">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Name</TableHead>
|
||||||
|
<TableHead>Email</TableHead>
|
||||||
|
<TableHead>Bookings</TableHead>
|
||||||
|
<TableHead>Last stay</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{rows.map((c) => (
|
||||||
|
<TableRow key={c.email}>
|
||||||
|
<TableCell className="font-medium">{c.name}</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground">
|
||||||
|
{c.email}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{c.bookingCount}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{c.lastStay ? formatDate(c.lastStay) : "—"}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2 md:hidden">
|
||||||
|
{rows.map((c) => (
|
||||||
|
<div key={c.email} className="rounded-xl border p-3 text-sm">
|
||||||
|
<p className="font-medium">{c.name}</p>
|
||||||
|
<p className="text-muted-foreground">{c.email}</p>
|
||||||
|
<p className="mt-1 text-xs">
|
||||||
|
{c.bookingCount} bookings
|
||||||
|
{c.lastStay && ` · last ${formatDate(c.lastStay)}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
297
src/pages/DashboardPage.tsx
Normal file
297
src/pages/DashboardPage.tsx
Normal file
|
|
@ -0,0 +1,297 @@
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
Area,
|
||||||
|
AreaChart,
|
||||||
|
CartesianGrid,
|
||||||
|
Legend,
|
||||||
|
Line,
|
||||||
|
LineChart,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Tooltip,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
} from "recharts";
|
||||||
|
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import { Progress } from "@/components/ui/progress";
|
||||||
|
import { apiGet } from "@/lib/api";
|
||||||
|
import type { Booking, DashboardPayload } from "@/lib/types";
|
||||||
|
import { formatDate, formatMoney } from "@/lib/format";
|
||||||
|
import { roomDisplayName } from "@/lib/room-utils";
|
||||||
|
|
||||||
|
const tooltipStyle = {
|
||||||
|
backgroundColor: "var(--navy)",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "8px",
|
||||||
|
color: "#fff",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function DashboardPage() {
|
||||||
|
const [data, setData] = useState<DashboardPayload | null>(null);
|
||||||
|
const [err, setErr] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
apiGet<DashboardPayload>("/dashboard")
|
||||||
|
.then(setData)
|
||||||
|
.catch((e) => setErr(String(e)));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (err) return <p className="text-destructive">{err}</p>;
|
||||||
|
if (!data) return <p className="text-muted-foreground">Loading…</p>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">Dashboard</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Bookings, visits, and revenue snapshot
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
|
<Card className="rounded-2xl">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
|
<CardTitle className="text-base">Booking trends</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="h-[240px] w-full min-h-[220px]">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<LineChart data={data.bookingSeries}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" className="opacity-40" />
|
||||||
|
<XAxis dataKey="date" tick={{ fontSize: 11 }} />
|
||||||
|
<YAxis tick={{ fontSize: 11 }} />
|
||||||
|
<Tooltip contentStyle={tooltipStyle} />
|
||||||
|
<Legend />
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="total"
|
||||||
|
stroke="var(--chart-1)"
|
||||||
|
strokeWidth={2}
|
||||||
|
name="Total"
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="online"
|
||||||
|
stroke="var(--chart-2)"
|
||||||
|
strokeWidth={2}
|
||||||
|
name="Online"
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="cancelled"
|
||||||
|
stroke="var(--chart-4)"
|
||||||
|
name="Cancelled"
|
||||||
|
/>
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="rounded-2xl">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Site visits</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="h-[240px] w-full min-h-[220px]">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<AreaChart data={data.visitsSeries}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" className="opacity-40" />
|
||||||
|
<XAxis dataKey="date" tick={{ fontSize: 11 }} />
|
||||||
|
<YAxis tick={{ fontSize: 11 }} />
|
||||||
|
<Tooltip contentStyle={tooltipStyle} />
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="views"
|
||||||
|
stroke="var(--chart-2)"
|
||||||
|
fill="var(--chart-3)"
|
||||||
|
fillOpacity={0.35}
|
||||||
|
name="Views"
|
||||||
|
/>
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 lg:grid-cols-3">
|
||||||
|
<Card className="rounded-2xl lg:col-span-2">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Room status</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{data.heatmap.map((h) => (
|
||||||
|
<div
|
||||||
|
key={h.roomId}
|
||||||
|
title={h.roomId}
|
||||||
|
className="size-10 rounded-lg border text-[10px] font-medium flex items-center justify-center"
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
h.state === "occupied"
|
||||||
|
? "var(--chart-1)"
|
||||||
|
: h.state === "vacant"
|
||||||
|
? "var(--chart-5)"
|
||||||
|
: h.state === "not_ready"
|
||||||
|
? "var(--muted)"
|
||||||
|
: "transparent",
|
||||||
|
color: h.state === "occupied" ? "#fff" : "inherit",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{h.roomId.slice(-2)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 flex flex-wrap gap-3 text-xs text-muted-foreground">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<span className="size-2 rounded-full bg-[var(--chart-5)]" />{" "}
|
||||||
|
Vacant
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<span className="size-2 rounded-full bg-[var(--chart-1)]" />{" "}
|
||||||
|
Occupied
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<span className="size-2 rounded-full bg-muted" /> Not ready
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="rounded-2xl overflow-hidden">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Guest rating</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2">
|
||||||
|
<p className="text-4xl font-bold">{data.rating.score}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">{data.rating.label}</p>
|
||||||
|
{data.rating.imageUrl && (
|
||||||
|
<img
|
||||||
|
src={data.rating.imageUrl}
|
||||||
|
alt=""
|
||||||
|
className="mt-2 h-28 w-full rounded-xl object-cover"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-4 pt-2 text-sm">
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">Discount uses</p>
|
||||||
|
<p className="font-semibold">{data.codeStats.discountRedemptions}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">Referrals</p>
|
||||||
|
<p className="font-semibold">{data.codeStats.referralRedemptions}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
|
<Card className="rounded-2xl">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Extra revenue</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{data.revenueExtras.map((r) => (
|
||||||
|
<div key={r.label}>
|
||||||
|
<div className="mb-1 flex justify-between text-sm">
|
||||||
|
<span>{r.label}</span>
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{formatMoney(r.current)} / {formatMoney(r.target)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Progress
|
||||||
|
value={Math.min(100, (r.current / r.target) * 100)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="rounded-2xl">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Upcoming</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2">
|
||||||
|
{data.calendarEvents.map((e) => (
|
||||||
|
<div
|
||||||
|
key={e.id}
|
||||||
|
className="rounded-xl border-l-4 border-l-primary bg-muted/30 px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<p className="font-medium">{e.title}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{formatDate(e.date)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="rounded-2xl">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Recent bookings</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="hidden md:block">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Guest</TableHead>
|
||||||
|
<TableHead>Stay</TableHead>
|
||||||
|
<TableHead>Room type</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead className="text-right">Total</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{data.recentBookings.map((b: Booking) => (
|
||||||
|
<TableRow key={b.id}>
|
||||||
|
<TableCell>
|
||||||
|
{b.guest.firstName} {b.guest.lastName}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground text-xs">
|
||||||
|
{formatDate(b.checkIn)} → {formatDate(b.checkOut)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-xs">
|
||||||
|
{roomDisplayName(b.roomId)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="secondary">{b.status}</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right font-medium">
|
||||||
|
{formatMoney(b.pricing.total)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
<CardContent className="space-y-3 md:hidden">
|
||||||
|
{data.recentBookings.map((b: Booking) => (
|
||||||
|
<div key={b.id} className="rounded-xl border p-3 text-sm">
|
||||||
|
<p className="font-medium">
|
||||||
|
{b.guest.firstName} {b.guest.lastName}
|
||||||
|
</p>
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
{formatDate(b.checkIn)} — {b.status}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 font-semibold">
|
||||||
|
{formatMoney(b.pricing.total)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
201
src/pages/DiscountCodesPage.tsx
Normal file
201
src/pages/DiscountCodesPage.tsx
Normal file
|
|
@ -0,0 +1,201 @@
|
||||||
|
import { Copy } from "lucide-react";
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import { useAuth } from "@/context/AuthContext";
|
||||||
|
import { apiGet, apiPatch, apiPost } from "@/lib/api";
|
||||||
|
import type { DiscountCode } from "@/lib/types";
|
||||||
|
|
||||||
|
function copy(s: string) {
|
||||||
|
void navigator.clipboard.writeText(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DiscountCodesPage() {
|
||||||
|
const { canManageCodes } = useAuth();
|
||||||
|
const [rows, setRows] = useState<DiscountCode[]>([]);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [custom, setCustom] = useState("");
|
||||||
|
const [generate, setGenerate] = useState(true);
|
||||||
|
const [value, setValue] = useState("10");
|
||||||
|
const [dtype, setDtype] = useState<"percent" | "fixed_amount">("percent");
|
||||||
|
|
||||||
|
const load = useCallback(() => {
|
||||||
|
apiGet<{ data: DiscountCode[] }>("/discount-codes").then((r) =>
|
||||||
|
setRows(r.data)
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
async function create(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
await apiPost("/discount-codes", {
|
||||||
|
generate,
|
||||||
|
code: generate ? undefined : custom,
|
||||||
|
discountType: dtype,
|
||||||
|
value: Number(value),
|
||||||
|
});
|
||||||
|
setOpen(false);
|
||||||
|
load();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggle(dc: DiscountCode) {
|
||||||
|
if (!canManageCodes) return;
|
||||||
|
await apiPatch(`/discount-codes/${dc.id}`, { isActive: !dc.isActive });
|
||||||
|
load();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-2xl font-bold">Discount codes</h1>
|
||||||
|
{canManageCodes && (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button>+ Generate code</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>New discount code</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<form onSubmit={create} className="grid gap-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={generate}
|
||||||
|
onChange={(e) => setGenerate(e.target.checked)}
|
||||||
|
id="gen"
|
||||||
|
/>
|
||||||
|
<Label htmlFor="gen">Auto-generate code</Label>
|
||||||
|
</div>
|
||||||
|
{!generate && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Custom code</Label>
|
||||||
|
<Input
|
||||||
|
value={custom}
|
||||||
|
onChange={(e) => setCustom(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Type</Label>
|
||||||
|
<Select
|
||||||
|
value={dtype}
|
||||||
|
onValueChange={(v) => setDtype(v as typeof dtype)}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="percent">Percent</SelectItem>
|
||||||
|
<SelectItem value="fixed_amount">Fixed amount</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Value</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => setValue(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button type="submit">Create</Button>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="rounded-2xl">
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Code</TableHead>
|
||||||
|
<TableHead>Type</TableHead>
|
||||||
|
<TableHead>Value</TableHead>
|
||||||
|
<TableHead>Redemptions</TableHead>
|
||||||
|
<TableHead>Active</TableHead>
|
||||||
|
<TableHead />
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{rows.map((d) => (
|
||||||
|
<TableRow key={d.id}>
|
||||||
|
<TableCell className="font-mono font-medium">{d.code}</TableCell>
|
||||||
|
<TableCell>{d.discountType}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{d.discountType === "percent"
|
||||||
|
? `${d.value}%`
|
||||||
|
: d.value}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{d.redemptionCount}
|
||||||
|
{d.maxRedemptions != null && ` / ${d.maxRedemptions}`}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={d.isActive ? "success" : "secondary"}>
|
||||||
|
{d.isActive ? "Active" : "Off"}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="flex gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
copy(d.code);
|
||||||
|
}}
|
||||||
|
title="Copy"
|
||||||
|
>
|
||||||
|
<Copy className="size-4" />
|
||||||
|
</Button>
|
||||||
|
{canManageCodes && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggle(d)}
|
||||||
|
>
|
||||||
|
Toggle
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
54
src/pages/LoginPage.tsx
Normal file
54
src/pages/LoginPage.tsx
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { useAuth } from "@/context/AuthContext";
|
||||||
|
import type { AdminRole } from "@/lib/types";
|
||||||
|
|
||||||
|
const roles: { id: AdminRole; label: string }[] = [
|
||||||
|
{ id: "viewer", label: "Viewer (read-only)" },
|
||||||
|
{ id: "front_desk", label: "Front desk" },
|
||||||
|
{ id: "finance", label: "Finance" },
|
||||||
|
{ id: "superadmin", label: "Super admin" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function LoginPage() {
|
||||||
|
const { setRole } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
function pick(role: AdminRole) {
|
||||||
|
setRole(role);
|
||||||
|
navigate("/dashboard", { replace: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center bg-muted/40 p-4">
|
||||||
|
<Card className="w-full max-w-md rounded-2xl shadow-md">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-2xl">Yaltopia Hotels Admin</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Mock sign-in — choose a role to explore RBAC (no password).
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="grid gap-2">
|
||||||
|
{roles.map((r) => (
|
||||||
|
<Button
|
||||||
|
key={r.id}
|
||||||
|
variant="outline"
|
||||||
|
className="h-12 justify-start rounded-xl"
|
||||||
|
onClick={() => pick(r.id)}
|
||||||
|
>
|
||||||
|
{r.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
203
src/pages/NewBookingPage.tsx
Normal file
203
src/pages/NewBookingPage.tsx
Normal file
|
|
@ -0,0 +1,203 @@
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { apiGet, apiPost } from "@/lib/api";
|
||||||
|
import type { Booking, Room } from "@/lib/types";
|
||||||
|
|
||||||
|
export function NewBookingPage() {
|
||||||
|
const nav = useNavigate();
|
||||||
|
const [rooms, setRooms] = useState<Room[]>([]);
|
||||||
|
const [roomId, setRoomId] = useState("");
|
||||||
|
const [checkIn, setCheckIn] = useState("");
|
||||||
|
const [checkOut, setCheckOut] = useState("");
|
||||||
|
const [guests, setGuests] = useState("2");
|
||||||
|
const [firstName, setFirstName] = useState("");
|
||||||
|
const [lastName, setLastName] = useState("");
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [phone, setPhone] = useState("");
|
||||||
|
const [pnr, setPnr] = useState("");
|
||||||
|
const [arrival, setArrival] = useState("14:00");
|
||||||
|
const [coupon, setCoupon] = useState("");
|
||||||
|
const [referral, setReferral] = useState("");
|
||||||
|
const [err, setErr] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
apiGet<{ data: Room[] }>("/rooms")
|
||||||
|
.then((r) => {
|
||||||
|
setRooms(r.data);
|
||||||
|
if (r.data[0]) setRoomId(r.data[0].id);
|
||||||
|
})
|
||||||
|
.catch(console.error);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function submit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setErr(null);
|
||||||
|
try {
|
||||||
|
const body: Partial<Booking> & Record<string, unknown> = {
|
||||||
|
guest: {
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
email,
|
||||||
|
phone,
|
||||||
|
flightBookingNumber: pnr,
|
||||||
|
arrivalTime: arrival,
|
||||||
|
},
|
||||||
|
checkIn,
|
||||||
|
checkOut,
|
||||||
|
roomId,
|
||||||
|
guests: Number(guests),
|
||||||
|
status: "confirmed",
|
||||||
|
payLaterHold: false,
|
||||||
|
};
|
||||||
|
if (coupon) body.pricing = { couponCode: coupon } as Booking["pricing"];
|
||||||
|
if (referral) body.referralCode = referral;
|
||||||
|
const created = await apiPost<Booking>("/bookings", body);
|
||||||
|
nav(`/bookings/${created.id}`);
|
||||||
|
} catch (e: unknown) {
|
||||||
|
setErr(e instanceof Error ? e.message : "Failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-3xl space-y-6">
|
||||||
|
<h1 className="text-2xl font-bold">New reservation</h1>
|
||||||
|
<form onSubmit={submit} className="space-y-4">
|
||||||
|
<Card className="rounded-2xl">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Stay</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div className="space-y-2 sm:col-span-2">
|
||||||
|
<Label>Room</Label>
|
||||||
|
<Select value={roomId} onValueChange={setRoomId}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Room" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{rooms.map((r) => (
|
||||||
|
<SelectItem key={r.id} value={r.id}>
|
||||||
|
{r.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Check-in</Label>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
required
|
||||||
|
value={checkIn}
|
||||||
|
onChange={(e) => setCheckIn(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Check-out</Label>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
required
|
||||||
|
value={checkOut}
|
||||||
|
onChange={(e) => setCheckOut(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Guests</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={guests}
|
||||||
|
onChange={(e) => setGuests(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="rounded-2xl">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Guest</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>First name</Label>
|
||||||
|
<Input
|
||||||
|
required
|
||||||
|
value={firstName}
|
||||||
|
onChange={(e) => setFirstName(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Last name</Label>
|
||||||
|
<Input
|
||||||
|
required
|
||||||
|
value={lastName}
|
||||||
|
onChange={(e) => setLastName(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2 sm:col-span-2">
|
||||||
|
<Label>Email</Label>
|
||||||
|
<Input
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Phone</Label>
|
||||||
|
<Input
|
||||||
|
value={phone}
|
||||||
|
onChange={(e) => setPhone(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Flight PNR</Label>
|
||||||
|
<Input value={pnr} onChange={(e) => setPnr(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Arrival time</Label>
|
||||||
|
<Input
|
||||||
|
type="time"
|
||||||
|
value={arrival}
|
||||||
|
onChange={(e) => setArrival(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="rounded-2xl">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Codes (optional)</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Discount code</Label>
|
||||||
|
<Input value={coupon} onChange={(e) => setCoupon(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Referral code</Label>
|
||||||
|
<Input
|
||||||
|
value={referral}
|
||||||
|
onChange={(e) => setReferral(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{err && <p className="text-sm text-destructive">{err}</p>}
|
||||||
|
<Button type="submit">Create booking</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
62
src/pages/PaymentsPage.tsx
Normal file
62
src/pages/PaymentsPage.tsx
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import { apiGet } from "@/lib/api";
|
||||||
|
import type { Payment } from "@/lib/types";
|
||||||
|
import { formatDateTime, formatMoney } from "@/lib/format";
|
||||||
|
|
||||||
|
export function PaymentsPage() {
|
||||||
|
const [rows, setRows] = useState<Payment[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
apiGet<{ data: Payment[] }>("/payments").then((r) => setRows(r.data));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h1 className="text-2xl font-bold">Payments</h1>
|
||||||
|
<Card className="rounded-2xl">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Payment records</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Booking</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead>When</TableHead>
|
||||||
|
<TableHead className="text-right">Amount</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{rows.map((p) => (
|
||||||
|
<TableRow key={p.id}>
|
||||||
|
<TableCell className="text-sm">{p.bookingId}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge>{p.status}</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-xs text-muted-foreground">
|
||||||
|
{formatDateTime(p.createdAt)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right font-medium">
|
||||||
|
{formatMoney(p.amount)} {p.last4 && `· ${p.last4}`}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
175
src/pages/ReferralCodesPage.tsx
Normal file
175
src/pages/ReferralCodesPage.tsx
Normal file
|
|
@ -0,0 +1,175 @@
|
||||||
|
import { Copy } from "lucide-react";
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import { useAuth } from "@/context/AuthContext";
|
||||||
|
import { apiGet, apiPatch, apiPost } from "@/lib/api";
|
||||||
|
import type { ReferralCode } from "@/lib/types";
|
||||||
|
|
||||||
|
function copy(s: string) {
|
||||||
|
void navigator.clipboard.writeText(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReferralCodesPage() {
|
||||||
|
const { canManageCodes } = useAuth();
|
||||||
|
const [rows, setRows] = useState<ReferralCode[]>([]);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [label, setLabel] = useState("");
|
||||||
|
const [custom, setCustom] = useState("");
|
||||||
|
const [generate, setGenerate] = useState(true);
|
||||||
|
|
||||||
|
const load = useCallback(() => {
|
||||||
|
apiGet<{ data: ReferralCode[] }>("/referral-codes").then((r) =>
|
||||||
|
setRows(r.data)
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
async function create(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
await apiPost("/referral-codes", {
|
||||||
|
generate,
|
||||||
|
code: generate ? undefined : custom,
|
||||||
|
label,
|
||||||
|
});
|
||||||
|
setOpen(false);
|
||||||
|
setLabel("");
|
||||||
|
load();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggle(rc: ReferralCode) {
|
||||||
|
if (!canManageCodes) return;
|
||||||
|
await apiPatch(`/referral-codes/${rc.id}`, { isActive: !rc.isActive });
|
||||||
|
load();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-2xl font-bold">Referral codes</h1>
|
||||||
|
{canManageCodes && (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button>+ Generate code</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>New referral code</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<form onSubmit={create} className="grid gap-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Campaign label</Label>
|
||||||
|
<Input
|
||||||
|
required
|
||||||
|
value={label}
|
||||||
|
onChange={(e) => setLabel(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={generate}
|
||||||
|
onChange={(e) => setGenerate(e.target.checked)}
|
||||||
|
id="rgen"
|
||||||
|
/>
|
||||||
|
<Label htmlFor="rgen">Auto-generate code</Label>
|
||||||
|
</div>
|
||||||
|
{!generate && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Custom code</Label>
|
||||||
|
<Input
|
||||||
|
value={custom}
|
||||||
|
onChange={(e) => setCustom(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Button type="submit">Create</Button>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="rounded-2xl">
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Code</TableHead>
|
||||||
|
<TableHead>Label</TableHead>
|
||||||
|
<TableHead>Redemptions</TableHead>
|
||||||
|
<TableHead>Active</TableHead>
|
||||||
|
<TableHead />
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{rows.map((r) => (
|
||||||
|
<TableRow key={r.id}>
|
||||||
|
<TableCell className="font-mono font-medium">{r.code}</TableCell>
|
||||||
|
<TableCell>{r.label}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{r.redemptionCount}
|
||||||
|
{r.maxRedemptions != null && ` / ${r.maxRedemptions}`}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={r.isActive ? "success" : "secondary"}>
|
||||||
|
{r.isActive ? "Active" : "Off"}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="flex flex-wrap gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
type="button"
|
||||||
|
onClick={() => copy(r.code)}
|
||||||
|
>
|
||||||
|
<Copy className="size-4" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" asChild>
|
||||||
|
<Link to={`/bookings?referral=${encodeURIComponent(r.code)}`}>
|
||||||
|
Bookings
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
{canManageCodes && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggle(r)}
|
||||||
|
>
|
||||||
|
Toggle
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
139
src/pages/ReservationsPage.tsx
Normal file
139
src/pages/ReservationsPage.tsx
Normal file
|
|
@ -0,0 +1,139 @@
|
||||||
|
import { format } from "date-fns";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { apiGet } from "@/lib/api";
|
||||||
|
import type { Room } from "@/lib/types";
|
||||||
|
|
||||||
|
interface TimelineResp {
|
||||||
|
days: string[];
|
||||||
|
rooms: Room[];
|
||||||
|
segments: {
|
||||||
|
bookingId: string;
|
||||||
|
guestName: string;
|
||||||
|
roomId: string;
|
||||||
|
start: string;
|
||||||
|
end: string;
|
||||||
|
status: string;
|
||||||
|
paymentLabel: string;
|
||||||
|
source: string;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReservationsPage() {
|
||||||
|
const [month, setMonth] = useState(format(new Date(), "yyyy-MM"));
|
||||||
|
const [data, setData] = useState<TimelineResp | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
apiGet<TimelineResp>(`/reservations/timeline?month=${month}`)
|
||||||
|
.then(setData)
|
||||||
|
.catch(console.error);
|
||||||
|
}, [month]);
|
||||||
|
|
||||||
|
if (!data)
|
||||||
|
return <p className="text-muted-foreground">Loading timeline…</p>;
|
||||||
|
|
||||||
|
const dayWidth = 56;
|
||||||
|
const roomCol = 120;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">Reservations</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Gantt-style view (mock data)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="month"
|
||||||
|
value={month}
|
||||||
|
onChange={(e) => setMonth(e.target.value)}
|
||||||
|
className="rounded-xl border border-input bg-background px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Badge>Occupied</Badge>
|
||||||
|
<Badge variant="secondary">Check-in / out</Badge>
|
||||||
|
<Badge variant="outline">Reserved</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="rounded-2xl">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Timeline</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="overflow-x-auto">
|
||||||
|
<div
|
||||||
|
className="relative min-w-max"
|
||||||
|
style={{
|
||||||
|
width: roomCol + data.days.length * dayWidth,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex border-b">
|
||||||
|
<div
|
||||||
|
className="shrink-0 border-r p-2 text-xs font-medium"
|
||||||
|
style={{ width: roomCol }}
|
||||||
|
>
|
||||||
|
Room
|
||||||
|
</div>
|
||||||
|
{data.days.map((d) => (
|
||||||
|
<div
|
||||||
|
key={d}
|
||||||
|
className="shrink-0 border-r p-1 text-center text-[10px] text-muted-foreground"
|
||||||
|
style={{ width: dayWidth }}
|
||||||
|
>
|
||||||
|
{d.slice(8)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{data.rooms.map((room) => (
|
||||||
|
<div key={room.id} className="flex border-b">
|
||||||
|
<div
|
||||||
|
className="shrink-0 border-r p-2 text-xs font-medium"
|
||||||
|
style={{ width: roomCol }}
|
||||||
|
>
|
||||||
|
{room.name}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="relative shrink-0"
|
||||||
|
style={{ width: data.days.length * dayWidth, height: 48 }}
|
||||||
|
>
|
||||||
|
{data.segments
|
||||||
|
.filter((s) => s.roomId === room.id)
|
||||||
|
.map((s) => {
|
||||||
|
const startIdx = data.days.findIndex(
|
||||||
|
(d) => d >= s.start
|
||||||
|
);
|
||||||
|
const endIdx = data.days.findIndex((d) => d >= s.end);
|
||||||
|
const si =
|
||||||
|
startIdx >= 0 ? startIdx : 0;
|
||||||
|
const ei =
|
||||||
|
endIdx >= 0 ? endIdx : data.days.length;
|
||||||
|
const span = Math.max(1, ei - si);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={s.bookingId}
|
||||||
|
className="absolute top-2 flex h-8 items-center rounded-lg border bg-accent px-2 text-[10px] shadow-sm"
|
||||||
|
style={{
|
||||||
|
left: si * dayWidth + 4,
|
||||||
|
width: span * dayWidth - 8,
|
||||||
|
}}
|
||||||
|
title={`${s.guestName} · ${s.paymentLabel}`}
|
||||||
|
>
|
||||||
|
<span className="truncate font-medium">
|
||||||
|
{s.guestName}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
173
src/pages/RoomsPage.tsx
Normal file
173
src/pages/RoomsPage.tsx
Normal file
|
|
@ -0,0 +1,173 @@
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import { apiGet, apiPost } from "@/lib/api";
|
||||||
|
import { ROOM_CATALOGUE } from "@/lib/constants";
|
||||||
|
import type { Room } from "@/lib/types";
|
||||||
|
import { formatMoney } from "@/lib/format";
|
||||||
|
|
||||||
|
export function RoomsPage() {
|
||||||
|
const [rooms, setRooms] = useState<Room[]>([]);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [slug, setSlug] = useState(ROOM_CATALOGUE[0].slug);
|
||||||
|
const [maxGuests, setMaxGuests] = useState("2");
|
||||||
|
const [baseRate, setBaseRate] = useState("120");
|
||||||
|
|
||||||
|
function load() {
|
||||||
|
apiGet<{ data: Room[] }>("/rooms").then((r) => setRooms(r.data));
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function addRoom(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
await apiPost<Room>("/rooms", {
|
||||||
|
name,
|
||||||
|
roomTypeSlug: slug,
|
||||||
|
maxGuests: Number(maxGuests),
|
||||||
|
baseRate: Number(baseRate),
|
||||||
|
status: "available",
|
||||||
|
floor: "",
|
||||||
|
});
|
||||||
|
setOpen(false);
|
||||||
|
setName("");
|
||||||
|
load();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-2xl font-bold">Rooms</h1>
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button>+ Add room</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Add room</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<form onSubmit={addRoom} className="grid gap-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Unit name</Label>
|
||||||
|
<Input
|
||||||
|
required
|
||||||
|
placeholder="Room 104"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Type</Label>
|
||||||
|
<Select value={slug} onValueChange={setSlug}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{ROOM_CATALOGUE.map((r) => (
|
||||||
|
<SelectItem key={r.slug} value={r.slug}>
|
||||||
|
{r.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Max guests</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={maxGuests}
|
||||||
|
onChange={(e) => setMaxGuests(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Base rate</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={baseRate}
|
||||||
|
onChange={(e) => setBaseRate(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button type="submit">Save</Button>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="rounded-2xl">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Inventory</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="hidden md:block">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Name</TableHead>
|
||||||
|
<TableHead>Type</TableHead>
|
||||||
|
<TableHead>Guests</TableHead>
|
||||||
|
<TableHead>Rate</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{rooms.map((r) => (
|
||||||
|
<TableRow key={r.id}>
|
||||||
|
<TableCell className="font-medium">{r.name}</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground">
|
||||||
|
{r.roomTypeSlug}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{r.maxGuests}</TableCell>
|
||||||
|
<TableCell>{formatMoney(r.baseRate)}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="secondary">{r.status}</Badge>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
<CardContent className="space-y-2 md:hidden">
|
||||||
|
{rooms.map((r) => (
|
||||||
|
<div key={r.id} className="rounded-xl border p-3 text-sm">
|
||||||
|
<p className="font-medium">{r.name}</p>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
{r.roomTypeSlug} · {formatMoney(r.baseRate)}
|
||||||
|
</p>
|
||||||
|
<Badge className="mt-2">{r.status}</Badge>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
19
src/pages/SettingsPage.tsx
Normal file
19
src/pages/SettingsPage.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { TAX_RATE } from "@/lib/constants";
|
||||||
|
|
||||||
|
export function SettingsPage() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h1 className="text-2xl font-bold">Settings</h1>
|
||||||
|
<Card className="rounded-2xl">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Property (mock)</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="text-sm text-muted-foreground space-y-2">
|
||||||
|
<p>Tax rate used in MSW pricing: {(TAX_RATE * 100).toFixed(0)}%</p>
|
||||||
|
<p>Connect real backend, auth, and PSP in a future phase.</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
81
src/pages/TransactionsPage.tsx
Normal file
81
src/pages/TransactionsPage.tsx
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import { apiGet } from "@/lib/api";
|
||||||
|
import type { Transaction } from "@/lib/types";
|
||||||
|
import { formatDateTime, formatMoney } from "@/lib/format";
|
||||||
|
|
||||||
|
export function TransactionsPage() {
|
||||||
|
const [rows, setRows] = useState<Transaction[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
apiGet<{ data: Transaction[] }>("/transactions").then((r) =>
|
||||||
|
setRows(r.data)
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h1 className="text-2xl font-bold">Transactions</h1>
|
||||||
|
<Card className="rounded-2xl">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Ledger</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="hidden md:block overflow-x-auto">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Date</TableHead>
|
||||||
|
<TableHead>Type</TableHead>
|
||||||
|
<TableHead>Booking</TableHead>
|
||||||
|
<TableHead>Description</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead className="text-right">Amount</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{rows.map((t) => (
|
||||||
|
<TableRow key={t.id}>
|
||||||
|
<TableCell className="text-xs whitespace-nowrap">
|
||||||
|
{formatDateTime(t.createdAt)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{t.type}</TableCell>
|
||||||
|
<TableCell className="text-xs">{t.bookingId ?? "—"}</TableCell>
|
||||||
|
<TableCell className="max-w-[200px] truncate text-sm">
|
||||||
|
{t.description}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="secondary">{t.status}</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right font-medium">
|
||||||
|
{formatMoney(t.amount)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
<CardContent className="space-y-3 md:hidden">
|
||||||
|
{rows.map((t) => (
|
||||||
|
<div key={t.id} className="rounded-xl border p-3 text-sm">
|
||||||
|
<p className="font-medium">{t.type}</p>
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
{formatDateTime(t.createdAt)}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 font-semibold">{formatMoney(t.amount)}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
128
src/pages/VisitsPage.tsx
Normal file
128
src/pages/VisitsPage.tsx
Normal file
|
|
@ -0,0 +1,128 @@
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
Bar,
|
||||||
|
BarChart,
|
||||||
|
CartesianGrid,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Tooltip,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
} from "recharts";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import { apiGet, apiPost } from "@/lib/api";
|
||||||
|
import type { SiteVisit } from "@/lib/types";
|
||||||
|
import { formatDateTime } from "@/lib/format";
|
||||||
|
|
||||||
|
export function VisitsPage() {
|
||||||
|
const [series, setSeries] = useState<
|
||||||
|
{ date: string; views: number; sessions: number }[]
|
||||||
|
>([]);
|
||||||
|
const [recent, setRecent] = useState<SiteVisit[]>([]);
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
const v = await apiGet<{
|
||||||
|
series: { date: string; views: number; sessions: number }[];
|
||||||
|
}>("/analytics/visits");
|
||||||
|
setSeries(v.series.slice(-21));
|
||||||
|
const r = await apiGet<{ data: SiteVisit[] }>("/analytics/visits/recent");
|
||||||
|
setRecent(r.data);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refresh();
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
|
async function simulateHit() {
|
||||||
|
await apiPost("/analytics/visits", {
|
||||||
|
path: "/booking",
|
||||||
|
device: "desktop",
|
||||||
|
});
|
||||||
|
await refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<h1 className="text-2xl font-bold">Site visits</h1>
|
||||||
|
<Button variant="outline" size="sm" onClick={simulateHit}>
|
||||||
|
Simulate page view
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="rounded-2xl">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Views by day</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="h-[280px] w-full">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<BarChart data={series}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" className="opacity-40" />
|
||||||
|
<XAxis dataKey="date" tick={{ fontSize: 10 }} />
|
||||||
|
<YAxis tick={{ fontSize: 10 }} />
|
||||||
|
<Tooltip />
|
||||||
|
<Bar
|
||||||
|
dataKey="views"
|
||||||
|
fill="var(--chart-2)"
|
||||||
|
radius={[4, 4, 0, 0]}
|
||||||
|
/>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="rounded-2xl">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Recent events</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="hidden md:block overflow-x-auto">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>When</TableHead>
|
||||||
|
<TableHead>Path</TableHead>
|
||||||
|
<TableHead>Device</TableHead>
|
||||||
|
<TableHead>Referrer</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{recent.slice(0, 15).map((v) => (
|
||||||
|
<TableRow key={v.id}>
|
||||||
|
<TableCell className="text-xs whitespace-nowrap">
|
||||||
|
{formatDateTime(v.occurredAt)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm">{v.path}</TableCell>
|
||||||
|
<TableCell className="text-xs">{v.device ?? "—"}</TableCell>
|
||||||
|
<TableCell className="max-w-[180px] truncate text-xs">
|
||||||
|
{v.referrer ?? "—"}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
<CardContent className="space-y-2 md:hidden">
|
||||||
|
{recent.slice(0, 10).map((v) => (
|
||||||
|
<div key={v.id} className="rounded-xl border p-2 text-xs">
|
||||||
|
<p className="font-medium">{v.path}</p>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
{formatDateTime(v.occurredAt)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
84
src/styles/globals.css
Normal file
84
src/styles/globals.css
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
@import "tailwindcss";
|
||||||
|
@import "tw-animate-css";
|
||||||
|
|
||||||
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
|
@theme inline {
|
||||||
|
--radius-sm: calc(var(--radius) - 4px);
|
||||||
|
--radius-md: calc(var(--radius) - 2px);
|
||||||
|
--radius-lg: var(--radius);
|
||||||
|
--radius-xl: calc(var(--radius) + 4px);
|
||||||
|
--color-background: var(--background);
|
||||||
|
--color-foreground: var(--foreground);
|
||||||
|
--color-card: var(--card);
|
||||||
|
--color-card-foreground: var(--card-foreground);
|
||||||
|
--color-popover: var(--popover);
|
||||||
|
--color-popover-foreground: var(--popover-foreground);
|
||||||
|
--color-primary: var(--primary);
|
||||||
|
--color-primary-foreground: var(--primary-foreground);
|
||||||
|
--color-secondary: var(--secondary);
|
||||||
|
--color-secondary-foreground: var(--secondary-foreground);
|
||||||
|
--color-muted: var(--muted);
|
||||||
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
|
--color-accent: var(--accent);
|
||||||
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
|
--color-destructive: var(--destructive);
|
||||||
|
--color-border: var(--border);
|
||||||
|
--color-input: var(--input);
|
||||||
|
--color-ring: var(--ring);
|
||||||
|
--color-chart-1: var(--chart-1);
|
||||||
|
--color-chart-2: var(--chart-2);
|
||||||
|
--color-chart-3: var(--chart-3);
|
||||||
|
--color-chart-4: var(--chart-4);
|
||||||
|
--color-chart-5: var(--chart-5);
|
||||||
|
--color-navy: var(--navy);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--radius: 0.875rem;
|
||||||
|
--background: oklch(0.985 0.004 250);
|
||||||
|
--foreground: oklch(0.22 0.04 260);
|
||||||
|
--card: oklch(1 0 0);
|
||||||
|
--card-foreground: oklch(0.22 0.04 260);
|
||||||
|
--popover: oklch(1 0 0);
|
||||||
|
--popover-foreground: oklch(0.22 0.04 260);
|
||||||
|
--primary: oklch(0.55 0.2 260);
|
||||||
|
--primary-foreground: oklch(0.99 0 0);
|
||||||
|
--secondary: oklch(0.96 0.02 250);
|
||||||
|
--secondary-foreground: oklch(0.28 0.05 260);
|
||||||
|
--muted: oklch(0.96 0.01 250);
|
||||||
|
--muted-foreground: oklch(0.48 0.02 260);
|
||||||
|
--accent: oklch(0.93 0.04 250);
|
||||||
|
--accent-foreground: oklch(0.32 0.08 260);
|
||||||
|
--destructive: oklch(0.55 0.2 25);
|
||||||
|
--border: oklch(0.91 0.01 250);
|
||||||
|
--input: oklch(0.91 0.01 250);
|
||||||
|
--ring: oklch(0.55 0.2 260);
|
||||||
|
--navy: oklch(0.28 0.08 260);
|
||||||
|
--chart-1: oklch(0.28 0.08 260);
|
||||||
|
--chart-2: oklch(0.55 0.2 260);
|
||||||
|
--chart-3: oklch(0.65 0.15 250);
|
||||||
|
--chart-4: oklch(0.75 0.1 250);
|
||||||
|
--chart-5: oklch(0.85 0.06 250);
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border outline-ring/50;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground antialiased;
|
||||||
|
font-family: "Inter", system-ui, sans-serif;
|
||||||
|
padding-bottom: env(safe-area-inset-bottom, 0px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pb-nav {
|
||||||
|
padding-bottom: calc(4.5rem + env(safe-area-inset-bottom, 0px));
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.pb-nav {
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/vite-env.d.ts
vendored
Normal file
1
src/vite-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
/// <reference types="vite/client" />
|
||||||
26
tsconfig.app.json
Normal file
26
tsconfig.app.json
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
4
tsconfig.json
Normal file
4
tsconfig.json
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
|
||||||
|
}
|
||||||
13
tsconfig.node.json
Normal file
13
tsconfig.node.json
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "ES2023",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
13
vite.config.ts
Normal file
13
vite.config.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
import path from "node:path";
|
||||||
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
import { defineConfig } from "vite";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react(), tailwindcss()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"@": path.resolve(__dirname, "./src"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user