AIVLAD commited on
Commit
f75bff1
·
1 Parent(s): e9c00d1

feat: add React frontend with nginx reverse proxy

Browse files

- Create React GUI with Vite 6, Tailwind CSS 3, Recharts
- Implement hash-based routing (dashboard, simulation, results)
- Create core components (Button, Card, Input, Spinner)
- Build pages: Dashboard, SimulationForm, SimulationResults
- Add API client with fetch wrapper
- Create multi-stage Dockerfile (Node→Python→Nginx)
- Configure nginx for port 7860 with /api proxy to Django
- Add supervisor for running Django + nginx together
- Add gunicorn for production WSGI server
- Update README with full-stack tech stack
- Frontend builds to 165KB bundle (gzipped: 52KB)

.gitignore CHANGED
@@ -1,6 +1,8 @@
1
  # Dependencies and environments
2
  .venv/
3
  *.egg-info/
 
 
4
 
5
  # Test artifacts
6
  .coverage
 
1
  # Dependencies and environments
2
  .venv/
3
  *.egg-info/
4
+ frontend/node_modules/
5
+ frontend/dist/
6
 
7
  # Test artifacts
8
  .coverage
Dockerfile CHANGED
@@ -1,4 +1,22 @@
1
- FROM python:3.12-slim
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
 
3
  WORKDIR /app
4
 
@@ -13,14 +31,49 @@ ENV UV_PROJECT_ENVIRONMENT=/app/.venv
13
  COPY pyproject.toml ./
14
  COPY uv.lock ./
15
 
16
- # Install dependencies using uv with BuildKit cache mount
17
  RUN --mount=type=cache,target=/root/.cache/uv \
18
  uv sync --frozen
19
 
20
  # Copy application code
21
  COPY . .
22
 
23
- # Run migrations and start Django on port 7860
24
- # Use python directly since dependencies already installed in venv
25
- CMD /app/.venv/bin/python manage.py migrate && \
26
- /app/.venv/bin/python manage.py runserver 0.0.0.0:7860
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Stage 1: Build React frontend
2
+ FROM node:22-alpine AS frontend-builder
3
+
4
+ WORKDIR /frontend
5
+
6
+ # Copy frontend package files
7
+ COPY frontend/package*.json ./
8
+
9
+ # Install dependencies
10
+ RUN npm ci
11
+
12
+ # Copy frontend source
13
+ COPY frontend/ ./
14
+
15
+ # Build React app
16
+ RUN npm run build
17
+
18
+ # Stage 2: Setup Python/Django backend
19
+ FROM python:3.12-slim AS backend
20
 
21
  WORKDIR /app
22
 
 
31
  COPY pyproject.toml ./
32
  COPY uv.lock ./
33
 
34
+ # Install dependencies using uv
35
  RUN --mount=type=cache,target=/root/.cache/uv \
36
  uv sync --frozen
37
 
38
  # Copy application code
39
  COPY . .
40
 
41
+ # Run migrations
42
+ RUN /app/.venv/bin/python manage.py migrate
43
+
44
+ # Stage 3: Final image with nginx
45
+ FROM nginx:alpine
46
+
47
+ # Install Python and supervisor
48
+ RUN apk add --no-cache python3 py3-pip supervisor
49
+
50
+ # Copy Python app and venv from backend stage
51
+ COPY --from=backend /app /app
52
+ COPY --from=backend /usr/local/bin/uv /usr/local/bin/uv
53
+
54
+ # Copy React build from frontend stage
55
+ COPY --from=frontend-builder /frontend/dist /usr/share/nginx/html
56
+
57
+ # Copy nginx configuration
58
+ COPY nginx.conf /etc/nginx/nginx.conf
59
+
60
+ # Create supervisor config
61
+ RUN echo '[supervisord]' > /etc/supervisor/conf.d/supervisord.conf && \
62
+ echo 'nodaemon=true' >> /etc/supervisor/conf.d/supervisord.conf && \
63
+ echo '' >> /etc/supervisor/conf.d/supervisord.conf && \
64
+ echo '[program:django]' >> /etc/supervisor/conf.d/supervisord.conf && \
65
+ echo 'command=/app/.venv/bin/gunicorn config.wsgi:application --bind 127.0.0.1:8000 --workers 2' >> /etc/supervisor/conf.d/supervisord.conf && \
66
+ echo 'directory=/app' >> /etc/supervisor/conf.d/supervisord.conf && \
67
+ echo 'autostart=true' >> /etc/supervisor/conf.d/supervisord.conf && \
68
+ echo 'autorestart=true' >> /etc/supervisor/conf.d/supervisord.conf && \
69
+ echo '' >> /etc/supervisor/conf.d/supervisord.conf && \
70
+ echo '[program:nginx]' >> /etc/supervisor/conf.d/supervisord.conf && \
71
+ echo 'command=nginx -g "daemon off;"' >> /etc/supervisor/conf.d/supervisord.conf && \
72
+ echo 'autostart=true' >> /etc/supervisor/conf.d/supervisord.conf && \
73
+ echo 'autorestart=true' >> /etc/supervisor/conf.d/supervisord.conf
74
+
75
+ WORKDIR /app
76
+
77
+ EXPOSE 7860
78
+
79
+ CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]
README.md CHANGED
@@ -7,9 +7,9 @@ sdk: docker
7
  app_port: 7860
8
  ---
9
 
10
- # Wall Construction API
11
 
12
- A Django REST API for tracking multi-profile wall construction operations, ice material consumption, and associated costs.
13
 
14
  ## Features
15
 
@@ -167,14 +167,28 @@ curl http://localhost:7860/api/profiles/
167
 
168
  ## Technology Stack
169
 
 
170
  - **Django 5.2.7 LTS** - Web framework
171
  - **Django REST Framework 3.16** - API framework
172
  - **Python 3.12** - Programming language
 
173
  - **uv** - Fast Python package manager
174
  - **SQLite** - File-based database
175
  - **ThreadPoolExecutor** - Parallel cost calculations
176
  - **pytest** - Testing framework
177
 
 
 
 
 
 
 
 
 
 
 
 
 
178
  ## Development Standards
179
 
180
  - Dependencies managed via `pyproject.toml` (NOT requirements.txt)
 
7
  app_port: 7860
8
  ---
9
 
10
+ # Wall Construction Tracker
11
 
12
+ A full-stack application for tracking multi-profile wall construction operations, ice material consumption, and associated costs. Features a React GUI frontend and Django REST API backend.
13
 
14
  ## Features
15
 
 
167
 
168
  ## Technology Stack
169
 
170
+ ### Backend
171
  - **Django 5.2.7 LTS** - Web framework
172
  - **Django REST Framework 3.16** - API framework
173
  - **Python 3.12** - Programming language
174
+ - **Gunicorn** - WSGI HTTP server
175
  - **uv** - Fast Python package manager
176
  - **SQLite** - File-based database
177
  - **ThreadPoolExecutor** - Parallel cost calculations
178
  - **pytest** - Testing framework
179
 
180
+ ### Frontend
181
+ - **React 18** - UI framework
182
+ - **Vite 6** - Build tool
183
+ - **Tailwind CSS 3** - Utility-first CSS
184
+ - **Recharts 2** - Chart library
185
+ - **Hash-based routing** - Client-side navigation
186
+
187
+ ### Infrastructure
188
+ - **Nginx** - Reverse proxy and static file server
189
+ - **Supervisor** - Process control system
190
+ - **Docker multi-stage builds** - Optimized deployment
191
+
192
  ## Development Standards
193
 
194
  - Dependencies managed via `pyproject.toml` (NOT requirements.txt)
frontend/index.html ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <link rel="icon" type="image/svg+xml" href="/vite.svg" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+ <title>Wall Construction Tracker</title>
8
+ </head>
9
+ <body>
10
+ <div id="root"></div>
11
+ <script type="module" src="/src/main.jsx"></script>
12
+ </body>
13
+ </html>
frontend/package-lock.json ADDED
The diff for this file is too large to render. See raw diff
 
frontend/package.json ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "wall-construction-gui",
3
+ "private": true,
4
+ "version": "1.0.0",
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "vite",
8
+ "build": "vite build",
9
+ "preview": "vite preview"
10
+ },
11
+ "dependencies": {
12
+ "react": "^18.3.1",
13
+ "react-dom": "^18.3.1",
14
+ "recharts": "^2.15.0"
15
+ },
16
+ "devDependencies": {
17
+ "@vitejs/plugin-react": "^4.3.1",
18
+ "vite": "^6.0.1",
19
+ "tailwindcss": "^3.4.1",
20
+ "autoprefixer": "^10.4.18",
21
+ "postcss": "^8.4.35"
22
+ }
23
+ }
frontend/postcss.config.js ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ export default {
2
+ plugins: {
3
+ tailwindcss: {},
4
+ autoprefixer: {},
5
+ },
6
+ }
frontend/src/App.jsx ADDED
@@ -0,0 +1,87 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useEffect } from 'react'
2
+ import Dashboard from './pages/Dashboard'
3
+ import SimulationForm from './pages/SimulationForm'
4
+ import SimulationResults from './pages/SimulationResults'
5
+
6
+ function App() {
7
+ const [route, setRoute] = useState(window.location.hash.slice(1) || 'dashboard')
8
+ const [params, setParams] = useState({})
9
+
10
+ useEffect(() => {
11
+ const handleHashChange = () => {
12
+ const hash = window.location.hash.slice(1)
13
+ const [path, query] = hash.split('?')
14
+ setRoute(path || 'dashboard')
15
+
16
+ // Parse query params
17
+ const searchParams = new URLSearchParams(query)
18
+ const paramsObj = {}
19
+ searchParams.forEach((value, key) => {
20
+ paramsObj[key] = value
21
+ })
22
+ setParams(paramsObj)
23
+ }
24
+
25
+ window.addEventListener('hashchange', handleHashChange)
26
+ return () => window.removeEventListener('hashchange', handleHashChange)
27
+ }, [])
28
+
29
+ const navigate = (path, queryParams = {}) => {
30
+ const query = new URLSearchParams(queryParams).toString()
31
+ window.location.hash = query ? `${path}?${query}` : path
32
+ }
33
+
34
+ return (
35
+ <div className="min-h-screen bg-gray-50">
36
+ <nav className="bg-white shadow-sm mb-8">
37
+ <div className="max-w-7xl mx-auto px-4 py-4">
38
+ <div className="flex items-center justify-between">
39
+ <h1 className="text-xl font-bold text-gray-900">
40
+ Wall Construction Tracker
41
+ </h1>
42
+ <div className="flex gap-4">
43
+ <button
44
+ onClick={() => navigate('dashboard')}
45
+ className={`px-3 py-2 rounded-md ${
46
+ route === 'dashboard'
47
+ ? 'bg-blue-100 text-blue-700 font-semibold'
48
+ : 'text-gray-600 hover:text-gray-900'
49
+ }`}
50
+ >
51
+ Dashboard
52
+ </button>
53
+ <button
54
+ onClick={() => navigate('simulation')}
55
+ className={`px-3 py-2 rounded-md ${
56
+ route === 'simulation'
57
+ ? 'bg-blue-100 text-blue-700 font-semibold'
58
+ : 'text-gray-600 hover:text-gray-900'
59
+ }`}
60
+ >
61
+ Run Simulation
62
+ </button>
63
+ <button
64
+ onClick={() => navigate('results')}
65
+ className={`px-3 py-2 rounded-md ${
66
+ route === 'results'
67
+ ? 'bg-blue-100 text-blue-700 font-semibold'
68
+ : 'text-gray-600 hover:text-gray-900'
69
+ }`}
70
+ >
71
+ View Results
72
+ </button>
73
+ </div>
74
+ </div>
75
+ </div>
76
+ </nav>
77
+
78
+ <main className="max-w-7xl mx-auto px-4 pb-12">
79
+ {route === 'dashboard' && <Dashboard navigate={navigate} params={params} />}
80
+ {route === 'simulation' && <SimulationForm navigate={navigate} params={params} />}
81
+ {route === 'results' && <SimulationResults navigate={navigate} params={params} />}
82
+ </main>
83
+ </div>
84
+ )
85
+ }
86
+
87
+ export default App
frontend/src/components/Button.jsx ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export default function Button({
2
+ children,
3
+ variant = 'primary',
4
+ onClick,
5
+ disabled = false,
6
+ type = 'button'
7
+ }) {
8
+ const variants = {
9
+ primary: 'bg-blue-600 hover:bg-blue-700 text-white',
10
+ secondary: 'bg-gray-200 hover:bg-gray-300 text-gray-900',
11
+ danger: 'bg-red-600 hover:bg-red-700 text-white'
12
+ }
13
+
14
+ return (
15
+ <button
16
+ type={type}
17
+ onClick={onClick}
18
+ disabled={disabled}
19
+ className={`
20
+ px-4 py-2 rounded-lg font-medium
21
+ transition-colors duration-200
22
+ disabled:opacity-50 disabled:cursor-not-allowed
23
+ ${variants[variant]}
24
+ `}
25
+ >
26
+ {children}
27
+ </button>
28
+ )
29
+ }
frontend/src/components/Card.jsx ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ export default function Card({ children, className = '' }) {
2
+ return (
3
+ <div className={`bg-white rounded-lg shadow-md p-6 ${className}`}>
4
+ {children}
5
+ </div>
6
+ )
7
+ }
frontend/src/components/Input.jsx ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export default function Input({
2
+ label,
3
+ type = 'text',
4
+ value,
5
+ onChange,
6
+ placeholder,
7
+ required = false,
8
+ error
9
+ }) {
10
+ return (
11
+ <div className="mb-4">
12
+ {label && (
13
+ <label className="block text-sm font-medium text-gray-700 mb-2">
14
+ {label}
15
+ {required && <span className="text-red-500 ml-1">*</span>}
16
+ </label>
17
+ )}
18
+ <input
19
+ type={type}
20
+ value={value}
21
+ onChange={(e) => onChange(e.target.value)}
22
+ placeholder={placeholder}
23
+ required={required}
24
+ className={`
25
+ w-full px-4 py-2 border rounded-lg
26
+ focus:outline-none focus:ring-2 focus:ring-blue-500
27
+ ${error ? 'border-red-500' : 'border-gray-300'}
28
+ `}
29
+ />
30
+ {error && (
31
+ <p className="text-red-500 text-sm mt-1">{error}</p>
32
+ )}
33
+ </div>
34
+ )
35
+ }
frontend/src/components/Spinner.jsx ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ export default function Spinner() {
2
+ return (
3
+ <div className="flex items-center justify-center p-8">
4
+ <div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
5
+ </div>
6
+ )
7
+ }
frontend/src/index.css ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @tailwind base;
2
+ @tailwind components;
3
+ @tailwind utilities;
4
+
5
+ :root {
6
+ --color-primary: #3b82f6;
7
+ --color-secondary: #64748b;
8
+ --color-success: #10b981;
9
+ --color-danger: #ef4444;
10
+ --color-warning: #f59e0b;
11
+ --color-ice: #93c5fd;
12
+ --color-gold: #fbbf24;
13
+ }
14
+
15
+ body {
16
+ @apply bg-gray-50 text-gray-900;
17
+ margin: 0;
18
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
19
+ 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
20
+ sans-serif;
21
+ -webkit-font-smoothing: antialiased;
22
+ -moz-osx-font-smoothing: grayscale;
23
+ }
24
+
25
+ code {
26
+ font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace;
27
+ }
frontend/src/main.jsx ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react'
2
+ import ReactDOM from 'react-dom/client'
3
+ import App from './App.jsx'
4
+ import './index.css'
5
+
6
+ ReactDOM.createRoot(document.getElementById('root')).render(
7
+ <React.StrictMode>
8
+ <App />
9
+ </React.StrictMode>,
10
+ )
frontend/src/pages/Dashboard.jsx ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import Card from '../components/Card'
2
+ import Button from '../components/Button'
3
+
4
+ export default function Dashboard({ navigate }) {
5
+ return (
6
+ <div className="space-y-6">
7
+ <div className="text-center py-12">
8
+ <h1 className="text-4xl font-bold text-gray-900 mb-4">
9
+ Wall Construction Tracker
10
+ </h1>
11
+ <p className="text-xl text-gray-600 mb-8">
12
+ Track ice usage and construction costs for the Great Wall of the North
13
+ </p>
14
+ </div>
15
+
16
+ <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
17
+ <Card>
18
+ <h3 className="text-lg font-semibold mb-2">Run Simulation</h3>
19
+ <p className="text-gray-600 mb-4">
20
+ Configure wall sections and simulate construction progress
21
+ </p>
22
+ <Button onClick={() => navigate('simulation')}>
23
+ Start Simulation
24
+ </Button>
25
+ </Card>
26
+
27
+ <Card>
28
+ <h3 className="text-lg font-semibold mb-2">View Results</h3>
29
+ <p className="text-gray-600 mb-4">
30
+ Analyze ice usage and construction costs by day
31
+ </p>
32
+ <Button variant="secondary" onClick={() => navigate('results')}>
33
+ View Results
34
+ </Button>
35
+ </Card>
36
+
37
+ <Card>
38
+ <h3 className="text-lg font-semibold mb-2">API Documentation</h3>
39
+ <p className="text-gray-600 mb-4">
40
+ Explore the REST API endpoints
41
+ </p>
42
+ <Button variant="secondary" onClick={() => window.location.href = '/api/'}>
43
+ Browse API
44
+ </Button>
45
+ </Card>
46
+ </div>
47
+ </div>
48
+ )
49
+ }
frontend/src/pages/SimulationForm.jsx ADDED
@@ -0,0 +1,107 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState } from 'react'
2
+ import Card from '../components/Card'
3
+ import Button from '../components/Button'
4
+ import Input from '../components/Input'
5
+ import Spinner from '../components/Spinner'
6
+ import { api } from '../utils/api'
7
+
8
+ export default function SimulationForm({ navigate }) {
9
+ const [config, setConfig] = useState('21 25 28\n17\n17 22 17 19 17')
10
+ const [numTeams, setNumTeams] = useState('10')
11
+ const [startDate, setStartDate] = useState('2025-10-20')
12
+ const [loading, setLoading] = useState(false)
13
+ const [error, setError] = useState(null)
14
+ const [result, setResult] = useState(null)
15
+
16
+ const handleSubmit = async (e) => {
17
+ e.preventDefault()
18
+ setLoading(true)
19
+ setError(null)
20
+ setResult(null)
21
+
22
+ try {
23
+ const data = await api.runSimulation(config, parseInt(numTeams), startDate)
24
+ setResult(data)
25
+ } catch (err) {
26
+ setError(err.message || 'Simulation failed')
27
+ } finally {
28
+ setLoading(false)
29
+ }
30
+ }
31
+
32
+ return (
33
+ <div className="max-w-4xl mx-auto space-y-6">
34
+ <h1 className="text-3xl font-bold text-gray-900">Run Simulation</h1>
35
+
36
+ <Card>
37
+ <form onSubmit={handleSubmit} className="space-y-4">
38
+ <div>
39
+ <label className="block text-sm font-medium text-gray-700 mb-2">
40
+ Wall Configuration
41
+ <span className="text-red-500 ml-1">*</span>
42
+ </label>
43
+ <textarea
44
+ value={config}
45
+ onChange={(e) => setConfig(e.target.value)}
46
+ required
47
+ rows="6"
48
+ className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
49
+ placeholder="Enter wall section heights (space or newline separated)"
50
+ />
51
+ <p className="text-sm text-gray-500 mt-1">
52
+ Example: "21 25 28" creates 3 sections with heights 21, 25, and 28 feet
53
+ </p>
54
+ </div>
55
+
56
+ <Input
57
+ label="Number of Teams"
58
+ type="number"
59
+ value={numTeams}
60
+ onChange={setNumTeams}
61
+ required
62
+ placeholder="10"
63
+ />
64
+
65
+ <Input
66
+ label="Start Date"
67
+ type="date"
68
+ value={startDate}
69
+ onChange={setStartDate}
70
+ required
71
+ />
72
+
73
+ <Button type="submit" disabled={loading}>
74
+ {loading ? 'Running Simulation...' : 'Run Simulation'}
75
+ </Button>
76
+ </form>
77
+
78
+ {loading && <Spinner />}
79
+
80
+ {error && (
81
+ <div className="mt-4 bg-red-50 border border-red-200 rounded-lg p-4">
82
+ <p className="text-red-800">{error}</p>
83
+ </div>
84
+ )}
85
+
86
+ {result && (
87
+ <div className="mt-6 space-y-4">
88
+ <h2 className="text-xl font-semibold text-green-600">Simulation Complete!</h2>
89
+ <div className="grid grid-cols-2 gap-4">
90
+ <div className="bg-blue-50 p-4 rounded-lg">
91
+ <p className="text-sm text-gray-600">Total Sections</p>
92
+ <p className="text-2xl font-bold text-blue-600">{result.total_sections}</p>
93
+ </div>
94
+ <div className="bg-green-50 p-4 rounded-lg">
95
+ <p className="text-sm text-gray-600">Total Days</p>
96
+ <p className="text-2xl font-bold text-green-600">{result.total_days}</p>
97
+ </div>
98
+ </div>
99
+ <Button onClick={() => navigate('results')}>
100
+ View Detailed Results
101
+ </Button>
102
+ </div>
103
+ )}
104
+ </Card>
105
+ </div>
106
+ )
107
+ }
frontend/src/pages/SimulationResults.jsx ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useEffect } from 'react'
2
+ import Card from '../components/Card'
3
+ import Spinner from '../components/Spinner'
4
+ import { api } from '../utils/api'
5
+
6
+ export default function SimulationResults({ navigate }) {
7
+ const [loading, setLoading] = useState(true)
8
+ const [error, setError] = useState(null)
9
+ const [data, setData] = useState(null)
10
+
11
+ useEffect(() => {
12
+ async function fetchData() {
13
+ try {
14
+ setLoading(true)
15
+ const result = await api.getOverviewTotal()
16
+ setData(result)
17
+ } catch (err) {
18
+ setError(err.message || 'Failed to load results')
19
+ } finally {
20
+ setLoading(false)
21
+ }
22
+ }
23
+ fetchData()
24
+ }, [])
25
+
26
+ if (loading) return <Spinner />
27
+
28
+ if (error) {
29
+ return (
30
+ <div className="max-w-4xl mx-auto">
31
+ <Card>
32
+ <div className="text-center py-12">
33
+ <h2 className="text-2xl font-semibold text-gray-900 mb-4">
34
+ No Simulation Data
35
+ </h2>
36
+ <p className="text-gray-600 mb-6">
37
+ {error}
38
+ </p>
39
+ <button
40
+ onClick={() => navigate('simulation')}
41
+ className="text-blue-600 hover:underline"
42
+ >
43
+ Run a simulation first
44
+ </button>
45
+ </div>
46
+ </Card>
47
+ </div>
48
+ )
49
+ }
50
+
51
+ return (
52
+ <div className="max-w-6xl mx-auto space-y-6">
53
+ <h1 className="text-3xl font-bold text-gray-900">Simulation Results</h1>
54
+
55
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
56
+ <Card>
57
+ <h3 className="text-lg font-semibold mb-2">Total Cost</h3>
58
+ <p className="text-4xl font-bold text-[var(--color-gold)]">
59
+ {data.cost} GD
60
+ </p>
61
+ <p className="text-sm text-gray-500 mt-2">Gold Dragons</p>
62
+ </Card>
63
+
64
+ <Card>
65
+ <h3 className="text-lg font-semibold mb-2">Completion Time</h3>
66
+ <p className="text-4xl font-bold text-blue-600">
67
+ {data.day || 'N/A'} {data.day === '1' ? 'day' : 'days'}
68
+ </p>
69
+ <p className="text-sm text-gray-500 mt-2">Total construction time</p>
70
+ </Card>
71
+ </div>
72
+
73
+ <Card>
74
+ <h3 className="text-lg font-semibold mb-4">Construction Overview</h3>
75
+ <p className="text-gray-600">
76
+ The simulation has been completed successfully. Total cost for the entire
77
+ construction project is <strong>{data.cost} Gold Dragons</strong>.
78
+ </p>
79
+ </Card>
80
+ </div>
81
+ )
82
+ }
frontend/src/utils/api.js ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const API_BASE = '/api'
2
+
3
+ class ApiError extends Error {
4
+ constructor(message, status, data) {
5
+ super(message)
6
+ this.status = status
7
+ this.data = data
8
+ }
9
+ }
10
+
11
+ async function request(endpoint, options = {}) {
12
+ const url = `${API_BASE}${endpoint}`
13
+
14
+ const config = {
15
+ headers: {
16
+ 'Content-Type': 'application/json',
17
+ ...options.headers
18
+ },
19
+ ...options
20
+ }
21
+
22
+ try {
23
+ const response = await fetch(url, config)
24
+
25
+ const data = await response.json()
26
+
27
+ if (!response.ok) {
28
+ throw new ApiError(
29
+ data.message || 'Request failed',
30
+ response.status,
31
+ data
32
+ )
33
+ }
34
+
35
+ return data
36
+ } catch (error) {
37
+ if (error instanceof ApiError) {
38
+ throw error
39
+ }
40
+ throw new ApiError('Network error', 0, { originalError: error })
41
+ }
42
+ }
43
+
44
+ export const api = {
45
+ // Profiles
46
+ getProfiles: () => request('/profiles/'),
47
+ getProfile: (id) => request(`/profiles/${id}/`),
48
+
49
+ // Simulation
50
+ runSimulation: (config, numTeams = 10, startDate) => request('/profiles/simulate/', {
51
+ method: 'POST',
52
+ body: JSON.stringify({ config, num_teams: numTeams, start_date: startDate })
53
+ }),
54
+
55
+ // Analytics
56
+ getProfileDays: (id, day) => request(`/profiles/${id}/days/${day}/`),
57
+ getProfileOverview: (id, day) => request(`/profiles/${id}/overview/${day}/`),
58
+ getOverviewByDay: (day) => request(`/profiles/overview/${day}/`),
59
+ getOverviewTotal: () => request('/profiles/overview/')
60
+ }
frontend/tailwind.config.js ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** @type {import('tailwindcss').Config} */
2
+ export default {
3
+ content: [
4
+ "./index.html",
5
+ "./src/**/*.{js,jsx}",
6
+ ],
7
+ theme: {
8
+ extend: {},
9
+ },
10
+ plugins: [],
11
+ }
frontend/vite.config.js ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { defineConfig } from 'vite'
2
+ import react from '@vitejs/plugin-react'
3
+
4
+ export default defineConfig({
5
+ plugins: [react()],
6
+ server: {
7
+ port: 5173,
8
+ proxy: {
9
+ '/api': {
10
+ target: 'http://localhost:8000',
11
+ changeOrigin: true
12
+ }
13
+ }
14
+ },
15
+ build: {
16
+ outDir: 'dist',
17
+ sourcemap: false,
18
+ minify: 'esbuild',
19
+ target: 'es2020'
20
+ }
21
+ })
nginx.conf ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ events {
2
+ worker_connections 1024;
3
+ }
4
+
5
+ http {
6
+ include /etc/nginx/mime.types;
7
+ default_type application/octet-stream;
8
+
9
+ server {
10
+ listen 7860;
11
+ server_name _;
12
+
13
+ root /usr/share/nginx/html;
14
+ index index.html;
15
+
16
+ # API proxy to Django backend
17
+ location /api {
18
+ proxy_pass http://127.0.0.1:8000;
19
+ proxy_set_header Host $host;
20
+ proxy_set_header X-Real-IP $remote_addr;
21
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
22
+ proxy_set_header X-Forwarded-Proto $scheme;
23
+ }
24
+
25
+ # SPA fallback - serve index.html for all routes except /api
26
+ location / {
27
+ try_files $uri $uri/ /index.html;
28
+ }
29
+ }
30
+ }
pyproject.toml CHANGED
@@ -13,6 +13,7 @@ dependencies = [
13
  "djangorestframework==3.16.0",
14
  "django-filter>=24.3",
15
  "pydantic>=2.10.6",
 
16
  ]
17
 
18
  [project.optional-dependencies]
 
13
  "djangorestframework==3.16.0",
14
  "django-filter>=24.3",
15
  "pydantic>=2.10.6",
16
+ "gunicorn>=23.0.0",
17
  ]
18
 
19
  [project.optional-dependencies]
uv.lock CHANGED
@@ -121,6 +121,7 @@ dependencies = [
121
  { name = "django" },
122
  { name = "django-filter" },
123
  { name = "djangorestframework" },
 
124
  { name = "loguru" },
125
  { name = "pydantic" },
126
  ]
@@ -153,6 +154,7 @@ requires-dist = [
153
  { name = "djangorestframework-stubs", marker = "extra == 'dev'", specifier = "==3.16.4" },
154
  { name = "factory-boy", marker = "extra == 'test'", specifier = "==3.3.3" },
155
  { name = "faker", marker = "extra == 'test'", specifier = "==33.3.0" },
 
156
  { name = "loguru", specifier = "==0.7.3" },
157
  { name = "mypy", marker = "extra == 'dev'", specifier = "==1.18.2" },
158
  { name = "pydantic", specifier = ">=2.10.6" },
@@ -281,6 +283,18 @@ wheels = [
281
  { url = "https://files.pythonhosted.org/packages/d2/7e/553601891ef96f030a1a6cc14d7957fb1c81e394ca89cafccb97e0eb5882/Faker-33.3.0-py3-none-any.whl", hash = "sha256:ae074d9c7ef65817a93b448141a5531a16b2ea2e563dc5774578197c7c84060c", size = 1894526, upload-time = "2025-01-03T21:53:27.711Z" },
282
  ]
283
 
 
 
 
 
 
 
 
 
 
 
 
 
284
  [[package]]
285
  name = "idna"
286
  version = "3.11"
 
121
  { name = "django" },
122
  { name = "django-filter" },
123
  { name = "djangorestframework" },
124
+ { name = "gunicorn" },
125
  { name = "loguru" },
126
  { name = "pydantic" },
127
  ]
 
154
  { name = "djangorestframework-stubs", marker = "extra == 'dev'", specifier = "==3.16.4" },
155
  { name = "factory-boy", marker = "extra == 'test'", specifier = "==3.3.3" },
156
  { name = "faker", marker = "extra == 'test'", specifier = "==33.3.0" },
157
+ { name = "gunicorn", specifier = ">=23.0.0" },
158
  { name = "loguru", specifier = "==0.7.3" },
159
  { name = "mypy", marker = "extra == 'dev'", specifier = "==1.18.2" },
160
  { name = "pydantic", specifier = ">=2.10.6" },
 
283
  { url = "https://files.pythonhosted.org/packages/d2/7e/553601891ef96f030a1a6cc14d7957fb1c81e394ca89cafccb97e0eb5882/Faker-33.3.0-py3-none-any.whl", hash = "sha256:ae074d9c7ef65817a93b448141a5531a16b2ea2e563dc5774578197c7c84060c", size = 1894526, upload-time = "2025-01-03T21:53:27.711Z" },
284
  ]
285
 
286
+ [[package]]
287
+ name = "gunicorn"
288
+ version = "23.0.0"
289
+ source = { registry = "https://pypi.org/simple" }
290
+ dependencies = [
291
+ { name = "packaging" },
292
+ ]
293
+ sdist = { url = "https://files.pythonhosted.org/packages/34/72/9614c465dc206155d93eff0ca20d42e1e35afc533971379482de953521a4/gunicorn-23.0.0.tar.gz", hash = "sha256:f014447a0101dc57e294f6c18ca6b40227a4c90e9bdb586042628030cba004ec", size = 375031, upload-time = "2024-08-10T20:25:27.378Z" }
294
+ wheels = [
295
+ { url = "https://files.pythonhosted.org/packages/cb/7d/6dac2a6e1eba33ee43f318edbed4ff29151a49b5d37f080aad1e6469bca4/gunicorn-23.0.0-py3-none-any.whl", hash = "sha256:ec400d38950de4dfd418cff8328b2c8faed0edb0d517d3394e457c317908ca4d", size = 85029, upload-time = "2024-08-10T20:25:24.996Z" },
296
+ ]
297
+
298
  [[package]]
299
  name = "idna"
300
  version = "3.11"