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>
This commit is contained in:
Docker Config Backup
2025-10-10 15:41:11 +02:00
commit 3b5d9fd940
40 changed files with 3777 additions and 0 deletions

26
.gitignore vendored Normal file
View 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/

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

164
NETWORK_SETUP.md Normal file
View 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
View 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
View File

@@ -0,0 +1,4 @@
USERNAME=your_username
PASSWORD=your_password
VEHICLE_REGISTRATION=4SH1148
DATABASE_URL=sqlite:///./journeybook.db

14
backend/Dockerfile Normal file
View 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
View 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)

View 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

View File

@@ -0,0 +1,3 @@
from .journeybook_filler import JourneybookFiller
__all__ = ['JourneybookFiller']

View 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}")

View File

@@ -0,0 +1,3 @@
from .journey import Journey, JourneyEntry, RefuelingEntry
__all__ = ["Journey", "JourneyEntry", "RefuelingEntry"]

37
backend/models/journey.py Normal file
View 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
View 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

View File

@@ -0,0 +1,4 @@
from .attendance_scraper import AttendanceScraper
from .journeybook_scraper import JourneybookScraper
__all__ = ["AttendanceScraper", "JourneybookScraper"]

View 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

View 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
View 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
View File

@@ -0,0 +1 @@
NEXT_PUBLIC_API_URL=http://100.110.142.68:8002

12
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,12 @@
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
EXPOSE 3000
CMD ["npm", "run", "dev"]

View 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>
)
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

27
frontend/package.json Normal file
View 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"
}
}

View File

@@ -0,0 +1,5 @@
module.exports = {
plugins: {
'@tailwindcss/postcss': {},
},
}

View 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 KiB

35
start-local.sh Executable file
View 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
View 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