Initial commit - Journey book (kniha jízd) automation system
Features: - FastAPI backend for scraping attendance and journey book data - Deterministic kilometer distribution with random variance - Refueling form filling with km values - Next.js frontend with date range selector - Docker deployment setup 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
26
.gitignore
vendored
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
env/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
.venv
|
||||||
|
*.egg-info/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
.env
|
||||||
|
*.db
|
||||||
|
*.sqlite
|
||||||
|
node_modules/
|
||||||
|
.next/
|
||||||
|
out/
|
||||||
|
.DS_Store
|
||||||
|
*.log
|
||||||
|
.pytest_cache/
|
||||||
|
.coverage
|
||||||
|
htmlcov/
|
||||||
|
*.xlsx
|
||||||
|
!old/*.xlsx
|
||||||
|
old/
|
||||||
BIN
.playwright-mcp/screenshot-date-range-form.png
Normal file
|
After Width: | Height: | Size: 180 KiB |
BIN
.playwright-mcp/screenshot-final-working.png
Normal file
|
After Width: | Height: | Size: 180 KiB |
BIN
.playwright-mcp/screenshot-final.png
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
.playwright-mcp/screenshot-fixed.png
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
.playwright-mcp/screenshot-working.png
Normal file
|
After Width: | Height: | Size: 180 KiB |
BIN
.playwright-mcp/screenshot.png
Normal file
|
After Width: | Height: | Size: 158 KiB |
164
NETWORK_SETUP.md
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
# External Access Configuration
|
||||||
|
|
||||||
|
## Quick Setup
|
||||||
|
|
||||||
|
### 1. Get Your Server IP
|
||||||
|
```bash
|
||||||
|
# Find your server's IP address
|
||||||
|
ip addr show | grep "inet " | grep -v 127.0.0.1
|
||||||
|
# Or
|
||||||
|
hostname -I
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Configure Frontend API URL
|
||||||
|
|
||||||
|
**Option A: Environment Variable (Recommended)**
|
||||||
|
```bash
|
||||||
|
# Edit frontend/.env.local
|
||||||
|
nano frontend/.env.local
|
||||||
|
|
||||||
|
# Set your server IP:
|
||||||
|
NEXT_PUBLIC_API_URL=http://YOUR_SERVER_IP:8000
|
||||||
|
```
|
||||||
|
|
||||||
|
**Option B: Docker Compose**
|
||||||
|
```bash
|
||||||
|
# Edit docker-compose.yml
|
||||||
|
nano docker-compose.yml
|
||||||
|
|
||||||
|
# Update the frontend environment section with your IP
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Update Firewall Rules
|
||||||
|
|
||||||
|
**UFW (Ubuntu/Debian):**
|
||||||
|
```bash
|
||||||
|
sudo ufw allow 3000/tcp # Frontend
|
||||||
|
sudo ufw allow 8000/tcp # Backend API
|
||||||
|
sudo ufw reload
|
||||||
|
```
|
||||||
|
|
||||||
|
**Firewalld (CentOS/RHEL):**
|
||||||
|
```bash
|
||||||
|
sudo firewall-cmd --permanent --add-port=3000/tcp
|
||||||
|
sudo firewall-cmd --permanent --add-port=8000/tcp
|
||||||
|
sudo firewall-cmd --reload
|
||||||
|
```
|
||||||
|
|
||||||
|
**iptables:**
|
||||||
|
```bash
|
||||||
|
sudo iptables -A INPUT -p tcp --dport 3000 -j ACCEPT
|
||||||
|
sudo iptables -A INPUT -p tcp --dport 8000 -j ACCEPT
|
||||||
|
sudo iptables-save
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Start the Application
|
||||||
|
```bash
|
||||||
|
./start.sh
|
||||||
|
# or
|
||||||
|
./start.sh docker
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Access from External Device
|
||||||
|
```
|
||||||
|
Frontend: http://YOUR_SERVER_IP:3000
|
||||||
|
Backend API: http://YOUR_SERVER_IP:8000/docs
|
||||||
|
```
|
||||||
|
|
||||||
|
## Production Setup (Nginx Reverse Proxy)
|
||||||
|
|
||||||
|
For production, use Nginx with SSL:
|
||||||
|
|
||||||
|
### Install Nginx
|
||||||
|
```bash
|
||||||
|
sudo apt install nginx certbot python3-certbot-nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
### Configure Nginx
|
||||||
|
```bash
|
||||||
|
sudo nano /etc/nginx/sites-available/kniha-jizd
|
||||||
|
```
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name your-domain.com;
|
||||||
|
|
||||||
|
# Frontend
|
||||||
|
location / {
|
||||||
|
proxy_pass http://localhost:3000;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection 'upgrade';
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_cache_bypass $http_upgrade;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Backend API
|
||||||
|
location /api {
|
||||||
|
proxy_pass http://localhost: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 X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Enable Site & SSL
|
||||||
|
```bash
|
||||||
|
sudo ln -s /etc/nginx/sites-available/kniha-jizd /etc/nginx/sites-enabled/
|
||||||
|
sudo nginx -t
|
||||||
|
sudo systemctl restart nginx
|
||||||
|
|
||||||
|
# Get SSL certificate
|
||||||
|
sudo certbot --nginx -d your-domain.com
|
||||||
|
```
|
||||||
|
|
||||||
|
### Update Frontend Config
|
||||||
|
```bash
|
||||||
|
# frontend/.env.local
|
||||||
|
NEXT_PUBLIC_API_URL=https://your-domain.com
|
||||||
|
```
|
||||||
|
|
||||||
|
## Security Recommendations
|
||||||
|
|
||||||
|
1. **Use HTTPS in production** - Never expose unencrypted credentials
|
||||||
|
2. **Restrict CORS** - Update `allow_origins` in `backend/api/main.py`
|
||||||
|
3. **Use environment variables** - Never commit credentials
|
||||||
|
4. **Enable rate limiting** - Prevent abuse
|
||||||
|
5. **Use VPN or SSH tunnel** - For development access
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Can't connect from outside
|
||||||
|
```bash
|
||||||
|
# Check if ports are listening on all interfaces
|
||||||
|
sudo netss -tlnp | grep -E '3000|8000'
|
||||||
|
|
||||||
|
# Should show 0.0.0.0:3000 and 0.0.0.0:8000
|
||||||
|
```
|
||||||
|
|
||||||
|
### Connection refused
|
||||||
|
- Check firewall rules
|
||||||
|
- Verify Docker binds to 0.0.0.0
|
||||||
|
- Check cloud provider security groups (AWS/GCP/Azure)
|
||||||
|
|
||||||
|
### CORS errors
|
||||||
|
- Verify NEXT_PUBLIC_API_URL is set correctly
|
||||||
|
- Check backend CORS middleware allows your origin
|
||||||
|
- Clear browser cache
|
||||||
|
|
||||||
|
## Cloud Provider Notes
|
||||||
|
|
||||||
|
### AWS EC2
|
||||||
|
- Add inbound rules to Security Group for ports 3000, 8000
|
||||||
|
|
||||||
|
### Google Cloud
|
||||||
|
- Add firewall rules: `gcloud compute firewall-rules create`
|
||||||
|
|
||||||
|
### Azure
|
||||||
|
- Configure Network Security Group inbound rules
|
||||||
|
|
||||||
|
### DigitalOcean
|
||||||
|
- Configure Cloud Firewall or droplet firewall
|
||||||
166
README.md
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
# Kniha Jízd - Journey Book Management System
|
||||||
|
|
||||||
|
Modern web application for automated journey book management with data scraping, calculation, and Excel export.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
✅ **Web Interface** - Modern React/Next.js UI
|
||||||
|
✅ **Data Scraping** - Automatic attendance & journey data fetching
|
||||||
|
✅ **Smart Calculation** - Random kilometer distribution with configurable variance
|
||||||
|
✅ **Excel Export** - Download processed data
|
||||||
|
✅ **Sick Day Filtering** - Automatic exclusion of vacation/sick days
|
||||||
|
✅ **Fuel Tracking** - Refueling entry management
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
- **Backend:** FastAPI (Python 3.11+)
|
||||||
|
- **Frontend:** Next.js 15 + TypeScript + Tailwind CSS
|
||||||
|
- **Data Processing:** Pandas, NumPy
|
||||||
|
- **Scraping:** BeautifulSoup4, Requests
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Option 1: Docker (Recommended)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./start.sh docker
|
||||||
|
```
|
||||||
|
|
||||||
|
### Option 2: Local Development
|
||||||
|
|
||||||
|
**Prerequisites:**
|
||||||
|
- Python 3.11+
|
||||||
|
- Node.js 20+
|
||||||
|
|
||||||
|
**Setup:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Install backend dependencies
|
||||||
|
cd backend
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
# 2. Configure credentials
|
||||||
|
cp .env.example .env
|
||||||
|
nano .env # Edit with your credentials
|
||||||
|
|
||||||
|
# 3. Install frontend dependencies
|
||||||
|
cd ../frontend
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# 4. Start both services
|
||||||
|
cd ..
|
||||||
|
./start.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
**Access:**
|
||||||
|
- 📊 Frontend: http://localhost:3000 (or http://YOUR_SERVER_IP:3000)
|
||||||
|
- 🔧 Backend API: http://localhost:8000 (or http://YOUR_SERVER_IP:8000)
|
||||||
|
- 📖 API Docs: http://localhost:8000/docs
|
||||||
|
|
||||||
|
**For external access:** See [NETWORK_SETUP.md](NETWORK_SETUP.md)
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
1. **Enter Credentials** - Company login details
|
||||||
|
2. **Select Month** - Choose period to process
|
||||||
|
3. **Set Kilometers** - Starting and ending odometer values
|
||||||
|
4. **Configure Vehicle** - SPZ registration (default: 4SH1148)
|
||||||
|
5. **Adjust Variance** - Random distribution factor (0.1 = 10%)
|
||||||
|
6. **Calculate** - Preview processed data
|
||||||
|
7. **Export** - Download Excel file
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### `POST /api/scrape/attendance`
|
||||||
|
Scrape attendance data for sick/vacation days
|
||||||
|
|
||||||
|
### `POST /api/scrape/journeybook`
|
||||||
|
Scrape journey book entries
|
||||||
|
|
||||||
|
### `POST /api/calculate`
|
||||||
|
Full pipeline: scrape → filter → calculate
|
||||||
|
|
||||||
|
### `POST /api/export/excel`
|
||||||
|
Generate and download Excel file
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
kniha_jizd/
|
||||||
|
├── backend/
|
||||||
|
│ ├── api/
|
||||||
|
│ │ └── main.py # FastAPI endpoints
|
||||||
|
│ ├── scrapers/
|
||||||
|
│ │ ├── attendance_scraper.py
|
||||||
|
│ │ └── journeybook_scraper.py
|
||||||
|
│ ├── calculators/
|
||||||
|
│ │ └── kilometer_calculator.py
|
||||||
|
│ ├── models/
|
||||||
|
│ │ └── journey.py # Pydantic models
|
||||||
|
│ └── requirements.txt
|
||||||
|
├── frontend/
|
||||||
|
│ ├── app/
|
||||||
|
│ │ ├── components/
|
||||||
|
│ │ │ ├── JourneyForm.tsx
|
||||||
|
│ │ │ └── DataPreview.tsx
|
||||||
|
│ │ ├── page.tsx # Main page
|
||||||
|
│ │ └── layout.tsx
|
||||||
|
│ └── package.json
|
||||||
|
├── old/ # Original scripts (archived)
|
||||||
|
├── docker-compose.yml
|
||||||
|
└── start.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Backend `.env`
|
||||||
|
|
||||||
|
```env
|
||||||
|
USERNAME=your_username
|
||||||
|
PASSWORD=your_password
|
||||||
|
VEHICLE_REGISTRATION=4SH1148
|
||||||
|
```
|
||||||
|
|
||||||
|
### Variance Parameter
|
||||||
|
|
||||||
|
Controls kilometer distribution randomness:
|
||||||
|
- `0.0` - Equal distribution
|
||||||
|
- `0.1` - 10% variance (recommended)
|
||||||
|
- `0.2` - 20% variance (more random)
|
||||||
|
|
||||||
|
## Future Enhancements (Phase 2)
|
||||||
|
|
||||||
|
- 📊 Dashboard with statistics
|
||||||
|
- 📈 Fuel efficiency charts
|
||||||
|
- 💰 Cost tracking
|
||||||
|
- 🗺️ Route history
|
||||||
|
- 📑 PDF reports
|
||||||
|
- 🔐 User authentication
|
||||||
|
- 💾 Database persistence
|
||||||
|
|
||||||
|
## Migrated Features
|
||||||
|
|
||||||
|
All functionality from original 3-script pipeline:
|
||||||
|
1. ✅ `knihajizd.py` → `scrapers/`
|
||||||
|
2. ✅ `fill_table.py` → `calculators/`
|
||||||
|
3. ✅ `fill_web.py` → Future: Playwright automation
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Backend hot reload
|
||||||
|
cd backend
|
||||||
|
uvicorn api.main:app --reload
|
||||||
|
|
||||||
|
# Frontend hot reload
|
||||||
|
cd frontend
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# Build for production
|
||||||
|
npm run build
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
4
backend/.env.example
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
USERNAME=your_username
|
||||||
|
PASSWORD=your_password
|
||||||
|
VEHICLE_REGISTRATION=4SH1148
|
||||||
|
DATABASE_URL=sqlite:///./journeybook.db
|
||||||
14
backend/Dockerfile
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
gcc \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
CMD ["uvicorn", "api.main:app", "--host", "0.0.0.0", "--port", "8002", "--reload"]
|
||||||
243
backend/api/main.py
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
from fastapi import FastAPI, HTTPException, Depends
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional
|
||||||
|
import pandas as pd
|
||||||
|
from io import BytesIO
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
from dateutil.relativedelta import relativedelta
|
||||||
|
|
||||||
|
from scrapers import AttendanceScraper, JourneybookScraper
|
||||||
|
from calculators.kilometer_calculator import KilometerCalculator
|
||||||
|
from fillers import JourneybookFiller
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
app = FastAPI(title="Kniha Jízd API", version="1.0.0")
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"],
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ScrapeRequest(BaseModel):
|
||||||
|
username: str
|
||||||
|
password: str
|
||||||
|
month: str
|
||||||
|
vehicle_registration: str = "4SH1148"
|
||||||
|
|
||||||
|
|
||||||
|
class CalculateRequest(BaseModel):
|
||||||
|
username: str
|
||||||
|
password: str
|
||||||
|
start_date: str # Format: YYYY-MM-DD
|
||||||
|
end_date: str # Format: YYYY-MM-DD
|
||||||
|
start_km: int
|
||||||
|
end_km: int
|
||||||
|
vehicle_registration: str = "4SH1148"
|
||||||
|
variance: float = 0.1
|
||||||
|
|
||||||
|
|
||||||
|
class FillRequest(CalculateRequest):
|
||||||
|
dry_run: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
async def health_check():
|
||||||
|
return {"status": "healthy"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/scrape/attendance")
|
||||||
|
async def scrape_attendance(request: ScrapeRequest):
|
||||||
|
"""Scrape attendance data for a month"""
|
||||||
|
try:
|
||||||
|
scraper = AttendanceScraper(request.username, request.password)
|
||||||
|
attendance_dates = scraper.scrape_month(request.month)
|
||||||
|
return {
|
||||||
|
"month": request.month,
|
||||||
|
"sick_vacation_days": attendance_dates,
|
||||||
|
"count": len(attendance_dates)
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Attendance scraping failed: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/scrape/journeybook")
|
||||||
|
async def scrape_journeybook(request: ScrapeRequest):
|
||||||
|
"""Scrape journeybook data for a month"""
|
||||||
|
try:
|
||||||
|
scraper = JourneybookScraper(request.username, request.password, request.vehicle_registration)
|
||||||
|
df = scraper.scrape_month(request.month)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"month": request.month,
|
||||||
|
"entries": df.to_dict(orient='records'),
|
||||||
|
"count": len(df)
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Journeybook scraping failed: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/calculate")
|
||||||
|
async def calculate_kilometers(request: CalculateRequest):
|
||||||
|
"""Scrape data, filter sick days, and recalculate kilometers"""
|
||||||
|
try:
|
||||||
|
attendance_scraper = AttendanceScraper(request.username, request.password)
|
||||||
|
journeybook_scraper = JourneybookScraper(request.username, request.password, request.vehicle_registration)
|
||||||
|
|
||||||
|
# Get all months in the date range
|
||||||
|
start = datetime.strptime(request.start_date, "%Y-%m-%d")
|
||||||
|
end = datetime.strptime(request.end_date, "%Y-%m-%d")
|
||||||
|
|
||||||
|
# Collect data from all months
|
||||||
|
all_attendance_dates = []
|
||||||
|
all_dfs = []
|
||||||
|
|
||||||
|
current = start
|
||||||
|
while current <= end:
|
||||||
|
month_str = current.strftime("%Y-%m")
|
||||||
|
logger.info(f"Scraping attendance for {month_str}")
|
||||||
|
attendance_dates = attendance_scraper.scrape_month(month_str)
|
||||||
|
all_attendance_dates.extend(attendance_dates)
|
||||||
|
|
||||||
|
logger.info(f"Scraping journeybook for {month_str}")
|
||||||
|
df_month = journeybook_scraper.scrape_month(month_str)
|
||||||
|
all_dfs.append(df_month)
|
||||||
|
|
||||||
|
current = current + relativedelta(months=1)
|
||||||
|
|
||||||
|
# Combine all months
|
||||||
|
df = pd.concat(all_dfs, ignore_index=True) if all_dfs else pd.DataFrame()
|
||||||
|
|
||||||
|
# Filter by actual date range, but preserve refueling rows (empty Datum)
|
||||||
|
if not df.empty:
|
||||||
|
date_parsed = pd.to_datetime(df["Datum"], format="%d.%m.%Y", errors='coerce')
|
||||||
|
is_refuel = df["Datum"].isna() | (df["Datum"] == "")
|
||||||
|
is_in_range = (date_parsed >= start) & (date_parsed <= end)
|
||||||
|
df = df[is_in_range | is_refuel]
|
||||||
|
|
||||||
|
logger.info(f"Filtering out {len(all_attendance_dates)} sick/vacation days")
|
||||||
|
# Only filter journey rows, not refueling rows
|
||||||
|
is_refuel = df["Datum"].isna() | (df["Datum"] == "")
|
||||||
|
df = df[~df["Datum"].isin(all_attendance_dates) | is_refuel]
|
||||||
|
|
||||||
|
logger.info("Recalculating kilometers")
|
||||||
|
df = KilometerCalculator.recalculate(df, request.start_km, request.end_km, request.variance)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"month": f"{request.start_date} - {request.end_date}",
|
||||||
|
"start_km": request.start_km,
|
||||||
|
"end_km": request.end_km,
|
||||||
|
"filtered_days": len(all_attendance_dates),
|
||||||
|
"entries": df.to_dict(orient='records'),
|
||||||
|
"total_entries": len(df)
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
import traceback
|
||||||
|
logger.error(f"Calculate failed: {e}")
|
||||||
|
logger.error(traceback.format_exc())
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/export/excel")
|
||||||
|
async def export_to_excel(request: CalculateRequest):
|
||||||
|
"""Generate and download Excel file"""
|
||||||
|
try:
|
||||||
|
attendance_scraper = AttendanceScraper(request.username, request.password)
|
||||||
|
journeybook_scraper = JourneybookScraper(request.username, request.password, request.vehicle_registration)
|
||||||
|
|
||||||
|
# Get all months in the date range
|
||||||
|
start = datetime.strptime(request.start_date, "%Y-%m-%d")
|
||||||
|
end = datetime.strptime(request.end_date, "%Y-%m-%d")
|
||||||
|
|
||||||
|
# Collect data from all months
|
||||||
|
all_attendance_dates = []
|
||||||
|
all_dfs = []
|
||||||
|
|
||||||
|
current = start
|
||||||
|
while current <= end:
|
||||||
|
month_str = current.strftime("%Y-%m")
|
||||||
|
attendance_dates = attendance_scraper.scrape_month(month_str)
|
||||||
|
all_attendance_dates.extend(attendance_dates)
|
||||||
|
df_month = journeybook_scraper.scrape_month(month_str)
|
||||||
|
all_dfs.append(df_month)
|
||||||
|
current = current + relativedelta(months=1)
|
||||||
|
|
||||||
|
# Combine all months
|
||||||
|
df = pd.concat(all_dfs, ignore_index=True) if all_dfs else pd.DataFrame()
|
||||||
|
|
||||||
|
# Filter by actual date range, but preserve refueling rows (empty Datum)
|
||||||
|
if not df.empty:
|
||||||
|
date_parsed = pd.to_datetime(df["Datum"], format="%d.%m.%Y", errors='coerce')
|
||||||
|
is_refuel = df["Datum"].isna() | (df["Datum"] == "")
|
||||||
|
is_in_range = (date_parsed >= start) & (date_parsed <= end)
|
||||||
|
df = df[is_in_range | is_refuel]
|
||||||
|
|
||||||
|
# Only filter journey rows, not refueling rows
|
||||||
|
is_refuel = df["Datum"].isna() | (df["Datum"] == "")
|
||||||
|
df = df[~df["Datum"].isin(all_attendance_dates) | is_refuel]
|
||||||
|
df = KilometerCalculator.recalculate(df, request.start_km, request.end_km, request.variance)
|
||||||
|
|
||||||
|
output = BytesIO()
|
||||||
|
df.to_excel(output, index=False, engine='openpyxl')
|
||||||
|
output.seek(0)
|
||||||
|
|
||||||
|
filename = f"journeybook_{request.start_date}_{request.end_date}.xlsx"
|
||||||
|
return StreamingResponse(
|
||||||
|
output,
|
||||||
|
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
headers={"Content-Disposition": f"attachment; filename={filename}"}
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Export failed: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/fill/journeybook")
|
||||||
|
async def fill_journeybook(request: FillRequest):
|
||||||
|
"""Fill calculated data back to kj.colsys.cz (restricted to January 2025)"""
|
||||||
|
try:
|
||||||
|
logger.info(f"Fill request received with dry_run={request.dry_run}")
|
||||||
|
|
||||||
|
# Only allow January 2025 for testing
|
||||||
|
if request.start_date != "2025-01-01" or request.end_date < "2025-01-31":
|
||||||
|
raise HTTPException(status_code=400, detail="Only January 2025 is allowed for testing")
|
||||||
|
|
||||||
|
attendance_scraper = AttendanceScraper(request.username, request.password)
|
||||||
|
journeybook_scraper = JourneybookScraper(request.username, request.password, request.vehicle_registration)
|
||||||
|
|
||||||
|
# Get January data
|
||||||
|
month_str = "2025-01"
|
||||||
|
attendance_dates = attendance_scraper.scrape_month(month_str)
|
||||||
|
df = journeybook_scraper.scrape_month(month_str)
|
||||||
|
|
||||||
|
# Filter sick/vacation days
|
||||||
|
df = df[~df["Datum"].isin(attendance_dates)]
|
||||||
|
|
||||||
|
# Recalculate kilometers
|
||||||
|
df = KilometerCalculator.recalculate(df, request.start_km, request.end_km, request.variance)
|
||||||
|
|
||||||
|
# Fill data back (supports dry_run mode)
|
||||||
|
filler = JourneybookFiller(request.username, request.password, request.vehicle_registration)
|
||||||
|
result = filler.fill_month(df, month_str, dry_run=request.dry_run)
|
||||||
|
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
import traceback
|
||||||
|
logger.error(f"Fill failed: {e}")
|
||||||
|
logger.error(traceback.format_exc())
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import uvicorn
|
||||||
|
uvicorn.run(app, host="0.0.0.0", port=8000)
|
||||||
147
backend/calculators/kilometer_calculator.py
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
import pandas as pd
|
||||||
|
import numpy as np
|
||||||
|
from typing import Optional
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class KilometerCalculator:
|
||||||
|
@staticmethod
|
||||||
|
def recalculate(
|
||||||
|
df: pd.DataFrame,
|
||||||
|
start_km: Optional[int] = None,
|
||||||
|
end_km: Optional[int] = None,
|
||||||
|
variance: float = 0.1
|
||||||
|
) -> pd.DataFrame:
|
||||||
|
"""
|
||||||
|
Recalculate kilometers with random distribution.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
df: DataFrame with journey data
|
||||||
|
start_km: Override starting kilometers (uses first row if None)
|
||||||
|
end_km: Override ending kilometers (uses last row if None)
|
||||||
|
variance: Random variance factor (default 0.1 = 10%)
|
||||||
|
"""
|
||||||
|
df = df.copy()
|
||||||
|
|
||||||
|
if start_km is None:
|
||||||
|
start_km = df.iloc[0]["Počáteční stav"]
|
||||||
|
if end_km is None:
|
||||||
|
end_km = df.iloc[-1]["Koncový stav"]
|
||||||
|
|
||||||
|
logger.info(f"Start KM: {start_km}, End KM: {end_km}")
|
||||||
|
|
||||||
|
# Set deterministic random seed based on start/end km to ensure consistent results
|
||||||
|
# This ensures the same input always produces the same output
|
||||||
|
seed = (start_km * 1000 + end_km) % (2**31)
|
||||||
|
np.random.seed(seed)
|
||||||
|
logger.info(f"Using deterministic seed: {seed}")
|
||||||
|
|
||||||
|
# Reset index FIRST to ensure continuous indices after filtering
|
||||||
|
df = df.reset_index(drop=True)
|
||||||
|
|
||||||
|
# Merge refueling rows into journey rows (consolidate by date)
|
||||||
|
# Scraped data structure: journey rows have dates, refueling rows follow with empty date
|
||||||
|
journey_rows = []
|
||||||
|
refuel_data = {} # Store refueling data by date
|
||||||
|
last_date = None
|
||||||
|
|
||||||
|
for i in range(len(df)):
|
||||||
|
datum = df.at[i, "Datum"]
|
||||||
|
refuel_amount = df.at[i, "Natankováno [l|kg]"]
|
||||||
|
|
||||||
|
if pd.notna(datum) and datum != "":
|
||||||
|
# This is a journey row with a date
|
||||||
|
journey_rows.append(i)
|
||||||
|
last_date = datum
|
||||||
|
elif pd.notna(refuel_amount) and refuel_amount != "" and last_date:
|
||||||
|
# This is a refueling row (no date, but has refueling amount)
|
||||||
|
# Associate it with the last journey date
|
||||||
|
if last_date not in refuel_data:
|
||||||
|
refuel_data[last_date] = []
|
||||||
|
refuel_data[last_date].append(refuel_amount)
|
||||||
|
|
||||||
|
# Maximum 2 refuelings per day
|
||||||
|
max_refuelings = 2
|
||||||
|
logger.info(f"Consolidated to {len(journey_rows)} journey days, {len(refuel_data)} days with refueling")
|
||||||
|
|
||||||
|
# Create new dataframe with only journey rows
|
||||||
|
df = df.iloc[journey_rows].copy()
|
||||||
|
df = df.reset_index(drop=True)
|
||||||
|
|
||||||
|
# Remove original refueling column
|
||||||
|
df = df.drop(columns=["Natankováno [l|kg]"])
|
||||||
|
|
||||||
|
# Create exactly 2 refueling columns (always)
|
||||||
|
df["Natankováno 1 [l|kg]"] = None
|
||||||
|
df["Tankováno při 1 [km]"] = None
|
||||||
|
df["Natankováno 2 [l|kg]"] = None
|
||||||
|
df["Tankováno při 2 [km]"] = None
|
||||||
|
|
||||||
|
# Fill in refueling data (max 2 per day)
|
||||||
|
for i in range(len(df)):
|
||||||
|
datum = df.at[i, "Datum"]
|
||||||
|
if datum in refuel_data:
|
||||||
|
amounts = refuel_data[datum][:2] # Take only first 2 refuelings
|
||||||
|
for idx, amount in enumerate(amounts, start=1):
|
||||||
|
df.at[i, f"Natankováno {idx} [l|kg]"] = amount
|
||||||
|
|
||||||
|
date_mask = df["Datum"].notna()
|
||||||
|
num_days = date_mask.sum()
|
||||||
|
|
||||||
|
if num_days == 0:
|
||||||
|
raise ValueError("No valid days found")
|
||||||
|
|
||||||
|
total_kilometers = end_km - start_km
|
||||||
|
logger.info(f"Total km to distribute: {total_kilometers} across {num_days} days")
|
||||||
|
|
||||||
|
avg_km_per_day = total_kilometers / num_days
|
||||||
|
km_per_day = np.abs(np.random.normal(avg_km_per_day, avg_km_per_day * variance, num_days))
|
||||||
|
km_per_day = np.round(km_per_day).astype(int)
|
||||||
|
|
||||||
|
difference = total_kilometers - np.sum(km_per_day)
|
||||||
|
logger.info(f"Difference to distribute: {difference}")
|
||||||
|
|
||||||
|
if difference != 0:
|
||||||
|
adjustment = int(difference // num_days)
|
||||||
|
km_per_day += adjustment
|
||||||
|
remaining = int(difference % num_days)
|
||||||
|
km_per_day[:remaining] += 1
|
||||||
|
|
||||||
|
df.loc[date_mask, "Ujeto [km]"] = km_per_day
|
||||||
|
|
||||||
|
# Recalculate km states for journey rows
|
||||||
|
current_km = start_km
|
||||||
|
|
||||||
|
for i in range(len(df)):
|
||||||
|
df.at[i, "Počáteční stav"] = current_km
|
||||||
|
df.at[i, "Koncový stav"] = current_km + int(df.at[i, "Ujeto [km]"])
|
||||||
|
current_km = df.at[i, "Koncový stav"]
|
||||||
|
|
||||||
|
# Set final end km for last row
|
||||||
|
df.at[len(df) - 1, "Koncový stav"] = end_km
|
||||||
|
|
||||||
|
# Calculate "Tankováno při [km]" for rows with refueling data
|
||||||
|
for i in range(len(df)):
|
||||||
|
start_km_val = df.at[i, "Počáteční stav"]
|
||||||
|
end_km_val = df.at[i, "Koncový stav"]
|
||||||
|
|
||||||
|
if isinstance(start_km_val, (int, float)) and isinstance(end_km_val, (int, float)):
|
||||||
|
# Check each refueling column
|
||||||
|
for refuel_num in range(1, max_refuelings + 1):
|
||||||
|
refuel_col = f"Natankováno {refuel_num} [l|kg]"
|
||||||
|
km_col = f"Tankováno při {refuel_num} [km]"
|
||||||
|
|
||||||
|
if refuel_col in df.columns and pd.notna(df.at[i, refuel_col]) and df.at[i, refuel_col] != "":
|
||||||
|
# Generate random km within the journey range
|
||||||
|
if end_km_val > start_km_val:
|
||||||
|
refuel_km = np.random.randint(int(start_km_val), int(end_km_val) + 1)
|
||||||
|
else:
|
||||||
|
refuel_km = start_km_val
|
||||||
|
df.at[i, km_col] = refuel_km
|
||||||
|
|
||||||
|
# Replace NaN values with None for JSON serialization
|
||||||
|
df = df.replace({np.nan: None})
|
||||||
|
|
||||||
|
return df
|
||||||
3
backend/fillers/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from .journeybook_filler import JourneybookFiller
|
||||||
|
|
||||||
|
__all__ = ['JourneybookFiller']
|
||||||
281
backend/fillers/journeybook_filler.py
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
import requests
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
import pandas as pd
|
||||||
|
import logging
|
||||||
|
from typing import Dict, Any, List
|
||||||
|
import time
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class JourneybookFiller:
|
||||||
|
def __init__(self, username: str, password: str, vehicle_registration: str = "4SH1148"):
|
||||||
|
self.username = username
|
||||||
|
self.password = password
|
||||||
|
self.vehicle_registration = vehicle_registration
|
||||||
|
self.base_url = "https://kj.colsys.cz/prehled_mesic.php"
|
||||||
|
self.session = requests.Session()
|
||||||
|
self.session.auth = (username, password)
|
||||||
|
self.session.verify = False
|
||||||
|
|
||||||
|
def fill_month(self, df: pd.DataFrame, month: str, dry_run: bool = True) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Fill journeybook data for a given month.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
df: DataFrame with calculated journey data
|
||||||
|
month: Month in format YYYY-MM (e.g., "2025-01")
|
||||||
|
dry_run: If True, only show what would be filled without actually submitting
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with results including success/failure counts
|
||||||
|
"""
|
||||||
|
url = f"{self.base_url}?rz={self.vehicle_registration}&den={month}-01"
|
||||||
|
|
||||||
|
logger.info(f"Fetching form for month {month}, vehicle {self.vehicle_registration}")
|
||||||
|
response = self.session.get(url)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
soup = BeautifulSoup(response.content, 'html.parser')
|
||||||
|
table = soup.find('table', class_='table table-striped table-bordered table-condensed table-sm')
|
||||||
|
|
||||||
|
if not table:
|
||||||
|
raise ValueError("Journeybook table not found")
|
||||||
|
|
||||||
|
# Extract forms with their parent rows to get dates
|
||||||
|
import re
|
||||||
|
table_rows = table.find('tbody').find_all('tr')
|
||||||
|
|
||||||
|
logger.info(f"Found {len(table_rows)} table rows")
|
||||||
|
logger.info(f"DataFrame has {len(df)} rows")
|
||||||
|
|
||||||
|
updates = []
|
||||||
|
deletes = []
|
||||||
|
|
||||||
|
for i, row in enumerate(table_rows):
|
||||||
|
form = row.find('form')
|
||||||
|
if not form:
|
||||||
|
continue # Skip rows without forms
|
||||||
|
|
||||||
|
# Extract date from the row's first cell
|
||||||
|
cells = row.find_all('td')
|
||||||
|
if not cells:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Get text from first cell (including button text)
|
||||||
|
date_text = cells[0].get_text(strip=True)
|
||||||
|
|
||||||
|
# Extract date pattern "2. 1. 2025" from text like "Zapiš2. 1. 2025Zapiš"
|
||||||
|
date_match = re.search(r'(\d{1,2}\.\s*\d{1,2}\.\s*\d{4})', date_text)
|
||||||
|
|
||||||
|
if date_match:
|
||||||
|
clean_date = date_match.group(1).replace(' ', '')
|
||||||
|
|
||||||
|
# Determine if this is a journey form or refueling form
|
||||||
|
form_data = self._extract_form_data(form)
|
||||||
|
is_refueling_form = any(btn.get("name") == "f_ulozitkm" for btn in form_data["buttons"])
|
||||||
|
|
||||||
|
# Match with DataFrame
|
||||||
|
matching_rows = df[df["Datum"] == clean_date]
|
||||||
|
if len(matching_rows) > 0:
|
||||||
|
# Row exists in our data - update it
|
||||||
|
row_data = matching_rows.iloc[0]
|
||||||
|
update = self._prepare_update(form_data, row_data, update_mode=True, is_refueling=is_refueling_form)
|
||||||
|
updates.append(update)
|
||||||
|
form_type = "refueling" if is_refueling_form else "journey"
|
||||||
|
logger.info(f"Matched {form_type} row {i} with date {clean_date}")
|
||||||
|
else:
|
||||||
|
# Row exists on website but not in our data - delete it
|
||||||
|
delete = self._prepare_update(form_data, None, update_mode=False, is_refueling=is_refueling_form)
|
||||||
|
deletes.append(delete)
|
||||||
|
logger.info(f"Will delete row {i} with date {clean_date}")
|
||||||
|
else:
|
||||||
|
logger.debug(f"Skipping row {i} (no date pattern)")
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
logger.info("DRY RUN MODE - No data will be submitted")
|
||||||
|
return {
|
||||||
|
"dry_run": True,
|
||||||
|
"month": month,
|
||||||
|
"updates_prepared": len(updates),
|
||||||
|
"deletes_prepared": len(deletes),
|
||||||
|
"updates": updates[:5], # Show first 5 as sample
|
||||||
|
"deletes": deletes[:5]
|
||||||
|
}
|
||||||
|
|
||||||
|
# Actually submit the data
|
||||||
|
results = {
|
||||||
|
"month": month,
|
||||||
|
"updates_total": len(updates),
|
||||||
|
"deletes_total": len(deletes),
|
||||||
|
"updates_successful": 0,
|
||||||
|
"updates_failed": 0,
|
||||||
|
"deletes_successful": 0,
|
||||||
|
"deletes_failed": 0,
|
||||||
|
"errors": []
|
||||||
|
}
|
||||||
|
|
||||||
|
# First, submit all updates
|
||||||
|
for i, update in enumerate(updates):
|
||||||
|
try:
|
||||||
|
logger.info(f"Updating row {i+1}/{len(updates)}")
|
||||||
|
self._submit_form(update)
|
||||||
|
results["updates_successful"] += 1
|
||||||
|
time.sleep(0.5) # Rate limiting
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to update row {i+1}: {e}")
|
||||||
|
results["updates_failed"] += 1
|
||||||
|
results["errors"].append({"type": "update", "row": i+1, "error": str(e)})
|
||||||
|
|
||||||
|
# Then, submit all deletes
|
||||||
|
for i, delete in enumerate(deletes):
|
||||||
|
try:
|
||||||
|
logger.info(f"Deleting row {i+1}/{len(deletes)}")
|
||||||
|
self._submit_form(delete)
|
||||||
|
results["deletes_successful"] += 1
|
||||||
|
time.sleep(0.5) # Rate limiting
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to delete row {i+1}: {e}")
|
||||||
|
results["deletes_failed"] += 1
|
||||||
|
results["errors"].append({"type": "delete", "row": i+1, "error": str(e)})
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
def _extract_form_data(self, form) -> Dict[str, Any]:
|
||||||
|
"""Extract all form fields and their current values"""
|
||||||
|
form_data = {
|
||||||
|
"action": form.get('action', ''),
|
||||||
|
"method": form.get('method', 'post'),
|
||||||
|
"fields": {},
|
||||||
|
"buttons": []
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get all input fields
|
||||||
|
for input_field in form.find_all('input'):
|
||||||
|
name = input_field.get('name', '')
|
||||||
|
value = input_field.get('value', '')
|
||||||
|
field_type = input_field.get('type', 'text')
|
||||||
|
|
||||||
|
if name:
|
||||||
|
form_data["fields"][name] = {
|
||||||
|
"value": value,
|
||||||
|
"type": field_type
|
||||||
|
}
|
||||||
|
|
||||||
|
# Track input buttons
|
||||||
|
if field_type in ['submit', 'button']:
|
||||||
|
form_data["buttons"].append({
|
||||||
|
"name": name,
|
||||||
|
"value": value,
|
||||||
|
"type": field_type
|
||||||
|
})
|
||||||
|
|
||||||
|
# CRITICAL: Also get <button> elements (not just <input type="submit">)
|
||||||
|
for button in form.find_all('button'):
|
||||||
|
name = button.get('name', '')
|
||||||
|
value = button.get('value', button.get_text(strip=True))
|
||||||
|
btn_type = button.get('type', 'submit')
|
||||||
|
|
||||||
|
if name:
|
||||||
|
form_data["buttons"].append({
|
||||||
|
"name": name,
|
||||||
|
"value": value,
|
||||||
|
"type": btn_type
|
||||||
|
})
|
||||||
|
|
||||||
|
return form_data
|
||||||
|
|
||||||
|
def _prepare_update(self, form_data: Dict, row_data: pd.Series, update_mode: bool = True, is_refueling: bool = False) -> Dict[str, Any]:
|
||||||
|
"""Prepare form data with updated values from DataFrame or for deletion
|
||||||
|
|
||||||
|
Args:
|
||||||
|
form_data: Extracted form data
|
||||||
|
row_data: DataFrame row with journey data (None for delete)
|
||||||
|
update_mode: True to update row, False to delete row
|
||||||
|
is_refueling: True if this is a refueling form, False if journey form
|
||||||
|
"""
|
||||||
|
update = {
|
||||||
|
"action": form_data["action"],
|
||||||
|
"method": form_data["method"],
|
||||||
|
"data": {},
|
||||||
|
"buttons": form_data.get("buttons", [])
|
||||||
|
}
|
||||||
|
|
||||||
|
# Copy all existing fields
|
||||||
|
for field_name, field_info in form_data["fields"].items():
|
||||||
|
update["data"][field_name] = field_info["value"]
|
||||||
|
|
||||||
|
if not update_mode:
|
||||||
|
# Delete mode - find and add "Smazat" (Delete) button
|
||||||
|
for button in update["buttons"]:
|
||||||
|
button_value = button.get("value", "")
|
||||||
|
if "Smazat" in button_value or "smazat" in button.get("name", "").lower():
|
||||||
|
if button.get("name"):
|
||||||
|
update["data"][button["name"]] = button["value"]
|
||||||
|
logger.info(f"Adding DELETE button: {button['name']}={button['value']}")
|
||||||
|
break
|
||||||
|
return update
|
||||||
|
|
||||||
|
# Update mode - handle refueling forms vs journey forms differently
|
||||||
|
if is_refueling:
|
||||||
|
# Refueling form - fill the km value from Tankováno při column
|
||||||
|
# We need to determine if this is refueling 1 or 2 for this date
|
||||||
|
# The form should have an f_km field that needs to be filled
|
||||||
|
|
||||||
|
# Check if this date has refueling data in the DataFrame
|
||||||
|
if "Tankováno při 1 [km]" in row_data and pd.notna(row_data["Tankováno při 1 [km]"]):
|
||||||
|
if "f_km" in update["data"]:
|
||||||
|
# Check if this is the first or second refueling form
|
||||||
|
current_km = update["data"].get("f_km", "0")
|
||||||
|
refuel_1_km = int(row_data["Tankováno při 1 [km]"])
|
||||||
|
|
||||||
|
# If f_km is 0 or empty, fill with refuel 1
|
||||||
|
if current_km == "0" or current_km == "":
|
||||||
|
update["data"]["f_km"] = str(refuel_1_km)
|
||||||
|
logger.info(f"Filling refuel km: f_km={refuel_1_km}")
|
||||||
|
# Otherwise check if there's a second refueling
|
||||||
|
elif "Tankováno při 2 [km]" in row_data and pd.notna(row_data["Tankováno při 2 [km]"]):
|
||||||
|
refuel_2_km = int(row_data["Tankováno při 2 [km]"])
|
||||||
|
if int(current_km) != refuel_1_km:
|
||||||
|
# This might be the second refueling form
|
||||||
|
update["data"]["f_km"] = str(refuel_2_km)
|
||||||
|
logger.info(f"Filling refuel 2 km: f_km={refuel_2_km}")
|
||||||
|
else:
|
||||||
|
# Journey form - fill with data from DataFrame
|
||||||
|
# ONLY update f_ujeto (distance traveled)
|
||||||
|
# Let kj.colsys.cz calculate f_cil_km (end km) automatically
|
||||||
|
if "Ujeto [km]" in row_data and pd.notna(row_data["Ujeto [km]"]):
|
||||||
|
if "f_ujeto" in update["data"]:
|
||||||
|
update["data"]["f_ujeto"] = str(int(row_data["Ujeto [km]"]))
|
||||||
|
|
||||||
|
# Add button click - look for "Uložit km" or "Přepočítat" buttons
|
||||||
|
# Exclude "Uzavřít měsíc" button
|
||||||
|
button_added = False
|
||||||
|
for button in update["buttons"]:
|
||||||
|
button_value = button.get("value", "")
|
||||||
|
button_name = button.get("name", "")
|
||||||
|
if "Uložit" in button_value or "Přepočítat" in button_value or "ulozit" in button_name.lower():
|
||||||
|
# Include the button in the POST data to trigger its action
|
||||||
|
if button_name:
|
||||||
|
update["data"][button_name] = button_value
|
||||||
|
logger.info(f"Adding button to POST: {button_name}={button_value}")
|
||||||
|
button_added = True
|
||||||
|
break
|
||||||
|
|
||||||
|
if not button_added:
|
||||||
|
logger.warning(f"No save button found! Available buttons: {update['buttons']}")
|
||||||
|
|
||||||
|
return update
|
||||||
|
|
||||||
|
def _submit_form(self, update: Dict[str, Any]):
|
||||||
|
"""Submit a form update"""
|
||||||
|
url = update["action"] if update["action"].startswith('http') else f"https://kj.colsys.cz/{update['action']}"
|
||||||
|
|
||||||
|
response = self.session.post(
|
||||||
|
url,
|
||||||
|
data=update["data"],
|
||||||
|
allow_redirects=False
|
||||||
|
)
|
||||||
|
|
||||||
|
response.raise_for_status()
|
||||||
|
logger.info(f"Form submitted successfully: {response.status_code}")
|
||||||
3
backend/models/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from .journey import Journey, JourneyEntry, RefuelingEntry
|
||||||
|
|
||||||
|
__all__ = ["Journey", "JourneyEntry", "RefuelingEntry"]
|
||||||
37
backend/models/journey.py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class JourneyEntry(BaseModel):
|
||||||
|
date: str
|
||||||
|
start_km: Optional[int] = None
|
||||||
|
end_km: Optional[int] = None
|
||||||
|
distance_km: Optional[int] = None
|
||||||
|
is_sick_day: bool = False
|
||||||
|
is_vacation: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class RefuelingEntry(BaseModel):
|
||||||
|
date: str
|
||||||
|
amount_liters: float
|
||||||
|
km_at_refuel: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
class Journey(BaseModel):
|
||||||
|
month: str = Field(..., pattern=r"^\d{4}-\d{2}$")
|
||||||
|
start_km: int = Field(..., gt=0)
|
||||||
|
end_km: int = Field(..., gt=0)
|
||||||
|
entries: list[JourneyEntry] = []
|
||||||
|
refueling_entries: list[RefuelingEntry] = []
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
json_schema_extra = {
|
||||||
|
"example": {
|
||||||
|
"month": "2024-03",
|
||||||
|
"start_km": 12000,
|
||||||
|
"end_km": 13500,
|
||||||
|
"entries": [],
|
||||||
|
"refueling_entries": []
|
||||||
|
}
|
||||||
|
}
|
||||||
15
backend/requirements.txt
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
fastapi==0.109.0
|
||||||
|
uvicorn[standard]==0.27.0
|
||||||
|
pydantic==2.5.3
|
||||||
|
pydantic-settings==2.1.0
|
||||||
|
sqlalchemy==2.0.25
|
||||||
|
alembic==1.13.1
|
||||||
|
pandas==2.2.0
|
||||||
|
numpy==1.26.3
|
||||||
|
requests==2.31.0
|
||||||
|
beautifulsoup4==4.12.3
|
||||||
|
playwright==1.41.0
|
||||||
|
python-dotenv==1.0.0
|
||||||
|
python-multipart==0.0.6
|
||||||
|
openpyxl==3.1.2
|
||||||
|
python-dateutil==2.8.2
|
||||||
4
backend/scrapers/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
from .attendance_scraper import AttendanceScraper
|
||||||
|
from .journeybook_scraper import JourneybookScraper
|
||||||
|
|
||||||
|
__all__ = ["AttendanceScraper", "JourneybookScraper"]
|
||||||
45
backend/scrapers/attendance_scraper.py
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import re
|
||||||
|
import requests
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
|
||||||
|
class AttendanceScraper:
|
||||||
|
def __init__(self, username: str, password: str):
|
||||||
|
self.username = username
|
||||||
|
self.password = password
|
||||||
|
self.base_url = "https://agenda.colsys.cz/dochazka/index.php"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def normalize_date(date_str: str) -> str:
|
||||||
|
return re.sub(r'\s+', '', date_str)
|
||||||
|
|
||||||
|
def scrape_month(self, month: str) -> List[str]:
|
||||||
|
"""
|
||||||
|
Scrape attendance data for a given month.
|
||||||
|
Returns list of dates with sick days, vacation, or unpaid leave.
|
||||||
|
"""
|
||||||
|
url = f"{self.base_url}?kdy={month}-01"
|
||||||
|
|
||||||
|
response = requests.get(url, auth=(self.username, self.password), verify=False)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
soup = BeautifulSoup(response.content, 'html.parser')
|
||||||
|
table = soup.find('table', class_='restrikce')
|
||||||
|
|
||||||
|
if not table:
|
||||||
|
raise ValueError("Attendance table not found")
|
||||||
|
|
||||||
|
attendance_dates = []
|
||||||
|
for row in table.find_all('tr')[1:]:
|
||||||
|
cells = row.find_all('td')
|
||||||
|
if len(cells) >= 3:
|
||||||
|
date = cells[0].text.strip()
|
||||||
|
presence = cells[2].text.strip()
|
||||||
|
|
||||||
|
if ("sick day" in presence.lower() or
|
||||||
|
"dovolená" in presence.lower() or
|
||||||
|
"neplacené volno" in presence.lower()):
|
||||||
|
attendance_dates.append(self.normalize_date(date))
|
||||||
|
|
||||||
|
return attendance_dates
|
||||||
73
backend/scrapers/journeybook_scraper.py
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import re
|
||||||
|
import requests
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
import pandas as pd
|
||||||
|
from typing import Dict, Any
|
||||||
|
|
||||||
|
|
||||||
|
class JourneybookScraper:
|
||||||
|
def __init__(self, username: str, password: str, vehicle_registration: str = "4SH1148"):
|
||||||
|
self.username = username
|
||||||
|
self.password = password
|
||||||
|
self.vehicle_registration = vehicle_registration
|
||||||
|
self.base_url = "https://kj.colsys.cz/prehled_mesic.php"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def normalize_date(date_str: str) -> str:
|
||||||
|
return re.sub(r'\s+', '', date_str)
|
||||||
|
|
||||||
|
def scrape_month(self, month: str) -> pd.DataFrame:
|
||||||
|
"""
|
||||||
|
Scrape journeybook data for a given month.
|
||||||
|
Returns DataFrame with columns: Datum, Počáteční stav, Koncový stav, Ujeto [km], Natankováno [l|kg]
|
||||||
|
"""
|
||||||
|
url = f"{self.base_url}?rz={self.vehicle_registration}&den={month}-01"
|
||||||
|
|
||||||
|
response = requests.get(url, auth=(self.username, self.password), verify=False)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
soup = BeautifulSoup(response.content, 'html.parser')
|
||||||
|
table = soup.find('table', class_='table table-striped table-bordered table-condensed table-sm')
|
||||||
|
|
||||||
|
if not table:
|
||||||
|
raise ValueError("Journeybook table not found")
|
||||||
|
|
||||||
|
headers = [th.text.strip() for th in table.find('thead').find_all('th')]
|
||||||
|
headers = [header.replace(" ", "") for header in headers]
|
||||||
|
|
||||||
|
columns_to_keep = ["Datum", "Počátečnístav", "Koncovýstav", "Ujeto[km]"]
|
||||||
|
new_headers = ["Datum", "Počáteční stav", "Koncový stav", "Ujeto [km]", "Natankováno [l|kg]"]
|
||||||
|
|
||||||
|
for col in columns_to_keep:
|
||||||
|
if col not in headers:
|
||||||
|
raise ValueError(f"Column '{col}' not found. Headers: {headers}")
|
||||||
|
|
||||||
|
rows = []
|
||||||
|
for row in table.find('tbody').find_all('tr'):
|
||||||
|
if "Tankováno" in row.text:
|
||||||
|
refuel_text = row.text.strip()
|
||||||
|
amount_match = re.search(r'natankováno\s(\d+\.\d+)\s\[l\|kg\]', refuel_text)
|
||||||
|
amount = amount_match.group(1) if amount_match else ""
|
||||||
|
rows.append([""] * len(columns_to_keep) + [amount])
|
||||||
|
elif row.find('form'):
|
||||||
|
cells = []
|
||||||
|
for cell in row.find_all('td'):
|
||||||
|
input_field = cell.find('input')
|
||||||
|
if input_field:
|
||||||
|
cells.append(input_field.get('value', ''))
|
||||||
|
else:
|
||||||
|
if headers[len(cells)] == "Datum":
|
||||||
|
date_match = re.search(r'\d{1,2}\.\s\d{1,2}\.\s\d{4}', cell.text.strip())
|
||||||
|
if date_match:
|
||||||
|
cells.append(self.normalize_date(date_match.group()))
|
||||||
|
else:
|
||||||
|
cells.append(cell.text.strip())
|
||||||
|
else:
|
||||||
|
cells.append(cell.text.strip())
|
||||||
|
|
||||||
|
filtered_cells = [cells[headers.index(col)] for col in columns_to_keep]
|
||||||
|
filtered_cells.append("")
|
||||||
|
rows.append(filtered_cells)
|
||||||
|
|
||||||
|
df = pd.DataFrame(rows, columns=new_headers)
|
||||||
|
return df
|
||||||
34
docker-compose.yml
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: ./backend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: kniha-jizd-backend
|
||||||
|
ports:
|
||||||
|
- "0.0.0.0:8002:8002"
|
||||||
|
environment:
|
||||||
|
- PYTHONUNBUFFERED=1
|
||||||
|
env_file:
|
||||||
|
- ./backend/.env
|
||||||
|
volumes:
|
||||||
|
- ./backend:/app
|
||||||
|
command: uvicorn api.main:app --host 0.0.0.0 --port 8002 --reload
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: ./frontend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: kniha-jizd-frontend
|
||||||
|
ports:
|
||||||
|
- "0.0.0.0:3000:3000"
|
||||||
|
environment:
|
||||||
|
- NEXT_PUBLIC_API_URL=http://100.110.142.68:8002
|
||||||
|
volumes:
|
||||||
|
- ./frontend:/app
|
||||||
|
- /app/node_modules
|
||||||
|
- /app/.next
|
||||||
|
command: npm run dev
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
1
frontend/.env.local
Normal file
@@ -0,0 +1 @@
|
|||||||
|
NEXT_PUBLIC_API_URL=http://100.110.142.68:8002
|
||||||
12
frontend/Dockerfile
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
FROM node:20-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
CMD ["npm", "run", "dev"]
|
||||||
215
frontend/app/components/DataPreview.tsx
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import API_URL from '@/lib/api'
|
||||||
|
|
||||||
|
interface DataPreviewProps {
|
||||||
|
data: any
|
||||||
|
loading: boolean
|
||||||
|
formData: any
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DataPreview({ data, loading, formData }: DataPreviewProps) {
|
||||||
|
const [filling, setFilling] = useState(false)
|
||||||
|
const [fillResult, setFillResult] = useState<any>(null)
|
||||||
|
|
||||||
|
const handleFillToWebsite = async () => {
|
||||||
|
if (!formData) return
|
||||||
|
|
||||||
|
setFilling(true)
|
||||||
|
setFillResult(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_URL}/api/fill/journeybook`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
username: formData.username,
|
||||||
|
password: formData.password,
|
||||||
|
start_date: formData.startDate,
|
||||||
|
end_date: formData.endDate,
|
||||||
|
start_km: parseInt(formData.startKm),
|
||||||
|
end_km: parseInt(formData.endKm),
|
||||||
|
vehicle_registration: formData.vehicleRegistration,
|
||||||
|
variance: parseFloat(formData.variance),
|
||||||
|
dry_run: false
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json()
|
||||||
|
throw new Error(errorData.detail || 'Chyba při vyplňování dat')
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json()
|
||||||
|
setFillResult(result)
|
||||||
|
} catch (err: any) {
|
||||||
|
alert('Chyba: ' + err.message)
|
||||||
|
} finally {
|
||||||
|
setFilling(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white/98 backdrop-blur-lg rounded-3xl shadow-2xl border border-white/30 overflow-hidden">
|
||||||
|
<div className="flex flex-col items-center justify-center h-96 p-8">
|
||||||
|
<div className="relative">
|
||||||
|
<div className="animate-spin rounded-full h-20 w-20 border-4 border-blue-200"></div>
|
||||||
|
<div className="animate-spin rounded-full h-20 w-20 border-t-4 border-blue-600 absolute top-0"></div>
|
||||||
|
</div>
|
||||||
|
<p className="mt-6 text-gray-700 font-semibold text-lg">Načítání dat...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white/98 backdrop-blur-lg rounded-3xl shadow-2xl border border-white/30 overflow-hidden">
|
||||||
|
<div className="bg-gradient-to-r from-purple-50 to-purple-100 px-8 py-6 border-b border-purple-200">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-12 h-12 bg-gradient-to-br from-purple-500 to-purple-600 rounded-xl flex items-center justify-center shadow-lg">
|
||||||
|
<svg className="w-7 h-7 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-3xl font-bold text-gray-800">Náhled dat</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-8">
|
||||||
|
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||||
|
<div className="w-20 h-20 bg-gray-100 rounded-full flex items-center justify-center mb-4">
|
||||||
|
<svg className="w-10 h-10 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p className="text-gray-500 font-medium">
|
||||||
|
Vyplňte formulář a klikněte na "Vypočítat"
|
||||||
|
</p>
|
||||||
|
<p className="text-gray-400 text-sm mt-1">
|
||||||
|
Data se zobrazí zde
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white/98 backdrop-blur-lg rounded-3xl shadow-2xl border border-white/30 overflow-hidden">
|
||||||
|
<div className="bg-gradient-to-r from-purple-50 to-purple-100 px-8 py-6 border-b border-purple-200">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-12 h-12 bg-gradient-to-br from-purple-500 to-purple-600 rounded-xl flex items-center justify-center shadow-lg">
|
||||||
|
<svg className="w-7 h-7 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-3xl font-bold text-gray-800">Náhled dat</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-8">
|
||||||
|
|
||||||
|
<div className="mb-6 grid grid-cols-2 gap-3 bg-gradient-to-br from-blue-50 to-indigo-50 p-5 rounded-xl border border-blue-100">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-600">Měsíc</p>
|
||||||
|
<p className="font-semibold text-lg">{data.month}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-600">Celkem záznamů</p>
|
||||||
|
<p className="font-semibold text-lg">{data.total_entries}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-600">Počáteční km</p>
|
||||||
|
<p className="font-semibold text-lg">{data.start_km.toLocaleString()}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-600">Koncový km</p>
|
||||||
|
<p className="font-semibold text-lg">{data.end_km.toLocaleString()}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-600">Celkem ujeto</p>
|
||||||
|
<p className="font-semibold text-lg">{(data.end_km - data.start_km).toLocaleString()} km</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-600">Filtrováno dnů</p>
|
||||||
|
<p className="font-semibold text-lg">{data.filtered_days}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-auto max-h-96 rounded-xl border border-gray-200">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-gradient-to-r from-gray-50 to-gray-100 sticky top-0">
|
||||||
|
<tr>
|
||||||
|
{data.entries.length > 0 && Object.keys(data.entries[0]).map((key: string) => (
|
||||||
|
<th key={key} className="px-4 py-3 text-left font-bold text-gray-700 border-b-2 border-gray-300 whitespace-nowrap">
|
||||||
|
{key}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-100 bg-white">
|
||||||
|
{data.entries.map((entry: any, index: number) => (
|
||||||
|
<tr key={index} className="hover:bg-blue-50 transition-colors">
|
||||||
|
{Object.keys(entry).map((key: string) => (
|
||||||
|
<td key={key} className="px-4 py-3 text-right text-gray-600">
|
||||||
|
{entry[key] !== null && entry[key] !== undefined ? entry[key] : '-'}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{formData && formData.startDate === '2025-01-01' && (
|
||||||
|
<div className="mt-6">
|
||||||
|
<button
|
||||||
|
onClick={handleFillToWebsite}
|
||||||
|
disabled={filling}
|
||||||
|
className="w-full bg-gradient-to-r from-orange-600 to-orange-700 hover:from-orange-700 hover:to-orange-800 text-white font-bold py-4 px-6 rounded-xl shadow-lg hover:shadow-2xl transform hover:-translate-y-1 transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<span className="flex items-center justify-center gap-3">
|
||||||
|
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
|
||||||
|
</svg>
|
||||||
|
<span className="text-lg">{filling ? 'Vyplňování...' : 'Vyplnit na web'}</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{fillResult && (
|
||||||
|
<div className={`mt-4 ${fillResult.dry_run ? 'bg-blue-50 border-blue-200' : 'bg-green-50 border-green-200'} border rounded-xl p-4`}>
|
||||||
|
<h3 className={`font-bold ${fillResult.dry_run ? 'text-blue-900' : 'text-green-900'} mb-2`}>
|
||||||
|
{fillResult.dry_run ? 'Výsledek DRY RUN:' : 'Výsledek vyplňování:'}
|
||||||
|
</h3>
|
||||||
|
<div className={`text-sm ${fillResult.dry_run ? 'text-blue-800' : 'text-green-800'}`}>
|
||||||
|
<p>Měsíc: {fillResult.month}</p>
|
||||||
|
<p>Celkem řádků: {fillResult.total_rows}</p>
|
||||||
|
{fillResult.dry_run ? (
|
||||||
|
<p>Připraveno aktualizací: {fillResult.updates_prepared}</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p>Úspěšně vyplněno: {fillResult.successful}</p>
|
||||||
|
<p>Chyby: {fillResult.failed}</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{fillResult.dry_run && (
|
||||||
|
<p className="mt-2 font-semibold text-orange-700">
|
||||||
|
⚠️ DRY RUN MODE - Data nebyla odeslána na web
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{!fillResult.dry_run && fillResult.successful > 0 && (
|
||||||
|
<p className="mt-2 font-semibold text-green-700">
|
||||||
|
✅ Data byla úspěšně vyplněna. Nyní zkontrolujte na webu a klikněte "Uzavřít měsíc" ručně.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
269
frontend/app/components/JourneyForm.tsx
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import API_URL from '@/lib/api'
|
||||||
|
|
||||||
|
interface JourneyFormProps {
|
||||||
|
onDataCalculated: (data: any) => void
|
||||||
|
setLoading: (loading: boolean) => void
|
||||||
|
onFormDataChange: (formData: any) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function JourneyForm({ onDataCalculated, setLoading, onFormDataChange }: JourneyFormProps) {
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
startDate: new Date().toISOString().slice(0, 10),
|
||||||
|
endDate: new Date().toISOString().slice(0, 10),
|
||||||
|
startKm: '',
|
||||||
|
endKm: '',
|
||||||
|
vehicleRegistration: '4SH1148',
|
||||||
|
variance: '0.1'
|
||||||
|
})
|
||||||
|
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setError('')
|
||||||
|
setLoading(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_URL}/api/calculate`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
username: formData.username,
|
||||||
|
password: formData.password,
|
||||||
|
start_date: formData.startDate,
|
||||||
|
end_date: formData.endDate,
|
||||||
|
start_km: parseInt(formData.startKm),
|
||||||
|
end_km: parseInt(formData.endKm),
|
||||||
|
vehicle_registration: formData.vehicleRegistration,
|
||||||
|
variance: parseFloat(formData.variance)
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json()
|
||||||
|
throw new Error(errorData.detail || 'Chyba při zpracování dat')
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
onDataCalculated(data)
|
||||||
|
onFormDataChange(formData)
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message)
|
||||||
|
onDataCalculated(null)
|
||||||
|
onFormDataChange(null)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleExport = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_URL}/api/export/excel`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
username: formData.username,
|
||||||
|
password: formData.password,
|
||||||
|
start_date: formData.startDate,
|
||||||
|
end_date: formData.endDate,
|
||||||
|
start_km: parseInt(formData.startKm),
|
||||||
|
end_km: parseInt(formData.endKm),
|
||||||
|
vehicle_registration: formData.vehicleRegistration,
|
||||||
|
variance: parseFloat(formData.variance)
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error('Export failed')
|
||||||
|
|
||||||
|
const blob = await response.blob()
|
||||||
|
const url = window.URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = `journeybook_${formData.startDate}_${formData.endDate}.xlsx`
|
||||||
|
a.click()
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white/98 backdrop-blur-lg rounded-3xl shadow-2xl overflow-hidden border border-white/30">
|
||||||
|
<div className="bg-gradient-to-r from-blue-50 to-blue-100 px-8 py-6 border-b border-blue-200">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-12 h-12 bg-gradient-to-br from-blue-500 to-blue-600 rounded-xl flex items-center justify-center shadow-lg">
|
||||||
|
<svg className="w-7 h-7 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-3xl font-bold text-gray-800">Vstupní údaje</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-8">
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-5">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Uživatelské jméno
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={formData.username}
|
||||||
|
onChange={(e) => setFormData({ ...formData, username: e.target.value })}
|
||||||
|
className="w-full px-4 py-3 bg-gray-50 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
|
||||||
|
placeholder="Zadejte uživatelské jméno"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Heslo
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
value={formData.password}
|
||||||
|
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||||||
|
className="w-full px-4 py-3 bg-gray-50 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
|
||||||
|
placeholder="Zadejte heslo"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Datum od
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
required
|
||||||
|
value={formData.startDate}
|
||||||
|
onChange={(e) => setFormData({ ...formData, startDate: e.target.value })}
|
||||||
|
className="w-full px-4 py-3 bg-gray-50 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Datum do
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
required
|
||||||
|
value={formData.endDate}
|
||||||
|
onChange={(e) => setFormData({ ...formData, endDate: e.target.value })}
|
||||||
|
className="w-full px-4 py-3 bg-gray-50 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Počáteční stav [km]
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
required
|
||||||
|
value={formData.startKm}
|
||||||
|
onChange={(e) => setFormData({ ...formData, startKm: e.target.value })}
|
||||||
|
className="w-full px-4 py-3 bg-gray-50 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Koncový stav [km]
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
required
|
||||||
|
value={formData.endKm}
|
||||||
|
onChange={(e) => setFormData({ ...formData, endKm: e.target.value })}
|
||||||
|
className="w-full px-4 py-3 bg-gray-50 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
SPZ vozidla
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.vehicleRegistration}
|
||||||
|
onChange={(e) => setFormData({ ...formData, vehicleRegistration: e.target.value })}
|
||||||
|
className="w-full px-4 py-3 bg-gray-50 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Variance (0-1)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
max="1"
|
||||||
|
value={formData.variance}
|
||||||
|
onChange={(e) => setFormData({ ...formData, variance: e.target.value })}
|
||||||
|
className="w-full px-4 py-3 bg-gray-50 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
Náhodná variace rozdělení kilometrů (doporučeno 0.1 = 10%)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-50 border-l-4 border-red-500 text-red-700 px-4 py-3 rounded-lg shadow-md flex items-start gap-3">
|
||||||
|
<svg className="w-5 h-5 flex-shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
<span>{error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-col sm:flex-row gap-4 pt-6">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="flex-1 bg-gradient-to-r from-blue-600 to-blue-700 hover:from-blue-700 hover:to-blue-800 text-white font-bold py-4 px-6 rounded-xl shadow-lg hover:shadow-2xl transform hover:-translate-y-1 transition-all duration-200"
|
||||||
|
>
|
||||||
|
<span className="flex items-center justify-center gap-3">
|
||||||
|
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 7h6m0 10v-3m-3 3h.01M9 17h.01M9 14h.01M12 14h.01M15 11h.01M12 11h.01M9 11h.01M7 21h10a2 2 0 002-2V5a2 2 0 00-2-2H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
<span className="text-lg">Vypočítat</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleExport}
|
||||||
|
className="flex-1 bg-gradient-to-r from-green-600 to-green-700 hover:from-green-700 hover:to-green-800 text-white font-bold py-4 px-6 rounded-xl shadow-lg hover:shadow-2xl transform hover:-translate-y-1 transition-all duration-200"
|
||||||
|
>
|
||||||
|
<span className="flex items-center justify-center gap-3">
|
||||||
|
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||||
|
</svg>
|
||||||
|
<span className="text-lg">Export Excel</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
20
frontend/app/globals.css
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
--color-primary: #2563eb;
|
||||||
|
--color-primary-dark: #1d4ed8;
|
||||||
|
--radius-card: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
min-height: 100vh;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
19
frontend/app/layout.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import type { Metadata } from 'next'
|
||||||
|
import './globals.css'
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'Kniha Jízd',
|
||||||
|
description: 'Journey Book Management System',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<html lang="cs">
|
||||||
|
<body>{children}</body>
|
||||||
|
</html>
|
||||||
|
)
|
||||||
|
}
|
||||||
40
frontend/app/page.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import JourneyForm from './components/JourneyForm'
|
||||||
|
import DataPreview from './components/DataPreview'
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
const [calculatedData, setCalculatedData] = useState(null)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [formData, setFormData] = useState(null)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen p-4 md:p-8 flex flex-col items-center justify-center">
|
||||||
|
<div className="max-w-7xl w-full my-auto">
|
||||||
|
<div className="text-center mb-10">
|
||||||
|
<div className="inline-flex items-center justify-center w-20 h-20 bg-white rounded-full shadow-2xl mb-6">
|
||||||
|
<svg className="w-10 h-10 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-5xl md:text-6xl font-bold text-white mb-4 drop-shadow-2xl">
|
||||||
|
Kniha Jízd
|
||||||
|
</h1>
|
||||||
|
<p className="text-xl md:text-2xl text-white/95 font-medium drop-shadow-lg">
|
||||||
|
Automatizovaný systém pro správu knihy jízd
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
|
||||||
|
<JourneyForm
|
||||||
|
onDataCalculated={setCalculatedData}
|
||||||
|
setLoading={setLoading}
|
||||||
|
onFormDataChange={setFormData}
|
||||||
|
/>
|
||||||
|
<DataPreview data={calculatedData} loading={loading} formData={formData} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
22
frontend/lib/api.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000'
|
||||||
|
|
||||||
|
export const apiClient = {
|
||||||
|
async post(endpoint: string, data: any) {
|
||||||
|
const response = await fetch(`${API_URL}${endpoint}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json()
|
||||||
|
throw new Error(errorData.detail || 'Request failed')
|
||||||
|
}
|
||||||
|
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default API_URL
|
||||||
6
frontend/next-env.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
/// <reference types="next" />
|
||||||
|
/// <reference types="next/image-types/global" />
|
||||||
|
/// <reference path="./.next/types/routes.d.ts" />
|
||||||
|
|
||||||
|
// NOTE: This file should not be edited
|
||||||
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
8
frontend/next.config.js
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {
|
||||||
|
experimental: {
|
||||||
|
allowedDevOrigins: ['http://100.110.142.68:3000']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = nextConfig
|
||||||
1764
frontend/package-lock.json
generated
Normal file
27
frontend/package.json
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"name": "frontend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"main": "index.js",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "next lint"
|
||||||
|
},
|
||||||
|
"keywords": [],
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"description": "",
|
||||||
|
"dependencies": {
|
||||||
|
"@tailwindcss/postcss": "^4.1.14",
|
||||||
|
"@types/node": "^24.7.1",
|
||||||
|
"@types/react": "^19.2.2",
|
||||||
|
"autoprefixer": "^10.4.21",
|
||||||
|
"next": "^15.5.4",
|
||||||
|
"postcss": "^8.5.6",
|
||||||
|
"react": "^19.2.0",
|
||||||
|
"react-dom": "^19.2.0",
|
||||||
|
"tailwindcss": "^4.1.14",
|
||||||
|
"typescript": "^5.9.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
5
frontend/postcss.config.js
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
'@tailwindcss/postcss': {},
|
||||||
|
},
|
||||||
|
}
|
||||||
12
frontend/tailwind.config.js
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
module.exports = {
|
||||||
|
content: [
|
||||||
|
'./pages/**/*.{js,ts,jsx,tsx,mdx}',
|
||||||
|
'./components/**/*.{js,ts,jsx,tsx,mdx}',
|
||||||
|
'./app/**/*.{js,ts,jsx,tsx,mdx}',
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
27
frontend/tsconfig.json
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2017",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
BIN
screenshot-final.png
Normal file
|
After Width: | Height: | Size: 180 KiB |
35
start-local.sh
Executable file
@@ -0,0 +1,35 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
echo "Starting Kniha Jízd Application (Local Mode)..."
|
||||||
|
|
||||||
|
if [ ! -f backend/.env ]; then
|
||||||
|
echo "Creating .env file from example..."
|
||||||
|
cp backend/.env.example backend/.env
|
||||||
|
echo "⚠️ Please edit backend/.env with your credentials!"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Set environment variable for local development
|
||||||
|
export NEXT_PUBLIC_API_URL="http://100.110.142.68:8002"
|
||||||
|
|
||||||
|
echo "Starting Backend on port 8002..."
|
||||||
|
cd backend
|
||||||
|
python -m uvicorn api.main:app --reload --host 0.0.0.0 --port 8002 &
|
||||||
|
BACKEND_PID=$!
|
||||||
|
|
||||||
|
echo "Starting Frontend on port 3000..."
|
||||||
|
cd ../frontend
|
||||||
|
npm run dev &
|
||||||
|
FRONTEND_PID=$!
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "✅ Application started!"
|
||||||
|
echo "📊 Frontend: http://100.110.142.68:3000"
|
||||||
|
echo "🔧 Backend API: http://100.110.142.68:8002"
|
||||||
|
echo "📖 API Docs: http://100.110.142.68:8002/docs"
|
||||||
|
echo ""
|
||||||
|
echo "Accessible via Tailscale network at above URLs"
|
||||||
|
echo "Press Ctrl+C to stop..."
|
||||||
|
|
||||||
|
trap "kill $BACKEND_PID $FRONTEND_PID 2>/dev/null" EXIT
|
||||||
|
wait
|
||||||
36
start.sh
Executable file
@@ -0,0 +1,36 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
echo "Starting Kniha Jízd Application..."
|
||||||
|
|
||||||
|
if [ ! -f backend/.env ]; then
|
||||||
|
echo "Creating .env file from example..."
|
||||||
|
cp backend/.env.example backend/.env
|
||||||
|
echo "⚠️ Please edit backend/.env with your credentials!"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$1" == "docker" ]; then
|
||||||
|
echo "Starting with Docker Compose..."
|
||||||
|
docker-compose up --build
|
||||||
|
else
|
||||||
|
echo "Starting Backend (FastAPI)..."
|
||||||
|
cd backend
|
||||||
|
python -m uvicorn api.main:app --reload --host 0.0.0.0 --port 8002 &
|
||||||
|
BACKEND_PID=$!
|
||||||
|
|
||||||
|
echo "Starting Frontend (Next.js)..."
|
||||||
|
cd ../frontend
|
||||||
|
npm run dev &
|
||||||
|
FRONTEND_PID=$!
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "✅ Application started!"
|
||||||
|
echo "📊 Frontend: http://100.110.142.68:3000"
|
||||||
|
echo "🔧 Backend API: http://100.110.142.68:8002"
|
||||||
|
echo "📖 API Docs: http://100.110.142.68:8002/docs"
|
||||||
|
echo ""
|
||||||
|
echo "Press Ctrl+C to stop..."
|
||||||
|
|
||||||
|
trap "kill $BACKEND_PID $FRONTEND_PID" EXIT
|
||||||
|
wait
|
||||||
|
fi
|
||||||