feat: založení základní stránky příjem/výdej + import a statistiky
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="cs">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#111827" />
|
||||
<meta name="description" content="Food Tracer — evidence snědených jídel a útraty za jídlo" />
|
||||
<title>Food Tracer</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>Pro běh aplikace je potřeba zapnutý JavaScript.</noscript>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/index.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"name": "@food-tracer/client",
|
||||
"version": "0.1.0",
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"homepage": ".",
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-svg-core": "^7.1.0",
|
||||
"@fortawesome/free-regular-svg-icons": "^7.1.0",
|
||||
"@fortawesome/free-solid-svg-icons": "^7.1.0",
|
||||
"@fortawesome/react-fontawesome": "^3.1.0",
|
||||
"@types/node": "^24.10.0",
|
||||
"@types/react": "^19.2.2",
|
||||
"@types/react-dom": "^19.2.2",
|
||||
"@vitejs/plugin-react": "^5.1.0",
|
||||
"bootstrap": "^5.3.8",
|
||||
"react": "^19.2.0",
|
||||
"react-bootstrap": "^2.10.10",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-jwt": "^1.3.0",
|
||||
"react-router": "^7.9.5",
|
||||
"react-router-dom": "^7.9.5",
|
||||
"react-toastify": "^11.0.5",
|
||||
"recharts": "^3.4.1",
|
||||
"sass": "^1.93.3",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.2.2",
|
||||
"vite-tsconfig-paths": "^5.1.4"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "vite",
|
||||
"build": "tsc --noEmit && vite build"
|
||||
},
|
||||
"devDependencies": {
|
||||
"prettier": "^3.6.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,723 @@
|
||||
// ============================================
|
||||
// DESIGN SYSTEM - FOOD TRACER
|
||||
// Vychází ze systému Luncheru, aby obě aplikace působily jako jedna rodina.
|
||||
// ============================================
|
||||
|
||||
:root, [data-bs-theme="light"] {
|
||||
// Primary colors
|
||||
--ft-primary: #16a34a;
|
||||
--ft-primary-hover: #15803d;
|
||||
--ft-primary-light: #dcfce7;
|
||||
|
||||
// Background colors
|
||||
--ft-bg: #f9fafb;
|
||||
--ft-bg-card: #ffffff;
|
||||
--ft-bg-hover: #f3f4f6;
|
||||
|
||||
// Text colors
|
||||
--ft-text: #111827;
|
||||
--ft-text-secondary: #4b5563;
|
||||
--ft-text-muted: #9ca3af;
|
||||
|
||||
// Border colors
|
||||
--ft-border: #e5e7eb;
|
||||
--ft-border-light: #f3f4f6;
|
||||
|
||||
// Navbar - Dark
|
||||
--ft-navbar-bg: #111827;
|
||||
--ft-navbar-text: #f9fafb;
|
||||
|
||||
// Action colors
|
||||
--ft-success: #16a34a;
|
||||
--ft-warning: #d97706;
|
||||
--ft-danger: #dc2626;
|
||||
--ft-info: #2563eb;
|
||||
|
||||
// Shadows
|
||||
--ft-shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
|
||||
--ft-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
|
||||
--ft-shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
|
||||
|
||||
// Border radius
|
||||
--ft-radius-sm: 6px;
|
||||
--ft-radius: 10px;
|
||||
--ft-radius-lg: 12px;
|
||||
--ft-radius-xl: 16px;
|
||||
|
||||
// Transitions
|
||||
--ft-transition: all 0.2s ease;
|
||||
--ft-transition-slow: all 0.3s ease;
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] {
|
||||
--ft-primary: #1db954;
|
||||
--ft-primary-hover: #1ed760;
|
||||
--ft-primary-light: #052e16;
|
||||
|
||||
--ft-bg: #121212;
|
||||
--ft-bg-card: #181818;
|
||||
--ft-bg-hover: #282828;
|
||||
|
||||
--ft-text: #ffffff;
|
||||
--ft-text-secondary: #b3b3b3;
|
||||
--ft-text-muted: #6b7280;
|
||||
|
||||
--ft-border: #282828;
|
||||
--ft-border-light: #1f1f1f;
|
||||
|
||||
--ft-navbar-bg: #000000;
|
||||
--ft-navbar-text: #ffffff;
|
||||
|
||||
--ft-success: #1db954;
|
||||
--ft-warning: #fbbf24;
|
||||
--ft-danger: #f87171;
|
||||
--ft-info: #60a5fa;
|
||||
|
||||
--ft-shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.5);
|
||||
--ft-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.5), 0 2px 4px -2px rgb(0 0 0 / 0.4);
|
||||
--ft-shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.5), 0 4px 6px -4px rgb(0 0 0 / 0.4);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// BASE
|
||||
// ============================================
|
||||
|
||||
html, body, #root {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
html {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--ft-bg);
|
||||
color: var(--ft-text);
|
||||
transition: var(--ft-transition-slow);
|
||||
}
|
||||
|
||||
.app-container {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: var(--ft-bg);
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
padding: 24px;
|
||||
flex: 1;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// NAVBAR
|
||||
// ============================================
|
||||
|
||||
.navbar {
|
||||
background: var(--ft-navbar-bg) !important;
|
||||
padding: 12px 24px;
|
||||
box-shadow: none;
|
||||
border: none;
|
||||
|
||||
.navbar-brand {
|
||||
color: var(--ft-navbar-text) !important;
|
||||
font-weight: 700;
|
||||
font-size: 1.25rem;
|
||||
letter-spacing: -0.02em;
|
||||
|
||||
&::before {
|
||||
content: '🍽';
|
||||
margin-right: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.nav {
|
||||
margin-left: auto;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.nav-link,
|
||||
.dropdown-toggle {
|
||||
color: var(--ft-navbar-text) !important;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.nav-pill {
|
||||
color: var(--ft-navbar-text);
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: var(--ft-radius-sm);
|
||||
padding: 6px 14px;
|
||||
font-weight: 500;
|
||||
font-size: 0.9rem;
|
||||
transition: var(--ft-transition);
|
||||
|
||||
&:hover {
|
||||
background: rgb(255 255 255 / 0.1);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: var(--ft-primary);
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.theme-toggle {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--ft-navbar-text);
|
||||
font-size: 1rem;
|
||||
padding: 6px 10px;
|
||||
border-radius: var(--ft-radius-sm);
|
||||
transition: var(--ft-transition);
|
||||
|
||||
&:hover {
|
||||
background: rgb(255 255 255 / 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
background: var(--ft-bg-card);
|
||||
border: 1px solid var(--ft-border);
|
||||
border-radius: var(--ft-radius);
|
||||
box-shadow: var(--ft-shadow-lg);
|
||||
padding: 6px;
|
||||
|
||||
.dropdown-item {
|
||||
color: var(--ft-text);
|
||||
border-radius: var(--ft-radius-sm);
|
||||
padding: 8px 12px;
|
||||
font-size: 0.9rem;
|
||||
|
||||
&:hover, &:focus {
|
||||
background: var(--ft-bg-hover);
|
||||
color: var(--ft-text);
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-divider {
|
||||
border-color: var(--ft-border);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// CARDS & PANELS
|
||||
// ============================================
|
||||
|
||||
.card-panel {
|
||||
background: var(--ft-bg-card);
|
||||
border: 1px solid var(--ft-border);
|
||||
border-radius: var(--ft-radius-lg);
|
||||
box-shadow: var(--ft-shadow-sm);
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
|
||||
h1 {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
margin: 0;
|
||||
color: var(--ft-text);
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
margin: 4px 0 0;
|
||||
color: var(--ft-text-secondary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// SUMMARY TILES
|
||||
// ============================================
|
||||
|
||||
.summary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.summary-tile {
|
||||
background: var(--ft-bg-card);
|
||||
border: 1px solid var(--ft-border);
|
||||
border-radius: var(--ft-radius-lg);
|
||||
box-shadow: var(--ft-shadow-sm);
|
||||
padding: 16px 18px;
|
||||
|
||||
.summary-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--ft-text-secondary);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.summary-value {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--ft-text);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.summary-hint {
|
||||
margin-top: 2px;
|
||||
color: var(--ft-text-muted);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
&.accent .summary-value {
|
||||
color: var(--ft-primary);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// FORMS
|
||||
// ============================================
|
||||
|
||||
.form-control,
|
||||
.form-select {
|
||||
background: var(--ft-bg-card);
|
||||
border: 1px solid var(--ft-border);
|
||||
border-radius: var(--ft-radius-sm);
|
||||
color: var(--ft-text);
|
||||
transition: var(--ft-transition);
|
||||
|
||||
&:focus {
|
||||
background: var(--ft-bg-card);
|
||||
color: var(--ft-text);
|
||||
border-color: var(--ft-primary);
|
||||
box-shadow: 0 0 0 3px var(--ft-primary-light);
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: var(--ft-text-muted);
|
||||
}
|
||||
}
|
||||
|
||||
.form-label {
|
||||
color: var(--ft-text-secondary);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.form-text {
|
||||
color: var(--ft-text-muted);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// BUTTONS
|
||||
// ============================================
|
||||
|
||||
.btn {
|
||||
border-radius: var(--ft-radius-sm);
|
||||
font-weight: 600;
|
||||
transition: var(--ft-transition);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--ft-primary);
|
||||
border-color: var(--ft-primary);
|
||||
|
||||
&:hover, &:focus, &:active {
|
||||
background: var(--ft-primary-hover) !important;
|
||||
border-color: var(--ft-primary-hover) !important;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background: var(--ft-primary);
|
||||
border-color: var(--ft-primary);
|
||||
opacity: 0.55;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-outline-secondary {
|
||||
color: var(--ft-text-secondary);
|
||||
border-color: var(--ft-border);
|
||||
|
||||
&:hover, &:focus, &:active {
|
||||
background: var(--ft-bg-hover) !important;
|
||||
border-color: var(--ft-border) !important;
|
||||
color: var(--ft-text) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--ft-text-muted);
|
||||
padding: 6px 8px;
|
||||
border-radius: var(--ft-radius-sm);
|
||||
transition: var(--ft-transition);
|
||||
|
||||
&:hover {
|
||||
background: var(--ft-bg-hover);
|
||||
color: var(--ft-text);
|
||||
}
|
||||
|
||||
&.danger:hover {
|
||||
color: var(--ft-danger);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// MODALS
|
||||
// ============================================
|
||||
|
||||
.modal-content {
|
||||
background: var(--ft-bg-card);
|
||||
color: var(--ft-text);
|
||||
border: 1px solid var(--ft-border);
|
||||
border-radius: var(--ft-radius-lg);
|
||||
box-shadow: var(--ft-shadow-lg);
|
||||
}
|
||||
|
||||
.modal-header,
|
||||
.modal-footer {
|
||||
border-color: var(--ft-border);
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-weight: 700;
|
||||
font-size: 1.15rem;
|
||||
}
|
||||
|
||||
.btn-close {
|
||||
filter: var(--ft-close-filter, none);
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .btn-close {
|
||||
filter: invert(1) grayscale(100%) brightness(200%);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// EMPTY STATE
|
||||
// ============================================
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 40px 20px;
|
||||
color: var(--ft-text-secondary);
|
||||
|
||||
.empty-icon {
|
||||
font-size: 2.4rem;
|
||||
color: var(--ft-text-muted);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.empty-title {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
color: var(--ft-text);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.empty-description {
|
||||
font-size: 0.9rem;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// BADGES
|
||||
// ============================================
|
||||
|
||||
.tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
background: var(--ft-bg-hover);
|
||||
color: var(--ft-text-secondary);
|
||||
border-radius: 999px;
|
||||
padding: 2px 10px;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tag-import {
|
||||
background: var(--ft-primary-light);
|
||||
color: var(--ft-primary);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// FORMULÁŘ JÍDLA — gramáž a kalorie
|
||||
// ============================================
|
||||
|
||||
.meal-fieldset {
|
||||
border: 1px solid var(--ft-border);
|
||||
border-radius: var(--ft-radius);
|
||||
padding: 12px 16px 16px;
|
||||
|
||||
legend {
|
||||
float: none;
|
||||
width: auto;
|
||||
padding: 0 6px;
|
||||
margin: 0 0 0 -6px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--ft-text-muted);
|
||||
}
|
||||
}
|
||||
|
||||
// Dopočtená hodnota se needituje, tak vypadá jako text, ne jako pole
|
||||
.derived-value {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 38px;
|
||||
padding: 0 2px;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
color: var(--ft-primary);
|
||||
}
|
||||
|
||||
.meal-modal-summary {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
margin-right: auto;
|
||||
color: var(--ft-text-secondary);
|
||||
font-size: 0.88rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// HLEDÁNÍ KALORIÍ
|
||||
// ============================================
|
||||
|
||||
.calorie-lookup {
|
||||
width: 100%;
|
||||
position: relative;
|
||||
|
||||
.calorie-lookup-hint {
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
font-size: 0.78rem;
|
||||
color: var(--ft-text-muted);
|
||||
}
|
||||
|
||||
.calorie-lookup-results {
|
||||
position: absolute;
|
||||
z-index: 5;
|
||||
top: calc(100% + 6px);
|
||||
right: 0;
|
||||
width: min(380px, 90vw);
|
||||
background: var(--ft-bg-card);
|
||||
border: 1px solid var(--ft-border);
|
||||
border-radius: var(--ft-radius);
|
||||
box-shadow: var(--ft-shadow-lg);
|
||||
padding: 6px;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.calorie-suggestion {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: var(--ft-radius-sm);
|
||||
padding: 7px 10px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--ft-text);
|
||||
transition: var(--ft-transition);
|
||||
|
||||
&:hover {
|
||||
background: var(--ft-bg-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.calorie-suggestion-icon {
|
||||
color: var(--ft-primary);
|
||||
font-size: 0.75rem;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.calorie-suggestion-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.calorie-suggestion-brand {
|
||||
color: var(--ft-text-muted);
|
||||
font-size: 0.76rem;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.calorie-suggestion-value {
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.calorie-lookup-external {
|
||||
display: block;
|
||||
padding: 8px 10px;
|
||||
margin-top: 4px;
|
||||
border-top: 1px solid var(--ft-border-light);
|
||||
font-size: 0.8rem;
|
||||
color: var(--ft-primary);
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// NASTAVENÍ — sazby za 100 g
|
||||
// ============================================
|
||||
|
||||
.settings-section-title {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--ft-text-secondary);
|
||||
margin: 0 0 6px;
|
||||
}
|
||||
|
||||
.rate-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid var(--ft-border-light);
|
||||
|
||||
.rate-source {
|
||||
flex: 1;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.rate-value {
|
||||
color: var(--ft-text-secondary);
|
||||
font-size: 0.87rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.rate-form {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 14px;
|
||||
|
||||
.rate-form-price {
|
||||
width: 130px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// ŠABLONY TRÉNINKŮ
|
||||
// ============================================
|
||||
|
||||
.workout-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid var(--ft-border-light);
|
||||
|
||||
.workout-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.workout-name {
|
||||
font-weight: 600;
|
||||
color: var(--ft-text);
|
||||
}
|
||||
|
||||
.workout-items {
|
||||
font-size: 0.8rem;
|
||||
color: var(--ft-text-muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.workout-calories {
|
||||
color: var(--ft-warning);
|
||||
font-weight: 700;
|
||||
font-size: 0.85rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.workout-item-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
|
||||
.workout-item-quantity {
|
||||
width: 90px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.workout-item-unit {
|
||||
width: 130px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.workout-item-rate {
|
||||
width: 110px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
flex-wrap: wrap;
|
||||
|
||||
.workout-item-quantity,
|
||||
.workout-item-unit,
|
||||
.workout-item-rate {
|
||||
width: auto;
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.workout-form-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
|
||||
.workout-draft-calories {
|
||||
margin-left: auto;
|
||||
color: var(--ft-warning);
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Routes, Route, Navigate } from "react-router-dom";
|
||||
import { ToastContainer } from "react-toastify";
|
||||
import { useAuth } from "./context/auth";
|
||||
import Header from "./components/Header";
|
||||
import Login from "./Login";
|
||||
import DayPage from "./pages/DayPage";
|
||||
import StatsPage from "./pages/StatsPage";
|
||||
import { DEN_URL, STATISTIKY_URL } from "./routes";
|
||||
|
||||
export default function AppRoutes() {
|
||||
const auth = useAuth();
|
||||
|
||||
if (!auth?.login) {
|
||||
return <Login />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app-container">
|
||||
<Header />
|
||||
<div className="wrapper">
|
||||
<Routes>
|
||||
<Route path={DEN_URL} element={<DayPage />} />
|
||||
<Route path={STATISTIKY_URL} element={<StatsPage />} />
|
||||
<Route path="*" element={<Navigate to={DEN_URL} replace />} />
|
||||
</Routes>
|
||||
</div>
|
||||
<ToastContainer position="bottom-right" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
.login-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
background: var(--ft-bg);
|
||||
}
|
||||
|
||||
.login-card {
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
background: var(--ft-bg-card);
|
||||
border: 1px solid var(--ft-border);
|
||||
border-radius: var(--ft-radius-xl);
|
||||
box-shadow: var(--ft-shadow-lg);
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
.login-logo {
|
||||
margin: 0;
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.03em;
|
||||
color: var(--ft-text);
|
||||
}
|
||||
|
||||
.login-subtitle {
|
||||
margin: 6px 0 24px;
|
||||
color: var(--ft-text-secondary);
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.login-form label {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--ft-text-secondary);
|
||||
}
|
||||
|
||||
.login-form input {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--ft-border);
|
||||
border-radius: var(--ft-radius-sm);
|
||||
background: var(--ft-bg);
|
||||
color: var(--ft-text);
|
||||
font-size: 0.95rem;
|
||||
transition: var(--ft-transition);
|
||||
}
|
||||
|
||||
.login-form input:focus {
|
||||
outline: none;
|
||||
border-color: var(--ft-primary);
|
||||
box-shadow: 0 0 0 3px var(--ft-primary-light);
|
||||
}
|
||||
|
||||
.login-form .hint {
|
||||
margin: 8px 0 0;
|
||||
font-size: 0.8rem;
|
||||
color: var(--ft-text-muted);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { Button } from 'react-bootstrap';
|
||||
import { useAuth } from './context/auth';
|
||||
import { login } from '../../types';
|
||||
import './Login.css';
|
||||
|
||||
/**
|
||||
* Formulář pro prvotní zadání přihlašovacího jména.
|
||||
*/
|
||||
export default function Login() {
|
||||
const auth = useAuth();
|
||||
const loginRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (auth && !auth.login) {
|
||||
// Vyzkoušíme přihlášení "naprázdno" — pokud projde, přihlásily nás trusted headers
|
||||
login().then(response => {
|
||||
if (response.data) {
|
||||
auth.setToken(response.data as unknown as string);
|
||||
}
|
||||
}).catch(() => { /* nepřihlášeno, uživatel zadá jméno ručně */ });
|
||||
}
|
||||
}, [auth]);
|
||||
|
||||
const doLogin = useCallback(async () => {
|
||||
const value = loginRef.current?.value ?? '';
|
||||
if (!value.replaceAll(/\s/g, '').length) return;
|
||||
const response = await login({ body: { login: value } });
|
||||
if (response.data) {
|
||||
auth?.setToken(response.data as unknown as string);
|
||||
}
|
||||
}, [auth]);
|
||||
|
||||
return (
|
||||
<div className='login-page'>
|
||||
<div className='login-card'>
|
||||
<h1 className='login-logo'>🍽 Food Tracer</h1>
|
||||
<p className='login-subtitle'>Přehled o tom, co jste snědli a kolik vás to stálo</p>
|
||||
<div className='login-form'>
|
||||
<div>
|
||||
<label htmlFor="login-input">Zobrazované jméno</label>
|
||||
<input
|
||||
id="login-input"
|
||||
ref={loginRef}
|
||||
type='text'
|
||||
placeholder="Např. Jan Novák"
|
||||
onKeyDown={event => {
|
||||
if (event.key === 'Enter') {
|
||||
doLogin();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<p className='hint'>
|
||||
Zadejte jméno nebo přezdívku, pod kterou se vaše záznamy uloží.
|
||||
Pokud používáte Luncher, hodí se stejné jméno jako tam.
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={doLogin}>Pokračovat</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
const TOKEN_KEY = "token";
|
||||
|
||||
/** Uloží token do local storage prohlížeče. */
|
||||
export const storeToken = (token: string) => {
|
||||
localStorage.setItem(TOKEN_KEY, token);
|
||||
}
|
||||
|
||||
/** Vrátí token z local storage, pokud tam je. */
|
||||
export const getToken = (): string | undefined => {
|
||||
return localStorage.getItem(TOKEN_KEY) ?? undefined;
|
||||
}
|
||||
|
||||
/** Odstraní token z local storage, pokud tam je. */
|
||||
export const deleteToken = () => {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
}
|
||||
|
||||
/** Vrátí datum v ISO formátu YYYY-MM-DD v lokální časové zóně. */
|
||||
export function formatDate(date: Date): string {
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const year = String(date.getFullYear());
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
/** Vrátí měsíc data ve formátu YYYY-MM (hodnota pro <input type="month">). */
|
||||
export function formatMonth(date: Date): string {
|
||||
return formatDate(date).substring(0, 7);
|
||||
}
|
||||
|
||||
/** Převede datum ve formátu YYYY-MM-DD na DD.MM.YYYY */
|
||||
export function formatDateString(date: string): string {
|
||||
const [year, month, day] = date.split('-');
|
||||
return `${day}.${month}.${year}`;
|
||||
}
|
||||
|
||||
/** Převede řetězec YYYY-MM-DD na Date v lokální časové zóně. */
|
||||
export function parseIsoDate(date: string): Date {
|
||||
const [year, month, day] = date.split('-').map(Number);
|
||||
return new Date(year, month - 1, day);
|
||||
}
|
||||
|
||||
/** Posune datum ve formátu YYYY-MM-DD o zadaný počet dní. */
|
||||
export function shiftDate(date: string, days: number): string {
|
||||
const shifted = parseIsoDate(date);
|
||||
shifted.setDate(shifted.getDate() + days);
|
||||
return formatDate(shifted);
|
||||
}
|
||||
|
||||
/** Vrátí první den měsíce (YYYY-MM) ve formátu YYYY-MM-DD. */
|
||||
export function getMonthStart(month: string): string {
|
||||
return `${month}-01`;
|
||||
}
|
||||
|
||||
/** Vrátí poslední den měsíce (YYYY-MM) ve formátu YYYY-MM-DD. */
|
||||
export function getMonthEnd(month: string): string {
|
||||
const [year, monthNumber] = month.split('-').map(Number);
|
||||
// Nultý den následujícího měsíce = poslední den tohoto měsíce
|
||||
return formatDate(new Date(year, monthNumber, 0));
|
||||
}
|
||||
|
||||
const DAY_OF_WEEK_FORMAT = new Intl.DateTimeFormat('cs-CZ', { weekday: 'long' });
|
||||
|
||||
const HUMAN_DATE_FORMAT = new Intl.DateTimeFormat('cs-CZ', { day: 'numeric', month: 'long', year: 'numeric' });
|
||||
|
||||
const MONTH_FORMAT = new Intl.DateTimeFormat('cs-CZ', { month: 'long', year: 'numeric' });
|
||||
|
||||
/** Vrátí název dne v týdnu pro datum ve formátu YYYY-MM-DD. */
|
||||
export function getDayOfWeek(date: string): string {
|
||||
return DAY_OF_WEEK_FORMAT.format(parseIsoDate(date));
|
||||
}
|
||||
|
||||
/** Vrátí čitelný zápis data, např. "3. března 2025". */
|
||||
export function getHumanDate(date: string): string {
|
||||
return HUMAN_DATE_FORMAT.format(parseIsoDate(date));
|
||||
}
|
||||
|
||||
/** Vrátí čitelný zápis měsíce (YYYY-MM), např. "březen 2025". */
|
||||
export function getHumanMonth(month: string): string {
|
||||
return MONTH_FORMAT.format(parseIsoDate(`${month}-01`));
|
||||
}
|
||||
|
||||
const PRICE_FORMAT = new Intl.NumberFormat('cs-CZ', {
|
||||
style: 'currency',
|
||||
currency: 'CZK',
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
|
||||
/**
|
||||
* Naformátuje cenu z haléřů na koruny, např. 15900 → "159,00 Kč".
|
||||
* Nevyplněnou cenu zobrazí jako pomlčku.
|
||||
*/
|
||||
export function formatPrice(halere?: number): string {
|
||||
if (halere == null) return '—';
|
||||
return PRICE_FORMAT.format(halere / 100);
|
||||
}
|
||||
|
||||
/** Naformátuje cenu z haléřů bez desetinných míst — pro souhrny a osy grafů. */
|
||||
export function formatPriceShort(halere: number): string {
|
||||
return `${Math.round(halere / 100).toLocaleString('cs-CZ')} Kč`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Převede uživatelem zadanou cenu v korunách na haléře.
|
||||
* Přijímá desetinnou čárku i tečku. Prázdný vstup vrací undefined.
|
||||
*/
|
||||
export function parsePriceInput(value: string): number | undefined {
|
||||
const trimmed = value.trim().replace(',', '.');
|
||||
if (!trimmed.length) return undefined;
|
||||
const parsed = Number(trimmed);
|
||||
return Number.isFinite(parsed) && parsed >= 0 ? Math.round(parsed * 100) : undefined;
|
||||
}
|
||||
|
||||
/** Převede haléře na text pro editační pole (koruny s desetinnou čárkou). */
|
||||
export function formatPriceInput(halere?: number): string {
|
||||
if (halere == null) return '';
|
||||
return (halere / 100).toFixed(2).replace('.', ',');
|
||||
}
|
||||
|
||||
/** Naformátuje počet kalorií, např. 850 → "850 kcal". */
|
||||
export function formatCalories(kcal?: number): string {
|
||||
if (!kcal) return '—';
|
||||
return `${kcal.toLocaleString('cs-CZ')} kcal`;
|
||||
}
|
||||
|
||||
/** Načte soubor jako Base64 řetězec (bez data: prefixu). */
|
||||
export function readFileAsBase64(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const result = String(reader.result);
|
||||
// FileReader vrací "data:<mime>;base64,<data>", server chce jen data
|
||||
resolve(result.substring(result.indexOf(',') + 1));
|
||||
};
|
||||
reader.onerror = () => reject(new Error('Soubor se nepodařilo načíst'));
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Dopočte gramáž a kalorie z jednotkových hodnot.
|
||||
*
|
||||
* Musí dávat stejné výsledky jako `deriveAmounts` na serveru — klient jen ukazuje
|
||||
* živý náhled toho, co server uloží.
|
||||
*
|
||||
* @param price cena v haléřích
|
||||
* @param pricePer100g cena za 100 g v haléřích
|
||||
* @param weight ručně zadaná gramáž v gramech, má přednost před dopočtem
|
||||
* @param caloriesPer100g energie na 100 g v kcal
|
||||
*/
|
||||
export function deriveAmounts(
|
||||
price?: number,
|
||||
pricePer100g?: number,
|
||||
weight?: number,
|
||||
caloriesPer100g?: number,
|
||||
): { weight?: number, calories?: number } {
|
||||
const resolvedWeight = weight ?? (
|
||||
price != null && pricePer100g ? Math.round(price / pricePer100g * 100) : undefined
|
||||
);
|
||||
const calories = resolvedWeight != null && caloriesPer100g
|
||||
? Math.round(resolvedWeight / 100 * caloriesPer100g)
|
||||
: undefined;
|
||||
return { weight: resolvedWeight, calories };
|
||||
}
|
||||
|
||||
/** Naformátuje gramáž, např. 400 → "400 g". */
|
||||
export function formatWeight(grams?: number): string {
|
||||
if (grams == null) return '—';
|
||||
return `${grams.toLocaleString('cs-CZ')} g`;
|
||||
}
|
||||
|
||||
/** Převede celé číslo z pole na number, prázdné pole na undefined. */
|
||||
export function parseIntInput(value: string): number | undefined {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed.length) return undefined;
|
||||
const parsed = Number(trimmed);
|
||||
return Number.isFinite(parsed) && parsed >= 0 ? Math.round(parsed) : undefined;
|
||||
}
|
||||
|
||||
/** Naformátuje množství aktivity i s jednotkou, např. "10 000 kroků". */
|
||||
export function formatQuantity(quantity: number, unitShort: string): string {
|
||||
return `${quantity.toLocaleString('cs-CZ')} ${unitShort}`;
|
||||
}
|
||||
|
||||
/** Dopočte spálené kalorie z množství a sazby na 100 jednotek. */
|
||||
export function deriveActivityCalories(quantity?: number, caloriesPer100Units?: number): number | undefined {
|
||||
if (quantity == null || !caloriesPer100Units) return undefined;
|
||||
return Math.round(quantity / 100 * caloriesPer100Units);
|
||||
}
|
||||
|
||||
/** Naformátuje bilanci se znaménkem, např. "−320 kcal" nebo "+150 kcal". */
|
||||
export function formatBalance(kcal: number): string {
|
||||
const sign = kcal < 0 ? '−' : '+';
|
||||
return `${sign}${Math.abs(kcal).toLocaleString('cs-CZ')} kcal`;
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { useState } from "react";
|
||||
import { Button, Spinner } from "react-bootstrap";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faMagnifyingGlass, faArrowUpRightFromSquare, faBookmark } from "@fortawesome/free-solid-svg-icons";
|
||||
import { CalorieSearchResult, searchCalories } from "../../../types";
|
||||
|
||||
type Props = {
|
||||
/** Název jídla, ke kterému se energie hledá. */
|
||||
name: string,
|
||||
/** Zavolá se po výběru návrhu s energií na 100 g v kcal. */
|
||||
onPick: (caloriesPer100g: number) => void,
|
||||
};
|
||||
|
||||
/** Návrhy pocházející z vlastní knihovny se odlišují ikonou a popiskem. */
|
||||
const LIBRARY_ORIGIN = 'library';
|
||||
|
||||
/**
|
||||
* Vyhledání energetické hodnoty k názvu jídla.
|
||||
*
|
||||
* Návrhy chodí z vlastní knihovny dřívějších zadání a od externího poskytovatele.
|
||||
* Když je poskytovatel nedostupný (což se stává), zůstane aspoň knihovna a odkaz
|
||||
* na KalorickéTabulky.cz, odkud si hodnotu uživatel opíše ručně — automaticky se
|
||||
* odtud nic nestahuje, veřejné API nemají.
|
||||
*/
|
||||
export default function CalorieLookup({ name, onPick }: Readonly<Props>) {
|
||||
const [result, setResult] = useState<CalorieSearchResult | undefined>();
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const canSearch = name.trim().length > 0 && !searching;
|
||||
|
||||
/**
|
||||
* Zkopíruje název jídla do schránky při otevření Kalorických tabulek.
|
||||
* Jejich vyhledávání nejde předvyplnit z URL, takže aspoň stačí vložit.
|
||||
*/
|
||||
const copyName = () => {
|
||||
navigator.clipboard?.writeText(name.trim())
|
||||
.then(() => setCopied(true))
|
||||
.catch(() => { /* schránka není dostupná, odkaz se stejně otevře */ });
|
||||
};
|
||||
|
||||
const search = async () => {
|
||||
if (!canSearch) return;
|
||||
setSearching(true);
|
||||
const response = await searchCalories({ query: { q: name.trim() } });
|
||||
setResult(response.data);
|
||||
setCopied(false);
|
||||
setSearching(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="calorie-lookup">
|
||||
<Button variant="outline-secondary" size="sm" onClick={search} disabled={!canSearch}>
|
||||
{searching
|
||||
? <Spinner animation="border" size="sm" />
|
||||
: <FontAwesomeIcon icon={faMagnifyingGlass} />}
|
||||
{' '}Najít kalorie
|
||||
</Button>
|
||||
|
||||
{!canSearch && !name.trim().length && (
|
||||
<span className="calorie-lookup-hint">nejdřív vyplňte název jídla</span>
|
||||
)}
|
||||
|
||||
{result && !searching && (
|
||||
<div className="calorie-lookup-results">
|
||||
{result.suggestions.map(suggestion => (
|
||||
<button
|
||||
type="button"
|
||||
className="calorie-suggestion"
|
||||
key={`${suggestion.origin}-${suggestion.name}-${suggestion.caloriesPer100g}`}
|
||||
onClick={() => onPick(suggestion.caloriesPer100g)}
|
||||
title="Použít tuto hodnotu"
|
||||
>
|
||||
{suggestion.origin === LIBRARY_ORIGIN && (
|
||||
<FontAwesomeIcon icon={faBookmark} className="calorie-suggestion-icon" />
|
||||
)}
|
||||
<span className="calorie-suggestion-name">{suggestion.name}</span>
|
||||
{suggestion.brand && (
|
||||
<span className="calorie-suggestion-brand">{suggestion.brand}</span>
|
||||
)}
|
||||
<span className="calorie-suggestion-value">{suggestion.caloriesPer100g} kcal/100 g</span>
|
||||
</button>
|
||||
))}
|
||||
|
||||
{!result.suggestions.length && (
|
||||
<div className="calorie-lookup-hint">
|
||||
Nic se nenašlo{!result.providerAvailable && ' a externí databáze je nedostupná'}.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result.providerAvailable === false && !!result.suggestions.length && (
|
||||
<div className="calorie-lookup-hint">
|
||||
Externí databáze je nedostupná, návrhy jsou jen z vašich dřívějších zadání.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<a
|
||||
className="calorie-lookup-external"
|
||||
href={result.externalSearchUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
onClick={copyName}
|
||||
>
|
||||
{copied ? 'Název zkopírován, vložte ho do jejich hledání' : 'Otevřít KalorickéTabulky.cz'}
|
||||
{' '}<FontAwesomeIcon icon={faArrowUpRightFromSquare} />
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useState } from "react";
|
||||
import { Navbar, Nav, NavDropdown } from "react-bootstrap";
|
||||
import { useLocation, useNavigate } from "react-router";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faSun, faMoon, faGear } from "@fortawesome/free-solid-svg-icons";
|
||||
import { useAuth } from "../context/auth";
|
||||
import { useSettings, ThemePreference } from "../context/settings";
|
||||
import { DEN_URL, STATISTIKY_URL } from "../routes";
|
||||
import SettingsModal from "./modals/SettingsModal";
|
||||
|
||||
/** Položky hlavní navigace. */
|
||||
const NAV_ITEMS = [
|
||||
{ url: DEN_URL, label: 'Můj den' },
|
||||
{ url: STATISTIKY_URL, label: 'Statistiky' },
|
||||
];
|
||||
|
||||
export default function Header() {
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const auth = useAuth();
|
||||
const settings = useSettings();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const effectiveDark = settings?.effectiveDark ?? false;
|
||||
|
||||
const toggleTheme = () => {
|
||||
const next: ThemePreference = effectiveDark ? 'light' : 'dark';
|
||||
settings?.setThemePreference(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<Navbar variant="dark" expand="lg">
|
||||
<Navbar.Brand href={DEN_URL}>Food Tracer</Navbar.Brand>
|
||||
<Navbar.Toggle aria-controls="main-navbar-nav" />
|
||||
<Navbar.Collapse id="main-navbar-nav">
|
||||
<Nav className="nav">
|
||||
{NAV_ITEMS.map(item => (
|
||||
<button
|
||||
key={item.url}
|
||||
className={`nav-pill ${location.pathname === item.url ? 'active' : ''}`}
|
||||
onClick={() => navigate(item.url)}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
className="theme-toggle"
|
||||
onClick={toggleTheme}
|
||||
title={effectiveDark ? 'Přepnout na světlý režim' : 'Přepnout na tmavý režim'}
|
||||
aria-label="Přepnout světlý/tmavý režim"
|
||||
>
|
||||
<FontAwesomeIcon icon={effectiveDark ? faSun : faMoon} />
|
||||
</button>
|
||||
<NavDropdown align="end" title={auth?.login} id="user-nav-dropdown">
|
||||
<NavDropdown.Item onClick={() => navigate(DEN_URL)}>Můj den</NavDropdown.Item>
|
||||
<NavDropdown.Item onClick={() => navigate(STATISTIKY_URL)}>Statistiky</NavDropdown.Item>
|
||||
<NavDropdown.Item onClick={() => setSettingsOpen(true)}>
|
||||
<FontAwesomeIcon icon={faGear} /> Nastavení
|
||||
</NavDropdown.Item>
|
||||
<NavDropdown.Divider />
|
||||
<NavDropdown.Item onClick={auth?.logout}>Odhlásit se</NavDropdown.Item>
|
||||
</NavDropdown>
|
||||
</Nav>
|
||||
</Navbar.Collapse>
|
||||
<SettingsModal isOpen={settingsOpen} onClose={() => setSettingsOpen(false)} />
|
||||
</Navbar>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button, Form, Modal } from "react-bootstrap";
|
||||
import { ActivityEntry, ActivityUnit, DayOverview, addActivity, updateActivity } from "../../../../types";
|
||||
import { ACTIVITY_PRESETS, ACTIVITY_UNITS_IN_ORDER, ACTIVITY_UNIT_NAMES } from "../../enums";
|
||||
import { deriveActivityCalories, formatCalories, parseIntInput } from "../../Utils";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean,
|
||||
/** Den, do kterého se zakládá nová aktivita. */
|
||||
date: string,
|
||||
/** Upravovaný záznam. Pokud chybí, jde o založení nového. */
|
||||
entry?: ActivityEntry,
|
||||
onClose: () => void,
|
||||
onSaved: (day: DayOverview) => void,
|
||||
};
|
||||
|
||||
/** Prázdný formulář pro novou aktivitu v daném dni. */
|
||||
function emptyForm(date: string) {
|
||||
return {
|
||||
date,
|
||||
name: '',
|
||||
unit: ActivityUnit.KROKY as ActivityUnit,
|
||||
quantity: '',
|
||||
caloriesPer100Units: '',
|
||||
calories: '',
|
||||
note: '',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Dialog pro přidání nebo úpravu pohybové aktivity.
|
||||
*
|
||||
* Kalorie se dopočítají z množství a spotřeby na 100 jednotek — sto proto, že
|
||||
* u kroků by sazba na jeden krok byla zlomek. Výběr známé aktivity předvyplní
|
||||
* jednotku i orientační sazbu.
|
||||
*/
|
||||
export default function ActivityModal({ isOpen, date, entry, onClose, onSaved }: Readonly<Props>) {
|
||||
const [form, setForm] = useState(() => emptyForm(date));
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
setForm(entry
|
||||
? {
|
||||
date: entry.date,
|
||||
name: entry.name,
|
||||
unit: entry.unit,
|
||||
quantity: String(entry.quantity),
|
||||
caloriesPer100Units: entry.caloriesPer100Units != null ? String(entry.caloriesPer100Units) : '',
|
||||
calories: entry.calories != null ? String(entry.calories) : '',
|
||||
note: entry.note ?? '',
|
||||
}
|
||||
: emptyForm(date));
|
||||
}, [isOpen, entry, date]);
|
||||
|
||||
const update = (changes: Partial<typeof form>) => setForm(current => ({ ...current, ...changes }));
|
||||
|
||||
/** Výběr z nabídky předvyplní jednotku i orientační sazbu. */
|
||||
const applyPreset = (name: string) => {
|
||||
const preset = ACTIVITY_PRESETS.find(item => item.name === name);
|
||||
if (!preset) {
|
||||
update({ name });
|
||||
return;
|
||||
}
|
||||
update({
|
||||
name: preset.name,
|
||||
unit: preset.unit,
|
||||
caloriesPer100Units: String(preset.caloriesPer100Units),
|
||||
});
|
||||
};
|
||||
|
||||
const quantity = parseIntInput(form.quantity);
|
||||
const caloriesPer100Units = parseIntInput(form.caloriesPer100Units);
|
||||
const derivedCalories = deriveActivityCalories(quantity, caloriesPer100Units);
|
||||
const manualCalories = parseIntInput(form.calories);
|
||||
const effectiveCalories = derivedCalories ?? manualCalories;
|
||||
|
||||
const canSave = form.name.trim().length > 0 && quantity != null && !saving;
|
||||
|
||||
const save = async () => {
|
||||
if (!canSave) return;
|
||||
setSaving(true);
|
||||
const body = {
|
||||
date: form.date,
|
||||
name: form.name.trim(),
|
||||
unit: form.unit,
|
||||
quantity,
|
||||
caloriesPer100Units,
|
||||
// Server si kalorie dopočte sám, ručně zadané použije jen jako zálohu
|
||||
calories: derivedCalories != null ? undefined : manualCalories,
|
||||
note: form.note.trim() || undefined,
|
||||
};
|
||||
const response = entry
|
||||
? await updateActivity({ body: { id: entry.id, ...body } })
|
||||
: await addActivity({ body });
|
||||
setSaving(false);
|
||||
if (response.data) {
|
||||
onSaved(response.data);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal show={isOpen} onHide={onClose} centered size="lg">
|
||||
<Modal.Header closeButton>
|
||||
<Modal.Title>{entry ? 'Úprava aktivity' : 'Nová aktivita'}</Modal.Title>
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
<Form onSubmit={event => { event.preventDefault(); save(); }}>
|
||||
<div className="row g-3 mb-3">
|
||||
<div className="col-sm-8">
|
||||
<Form.Label>Aktivita</Form.Label>
|
||||
<Form.Control
|
||||
autoFocus
|
||||
list="activity-presets"
|
||||
value={form.name}
|
||||
placeholder="Např. Chůze"
|
||||
onChange={event => applyPreset(event.target.value)}
|
||||
/>
|
||||
<datalist id="activity-presets">
|
||||
{ACTIVITY_PRESETS.map(preset => (
|
||||
<option key={preset.name} value={preset.name} />
|
||||
))}
|
||||
</datalist>
|
||||
</div>
|
||||
<div className="col-sm-4">
|
||||
<Form.Label>Datum</Form.Label>
|
||||
<Form.Control
|
||||
type="date"
|
||||
value={form.date}
|
||||
onChange={event => event.target.value && update({ date: event.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<fieldset className="meal-fieldset mb-3">
|
||||
<legend>Množství a spotřeba</legend>
|
||||
<div className="row g-3">
|
||||
<div className="col-sm-3">
|
||||
<Form.Label>Množství</Form.Label>
|
||||
<Form.Control
|
||||
inputMode="numeric"
|
||||
value={form.quantity}
|
||||
placeholder="10000"
|
||||
onChange={event => update({ quantity: event.target.value.replace(/\D/g, '') })}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-sm-3">
|
||||
<Form.Label>Jednotka</Form.Label>
|
||||
<Form.Select
|
||||
value={form.unit}
|
||||
onChange={event => update({ unit: event.target.value as ActivityUnit })}
|
||||
>
|
||||
{ACTIVITY_UNITS_IN_ORDER.map(unit => (
|
||||
<option key={unit} value={unit}>{ACTIVITY_UNIT_NAMES[unit]}</option>
|
||||
))}
|
||||
</Form.Select>
|
||||
</div>
|
||||
<div className="col-sm-3">
|
||||
<Form.Label>Kcal na 100 jedn.</Form.Label>
|
||||
<Form.Control
|
||||
inputMode="numeric"
|
||||
value={form.caloriesPer100Units}
|
||||
placeholder="např. 4"
|
||||
onChange={event => update({ caloriesPer100Units: event.target.value.replace(/\D/g, '') })}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-sm-3">
|
||||
<Form.Label>Spáleno</Form.Label>
|
||||
{derivedCalories != null ? (
|
||||
<div className="derived-value">{formatCalories(derivedCalories)}</div>
|
||||
) : (
|
||||
<div className="input-group">
|
||||
<Form.Control
|
||||
inputMode="numeric"
|
||||
value={form.calories}
|
||||
placeholder="volitelné"
|
||||
onChange={event => update({ calories: event.target.value.replace(/\D/g, '') })}
|
||||
/>
|
||||
<span className="input-group-text">kcal</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<Form.Group>
|
||||
<Form.Label>Poznámka</Form.Label>
|
||||
<Form.Control
|
||||
as="textarea"
|
||||
rows={2}
|
||||
value={form.note}
|
||||
onChange={event => update({ note: event.target.value })}
|
||||
/>
|
||||
</Form.Group>
|
||||
</Form>
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<div className="meal-modal-summary">
|
||||
{effectiveCalories != null && <span>{formatCalories(effectiveCalories)}</span>}
|
||||
</div>
|
||||
<Button variant="outline-secondary" onClick={onClose}>Zrušit</Button>
|
||||
<Button onClick={save} disabled={!canSave}>
|
||||
{saving ? 'Ukládám…' : 'Uložit'}
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Alert, Button, Form, Modal, Spinner } from "react-bootstrap";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faFileImport, faTriangleExclamation } from "@fortawesome/free-solid-svg-icons";
|
||||
import { ImportResult, MealType, importLuncher } from "../../../../types";
|
||||
import { MEAL_TYPES_IN_ORDER, MEAL_TYPE_ICONS, MEAL_TYPE_NAMES } from "../../enums";
|
||||
import { formatDateString, formatPrice, readFileAsBase64 } from "../../Utils";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean,
|
||||
onClose: () => void,
|
||||
/** Zavolá se po dokončení importu, aby se přepočítaly statistiky. */
|
||||
onImported: () => void,
|
||||
};
|
||||
|
||||
/** Kolik záznamů náhledu se vypíše, než se zbytek shrne do jednoho řádku. */
|
||||
const PREVIEW_LIMIT = 8;
|
||||
|
||||
/**
|
||||
* Dialog pro import měsíčního přehledu ze stránky statistik Luncheru.
|
||||
*
|
||||
* Import běží dvoufázově — nejdřív náhled (dryRun), aby uživatel viděl, co se
|
||||
* založí a kolik řádků už má, a teprve po potvrzení se data uloží.
|
||||
*/
|
||||
export default function ImportModal({ isOpen, onClose, onImported }: Readonly<Props>) {
|
||||
const [file, setFile] = useState<File | undefined>();
|
||||
const [mealType, setMealType] = useState<MealType>(MealType.OBED);
|
||||
const [preview, setPreview] = useState<ImportResult | undefined>();
|
||||
const [result, setResult] = useState<ImportResult | undefined>();
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
// Každé otevření začíná načisto, aby nezůstal viset náhled z minula
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
setFile(undefined);
|
||||
setPreview(undefined);
|
||||
setResult(undefined);
|
||||
setBusy(false);
|
||||
}, [isOpen]);
|
||||
|
||||
/** Odešle soubor na server — buď jako náhled, nebo jako ostrý import. */
|
||||
const send = async (dryRun: boolean) => {
|
||||
if (!file) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const content = await readFileAsBase64(file);
|
||||
const response = await importLuncher({
|
||||
body: { fileName: file.name, content, defaultMealType: mealType, dryRun },
|
||||
});
|
||||
if (!response.data) return;
|
||||
if (dryRun) {
|
||||
setPreview(response.data);
|
||||
} else {
|
||||
setResult(response.data);
|
||||
setPreview(undefined);
|
||||
onImported();
|
||||
}
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const chooseFile = (chosen?: File) => {
|
||||
setFile(chosen);
|
||||
setPreview(undefined);
|
||||
setResult(undefined);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal show={isOpen} onHide={onClose} centered size="lg">
|
||||
<Modal.Header closeButton>
|
||||
<Modal.Title>
|
||||
<FontAwesomeIcon icon={faFileImport} /> Import z Luncheru
|
||||
</Modal.Title>
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
<p className="text-body-secondary small">
|
||||
Na stránce statistik v Luncheru si stáhněte přehled za měsíc (XLSX, CSV nebo JSON)
|
||||
a nahrajte ho sem. Záznamy se založí do dnů, ke kterým patří. Opakovaný import
|
||||
stejného měsíce data nezduplikuje.
|
||||
</p>
|
||||
|
||||
<Form.Group className="mb-3">
|
||||
<Form.Label>Soubor s přehledem</Form.Label>
|
||||
<Form.Control
|
||||
type="file"
|
||||
accept=".xlsx,.csv,.json"
|
||||
onChange={event => chooseFile((event.target as HTMLInputElement).files?.[0])}
|
||||
/>
|
||||
</Form.Group>
|
||||
|
||||
<Form.Group className="mb-3">
|
||||
<Form.Label>Založit jako</Form.Label>
|
||||
<Form.Select
|
||||
value={mealType}
|
||||
onChange={event => { setMealType(event.target.value as MealType); setPreview(undefined); }}
|
||||
>
|
||||
{MEAL_TYPES_IN_ORDER.map(type => (
|
||||
<option key={type} value={type}>
|
||||
{MEAL_TYPE_ICONS[type]} {MEAL_TYPE_NAMES[type]}
|
||||
</option>
|
||||
))}
|
||||
</Form.Select>
|
||||
<Form.Text>
|
||||
Luncher řeší výběr obědů, proto je předvolený oběd. Jednotlivá jídla si pak
|
||||
můžete v přehledu dne upravit nebo doplnit o další.
|
||||
</Form.Text>
|
||||
</Form.Group>
|
||||
|
||||
{busy && (
|
||||
<div className="text-center py-3">
|
||||
<Spinner animation="border" variant="success" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{preview && !busy && (
|
||||
<Alert variant={preview.imported ? 'success' : 'secondary'}>
|
||||
<strong>Náhled importu</strong>
|
||||
<div className="small mt-1">
|
||||
V souboru je {preview.rowCount} řádků. Založí se {preview.imported} záznamů
|
||||
{preview.skipped > 0 && <> a {preview.skipped} se přeskočí, protože už je máte</>}.
|
||||
{preview.days.length > 0 && (
|
||||
<> Dotčené dny: {formatDateString(preview.days[0])}
|
||||
{preview.days.length > 1 && <> – {formatDateString(preview.days[preview.days.length - 1])}</>}
|
||||
{' '}({preview.days.length}).</>
|
||||
)}
|
||||
{' '}Celkem za {formatPrice(preview.totalPrice)}.
|
||||
</div>
|
||||
{preview.entries.length > 0 && (
|
||||
<ul className="import-preview-list">
|
||||
{preview.entries.slice(0, PREVIEW_LIMIT).map(entry => (
|
||||
<li key={entry.id}>
|
||||
<span className="import-preview-date">{formatDateString(entry.date)}</span>
|
||||
{entry.name}
|
||||
{entry.source && <span className="text-body-secondary"> · {entry.source}</span>}
|
||||
<span className="import-preview-price">{formatPrice(entry.price)}</span>
|
||||
</li>
|
||||
))}
|
||||
{preview.entries.length > PREVIEW_LIMIT && (
|
||||
<li className="text-body-secondary">
|
||||
… a dalších {preview.entries.length - PREVIEW_LIMIT}
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{result && !busy && (
|
||||
<Alert variant="success">
|
||||
<strong>Import dokončen</strong>
|
||||
<div className="small mt-1">
|
||||
Založeno {result.imported} záznamů ve {result.days.length} dnech
|
||||
{result.skipped > 0 && <>, {result.skipped} přeskočeno jako již existující</>}.
|
||||
Celkem za {formatPrice(result.totalPrice)}.
|
||||
</div>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{(preview ?? result)?.warnings.map(warning => (
|
||||
<div className="import-warning" key={warning}>
|
||||
<FontAwesomeIcon icon={faTriangleExclamation} /> {warning}
|
||||
</div>
|
||||
))}
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<Button variant="outline-secondary" onClick={onClose}>
|
||||
{result ? 'Zavřít' : 'Zrušit'}
|
||||
</Button>
|
||||
{!result && !preview && (
|
||||
<Button onClick={() => send(true)} disabled={!file || busy}>
|
||||
Zobrazit náhled
|
||||
</Button>
|
||||
)}
|
||||
{!result && preview && (
|
||||
<Button onClick={() => send(false)} disabled={busy || preview.imported === 0}>
|
||||
Naimportovat {preview.imported} záznamů
|
||||
</Button>
|
||||
)}
|
||||
</Modal.Footer>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Button, Form, Modal } from "react-bootstrap";
|
||||
import { DayRecord, MealEntry, MealType, UserSettings, addMeal, getSettings, updateMeal } from "../../../../types";
|
||||
import { MEAL_TYPES_IN_ORDER, MEAL_TYPE_ICONS, MEAL_TYPE_NAMES } from "../../enums";
|
||||
import {
|
||||
deriveAmounts, formatCalories, formatPriceInput, formatWeight, parseIntInput, parsePriceInput,
|
||||
} from "../../Utils";
|
||||
import CalorieLookup from "../CalorieLookup";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean,
|
||||
/** Den, do kterého se zakládá nové jídlo. */
|
||||
date: string,
|
||||
/** Upravovaný záznam. Pokud chybí, jde o založení nového. */
|
||||
entry?: MealEntry,
|
||||
onClose: () => void,
|
||||
onSaved: (day: DayRecord) => void,
|
||||
};
|
||||
|
||||
/** Prázdný formulář pro nové jídlo v daném dni. */
|
||||
function emptyForm(date: string) {
|
||||
return {
|
||||
date,
|
||||
mealType: MealType.OBED as MealType,
|
||||
name: '',
|
||||
source: '',
|
||||
price: '',
|
||||
pricePer100g: '',
|
||||
weight: '',
|
||||
caloriesPer100g: '',
|
||||
calories: '',
|
||||
note: '',
|
||||
};
|
||||
}
|
||||
|
||||
/** Znormalizuje název zdroje pro porovnání se sazbami z nastavení. */
|
||||
function normalizeSource(source: string): string {
|
||||
return source.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase().trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Dialog pro přidání nebo úpravu jídla.
|
||||
*
|
||||
* Kromě ceny umí i gramáž — u podniků účtujících podle váhy (TechTower, 44 Kč/100 g)
|
||||
* se dopočte z ceny a sazby, ale dá se přepsat. Z gramáže a energie na 100 g se
|
||||
* pak dopočtou kalorie. Každá položka má vlastní sazbu, takže hlavní jídlo a salát
|
||||
* s jiným cenováním můžou být ve stejném dni vedle sebe.
|
||||
*/
|
||||
export default function MealModal({ isOpen, date, entry, onClose, onSaved }: Readonly<Props>) {
|
||||
const [form, setForm] = useState(() => emptyForm(date));
|
||||
const [settings, setSettings] = useState<UserSettings | undefined>();
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// Při každém otevření se formulář naplní dle toho, co se upravuje
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
setForm(entry
|
||||
? {
|
||||
date: entry.date,
|
||||
mealType: entry.mealType,
|
||||
name: entry.name,
|
||||
source: entry.source ?? '',
|
||||
price: formatPriceInput(entry.price),
|
||||
pricePer100g: formatPriceInput(entry.pricePer100g),
|
||||
weight: entry.weight != null ? String(entry.weight) : '',
|
||||
caloriesPer100g: entry.caloriesPer100g != null ? String(entry.caloriesPer100g) : '',
|
||||
calories: entry.calories != null ? String(entry.calories) : '',
|
||||
note: entry.note ?? '',
|
||||
}
|
||||
: emptyForm(date));
|
||||
}, [isOpen, entry, date]);
|
||||
|
||||
// Sazby podniků se načtou jednou při otevření a slouží k předvyplnění Kč/100 g
|
||||
useEffect(() => {
|
||||
if (!isOpen || settings) return;
|
||||
getSettings().then(response => setSettings(response.data));
|
||||
}, [isOpen, settings]);
|
||||
|
||||
const update = (changes: Partial<typeof form>) => setForm(current => ({ ...current, ...changes }));
|
||||
|
||||
/** Sazba nastavená pro právě zadaný zdroj, pokud nějaká je. */
|
||||
const rateForSource = useMemo(() => {
|
||||
const key = normalizeSource(form.source);
|
||||
if (!key.length) return undefined;
|
||||
return settings?.sourceRates.find(rate => normalizeSource(rate.source) === key)?.pricePer100g;
|
||||
}, [settings, form.source]);
|
||||
|
||||
/**
|
||||
* Doplní sazbu podle zdroje, jakmile ji uživatel vybere a pole je prázdné.
|
||||
* Ručně zadanou sazbu nepřepisujeme — u salátu bývá jiná než u hlavního jídla.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (rateForSource && !form.pricePer100g.trim().length) {
|
||||
setForm(current => ({ ...current, pricePer100g: formatPriceInput(rateForSource) }));
|
||||
}
|
||||
}, [rateForSource, form.pricePer100g]);
|
||||
|
||||
const price = parsePriceInput(form.price);
|
||||
const pricePer100g = parsePriceInput(form.pricePer100g);
|
||||
const manualWeight = parseIntInput(form.weight);
|
||||
const caloriesPer100g = parseIntInput(form.caloriesPer100g);
|
||||
const derived = deriveAmounts(price, pricePer100g, manualWeight, caloriesPer100g);
|
||||
|
||||
/** Kalorie zadané ručně se použijí jen tehdy, když nejdou dopočítat. */
|
||||
const manualCalories = parseIntInput(form.calories);
|
||||
const effectiveCalories = derived.calories ?? manualCalories;
|
||||
|
||||
const canSave = form.name.trim().length > 0 && !saving;
|
||||
|
||||
const save = async () => {
|
||||
if (!canSave) return;
|
||||
setSaving(true);
|
||||
const body = {
|
||||
date: form.date,
|
||||
mealType: form.mealType,
|
||||
name: form.name.trim(),
|
||||
source: form.source.trim() || undefined,
|
||||
price,
|
||||
pricePer100g,
|
||||
weight: manualWeight,
|
||||
caloriesPer100g,
|
||||
// Server si kalorie dopočte sám, ručně poslané použije jen jako zálohu
|
||||
calories: derived.calories != null ? undefined : manualCalories,
|
||||
note: form.note.trim() || undefined,
|
||||
};
|
||||
const response = entry
|
||||
? await updateMeal({ body: { id: entry.id, ...body } })
|
||||
: await addMeal({ body });
|
||||
setSaving(false);
|
||||
if (response.data) {
|
||||
onSaved(response.data);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal show={isOpen} onHide={onClose} centered size="lg">
|
||||
<Modal.Header closeButton>
|
||||
<Modal.Title>{entry ? 'Úprava jídla' : 'Nové jídlo'}</Modal.Title>
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
<Form onSubmit={event => { event.preventDefault(); save(); }}>
|
||||
<Form.Group className="mb-3">
|
||||
<Form.Label>Jídlo</Form.Label>
|
||||
<Form.Control
|
||||
autoFocus
|
||||
value={form.name}
|
||||
placeholder="Např. Kuřecí gyros s hranolkami"
|
||||
onChange={event => update({ name: event.target.value })}
|
||||
/>
|
||||
</Form.Group>
|
||||
|
||||
<div className="row g-3 mb-3">
|
||||
<div className="col-sm-4">
|
||||
<Form.Label>Chod</Form.Label>
|
||||
<Form.Select
|
||||
value={form.mealType}
|
||||
onChange={event => update({ mealType: event.target.value as MealType })}
|
||||
>
|
||||
{MEAL_TYPES_IN_ORDER.map(mealType => (
|
||||
<option key={mealType} value={mealType}>
|
||||
{MEAL_TYPE_ICONS[mealType]} {MEAL_TYPE_NAMES[mealType]}
|
||||
</option>
|
||||
))}
|
||||
</Form.Select>
|
||||
</div>
|
||||
<div className="col-sm-4">
|
||||
<Form.Label>Datum</Form.Label>
|
||||
<Form.Control
|
||||
type="date"
|
||||
value={form.date}
|
||||
onChange={event => event.target.value && update({ date: event.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-sm-4">
|
||||
<Form.Label>Zdroj</Form.Label>
|
||||
<Form.Control
|
||||
list="known-sources"
|
||||
value={form.source}
|
||||
placeholder="TechTower, doma…"
|
||||
onChange={event => update({ source: event.target.value })}
|
||||
/>
|
||||
<datalist id="known-sources">
|
||||
{settings?.sourceRates.map(rate => (
|
||||
<option key={rate.source} value={rate.source} />
|
||||
))}
|
||||
</datalist>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<fieldset className="meal-fieldset mb-3">
|
||||
<legend>Cena a gramáž</legend>
|
||||
<div className="row g-3">
|
||||
<div className="col-sm-4">
|
||||
<Form.Label>Cena</Form.Label>
|
||||
<div className="input-group">
|
||||
<Form.Control
|
||||
inputMode="decimal"
|
||||
value={form.price}
|
||||
placeholder="0,00"
|
||||
onChange={event => update({ price: event.target.value })}
|
||||
/>
|
||||
<span className="input-group-text">Kč</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-sm-4">
|
||||
<Form.Label>Cena za 100 g</Form.Label>
|
||||
<div className="input-group">
|
||||
<Form.Control
|
||||
inputMode="decimal"
|
||||
value={form.pricePer100g}
|
||||
placeholder="např. 44,00"
|
||||
onChange={event => update({ pricePer100g: event.target.value })}
|
||||
/>
|
||||
<span className="input-group-text">Kč</span>
|
||||
</div>
|
||||
{rateForSource != null && (
|
||||
<Form.Text>sazba zdroje {form.source.trim()}</Form.Text>
|
||||
)}
|
||||
</div>
|
||||
<div className="col-sm-4">
|
||||
<Form.Label>Gramáž</Form.Label>
|
||||
<div className="input-group">
|
||||
<Form.Control
|
||||
inputMode="numeric"
|
||||
value={form.weight}
|
||||
placeholder={derived.weight != null ? String(derived.weight) : 'volitelné'}
|
||||
onChange={event => update({ weight: event.target.value.replace(/\D/g, '') })}
|
||||
/>
|
||||
<span className="input-group-text">g</span>
|
||||
</div>
|
||||
{manualWeight == null && derived.weight != null && (
|
||||
<Form.Text>dopočteno z ceny</Form.Text>
|
||||
)}
|
||||
{manualWeight != null && pricePer100g != null && (
|
||||
<Form.Text>zadáno ručně, cena se nemění</Form.Text>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset className="meal-fieldset mb-3">
|
||||
<legend>Kalorie</legend>
|
||||
<div className="row g-3">
|
||||
<div className="col-sm-4">
|
||||
<Form.Label>Energie na 100 g</Form.Label>
|
||||
<div className="input-group">
|
||||
<Form.Control
|
||||
inputMode="numeric"
|
||||
value={form.caloriesPer100g}
|
||||
placeholder="např. 172"
|
||||
onChange={event => update({ caloriesPer100g: event.target.value.replace(/\D/g, '') })}
|
||||
/>
|
||||
<span className="input-group-text">kcal</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-sm-4">
|
||||
<Form.Label>Kalorie celkem</Form.Label>
|
||||
{derived.calories != null ? (
|
||||
<div className="derived-value">{formatCalories(derived.calories)}</div>
|
||||
) : (
|
||||
<div className="input-group">
|
||||
<Form.Control
|
||||
inputMode="numeric"
|
||||
value={form.calories}
|
||||
placeholder="volitelné"
|
||||
onChange={event => update({ calories: event.target.value.replace(/\D/g, '') })}
|
||||
/>
|
||||
<span className="input-group-text">kcal</span>
|
||||
</div>
|
||||
)}
|
||||
{derived.calories != null && (
|
||||
<Form.Text>
|
||||
{formatWeight(derived.weight)} × {caloriesPer100g} kcal/100 g
|
||||
</Form.Text>
|
||||
)}
|
||||
</div>
|
||||
<div className="col-sm-4 d-flex align-items-end">
|
||||
<CalorieLookup
|
||||
name={form.name}
|
||||
onPick={value => update({ caloriesPer100g: String(value) })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<Form.Group>
|
||||
<Form.Label>Poznámka</Form.Label>
|
||||
<Form.Control
|
||||
as="textarea"
|
||||
rows={2}
|
||||
value={form.note}
|
||||
onChange={event => update({ note: event.target.value })}
|
||||
/>
|
||||
</Form.Group>
|
||||
</Form>
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<div className="meal-modal-summary">
|
||||
{derived.weight != null && <span>{formatWeight(derived.weight)}</span>}
|
||||
{effectiveCalories != null && <span>{formatCalories(effectiveCalories)}</span>}
|
||||
</div>
|
||||
<Button variant="outline-secondary" onClick={onClose}>Zrušit</Button>
|
||||
<Button onClick={save} disabled={!canSave}>
|
||||
{saving ? 'Ukládám…' : 'Uložit'}
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button, Form, Modal } from "react-bootstrap";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faTrash, faPlus } from "@fortawesome/free-solid-svg-icons";
|
||||
import { UserSettings, getSettings, saveBasalCalories, saveSourceRate } from "../../../../types";
|
||||
import { formatPriceInput, parseIntInput, parsePriceInput } from "../../Utils";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean,
|
||||
onClose: () => void,
|
||||
};
|
||||
|
||||
/**
|
||||
* Nastavení sazeb za 100 g u podniků, které účtují podle váhy.
|
||||
*
|
||||
* Sazba slouží jako výchozí hodnota při zadávání jídla — u konkrétní položky
|
||||
* ji jde přepsat, protože salát bývá účtovaný jinak než hlavní jídlo.
|
||||
*/
|
||||
export default function SettingsModal({ isOpen, onClose }: Readonly<Props>) {
|
||||
const [settings, setSettings] = useState<UserSettings | undefined>();
|
||||
const [newSource, setNewSource] = useState('');
|
||||
const [newRate, setNewRate] = useState('');
|
||||
const [basal, setBasal] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
setNewSource('');
|
||||
setNewRate('');
|
||||
getSettings().then(response => {
|
||||
setSettings(response.data);
|
||||
setBasal(response.data?.basalCalories != null ? String(response.data.basalCalories) : '');
|
||||
});
|
||||
}, [isOpen]);
|
||||
|
||||
const save = async (source: string, pricePer100g: number | null) => {
|
||||
setSaving(true);
|
||||
const response = await saveSourceRate({ body: { source, pricePer100g } });
|
||||
setSaving(false);
|
||||
if (response.data) {
|
||||
setSettings(response.data);
|
||||
}
|
||||
};
|
||||
|
||||
/** Uloží klidový výdej. Prázdné pole ho odstraní. */
|
||||
const saveBasal = async () => {
|
||||
setSaving(true);
|
||||
const response = await saveBasalCalories({ body: { basalCalories: parseIntInput(basal) ?? null } });
|
||||
setSaving(false);
|
||||
if (response.data) {
|
||||
setSettings(response.data);
|
||||
}
|
||||
};
|
||||
|
||||
const addRate = async () => {
|
||||
const rate = parsePriceInput(newRate);
|
||||
if (!newSource.trim().length || !rate) return;
|
||||
await save(newSource.trim(), rate);
|
||||
setNewSource('');
|
||||
setNewRate('');
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal show={isOpen} onHide={onClose} centered>
|
||||
<Modal.Header closeButton>
|
||||
<Modal.Title>Nastavení</Modal.Title>
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
<h3 className="settings-section-title">Klidový výdej</h3>
|
||||
<p className="text-body-secondary small">
|
||||
Kolik kcal spálíte za den bez pohybu (bazální metabolismus). Bez něj
|
||||
porovnává bilance dne jen jídlo proti pohybu a nejde o skutečný deficit.
|
||||
</p>
|
||||
<div className="rate-form mb-4">
|
||||
<div className="input-group">
|
||||
<Form.Control
|
||||
inputMode="numeric"
|
||||
value={basal}
|
||||
placeholder="např. 1600"
|
||||
onChange={event => setBasal(event.target.value.replace(/\D/g, ''))}
|
||||
/>
|
||||
<span className="input-group-text">kcal / den</span>
|
||||
</div>
|
||||
<Button onClick={saveBasal} disabled={saving}>Uložit</Button>
|
||||
</div>
|
||||
|
||||
<h3 className="settings-section-title">Cena za 100 g</h3>
|
||||
<p className="text-body-secondary small">
|
||||
U podniků, které účtují podle váhy, se z ceny jídla a této sazby dopočítá
|
||||
gramáž a z ní kalorie. U konkrétního jídla jde sazbu vždy přepsat.
|
||||
</p>
|
||||
|
||||
{settings?.sourceRates.length === 0 && (
|
||||
<p className="text-body-secondary small fst-italic">Zatím nemáte žádnou sazbu.</p>
|
||||
)}
|
||||
|
||||
{settings?.sourceRates.map(rate => (
|
||||
<div className="rate-row" key={rate.source}>
|
||||
<span className="rate-source">{rate.source}</span>
|
||||
<span className="rate-value">{formatPriceInput(rate.pricePer100g)} Kč / 100 g</span>
|
||||
<button
|
||||
className="btn-icon danger"
|
||||
onClick={() => save(rate.source, null)}
|
||||
disabled={saving}
|
||||
title="Odebrat sazbu"
|
||||
aria-label={`Odebrat sazbu ${rate.source}`}
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrash} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<Form
|
||||
className="rate-form"
|
||||
onSubmit={event => { event.preventDefault(); addRate(); }}
|
||||
>
|
||||
<Form.Control
|
||||
value={newSource}
|
||||
placeholder="Podnik, např. TechTower"
|
||||
onChange={event => setNewSource(event.target.value)}
|
||||
/>
|
||||
<div className="input-group rate-form-price">
|
||||
<Form.Control
|
||||
inputMode="decimal"
|
||||
value={newRate}
|
||||
placeholder="44,00"
|
||||
onChange={event => setNewRate(event.target.value)}
|
||||
/>
|
||||
<span className="input-group-text">Kč</span>
|
||||
</div>
|
||||
<Button
|
||||
onClick={addRate}
|
||||
disabled={saving || !newSource.trim().length || !parsePriceInput(newRate)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faPlus} />
|
||||
</Button>
|
||||
</Form>
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<Button variant="outline-secondary" onClick={onClose}>Zavřít</Button>
|
||||
</Modal.Footer>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button, Form, Modal } from "react-bootstrap";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faTrash, faPlus, faPlay, faPenToSquare } from "@fortawesome/free-solid-svg-icons";
|
||||
import {
|
||||
ActivityUnit, DayOverview, WorkoutTemplate, WorkoutTemplateItem,
|
||||
applyWorkoutTemplate, deleteWorkoutTemplate, getWorkoutTemplates, saveWorkoutTemplate,
|
||||
} from "../../../../types";
|
||||
import { ACTIVITY_PRESETS, ACTIVITY_UNITS_IN_ORDER, ACTIVITY_UNIT_NAMES, ACTIVITY_UNIT_SHORT } from "../../enums";
|
||||
import { deriveActivityCalories, formatCalories, formatQuantity, parseIntInput } from "../../Utils";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean,
|
||||
/** Den, do kterého se šablona použije. */
|
||||
date: string,
|
||||
onClose: () => void,
|
||||
/** Zavolá se po použití šablony s aktualizovaným přehledem dne. */
|
||||
onApplied: (day: DayOverview) => void,
|
||||
};
|
||||
|
||||
/** Rozpracovaná položka šablony — hodnoty jsou texty, protože se editují v polích. */
|
||||
type DraftItem = {
|
||||
name: string,
|
||||
unit: ActivityUnit,
|
||||
quantity: string,
|
||||
caloriesPer100Units: string,
|
||||
};
|
||||
|
||||
function emptyItem(): DraftItem {
|
||||
return { name: '', unit: ActivityUnit.OPAKOVANI, quantity: '', caloriesPer100Units: '' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Správa šablon tréninků a jejich použití na den.
|
||||
*
|
||||
* Šablona je seznam cviků, který se jedním kliknutím založí do dne — opakovaný
|
||||
* trénink tak není potřeba zadávat cvik po cviku znovu. Založené položky jsou
|
||||
* pak samostatné aktivity, takže úprava jedné z nich šablonu nemění.
|
||||
*/
|
||||
export default function WorkoutModal({ isOpen, date, onClose, onApplied }: Readonly<Props>) {
|
||||
const [templates, setTemplates] = useState<WorkoutTemplate[]>([]);
|
||||
const [editedId, setEditedId] = useState<string | undefined>();
|
||||
const [name, setName] = useState('');
|
||||
const [items, setItems] = useState<DraftItem[]>([emptyItem()]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const resetForm = () => {
|
||||
setEditedId(undefined);
|
||||
setName('');
|
||||
setItems([emptyItem()]);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
resetForm();
|
||||
getWorkoutTemplates().then(response => setTemplates(response.data ?? []));
|
||||
}, [isOpen]);
|
||||
|
||||
const updateItem = (index: number, changes: Partial<DraftItem>) =>
|
||||
setItems(current => current.map((item, i) => (i === index ? { ...item, ...changes } : item)));
|
||||
|
||||
/** Výběr známého cviku předvyplní jednotku i orientační sazbu. */
|
||||
const applyPreset = (index: number, value: string) => {
|
||||
const preset = ACTIVITY_PRESETS.find(item => item.name === value);
|
||||
updateItem(index, preset
|
||||
? { name: preset.name, unit: preset.unit, caloriesPer100Units: String(preset.caloriesPer100Units) }
|
||||
: { name: value });
|
||||
};
|
||||
|
||||
/** Převede rozpracované položky na ty, které jdou uložit. */
|
||||
const toItems = (): WorkoutTemplateItem[] => items
|
||||
.filter(item => item.name.trim().length && parseIntInput(item.quantity) != null)
|
||||
.map(item => ({
|
||||
name: item.name.trim(),
|
||||
unit: item.unit,
|
||||
quantity: parseIntInput(item.quantity)!,
|
||||
...(parseIntInput(item.caloriesPer100Units) != null
|
||||
? { caloriesPer100Units: parseIntInput(item.caloriesPer100Units) }
|
||||
: {}),
|
||||
}));
|
||||
|
||||
const canSave = name.trim().length > 0 && toItems().length > 0 && !busy;
|
||||
|
||||
const save = async () => {
|
||||
if (!canSave) return;
|
||||
setBusy(true);
|
||||
const response = await saveWorkoutTemplate({ body: { id: editedId, name: name.trim(), items: toItems() } });
|
||||
setBusy(false);
|
||||
if (response.data) {
|
||||
setTemplates(response.data);
|
||||
resetForm();
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async (id: string) => {
|
||||
setBusy(true);
|
||||
const response = await deleteWorkoutTemplate({ body: { id } });
|
||||
setBusy(false);
|
||||
if (response.data) {
|
||||
setTemplates(response.data);
|
||||
if (editedId === id) resetForm();
|
||||
}
|
||||
};
|
||||
|
||||
const apply = async (id: string) => {
|
||||
setBusy(true);
|
||||
const response = await applyWorkoutTemplate({ body: { id, date } });
|
||||
setBusy(false);
|
||||
if (response.data) {
|
||||
onApplied(response.data);
|
||||
}
|
||||
};
|
||||
|
||||
/** Načte šablonu do formuláře k úpravě. */
|
||||
const edit = (template: WorkoutTemplate) => {
|
||||
setEditedId(template.id);
|
||||
setName(template.name);
|
||||
setItems(template.items.map(item => ({
|
||||
name: item.name,
|
||||
unit: item.unit,
|
||||
quantity: String(item.quantity),
|
||||
caloriesPer100Units: item.caloriesPer100Units != null ? String(item.caloriesPer100Units) : '',
|
||||
})));
|
||||
};
|
||||
|
||||
/** Průběžný odhad kalorií rozpracované šablony. */
|
||||
const draftCalories = items.reduce((sum, item) =>
|
||||
sum + (deriveActivityCalories(parseIntInput(item.quantity), parseIntInput(item.caloriesPer100Units)) ?? 0), 0);
|
||||
|
||||
return (
|
||||
<Modal show={isOpen} onHide={onClose} centered size="lg">
|
||||
<Modal.Header closeButton>
|
||||
<Modal.Title>Šablony tréninků</Modal.Title>
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
{templates.length === 0 && (
|
||||
<p className="text-body-secondary small fst-italic">
|
||||
Zatím nemáte žádnou šablonu. Sestavte si ji níže — příště pak stačí jedno kliknutí.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{templates.map(template => (
|
||||
<div className="workout-row" key={template.id}>
|
||||
<div className="workout-info">
|
||||
<div className="workout-name">{template.name}</div>
|
||||
<div className="workout-items">
|
||||
{template.items.map(item =>
|
||||
`${item.name} ${formatQuantity(item.quantity, ACTIVITY_UNIT_SHORT[item.unit])}`
|
||||
).join(' · ')}
|
||||
</div>
|
||||
</div>
|
||||
<span className="workout-calories">{formatCalories(template.estimatedCalories)}</span>
|
||||
<Button size="sm" onClick={() => apply(template.id)} disabled={busy} title="Použít na tento den">
|
||||
<FontAwesomeIcon icon={faPlay} /> Použít
|
||||
</Button>
|
||||
<button className="btn-icon" onClick={() => edit(template)} title="Upravit" aria-label="Upravit šablonu">
|
||||
<FontAwesomeIcon icon={faPenToSquare} />
|
||||
</button>
|
||||
<button
|
||||
className="btn-icon danger"
|
||||
onClick={() => remove(template.id)}
|
||||
disabled={busy}
|
||||
title="Smazat"
|
||||
aria-label="Smazat šablonu"
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrash} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<fieldset className="meal-fieldset mt-3">
|
||||
<legend>{editedId ? 'Úprava šablony' : 'Nová šablona'}</legend>
|
||||
|
||||
<Form.Group className="mb-3">
|
||||
<Form.Label>Název</Form.Label>
|
||||
<Form.Control
|
||||
value={name}
|
||||
placeholder="Např. Workout day 1"
|
||||
onChange={event => setName(event.target.value)}
|
||||
/>
|
||||
</Form.Group>
|
||||
|
||||
{items.map((item, index) => (
|
||||
// Položky nemají stabilní id, dokud se šablona neuloží — index je tu jediný klíč
|
||||
<div className="workout-item-row" key={index}>
|
||||
<Form.Control
|
||||
list="activity-presets-workout"
|
||||
value={item.name}
|
||||
placeholder="Cvik"
|
||||
onChange={event => applyPreset(index, event.target.value)}
|
||||
/>
|
||||
<Form.Control
|
||||
className="workout-item-quantity"
|
||||
inputMode="numeric"
|
||||
value={item.quantity}
|
||||
placeholder="60"
|
||||
onChange={event => updateItem(index, { quantity: event.target.value.replace(/\D/g, '') })}
|
||||
/>
|
||||
<Form.Select
|
||||
className="workout-item-unit"
|
||||
value={item.unit}
|
||||
onChange={event => updateItem(index, { unit: event.target.value as ActivityUnit })}
|
||||
>
|
||||
{ACTIVITY_UNITS_IN_ORDER.map(unit => (
|
||||
<option key={unit} value={unit}>{ACTIVITY_UNIT_NAMES[unit]}</option>
|
||||
))}
|
||||
</Form.Select>
|
||||
<Form.Control
|
||||
className="workout-item-rate"
|
||||
inputMode="numeric"
|
||||
value={item.caloriesPer100Units}
|
||||
placeholder="kcal/100"
|
||||
onChange={event => updateItem(index, { caloriesPer100Units: event.target.value.replace(/\D/g, '') })}
|
||||
/>
|
||||
<button
|
||||
className="btn-icon danger"
|
||||
onClick={() => setItems(current => current.filter((_, i) => i !== index))}
|
||||
disabled={items.length === 1}
|
||||
title="Odebrat položku"
|
||||
aria-label="Odebrat položku"
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrash} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<datalist id="activity-presets-workout">
|
||||
{ACTIVITY_PRESETS.map(preset => (
|
||||
<option key={preset.name} value={preset.name} />
|
||||
))}
|
||||
</datalist>
|
||||
|
||||
<div className="workout-form-footer">
|
||||
<Button
|
||||
variant="outline-secondary"
|
||||
size="sm"
|
||||
onClick={() => setItems(current => [...current, emptyItem()])}
|
||||
>
|
||||
<FontAwesomeIcon icon={faPlus} /> Další cvik
|
||||
</Button>
|
||||
{draftCalories > 0 && (
|
||||
<span className="workout-draft-calories">celkem {formatCalories(draftCalories)}</span>
|
||||
)}
|
||||
{editedId && (
|
||||
<Button variant="outline-secondary" size="sm" onClick={resetForm}>Zrušit úpravu</Button>
|
||||
)}
|
||||
<Button size="sm" onClick={save} disabled={!canSave}>
|
||||
{editedId ? 'Uložit změny' : 'Vytvořit šablonu'}
|
||||
</Button>
|
||||
</div>
|
||||
</fieldset>
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<Button variant="outline-secondary" onClick={onClose}>Zavřít</Button>
|
||||
</Modal.Footer>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import React, { ReactNode, useContext, useEffect, useState } from "react";
|
||||
import { useJwt } from "react-jwt";
|
||||
import { deleteToken, getToken, storeToken } from "../Utils";
|
||||
|
||||
export type AuthContextProps = {
|
||||
login?: string,
|
||||
trusted?: boolean,
|
||||
setToken: (token: string) => void,
|
||||
logout: () => void,
|
||||
}
|
||||
|
||||
type ContextProps = {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
const authContext = React.createContext<AuthContextProps | null>(null);
|
||||
|
||||
export function ProvideAuth(props: Readonly<ContextProps>) {
|
||||
const auth = useProvideAuth();
|
||||
return <authContext.Provider value={auth}>{props.children}</authContext.Provider>
|
||||
}
|
||||
|
||||
export const useAuth = () => {
|
||||
return useContext(authContext);
|
||||
}
|
||||
|
||||
function useProvideAuth(): AuthContextProps {
|
||||
const [loginName, setLoginName] = useState<string | undefined>();
|
||||
const [trusted, setTrusted] = useState<boolean | undefined>();
|
||||
const [token, setToken] = useState<string | undefined>(getToken());
|
||||
const { decodedToken } = useJwt(token ?? '');
|
||||
|
||||
useEffect(() => {
|
||||
if (token && token.length > 0) {
|
||||
storeToken(token);
|
||||
} else {
|
||||
deleteToken();
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
useEffect(() => {
|
||||
if (decodedToken) {
|
||||
setLoginName((decodedToken as any).login);
|
||||
setTrusted((decodedToken as any).trusted);
|
||||
} else {
|
||||
setLoginName(undefined);
|
||||
setTrusted(undefined);
|
||||
}
|
||||
}, [decodedToken]);
|
||||
|
||||
function logout() {
|
||||
const isTrusted = (decodedToken as any)?.trusted;
|
||||
const logoutUrl = (decodedToken as any)?.logoutUrl;
|
||||
setToken(undefined);
|
||||
setLoginName(undefined);
|
||||
setTrusted(undefined);
|
||||
if (isTrusted && logoutUrl?.length) {
|
||||
globalThis.location.replace(logoutUrl);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
login: loginName,
|
||||
trusted,
|
||||
setToken,
|
||||
logout,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import React, { ReactNode, useCallback, useContext, useEffect, useMemo, useState } from "react";
|
||||
|
||||
/** Volba motivu — buď pevná, nebo řízená nastavením operačního systému. */
|
||||
export type ThemePreference = 'light' | 'dark' | 'system';
|
||||
|
||||
const THEME_KEY = 'themePreference';
|
||||
|
||||
export type SettingsContextProps = {
|
||||
themePreference: ThemePreference,
|
||||
/** Skutečně použitý motiv po vyhodnocení volby 'system'. */
|
||||
effectiveDark: boolean,
|
||||
setThemePreference: (preference: ThemePreference) => void,
|
||||
};
|
||||
|
||||
const settingsContext = React.createContext<SettingsContextProps | null>(null);
|
||||
|
||||
export const useSettings = () => useContext(settingsContext);
|
||||
|
||||
/** Vrátí uloženou volbu motivu, nebo 'system', pokud uživatel nic nezvolil. */
|
||||
function getStoredPreference(): ThemePreference {
|
||||
const stored = localStorage.getItem(THEME_KEY);
|
||||
return stored === 'light' || stored === 'dark' ? stored : 'system';
|
||||
}
|
||||
|
||||
export function ProvideSettings(props: Readonly<{ children: ReactNode }>) {
|
||||
const [themePreference, setStoredPreference] = useState<ThemePreference>(getStoredPreference);
|
||||
const [systemDark, setSystemDark] = useState(() =>
|
||||
globalThis.matchMedia?.('(prefers-color-scheme: dark)').matches ?? false);
|
||||
|
||||
// Při volbě 'system' musí přepnutí motivu v OS překreslit i běžící aplikaci
|
||||
useEffect(() => {
|
||||
const query = globalThis.matchMedia?.('(prefers-color-scheme: dark)');
|
||||
if (!query) return;
|
||||
const listener = (event: MediaQueryListEvent) => setSystemDark(event.matches);
|
||||
query.addEventListener('change', listener);
|
||||
return () => query.removeEventListener('change', listener);
|
||||
}, []);
|
||||
|
||||
const effectiveDark = themePreference === 'system' ? systemDark : themePreference === 'dark';
|
||||
|
||||
// Bootstrap i vlastní proměnné se řídí atributem na <html>
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute('data-bs-theme', effectiveDark ? 'dark' : 'light');
|
||||
}, [effectiveDark]);
|
||||
|
||||
const setThemePreference = useCallback((preference: ThemePreference) => {
|
||||
setStoredPreference(preference);
|
||||
if (preference === 'system') {
|
||||
localStorage.removeItem(THEME_KEY);
|
||||
} else {
|
||||
localStorage.setItem(THEME_KEY, preference);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({ themePreference, effectiveDark, setThemePreference }),
|
||||
[themePreference, effectiveDark, setThemePreference]);
|
||||
|
||||
return <settingsContext.Provider value={value}>{props.children}</settingsContext.Provider>;
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { ActivityUnit, MealType } from "../../types";
|
||||
|
||||
/** Lidsky čitelné názvy typů jídel. */
|
||||
export const MEAL_TYPE_NAMES: Record<MealType, string> = {
|
||||
SNIDANE: 'Snídaně',
|
||||
DOPOLEDNI_SVACINA: 'Dopolední svačina',
|
||||
OBED: 'Oběd',
|
||||
ODPOLEDNI_SVACINA: 'Odpolední svačina',
|
||||
VECERE: 'Večeře',
|
||||
JINE: 'Jiné',
|
||||
};
|
||||
|
||||
/** Emoji doprovázející typ jídla — odlišuje chody v seznamu na první pohled. */
|
||||
export const MEAL_TYPE_ICONS: Record<MealType, string> = {
|
||||
SNIDANE: '🥐',
|
||||
DOPOLEDNI_SVACINA: '🍎',
|
||||
OBED: '🍲',
|
||||
ODPOLEDNI_SVACINA: '🥨',
|
||||
VECERE: '🍽',
|
||||
JINE: '🍫',
|
||||
};
|
||||
|
||||
/** Typy jídel v pořadí, v jakém probíhá den. Určuje i pořadí v seznamech a výběrech. */
|
||||
export const MEAL_TYPES_IN_ORDER: MealType[] = [
|
||||
MealType.SNIDANE,
|
||||
MealType.DOPOLEDNI_SVACINA,
|
||||
MealType.OBED,
|
||||
MealType.ODPOLEDNI_SVACINA,
|
||||
MealType.VECERE,
|
||||
MealType.JINE,
|
||||
];
|
||||
|
||||
/** Lidsky čitelné názvy jednotek aktivit. */
|
||||
export const ACTIVITY_UNIT_NAMES: Record<ActivityUnit, string> = {
|
||||
KROKY: 'kroky',
|
||||
MINUTY: 'minuty',
|
||||
KM: 'km',
|
||||
OPAKOVANI: 'opakování',
|
||||
};
|
||||
|
||||
/** Zkratka jednotky za číslo, např. "10 000 kroků". */
|
||||
export const ACTIVITY_UNIT_SHORT: Record<ActivityUnit, string> = {
|
||||
KROKY: 'kroků',
|
||||
MINUTY: 'min',
|
||||
KM: 'km',
|
||||
OPAKOVANI: '×',
|
||||
};
|
||||
|
||||
/** Jednotky v pořadí, v jakém se nabízejí. */
|
||||
export const ACTIVITY_UNITS_IN_ORDER: ActivityUnit[] = [
|
||||
ActivityUnit.KROKY,
|
||||
ActivityUnit.MINUTY,
|
||||
ActivityUnit.KM,
|
||||
ActivityUnit.OPAKOVANI,
|
||||
];
|
||||
|
||||
/**
|
||||
* Orientační spotřeba běžných aktivit v kcal na 100 jednotek.
|
||||
*
|
||||
* Hodnoty jsou hrubý odhad pro člověka kolem 75 kg a slouží jen jako
|
||||
* předvyplnění — uživatel si je může kdykoli přepsat.
|
||||
*/
|
||||
export const ACTIVITY_PRESETS: { name: string, unit: ActivityUnit, caloriesPer100Units: number }[] = [
|
||||
{ name: 'Chůze', unit: ActivityUnit.KROKY, caloriesPer100Units: 4 },
|
||||
{ name: 'Svižná chůze', unit: ActivityUnit.MINUTY, caloriesPer100Units: 500 },
|
||||
{ name: 'Běh', unit: ActivityUnit.MINUTY, caloriesPer100Units: 1000 },
|
||||
{ name: 'Kolo', unit: ActivityUnit.MINUTY, caloriesPer100Units: 700 },
|
||||
{ name: 'Plavání', unit: ActivityUnit.MINUTY, caloriesPer100Units: 800 },
|
||||
{ name: 'Posilovna', unit: ActivityUnit.MINUTY, caloriesPer100Units: 600 },
|
||||
{ name: 'Kliky', unit: ActivityUnit.OPAKOVANI, caloriesPer100Units: 50 },
|
||||
{ name: 'Dřepy', unit: ActivityUnit.OPAKOVANI, caloriesPer100Units: 40 },
|
||||
{ name: 'Shyby', unit: ActivityUnit.OPAKOVANI, caloriesPer100Units: 100 },
|
||||
];
|
||||
@@ -0,0 +1,35 @@
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
||||
monospace;
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--ft-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: var(--ft-primary-light);
|
||||
color: var(--ft-primary);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { BrowserRouter } from 'react-router';
|
||||
import { toast } from 'react-toastify';
|
||||
import { ProvideAuth } from './context/auth';
|
||||
import { ProvideSettings } from './context/settings';
|
||||
import AppRoutes from './AppRoutes';
|
||||
import { client } from '../../types/gen/client.gen';
|
||||
import { getToken } from './Utils';
|
||||
import 'bootstrap/dist/css/bootstrap.min.css';
|
||||
import 'react-toastify/dist/ReactToastify.css';
|
||||
import './index.css';
|
||||
import './App.scss';
|
||||
|
||||
client.setConfig({
|
||||
auth: () => getToken(),
|
||||
baseUrl: '/api', // openapi-ts si to neumí převzít z api.yml
|
||||
});
|
||||
|
||||
// Interceptor na vyhození toasteru při chybě
|
||||
client.interceptors.response.use(async response => {
|
||||
// Login je výjimka — voláme ho "naprázdno", abychom zjistili, zda nás nepřihlásily trusted headers
|
||||
if (!response.ok && !response.url.includes('/login')) {
|
||||
const json = await response.clone().json().catch(() => ({}));
|
||||
toast.error(json.error ?? 'Něco se nepovedlo', { theme: 'colored' });
|
||||
}
|
||||
return response;
|
||||
});
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<ProvideAuth>
|
||||
<ProvideSettings>
|
||||
<BrowserRouter>
|
||||
<AppRoutes />
|
||||
</BrowserRouter>
|
||||
</ProvideSettings>
|
||||
</ProvideAuth>
|
||||
</React.StrictMode>
|
||||
);
|
||||
@@ -0,0 +1,200 @@
|
||||
.day-picker {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
padding: 12px 16px;
|
||||
|
||||
.day-picker-current {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.day-picker-date {
|
||||
font-weight: 700;
|
||||
font-size: 1.05rem;
|
||||
color: var(--ft-text);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.day-picker-weekday {
|
||||
font-size: 0.82rem;
|
||||
color: var(--ft-text-secondary);
|
||||
text-transform: lowercase;
|
||||
}
|
||||
|
||||
.day-picker-input {
|
||||
width: auto;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
@media (max-width: 576px) {
|
||||
flex-wrap: wrap;
|
||||
|
||||
.day-picker-input {
|
||||
order: 10;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.meal-group {
|
||||
margin-bottom: 20px;
|
||||
|
||||
.meal-group-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--ft-text-secondary);
|
||||
margin: 0 4px 8px;
|
||||
}
|
||||
|
||||
.meal-group-icon {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.meal-group-total {
|
||||
margin-left: auto;
|
||||
color: var(--ft-text-muted);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0;
|
||||
text-transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
.meal-list {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.meal-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 14px 18px;
|
||||
border-bottom: 1px solid var(--ft-border-light);
|
||||
transition: var(--ft-transition);
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--ft-bg-hover);
|
||||
}
|
||||
|
||||
.meal-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.meal-name {
|
||||
font-weight: 600;
|
||||
color: var(--ft-text);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.meal-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.meal-note {
|
||||
margin-top: 5px;
|
||||
font-size: 0.83rem;
|
||||
color: var(--ft-text-muted);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.meal-price {
|
||||
font-weight: 700;
|
||||
color: var(--ft-text);
|
||||
white-space: nowrap;
|
||||
padding-top: 1px;
|
||||
}
|
||||
|
||||
.meal-actions {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// ZÁLOŽKY PŘÍJEM / VÝDEJ
|
||||
// ============================================
|
||||
|
||||
.day-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-bottom: 16px;
|
||||
border-bottom: 1px solid var(--ft-border);
|
||||
}
|
||||
|
||||
.day-tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
padding: 10px 18px;
|
||||
margin-bottom: -1px;
|
||||
font-weight: 600;
|
||||
font-size: 0.92rem;
|
||||
color: var(--ft-text-secondary);
|
||||
transition: var(--ft-transition);
|
||||
|
||||
&:hover {
|
||||
color: var(--ft-text);
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: var(--ft-primary);
|
||||
border-bottom-color: var(--ft-primary);
|
||||
}
|
||||
|
||||
.day-tab-count {
|
||||
background: var(--ft-bg-hover);
|
||||
border-radius: 999px;
|
||||
padding: 1px 8px;
|
||||
font-size: 0.76rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
&.active .day-tab-count {
|
||||
background: var(--ft-primary-light);
|
||||
color: var(--ft-primary);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// BILANCE
|
||||
// ============================================
|
||||
|
||||
.balance-tile {
|
||||
&.deficit .summary-value {
|
||||
color: var(--ft-success);
|
||||
}
|
||||
|
||||
&.surplus .summary-value {
|
||||
color: var(--ft-warning);
|
||||
}
|
||||
}
|
||||
|
||||
// Spálené kalorie se odlišují od ceny, aby v seznamu nesplývaly
|
||||
.meal-price.burn {
|
||||
color: var(--ft-warning);
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Button, Spinner } from "react-bootstrap";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import {
|
||||
faChevronLeft, faChevronRight, faPlus, faPenToSquare, faTrash,
|
||||
faUtensils, faPersonRunning, faCoins, faFire, faScaleBalanced, faDumbbell,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { ActivityEntry, DayOverview, MealEntry, deleteActivity, deleteMeal, getDayOverview } from "../../../types";
|
||||
import {
|
||||
ACTIVITY_UNIT_SHORT, MEAL_TYPES_IN_ORDER, MEAL_TYPE_ICONS, MEAL_TYPE_NAMES,
|
||||
} from "../enums";
|
||||
import {
|
||||
formatBalance, formatCalories, formatDate, formatPrice, formatQuantity, formatWeight,
|
||||
getDayOfWeek, getHumanDate, shiftDate,
|
||||
} from "../Utils";
|
||||
import MealModal from "../components/modals/MealModal";
|
||||
import ActivityModal from "../components/modals/ActivityModal";
|
||||
import WorkoutModal from "../components/modals/WorkoutModal";
|
||||
import "./DayPage.scss";
|
||||
|
||||
/** Záložky dne — co do těla přišlo a co se vydalo. */
|
||||
type Tab = 'prijem' | 'vydej';
|
||||
|
||||
/** Prázdný přehled dne — použije se, než dorazí data ze serveru. */
|
||||
function emptyOverview(date: string): DayOverview {
|
||||
return {
|
||||
date,
|
||||
meals: { date, entries: [], totalPrice: 0, totalCalories: 0 },
|
||||
activities: { date, entries: [], totalCalories: 0 },
|
||||
energy: { intake: 0, activityBurn: 0, basal: 0, totalBurn: 0, balance: 0, hasBasal: false },
|
||||
};
|
||||
}
|
||||
|
||||
export default function DayPage() {
|
||||
const [date, setDate] = useState<string>(() => formatDate(new Date()));
|
||||
const [day, setDay] = useState<DayOverview>(() => emptyOverview(date));
|
||||
const [tab, setTab] = useState<Tab>('prijem');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editedMeal, setEditedMeal] = useState<MealEntry | undefined>();
|
||||
const [mealModalOpen, setMealModalOpen] = useState(false);
|
||||
const [editedActivity, setEditedActivity] = useState<ActivityEntry | undefined>();
|
||||
const [activityModalOpen, setActivityModalOpen] = useState(false);
|
||||
const [workoutModalOpen, setWorkoutModalOpen] = useState(false);
|
||||
|
||||
const loadDay = useCallback(async (forDate: string) => {
|
||||
setLoading(true);
|
||||
const response = await getDayOverview({ query: { date: forDate } });
|
||||
setDay(response.data ?? emptyOverview(forDate));
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => { loadDay(date); }, [date, loadDay]);
|
||||
|
||||
const isToday = date === formatDate(new Date());
|
||||
|
||||
/** Jídla rozdělená do chodů — zobrazují se jen chody, které něco obsahují. */
|
||||
const mealGroups = useMemo(() => MEAL_TYPES_IN_ORDER
|
||||
.map(mealType => ({
|
||||
mealType,
|
||||
entries: day.meals.entries.filter(entry => entry.mealType === mealType),
|
||||
}))
|
||||
.filter(group => group.entries.length > 0), [day.meals.entries]);
|
||||
|
||||
const { energy } = day;
|
||||
const isDeficit = energy.balance < 0;
|
||||
|
||||
const removeMeal = async (entry: MealEntry) => {
|
||||
await deleteMeal({ body: { id: entry.id, date: entry.date } });
|
||||
loadDay(date);
|
||||
};
|
||||
|
||||
const removeActivity = async (entry: ActivityEntry) => {
|
||||
const response = await deleteActivity({ body: { id: entry.id, date: entry.date } });
|
||||
if (response.data) setDay(response.data);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-header">
|
||||
<h1>Můj den</h1>
|
||||
{tab === 'prijem' ? (
|
||||
<Button onClick={() => { setEditedMeal(undefined); setMealModalOpen(true); }}>
|
||||
<FontAwesomeIcon icon={faPlus} /> Přidat jídlo
|
||||
</Button>
|
||||
) : (
|
||||
<div className="header-actions">
|
||||
<Button variant="outline-secondary" onClick={() => setWorkoutModalOpen(true)}>
|
||||
<FontAwesomeIcon icon={faDumbbell} /> Šablony
|
||||
</Button>
|
||||
<Button onClick={() => { setEditedActivity(undefined); setActivityModalOpen(true); }}>
|
||||
<FontAwesomeIcon icon={faPlus} /> Přidat aktivitu
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="day-picker card-panel">
|
||||
<button
|
||||
className="btn-icon"
|
||||
onClick={() => setDate(shiftDate(date, -1))}
|
||||
aria-label="Předchozí den"
|
||||
title="Předchozí den"
|
||||
>
|
||||
<FontAwesomeIcon icon={faChevronLeft} />
|
||||
</button>
|
||||
<div className="day-picker-current">
|
||||
<div className="day-picker-date">{getHumanDate(date)}</div>
|
||||
<div className="day-picker-weekday">{isToday ? 'dnes' : getDayOfWeek(date)}</div>
|
||||
</div>
|
||||
<button
|
||||
className="btn-icon"
|
||||
onClick={() => setDate(shiftDate(date, 1))}
|
||||
aria-label="Následující den"
|
||||
title="Následující den"
|
||||
>
|
||||
<FontAwesomeIcon icon={faChevronRight} />
|
||||
</button>
|
||||
<input
|
||||
type="date"
|
||||
className="form-control day-picker-input"
|
||||
value={date}
|
||||
onChange={event => event.target.value && setDate(event.target.value)}
|
||||
/>
|
||||
{!isToday && (
|
||||
<Button variant="outline-secondary" size="sm" onClick={() => setDate(formatDate(new Date()))}>
|
||||
Dnes
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="summary-grid">
|
||||
<div className="summary-tile">
|
||||
<div className="summary-label"><FontAwesomeIcon icon={faUtensils} /> Příjem</div>
|
||||
<div className="summary-value">{formatCalories(energy.intake)}</div>
|
||||
<div className="summary-hint">{formatPrice(day.meals.totalPrice)}</div>
|
||||
</div>
|
||||
<div className="summary-tile">
|
||||
<div className="summary-label"><FontAwesomeIcon icon={faFire} /> Výdej</div>
|
||||
<div className="summary-value">{formatCalories(energy.totalBurn)}</div>
|
||||
<div className="summary-hint">
|
||||
{energy.hasBasal
|
||||
? `klid ${energy.basal} + pohyb ${energy.activityBurn} kcal`
|
||||
: `pohyb ${energy.activityBurn} kcal`}
|
||||
</div>
|
||||
</div>
|
||||
<div className={`summary-tile balance-tile ${isDeficit ? 'deficit' : 'surplus'}`}>
|
||||
<div className="summary-label"><FontAwesomeIcon icon={faScaleBalanced} /> Bilance</div>
|
||||
<div className="summary-value">{formatBalance(energy.balance)}</div>
|
||||
<div className="summary-hint">
|
||||
{energy.hasBasal
|
||||
? (isDeficit ? 'jste v deficitu' : 'jste v přebytku')
|
||||
: 'nastavte klidový výdej pro skutečný deficit'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="summary-tile accent">
|
||||
<div className="summary-label"><FontAwesomeIcon icon={faCoins} /> Útrata</div>
|
||||
<div className="summary-value">{formatPrice(day.meals.totalPrice)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="day-tabs">
|
||||
<button
|
||||
className={`day-tab ${tab === 'prijem' ? 'active' : ''}`}
|
||||
onClick={() => setTab('prijem')}
|
||||
>
|
||||
<FontAwesomeIcon icon={faUtensils} /> Příjem
|
||||
<span className="day-tab-count">{day.meals.entries.length}</span>
|
||||
</button>
|
||||
<button
|
||||
className={`day-tab ${tab === 'vydej' ? 'active' : ''}`}
|
||||
onClick={() => setTab('vydej')}
|
||||
>
|
||||
<FontAwesomeIcon icon={faPersonRunning} /> Výdej
|
||||
<span className="day-tab-count">{day.activities.entries.length}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading && (
|
||||
<div className="empty-state">
|
||||
<Spinner animation="border" variant="success" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && tab === 'prijem' && (
|
||||
<>
|
||||
{!day.meals.entries.length && (
|
||||
<div className="card-panel empty-state">
|
||||
<div className="empty-icon"><FontAwesomeIcon icon={faUtensils} /></div>
|
||||
<div className="empty-title">Za tento den zatím nic nemáte</div>
|
||||
<p className="empty-description">
|
||||
Přidejte jídlo ručně, nebo si ve statistikách naimportujte měsíční přehled z Luncheru.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{mealGroups.map(group => (
|
||||
<div className="meal-group" key={group.mealType}>
|
||||
<h2 className="meal-group-title">
|
||||
<span className="meal-group-icon">{MEAL_TYPE_ICONS[group.mealType]}</span>
|
||||
{MEAL_TYPE_NAMES[group.mealType]}
|
||||
<span className="meal-group-total">
|
||||
{formatPrice(group.entries.reduce((sum, entry) => sum + (entry.price ?? 0), 0))}
|
||||
</span>
|
||||
</h2>
|
||||
<div className="meal-list card-panel">
|
||||
{group.entries.map(entry => (
|
||||
<MealRow
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
onEdit={() => { setEditedMeal(entry); setMealModalOpen(true); }}
|
||||
onDelete={() => removeMeal(entry)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{!loading && tab === 'vydej' && (
|
||||
<>
|
||||
{!day.activities.entries.length && (
|
||||
<div className="card-panel empty-state">
|
||||
<div className="empty-icon"><FontAwesomeIcon icon={faPersonRunning} /></div>
|
||||
<div className="empty-title">Za tento den nemáte žádný pohyb</div>
|
||||
<p className="empty-description">
|
||||
Přidejte aktivitu, nebo použijte šablonu tréninku a založí se celá naráz.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{!!day.activities.entries.length && (
|
||||
<div className="meal-list card-panel">
|
||||
{day.activities.entries.map(entry => (
|
||||
<ActivityRow
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
onEdit={() => { setEditedActivity(entry); setActivityModalOpen(true); }}
|
||||
onDelete={() => removeActivity(entry)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<MealModal
|
||||
isOpen={mealModalOpen}
|
||||
date={date}
|
||||
entry={editedMeal}
|
||||
onClose={() => setMealModalOpen(false)}
|
||||
onSaved={saved => {
|
||||
setMealModalOpen(false);
|
||||
// Úprava mohla jídlo přesunout jinam — pak přepneme na jeho nový den
|
||||
if (saved.date !== date) {
|
||||
setDate(saved.date);
|
||||
} else {
|
||||
loadDay(date);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<ActivityModal
|
||||
isOpen={activityModalOpen}
|
||||
date={date}
|
||||
entry={editedActivity}
|
||||
onClose={() => setActivityModalOpen(false)}
|
||||
onSaved={saved => {
|
||||
setActivityModalOpen(false);
|
||||
if (saved.date !== date) {
|
||||
setDate(saved.date);
|
||||
} else {
|
||||
setDay(saved);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<WorkoutModal
|
||||
isOpen={workoutModalOpen}
|
||||
date={date}
|
||||
onClose={() => setWorkoutModalOpen(false)}
|
||||
onApplied={saved => {
|
||||
setDay(saved);
|
||||
setWorkoutModalOpen(false);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type MealRowProps = {
|
||||
entry: MealEntry,
|
||||
onEdit: () => void,
|
||||
onDelete: () => void,
|
||||
};
|
||||
|
||||
/** Jeden řádek seznamu jídel. */
|
||||
function MealRow({ entry, onEdit, onDelete }: Readonly<MealRowProps>) {
|
||||
return (
|
||||
<div className="meal-row">
|
||||
<div className="meal-main">
|
||||
<div className="meal-name">{entry.name}</div>
|
||||
<div className="meal-meta">
|
||||
{entry.source && <span className="tag">{entry.source}</span>}
|
||||
{entry.importSource && <span className="tag tag-import">z Luncheru</span>}
|
||||
{entry.weight != null && <span className="tag">{formatWeight(entry.weight)}</span>}
|
||||
{entry.calories != null && <span className="tag">{formatCalories(entry.calories)}</span>}
|
||||
</div>
|
||||
{entry.note && <div className="meal-note">{entry.note}</div>}
|
||||
</div>
|
||||
<div className="meal-price">{formatPrice(entry.price)}</div>
|
||||
<RowActions onEdit={onEdit} onDelete={onDelete} label="jídlo" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type ActivityRowProps = {
|
||||
entry: ActivityEntry,
|
||||
onEdit: () => void,
|
||||
onDelete: () => void,
|
||||
};
|
||||
|
||||
/** Jeden řádek seznamu aktivit. */
|
||||
function ActivityRow({ entry, onEdit, onDelete }: Readonly<ActivityRowProps>) {
|
||||
return (
|
||||
<div className="meal-row">
|
||||
<div className="meal-main">
|
||||
<div className="meal-name">{entry.name}</div>
|
||||
<div className="meal-meta">
|
||||
<span className="tag">{formatQuantity(entry.quantity, ACTIVITY_UNIT_SHORT[entry.unit])}</span>
|
||||
{entry.templateId && <span className="tag tag-import">ze šablony</span>}
|
||||
</div>
|
||||
{entry.note && <div className="meal-note">{entry.note}</div>}
|
||||
</div>
|
||||
<div className="meal-price burn">{formatCalories(entry.calories)}</div>
|
||||
<RowActions onEdit={onEdit} onDelete={onDelete} label="aktivitu" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Tlačítka pro úpravu a smazání řádku. */
|
||||
function RowActions({ onEdit, onDelete, label }: Readonly<{ onEdit: () => void, onDelete: () => void, label: string }>) {
|
||||
return (
|
||||
<div className="meal-actions">
|
||||
<button className="btn-icon" onClick={onEdit} title="Upravit" aria-label={`Upravit ${label}`}>
|
||||
<FontAwesomeIcon icon={faPenToSquare} />
|
||||
</button>
|
||||
<button className="btn-icon danger" onClick={onDelete} title="Smazat" aria-label={`Smazat ${label}`}>
|
||||
<FontAwesomeIcon icon={faTrash} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
.range-picker {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 16px;
|
||||
padding: 12px 16px;
|
||||
|
||||
.range-modes {
|
||||
display: inline-flex;
|
||||
background: var(--ft-bg-hover);
|
||||
border-radius: var(--ft-radius-sm);
|
||||
padding: 3px;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.range-mode {
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: calc(var(--ft-radius-sm) - 2px);
|
||||
padding: 5px 16px;
|
||||
font-size: 0.87rem;
|
||||
font-weight: 600;
|
||||
color: var(--ft-text-secondary);
|
||||
transition: var(--ft-transition);
|
||||
|
||||
&:hover {
|
||||
color: var(--ft-text);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: var(--ft-bg-card);
|
||||
color: var(--ft-primary);
|
||||
box-shadow: var(--ft-shadow-sm);
|
||||
}
|
||||
}
|
||||
|
||||
.range-input {
|
||||
width: auto;
|
||||
min-width: 140px;
|
||||
}
|
||||
}
|
||||
|
||||
.chart-panel {
|
||||
margin-bottom: 16px;
|
||||
|
||||
.chart-title {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--ft-text-secondary);
|
||||
margin: 0 0 14px;
|
||||
}
|
||||
|
||||
// Recharts kreslí popisky vlastními <text>, barvu jim musíme vnutit
|
||||
.recharts-legend-item-text,
|
||||
.recharts-cartesian-axis-tick-value {
|
||||
fill: var(--ft-text-secondary) !important;
|
||||
color: var(--ft-text-secondary) !important;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
}
|
||||
|
||||
.chart-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.source-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.9rem;
|
||||
|
||||
th {
|
||||
text-align: left;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--ft-text-muted);
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid var(--ft-border);
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 9px 0;
|
||||
border-bottom: 1px solid var(--ft-border-light);
|
||||
color: var(--ft-text);
|
||||
}
|
||||
|
||||
tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.numeric {
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
td.numeric {
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.import-preview-list {
|
||||
list-style: none;
|
||||
margin: 12px 0 0;
|
||||
padding: 0;
|
||||
font-size: 0.85rem;
|
||||
|
||||
li {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
padding: 4px 0;
|
||||
border-top: 1px solid var(--ft-border-light);
|
||||
}
|
||||
|
||||
.import-preview-date {
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--ft-text-secondary);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.import-preview-price {
|
||||
margin-left: auto;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.import-warning {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
font-size: 0.82rem;
|
||||
color: var(--ft-warning);
|
||||
padding: 3px 0;
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Button, Spinner } from "react-bootstrap";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faCoins, faFire, faListCheck, faCalendarDay, faFileImport, faChartColumn } from "@fortawesome/free-solid-svg-icons";
|
||||
import {
|
||||
Bar, BarChart, CartesianGrid, Cell, Pie, PieChart, ResponsiveContainer, Tooltip, XAxis, YAxis, Legend,
|
||||
} from "recharts";
|
||||
import { FoodStats, MealType, getFoodStats } from "../../../types";
|
||||
import { MEAL_TYPE_NAMES } from "../enums";
|
||||
import {
|
||||
formatCalories, formatDateString, formatMonth, formatPrice, formatPriceShort,
|
||||
getHumanMonth, getMonthEnd, getMonthStart,
|
||||
} from "../Utils";
|
||||
import ImportModal from "../components/modals/ImportModal";
|
||||
import "./StatsPage.scss";
|
||||
|
||||
/** Rozsahy, za které lze statistiky zobrazit. */
|
||||
type RangeMode = 'month' | 'year' | 'all';
|
||||
|
||||
const RANGE_LABELS: Record<RangeMode, string> = {
|
||||
month: 'Měsíc',
|
||||
year: 'Rok',
|
||||
all: 'Vše',
|
||||
};
|
||||
|
||||
/** Nejstarší rok, do kterého sahá volba "Vše". Starší data nikdo evidovat nebude. */
|
||||
const EARLIEST_YEAR = 2015;
|
||||
|
||||
/** Barvy výsečí grafu dle typu jídla — laděné do zelené palety aplikace. */
|
||||
const PIE_COLORS = ['#16a34a', '#22c55e', '#4ade80', '#86efac', '#0ea5e9', '#a3a3a3'];
|
||||
|
||||
/** Vzhled bubliny s hodnotou — recharts ji kreslí inline stylem, ne přes CSS třídu. */
|
||||
const TOOLTIP_STYLE = {
|
||||
background: 'var(--ft-bg-card)',
|
||||
border: '1px solid var(--ft-border)',
|
||||
borderRadius: 'var(--ft-radius-sm)',
|
||||
color: 'var(--ft-text)',
|
||||
};
|
||||
|
||||
/** Naformátuje hodnotu v bublině grafu na koruny. Hodnoty chodí z rechartsu jako `unknown`. */
|
||||
function formatChartPrice(value: unknown): string {
|
||||
return `${Number(value).toLocaleString('cs-CZ')} Kč`;
|
||||
}
|
||||
|
||||
export default function StatsPage() {
|
||||
const [mode, setMode] = useState<RangeMode>('month');
|
||||
const [month, setMonth] = useState<string>(() => formatMonth(new Date()));
|
||||
const [year, setYear] = useState<number>(() => new Date().getFullYear());
|
||||
const [stats, setStats] = useState<FoodStats | undefined>();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [importOpen, setImportOpen] = useState(false);
|
||||
|
||||
/** Přepočte hranice období z aktuálně zvoleného rozsahu. */
|
||||
const range = useMemo(() => {
|
||||
if (mode === 'month') {
|
||||
return { from: getMonthStart(month), to: getMonthEnd(month) };
|
||||
}
|
||||
if (mode === 'year') {
|
||||
return { from: `${year}-01-01`, to: `${year}-12-31` };
|
||||
}
|
||||
return { from: `${EARLIEST_YEAR}-01-01`, to: `${new Date().getFullYear()}-12-31` };
|
||||
}, [mode, month, year]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
const response = await getFoodStats({ query: range });
|
||||
setStats(response.data);
|
||||
setLoading(false);
|
||||
}, [range]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
/** Řada pro sloupcový graf — dny u měsíce, měsíce u roku, roky u celého přehledu. */
|
||||
const series = useMemo(() => {
|
||||
if (!stats) return [];
|
||||
if (mode === 'month') {
|
||||
return stats.byDay.map(total => ({
|
||||
label: formatDateString(total.period).slice(0, 5),
|
||||
price: total.price / 100,
|
||||
calories: total.calories,
|
||||
}));
|
||||
}
|
||||
if (mode === 'year') {
|
||||
return stats.byMonth.map(total => ({
|
||||
label: getHumanMonth(total.period).split(' ')[0],
|
||||
price: total.price / 100,
|
||||
calories: total.calories,
|
||||
}));
|
||||
}
|
||||
return stats.byYear.map(total => ({
|
||||
label: total.period,
|
||||
price: total.price / 100,
|
||||
calories: total.calories,
|
||||
}));
|
||||
}, [stats, mode]);
|
||||
|
||||
const pieData = useMemo(() => (stats?.byMealType ?? [])
|
||||
.filter(total => total.price > 0)
|
||||
.map(total => ({
|
||||
name: MEAL_TYPE_NAMES[total.key as MealType] ?? total.key,
|
||||
value: total.price / 100,
|
||||
})), [stats]);
|
||||
|
||||
const averagePerDay = stats?.dayCount ? Math.round(stats.totalPrice / stats.dayCount) : 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h1>Statistiky</h1>
|
||||
<p className="page-subtitle">Kolik jste utratili za jídlo a za co</p>
|
||||
</div>
|
||||
<Button onClick={() => setImportOpen(true)}>
|
||||
<FontAwesomeIcon icon={faFileImport} /> Import z Luncheru
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="range-picker card-panel">
|
||||
<div className="range-modes">
|
||||
{(Object.keys(RANGE_LABELS) as RangeMode[]).map(value => (
|
||||
<button
|
||||
key={value}
|
||||
className={`range-mode ${mode === value ? 'active' : ''}`}
|
||||
onClick={() => setMode(value)}
|
||||
>
|
||||
{RANGE_LABELS[value]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{mode === 'month' && (
|
||||
<input
|
||||
type="month"
|
||||
className="form-control range-input"
|
||||
value={month}
|
||||
onChange={event => event.target.value && setMonth(event.target.value)}
|
||||
/>
|
||||
)}
|
||||
{mode === 'year' && (
|
||||
<input
|
||||
type="number"
|
||||
className="form-control range-input"
|
||||
value={year}
|
||||
min={EARLIEST_YEAR}
|
||||
max={new Date().getFullYear()}
|
||||
onChange={event => setYear(Number(event.target.value))}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="summary-grid">
|
||||
<div className="summary-tile accent">
|
||||
<div className="summary-label"><FontAwesomeIcon icon={faCoins} /> Celkem utraceno</div>
|
||||
<div className="summary-value">{formatPrice(stats?.totalPrice ?? 0)}</div>
|
||||
</div>
|
||||
<div className="summary-tile">
|
||||
<div className="summary-label"><FontAwesomeIcon icon={faCalendarDay} /> Průměr na den</div>
|
||||
<div className="summary-value">{formatPrice(averagePerDay)}</div>
|
||||
<div className="summary-hint">z {stats?.dayCount ?? 0} vykázaných dní</div>
|
||||
</div>
|
||||
<div className="summary-tile">
|
||||
<div className="summary-label"><FontAwesomeIcon icon={faListCheck} /> Jídel</div>
|
||||
<div className="summary-value">{stats?.entryCount ?? 0}</div>
|
||||
</div>
|
||||
<div className="summary-tile">
|
||||
<div className="summary-label"><FontAwesomeIcon icon={faFire} /> Kalorie</div>
|
||||
<div className="summary-value">{formatCalories(stats?.totalCalories)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading && (
|
||||
<div className="empty-state">
|
||||
<Spinner animation="border" variant="success" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !stats?.entryCount && (
|
||||
<div className="card-panel empty-state">
|
||||
<div className="empty-icon"><FontAwesomeIcon icon={faChartColumn} /></div>
|
||||
<div className="empty-title">Za zvolené období nemáte žádná data</div>
|
||||
<p className="empty-description">
|
||||
Přidejte jídla v přehledu dne, nebo si sem naimportujte měsíční přehled z Luncheru.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !!stats?.entryCount && (
|
||||
<>
|
||||
<div className="card-panel chart-panel">
|
||||
<h2 className="chart-title">Útrata v čase</h2>
|
||||
<ResponsiveContainer width="100%" height={260}>
|
||||
<BarChart data={series} margin={{ top: 8, right: 8, bottom: 0, left: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--ft-border)" vertical={false} />
|
||||
<XAxis dataKey="label" tick={{ fontSize: 12 }} stroke="var(--ft-text-muted)" />
|
||||
<YAxis tick={{ fontSize: 12 }} stroke="var(--ft-text-muted)" width={56} />
|
||||
<Tooltip
|
||||
formatter={value => [formatChartPrice(value), 'Útrata']}
|
||||
contentStyle={TOOLTIP_STYLE}
|
||||
/>
|
||||
<Bar dataKey="price" fill="var(--ft-primary)" radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
<div className="chart-row">
|
||||
<div className="card-panel chart-panel">
|
||||
<h2 className="chart-title">Podle chodu</h2>
|
||||
<ResponsiveContainer width="100%" height={240}>
|
||||
<PieChart>
|
||||
<Pie data={pieData} dataKey="value" nameKey="name" innerRadius={50} outerRadius={85}>
|
||||
{pieData.map((entry, index) => (
|
||||
<Cell key={entry.name} fill={PIE_COLORS[index % PIE_COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Legend />
|
||||
<Tooltip
|
||||
formatter={value => formatChartPrice(value)}
|
||||
contentStyle={TOOLTIP_STYLE}
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
<div className="card-panel chart-panel">
|
||||
<h2 className="chart-title">Kde nejvíc utrácíte</h2>
|
||||
<table className="source-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Zdroj</th>
|
||||
<th className="numeric">Jídel</th>
|
||||
<th className="numeric">Útrata</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{stats.bySource.slice(0, 8).map(total => (
|
||||
<tr key={total.key}>
|
||||
<td>{total.key}</td>
|
||||
<td className="numeric">{total.count}</td>
|
||||
<td className="numeric">{formatPriceShort(total.price)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<ImportModal
|
||||
isOpen={importOpen}
|
||||
onClose={() => setImportOpen(false)}
|
||||
onImported={load}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Adresy stránek aplikace.
|
||||
*
|
||||
* Žijí ve vlastním modulu, a ne v AppRoutes — hlavička je potřebuje, AppRoutes
|
||||
* potřebuje hlavičku, a kruhový import by konstanty nechal při načtení nedefinované.
|
||||
*/
|
||||
export const DEN_URL = '/';
|
||||
export const STATISTIKY_URL = '/statistiky';
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"types": ["vite/client"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"moduleResolution": "bundler",
|
||||
"module": "ESNext",
|
||||
"target": "ESNext",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": ["src", "../types"]
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import viteTsconfigPaths from 'vite-tsconfig-paths'
|
||||
|
||||
export default defineConfig({
|
||||
base: '',
|
||||
plugins: [react(), viteTsconfigPaths()],
|
||||
build: {
|
||||
sourcemap: true,
|
||||
},
|
||||
server: {
|
||||
open: true,
|
||||
host: '0.0.0.0',
|
||||
port: 3000,
|
||||
proxy: {
|
||||
'/api': 'http://localhost:3001',
|
||||
}
|
||||
},
|
||||
})
|
||||
+1527
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user