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:
1
frontend/.env.local
Normal file
1
frontend/.env.local
Normal file
@@ -0,0 +1 @@
|
||||
NEXT_PUBLIC_API_URL=http://100.110.142.68:8002
|
||||
12
frontend/Dockerfile
Normal file
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
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
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
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
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
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
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
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
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
1764
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
27
frontend/package.json
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
5
frontend/postcss.config.js
Normal file
@@ -0,0 +1,5 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
},
|
||||
}
|
||||
12
frontend/tailwind.config.js
Normal file
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
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"]
|
||||
}
|
||||
Reference in New Issue
Block a user