Forecasting app: hybrid port to HNF stack

Python FastAPI ML backend kept intact; auth replaced with central hnf_session cookie verification. Frontend rebuilt on React 18 + TS + Vite with stack design system, Plotly charts retained. Shared Postgres via DATABASE_URL; schema applied on startup.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-04 18:49:34 +00:00
commit 75d2c1fa9d
103 changed files with 70316 additions and 0 deletions

15
frontend/Dockerfile Normal file
View file

@ -0,0 +1,15 @@
FROM node:22-alpine AS builder
ARG VITE_HOTEL_NAME=Hotel
ENV VITE_HOTEL_NAME=$VITE_HOTEL_NAME
WORKDIR /app
COPY package.json ./
RUN npm install
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html/forecasting
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80

13
frontend/index.html Normal file
View file

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/forecasting/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Forecasting</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

35
frontend/nginx.conf Normal file
View file

@ -0,0 +1,35 @@
server {
listen 80;
server_name _;
# 1. Central auth proxy
location /forecasting/api/auth/ {
proxy_pass http://10.10.10.101:3001/api/auth/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# 2. App backend (Python FastAPI on port 8000)
location /forecasting/api/ {
proxy_pass http://backend:8000/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Cookie $http_cookie;
proxy_read_timeout 300s;
proxy_connect_timeout 30s;
client_max_body_size 50M;
}
# 3. Health
location /forecasting/health {
proxy_pass http://backend:8000/health;
}
# 4. SPA fallback
location /forecasting/ {
root /usr/share/nginx/html;
try_files $uri $uri/ /forecasting/index.html;
}
}

5509
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

30
frontend/package.json Normal file
View file

@ -0,0 +1,30 @@
{
"name": "forecasting-frontend",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"@tanstack/react-query": "^5.32.0",
"axios": "^1.6.8",
"lucide-react": "^0.395.0",
"plotly.js": "^2.29.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-plotly.js": "^2.6.0",
"react-router-dom": "^6.22.0"
},
"devDependencies": {
"@types/plotly.js": "^2.12.29",
"@types/react": "^18.3.1",
"@types/react-dom": "^18.3.0",
"@types/react-plotly.js": "^2.6.4",
"@vitejs/plugin-react": "^4.2.1",
"typescript": "^5.4.5",
"vite": "^5.2.11"
}
}

34
frontend/src/App.tsx Normal file
View file

@ -0,0 +1,34 @@
import { Routes, Route, Navigate } from 'react-router-dom'
import AuthGate from './components/AuthGate'
import Layout from './components/Layout'
import Dashboard from './pages/Dashboard'
import Forecasts from './pages/Forecasts'
import History from './pages/History'
import Bookability from './pages/Bookability'
import CompetitorRates from './pages/CompetitorRates'
import Accuracy from './pages/Accuracy'
import Settings from './pages/Settings'
export default function App() {
return (
<AuthGate>
<Layout>
<Routes>
<Route path="/" element={<Navigate to="/dashboard" replace />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/forecasts" element={<Forecasts />} />
<Route path="/forecasts/:page" element={<Forecasts />} />
<Route path="/history" element={<History />} />
<Route path="/history/:report" element={<History />} />
<Route path="/bookability" element={<Bookability />} />
<Route path="/competitor-rates" element={<CompetitorRates />} />
<Route path="/accuracy" element={<Accuracy />} />
<Route path="/accuracy/:tab" element={<Accuracy />} />
<Route path="/settings" element={<Settings />} />
<Route path="/settings/:tab" element={<Settings />} />
<Route path="*" element={<Navigate to="/dashboard" replace />} />
</Routes>
</Layout>
</AuthGate>
)
}

20
frontend/src/api.ts Normal file
View file

@ -0,0 +1,20 @@
import axios from 'axios'
const BASE = '/forecasting/api'
const api = axios.create({
baseURL: BASE,
withCredentials: true,
})
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
window.location.href = '/auth/login?redirect=' + encodeURIComponent(window.location.pathname)
}
return Promise.reject(error)
}
)
export default api

View file

@ -0,0 +1,47 @@
import { createContext, useContext, useEffect, useState } from 'react'
import type { ReactNode } from 'react'
import type { User } from '../types'
interface AuthCtx { user: User }
const Ctx = createContext<AuthCtx | null>(null)
export function useAuth() {
const ctx = useContext(Ctx)
if (!ctx) throw new Error('useAuth outside AuthGate')
return ctx
}
export default function AuthGate({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null)
const [checking, setChecking] = useState(true)
useEffect(() => {
fetch('/forecasting/api/auth/verify?app=forecasting', { credentials: 'include' })
.then(r => {
if (!r.ok) throw new Error('unauth')
return r.json()
})
.then(data => setUser({
email: data.email || data.sub || '',
name: data.name || data.display_name || '',
is_admin: data.is_admin ?? false,
caps: data.caps ?? [],
}))
.catch(() => {
window.location.href = '/auth/login?redirect=' + encodeURIComponent(window.location.pathname)
})
.finally(() => setChecking(false))
}, [])
if (checking) {
return (
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'var(--navy-dark)' }}>
<div className="spinner" style={{ width: 32, height: 32 }} />
</div>
)
}
if (!user) return null
return <Ctx.Provider value={{ user }}>{children}</Ctx.Provider>
}

View file

@ -0,0 +1,59 @@
import { NavLink } from 'react-router-dom'
import {
TrendingUp, BarChart2, Calendar, Target, Globe, History,
Settings, Bot,
} from 'lucide-react'
import { useAuth } from './AuthGate'
import { can } from '../types'
import type { ReactNode } from 'react'
const ICON = { size: 16, strokeWidth: 1.75 }
const NAV = [
{ to: '/dashboard', label: 'Dashboard', icon: Bot, cap: 'view' },
{ to: '/forecasts', label: 'Forecasts', icon: TrendingUp, cap: 'view' },
{ to: '/history', label: 'History', icon: History, cap: 'view' },
{ to: '/bookability', label: 'Bookability', icon: Calendar, cap: 'view_bookability' },
{ to: '/competitor-rates', label: 'Competitors', icon: Globe, cap: 'view_competitor_rates' },
{ to: '/accuracy', label: 'Accuracy', icon: Target, cap: 'view_accuracy' },
{ to: '/settings', label: 'Settings', icon: Settings, cap: 'settings' },
]
export default function Layout({ children }: { children: ReactNode }) {
const { user } = useAuth()
const items = NAV.filter(n => can(user, n.cap))
return (
<div className="app-shell">
<aside className="sidebar">
<div className="sidebar-logo">
<BarChart2 size={18} strokeWidth={1.75} />
Forecasting
</div>
<nav className="sidebar-nav">
{items.map(({ to, label, icon: Icon }) => (
<NavLink key={to} to={to} className={({ isActive }) => isActive ? 'active' : ''}>
<Icon {...ICON} />
{label}
</NavLink>
))}
</nav>
<div className="sidebar-user">{user.name || user.email}</div>
</aside>
<header className="top-bar">
<BarChart2 size={18} strokeWidth={1.75} color="var(--gold)" />
<span className="top-bar-title">Forecasting</span>
<nav className="top-bar-nav">
{items.map(({ to, label }) => (
<NavLink key={to} to={to} className={({ isActive }) => isActive ? 'active' : ''}>
{label}
</NavLink>
))}
</nav>
</header>
<main className="page-content">{children}</main>
</div>
)
}

361
frontend/src/index.css Normal file
View file

@ -0,0 +1,361 @@
:root {
--navy: #1a1a2e;
--navy-dark: #0f0f20;
--gold: #c9a84c;
--gold-light: #e8c96d;
--surface: rgba(255,255,255,0.07);
--surface-2: rgba(255,255,255,0.08);
--text: rgba(255,255,255,0.88);
--text-muted: rgba(255,255,255,0.48);
--body-bg: #f4f5f7;
--card-bg: #ffffff;
--card-border: #e4e8ee;
--text-dark: #1e293b;
--text-mid: #64748b;
--shadow-sm: 0 1px 3px rgba(0,0,0,0.07), 0 1px 2px rgba(0,0,0,0.04);
--shadow-md: 0 4px 12px rgba(0,0,0,0.08);
--danger: #dc2626;
--success: #16a34a;
--warning: #d97706;
--radius: 10px;
--font: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: var(--body-bg);
color: var(--text-dark);
font-family: var(--font);
font-size: 14px;
line-height: 1.5;
}
/* App shell layout */
.app-shell {
display: grid;
grid-template-columns: 220px 1fr;
grid-template-rows: auto 1fr;
min-height: 100vh;
}
.sidebar {
grid-column: 1;
grid-row: 1 / -1;
background: var(--navy);
display: flex;
flex-direction: column;
padding: 0;
position: sticky;
top: 0;
height: 100vh;
overflow-y: auto;
}
.sidebar-logo {
display: flex;
align-items: center;
gap: 10px;
padding: 20px 16px 16px;
font-size: 15px;
font-weight: 600;
color: var(--text);
border-bottom: 1px solid var(--surface);
}
.sidebar-nav {
flex: 1;
padding: 12px 8px;
display: flex;
flex-direction: column;
gap: 2px;
}
.sidebar-nav a {
display: flex;
align-items: center;
gap: 10px;
padding: 9px 10px;
border-radius: 8px;
color: var(--text-muted);
text-decoration: none;
font-size: 13.5px;
transition: background 0.15s, color 0.15s;
}
.sidebar-nav a:hover { background: var(--surface); color: var(--text); }
.sidebar-nav a.active { background: rgba(201,168,76,0.15); color: var(--gold); }
.sidebar-user {
padding: 12px 16px;
font-size: 12px;
color: var(--text-muted);
border-top: 1px solid var(--surface);
}
/* Top bar — mobile/collapsed fallback */
.top-bar {
display: none;
}
.page-content {
grid-column: 2;
grid-row: 1 / -1;
min-width: 0;
padding: 24px;
background: var(--body-bg);
}
/* Cards */
.card {
background: var(--card-bg);
border: 1px solid var(--card-border);
border-radius: var(--radius);
box-shadow: var(--shadow-sm);
}
.card-header {
padding: 16px 20px;
border-bottom: 1px solid var(--card-border);
font-size: 14px;
font-weight: 600;
color: var(--text-dark);
display: flex;
align-items: center;
justify-content: space-between;
}
.card-body { padding: 20px; }
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 14px;
border-radius: 7px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
border: none;
transition: background 0.15s, opacity 0.15s;
}
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-primary {
background: var(--gold);
color: var(--navy);
}
.btn-primary:hover:not(:disabled) { background: var(--gold-light); }
.btn-secondary {
background: var(--navy);
color: var(--text);
}
.btn-secondary:hover:not(:disabled) { background: var(--navy-dark); }
.btn-outline {
background: transparent;
color: var(--text-dark);
border: 1px solid var(--card-border);
}
.btn-outline:hover:not(:disabled) { background: var(--body-bg); }
.btn-danger {
background: var(--danger);
color: white;
}
.btn-danger:hover:not(:disabled) { opacity: 0.85; }
.btn-sm { padding: 4px 10px; font-size: 12px; }
/* Form elements */
input, select, textarea {
background: var(--card-bg);
border: 1px solid var(--card-border);
border-radius: 7px;
padding: 7px 10px;
font-size: 13px;
color: var(--text-dark);
width: 100%;
outline: none;
transition: border-color 0.15s;
}
input:focus, select:focus, textarea:focus { border-color: var(--gold); }
/* Badges */
.badge {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: 20px;
font-size: 11px;
font-weight: 500;
}
.badge-success { background: #dcfce7; color: #16a34a; }
.badge-warning { background: #fef3c7; color: #d97706; }
.badge-danger { background: #fee2e2; color: #dc2626; }
.badge-info { background: #dbeafe; color: #2563eb; }
.badge-neutral { background: #f1f5f9; color: #64748b; }
/* Tables */
.table-wrap { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
th {
text-align: left;
padding: 8px 12px;
background: #f8fafc;
border-bottom: 1px solid var(--card-border);
font-weight: 600;
color: var(--text-mid);
white-space: nowrap;
}
td {
padding: 8px 12px;
border-bottom: 1px solid #f1f5f9;
color: var(--text-dark);
}
tr:last-child td { border-bottom: none; }
tr:hover td { background: #f8fafc; }
/* Page header */
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 24px;
gap: 16px;
}
.page-title {
font-size: 20px;
font-weight: 700;
color: var(--text-dark);
}
.page-subtitle { font-size: 13px; color: var(--text-mid); margin-top: 2px; }
/* Sub-nav tabs */
.sub-nav {
display: flex;
gap: 4px;
border-bottom: 1px solid var(--card-border);
margin-bottom: 24px;
overflow-x: auto;
}
.sub-nav-item {
padding: 8px 14px;
font-size: 13px;
font-weight: 500;
color: var(--text-mid);
cursor: pointer;
border-bottom: 2px solid transparent;
white-space: nowrap;
background: none;
border-left: none;
border-right: none;
border-top: none;
transition: color 0.15s, border-color 0.15s;
}
.sub-nav-item:hover { color: var(--text-dark); }
.sub-nav-item.active { color: var(--gold); border-bottom-color: var(--gold); }
/* Status dot */
.status-dot {
width: 8px; height: 8px;
border-radius: 50%;
display: inline-block;
}
.status-dot.green { background: var(--success); }
.status-dot.yellow { background: var(--warning); }
.status-dot.red { background: var(--danger); }
.status-dot.grey { background: #94a3b8; }
/* Spinner */
.spinner {
width: 20px; height: 20px;
border: 2px solid var(--card-border);
border-top-color: var(--gold);
border-radius: 50%;
animation: spin 0.7s linear infinite;
display: inline-block;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* Loading / empty states */
.loading-state {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
padding: 60px 20px;
color: var(--text-mid);
font-size: 13px;
}
.empty-state {
text-align: center;
padding: 60px 20px;
color: var(--text-mid);
font-size: 13px;
}
/* Grid helpers */
.grid-2 { display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px; }
.grid-3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; }
.grid-4 { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; }
/* Stat card */
.stat-card {
background: var(--card-bg);
border: 1px solid var(--card-border);
border-radius: var(--radius);
padding: 16px 20px;
box-shadow: var(--shadow-sm);
}
.stat-label { font-size: 12px; color: var(--text-mid); margin-bottom: 4px; }
.stat-value { font-size: 24px; font-weight: 700; color: var(--text-dark); }
.stat-delta { font-size: 12px; margin-top: 4px; }
.stat-delta.positive { color: var(--success); }
.stat-delta.negative { color: var(--danger); }
.stat-delta.neutral { color: var(--text-mid); }
/* Responsive — at narrow widths hide sidebar, show top-bar */
@media (max-width: 900px) {
.app-shell {
grid-template-columns: 1fr;
grid-template-rows: auto 1fr;
}
.sidebar { display: none; }
.top-bar {
display: flex;
align-items: center;
gap: 12px;
background: var(--navy);
padding: 0 16px;
height: 52px;
grid-column: 1;
overflow-x: auto;
}
.top-bar-title {
font-size: 14px;
font-weight: 600;
color: var(--text);
white-space: nowrap;
margin-right: 8px;
}
.top-bar-nav { display: flex; gap: 4px; }
.top-bar-nav a {
color: var(--text-muted);
text-decoration: none;
font-size: 12.5px;
padding: 6px 10px;
border-radius: 6px;
white-space: nowrap;
}
.top-bar-nav a:hover { color: var(--text); background: var(--surface); }
.top-bar-nav a.active { color: var(--gold); }
.page-content {
grid-column: 1;
padding: 16px;
}
}

20
frontend/src/main.tsx Normal file
View file

@ -0,0 +1,20 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import App from './App'
import './index.css'
const qc = new QueryClient({
defaultOptions: { queries: { retry: 1, staleTime: 30_000 } },
})
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<BrowserRouter basename="/forecasting">
<QueryClientProvider client={qc}>
<App />
</QueryClientProvider>
</BrowserRouter>
</React.StrictMode>,
)

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,133 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { RefreshCw, Bot, Clock } from 'lucide-react'
import api from '../api'
interface AIInsight {
id: number
generated_at: string
content: string
model: string
input_tokens: number
output_tokens: number
triggered_by: string
}
function formatAge(iso: string): string {
const ms = Date.now() - new Date(iso).getTime()
const mins = Math.floor(ms / 60000)
if (mins < 1) return 'just now'
if (mins < 60) return `${mins}m ago`
const hours = Math.floor(mins / 60)
if (hours < 24) return `${hours}h ago`
return `${Math.floor(hours / 24)}d ago`
}
function renderContent(text: string) {
return text.split('\n').map((line, i) => {
const processed = line.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
if (line.startsWith('- ') || line.startsWith('* ')) {
return (
<div key={i} style={{ display: 'flex', gap: 8, marginBottom: 4 }}>
<span style={{ color: 'var(--gold)', flexShrink: 0 }}></span>
<span dangerouslySetInnerHTML={{ __html: processed.slice(2) }} />
</div>
)
}
if (line.startsWith('## ') || line.startsWith('# ')) {
const txt = line.replace(/^#+\s*/, '')
return <p key={i} style={{ fontWeight: 600, marginTop: 12, marginBottom: 6, color: 'var(--text-dark)' }}>{txt}</p>
}
if (line.trim() === '') return <div key={i} style={{ height: 8 }} />
return <p key={i} style={{ marginBottom: 4 }} dangerouslySetInnerHTML={{ __html: processed }} />
})
}
export default function Dashboard() {
const qc = useQueryClient()
const [genError, setGenError] = useState<string | null>(null)
const { data: insight, isLoading } = useQuery<AIInsight | null>({
queryKey: ['ai-insights-latest'],
queryFn: () => api.get('/ai-insights/latest').then(r => r.data),
refetchInterval: 5 * 60_000,
})
const generate = useMutation({
mutationFn: () => api.post('/ai-insights/generate').then(r => r.data),
onSuccess: () => {
setGenError(null)
qc.invalidateQueries({ queryKey: ['ai-insights-latest'] })
},
onError: (err: any) => {
setGenError(err.response?.data?.detail || 'Failed to generate insight')
},
})
return (
<div>
<div className="page-header">
<div>
<div className="page-title">Dashboard</div>
<div className="page-subtitle">Daily AI-generated forecast summary</div>
</div>
<button
className="btn btn-primary"
onClick={() => generate.mutate()}
disabled={generate.isPending}
>
<RefreshCw size={14} strokeWidth={1.75} />
{generate.isPending ? 'Generating…' : 'Generate Now'}
</button>
</div>
{genError && (
<div style={{ background: '#fee2e2', border: '1px solid #fca5a5', borderRadius: 8, padding: '10px 14px', marginBottom: 16, color: '#dc2626', fontSize: 13 }}>
{genError}
</div>
)}
<div className="card">
<div className="card-header">
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Bot size={16} strokeWidth={1.75} color="var(--gold)" />
AI Insight
</span>
{insight && (
<span style={{ display: 'flex', alignItems: 'center', gap: 5, fontSize: 12, color: 'var(--text-mid)', fontWeight: 400 }}>
<Clock size={12} strokeWidth={1.75} />
{formatAge(insight.generated_at)}
{insight.model && <span style={{ marginLeft: 6, background: '#f1f5f9', borderRadius: 4, padding: '1px 6px' }}>{insight.model}</span>}
</span>
)}
</div>
<div className="card-body" style={{ fontSize: 13.5, lineHeight: 1.65, color: 'var(--text-dark)' }}>
{isLoading && (
<div className="loading-state">
<div className="spinner" />
Loading insight
</div>
)}
{!isLoading && !insight && (
<div className="empty-state">
<Bot size={32} strokeWidth={1.75} color="var(--text-mid)" style={{ margin: '0 auto 12px' }} />
<p>No insight generated yet.</p>
<p style={{ marginTop: 6 }}>Click <strong>Generate Now</strong> to produce a daily summary.</p>
</div>
)}
{insight && (
<>
<div style={{ marginBottom: 16 }}>{renderContent(insight.content)}</div>
{(insight.input_tokens || insight.output_tokens) && (
<div style={{ fontSize: 11, color: 'var(--text-mid)', marginTop: 16, paddingTop: 12, borderTop: '1px solid var(--card-border)' }}>
{insight.input_tokens} / {insight.output_tokens} tokens
· {insight.triggered_by}
</div>
)}
</>
)}
</div>
</div>
</div>
)
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

11
frontend/src/types.ts Normal file
View file

@ -0,0 +1,11 @@
export interface User {
email: string
name: string
is_admin: boolean
caps: string[]
}
export function can(user: User | null, cap: string): boolean {
if (!user) return false
return user.is_admin || user.caps.includes(cap)
}

9
frontend/src/vite-env.d.ts vendored Normal file
View file

@ -0,0 +1,9 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_HOTEL_NAME: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}

20
frontend/tsconfig.json Normal file
View file

@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}

7
frontend/vite.config.ts Normal file
View file

@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
base: '/forecasting/',
})