Compare commits
2 Commits
276cc21c5a
...
1d5b730e11
| Author | SHA1 | Date | |
|---|---|---|---|
| 1d5b730e11 | |||
| 072f44c213 |
22
AGENTS.md
22
AGENTS.md
@ -33,21 +33,33 @@ It describes what the project is, what is already implemented, and what still ne
|
|||||||
- Top bar/title: `frontend/src/components/navigation/AppTopbar.vue`.
|
- Top bar/title: `frontend/src/components/navigation/AppTopbar.vue`.
|
||||||
- Pages: `TodayView`, `MealsView`, `MealDetailView`, `IngredientsView`, `StatsView`, `SettingsView`.
|
- Pages: `TodayView`, `MealsView`, `MealDetailView`, `IngredientsView`, `StatsView`, `SettingsView`.
|
||||||
- Route guard is active for `/app/*` (requires token), guest-only for `/`.
|
- Route guard is active for `/app/*` (requires token), guest-only for `/`.
|
||||||
|
- Frontend UX layer is implemented:
|
||||||
|
- global toasts: `frontend/src/components/common/ToastHost.vue`
|
||||||
|
- global confirm modal: `frontend/src/components/common/ConfirmModalHost.vue`
|
||||||
|
- state management: `frontend/src/stores/ui.ts`
|
||||||
|
- mounted globally in `frontend/src/App.vue`
|
||||||
|
- card-level loading/error states are wired in `TodayView`, `MealsView`, `MealDetailView`, `IngredientsView`
|
||||||
- Frontend i18n is wired and used across new UI:
|
- Frontend i18n is wired and used across new UI:
|
||||||
- setup in `frontend/src/i18n/index.ts`
|
- setup in `frontend/src/i18n/index.ts`
|
||||||
- locale files in `frontend/src/locales/{sk,cs,en,es,de}.json`
|
- locale files in `frontend/src/locales/{sk,cs,en,es,de}.json`
|
||||||
- language switcher updates locale dynamically.
|
- language switcher updates locale dynamically.
|
||||||
- new app UI keys are added (`nav`, `pageTitles`, `mealTypes`, `common`, `nutrition`, `today`, `meals`, `ingredients`, `stats`, `settings`).
|
- app UI keys include (`nav`, `pageTitles`, `mealTypes`, `common`, `nutrition`, `today`, `meals`, `ingredients`, `stats`, `settings`, `ux`)
|
||||||
- Frontend theme system is implemented:
|
- Frontend theme system is implemented:
|
||||||
- light/dark mode toggle in auth page
|
- centralized theme store: `frontend/src/stores/theme.ts`
|
||||||
|
- shared toggle component: `frontend/src/components/common/ThemeToggle.vue`
|
||||||
|
- toggle available in auth, app topbar, and settings
|
||||||
- design tokens in `frontend/src/assets/css/style.css` (`:root` variables).
|
- design tokens in `frontend/src/assets/css/style.css` (`:root` variables).
|
||||||
- App logo is served from `frontend/public/Nutrio.png` (copied from `doc/Nutrio.png`).
|
- App logo is served from `frontend/public/Nutrio.png` (copied from `doc/Nutrio.png`).
|
||||||
- Font Awesome is installed and registered globally in `frontend/src/main.ts`.
|
- Font Awesome is installed and registered globally in `frontend/src/main.ts`.
|
||||||
- Pinia is installed and configured in `frontend/src/main.ts` via `frontend/src/stores/index.ts`.
|
- Pinia is installed and configured in `frontend/src/main.ts` via `frontend/src/stores/index.ts`.
|
||||||
- Frontend domain/store structure exists:
|
- Frontend domain/store structure exists:
|
||||||
- `frontend/src/types/domain.ts`
|
- `frontend/src/types/domain.ts`
|
||||||
- `frontend/src/stores/{auth,ingredients,meals,diary}.ts`
|
- `frontend/src/stores/{auth,theme,ui,ingredients,meals,diary}.ts`
|
||||||
- `frontend/src/utils/{nutrition,api,date}.ts`
|
- `frontend/src/utils/{nutrition,api,date,error}.ts`
|
||||||
|
- Ingredients form UX detail:
|
||||||
|
- create/edit form in `IngredientsView` is hidden by default
|
||||||
|
- shows only after `Nová surovina` or `Upraviť`
|
||||||
|
- hides again after successful save (form remount resets fields)
|
||||||
- `frontend/src/BackendAPI.ts` is generated via `backend/scripts/buildTypeScript.php` and should not be edited manually.
|
- `frontend/src/BackendAPI.ts` is generated via `backend/scripts/buildTypeScript.php` and should not be edited manually.
|
||||||
- `backend/data.json` contains sample meal data (not currently wired into DB/API flow).
|
- `backend/data.json` contains sample meal data (not currently wired into DB/API flow).
|
||||||
|
|
||||||
@ -181,7 +193,7 @@ Frontend:
|
|||||||
## Product Behavior Target (what to build next)
|
## Product Behavior Target (what to build next)
|
||||||
|
|
||||||
- Harden auth (token transport/header strategy, token revoke strategy, brute-force/rate-limits).
|
- Harden auth (token transport/header strategy, token revoke strategy, brute-force/rate-limits).
|
||||||
- Polish authenticated frontend UX (validation messages, delete confirmations, optimistic updates, better loading/error states).
|
- Polish authenticated frontend UX further (more granular field validation, retry actions, richer empty states).
|
||||||
- Add diary range screen/workflow on frontend (backend endpoint already exists).
|
- Add diary range screen/workflow on frontend (backend endpoint already exists).
|
||||||
- Add i18n coverage for any future UI additions and keep keys stable.
|
- Add i18n coverage for any future UI additions and keep keys stable.
|
||||||
- Add API tests for validation, ownership checks, and totals calculation consistency.
|
- Add API tests for validation, ownership checks, and totals calculation consistency.
|
||||||
|
|||||||
@ -41,5 +41,8 @@ Výstup: konkrétny návrh + ukážky kódu, nie všeobecné rady.
|
|||||||
----- 2026-02-14 07:16:25 -----------------------------------------------------
|
----- 2026-02-14 07:16:25 -----------------------------------------------------
|
||||||
dopln kluce pre UI texty pre preklad, pre kluce pouzivaj anglictinu, dopln potom aj vsetky preklady vo frontend/src/locales/*.json
|
dopln kluce pre UI texty pre preklad, pre kluce pouzivaj anglictinu, dopln potom aj vsetky preklady vo frontend/src/locales/*.json
|
||||||
|
|
||||||
----- 2026-02-14 07:33:30 -----------------------------------------------------
|
----- 2026-02-14 08:05:38 -----------------------------------------------------
|
||||||
doplniť jemnejší UX flow (toasty, confirm modaly pri delete, loading/error states per card)
|
na zobrazeni app/ingredients sprav aby formular pre pridanie novej suroviny sa zobrazil az ked kliknem na tlacitko Nova surovina, a po uspesnom ulozeni ten formular pre pridanie novej suroviny skry, nezabudni vynulovat udaje vo formulari po ulozeni
|
||||||
|
|
||||||
|
----- 2026-02-14 08:08:33 -----------------------------------------------------
|
||||||
|
dopln jemnejší UX flow (toasty, confirm modaly pri delete, loading/error states per card)
|
||||||
@ -1,7 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { RouterView } from 'vue-router'
|
import { RouterView } from 'vue-router'
|
||||||
|
import ToastHost from '@/components/common/ToastHost.vue'
|
||||||
|
import ConfirmModalHost from '@/components/common/ConfirmModalHost.vue'
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<RouterView />
|
<RouterView />
|
||||||
|
<ToastHost />
|
||||||
|
<ConfirmModalHost />
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -633,6 +633,98 @@ select {
|
|||||||
font-size: var(--fs-sm);
|
font-size: var(--fs-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.card-state {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--color-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-state--error {
|
||||||
|
color: var(--color-error);
|
||||||
|
background: var(--color-error-bg);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 0.65rem 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-host {
|
||||||
|
position: fixed;
|
||||||
|
right: var(--space-md);
|
||||||
|
bottom: var(--space-md);
|
||||||
|
z-index: 40;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-item {
|
||||||
|
min-width: min(360px, calc(100vw - 2rem));
|
||||||
|
max-width: min(460px, calc(100vw - 2rem));
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
background: var(--color-surface);
|
||||||
|
box-shadow: var(--shadow-soft);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 0.65rem 0.75rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-item--success {
|
||||||
|
border-color: color-mix(in srgb, var(--color-green) 50%, var(--color-border));
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-item--error {
|
||||||
|
border-color: color-mix(in srgb, var(--color-error) 50%, var(--color-border));
|
||||||
|
background: color-mix(in srgb, var(--color-error-bg) 50%, var(--color-surface));
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-item--info {
|
||||||
|
border-color: color-mix(in srgb, var(--color-gray) 40%, var(--color-border));
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-item__close {
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.42);
|
||||||
|
backdrop-filter: blur(2px);
|
||||||
|
z-index: 50;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: var(--space-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-modal {
|
||||||
|
width: min(520px, 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-modal h3 {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-modal p {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: var(--space-md);
|
||||||
|
color: var(--color-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-modal__actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
.app-bottom-tabs {
|
.app-bottom-tabs {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
@ -699,4 +791,15 @@ select {
|
|||||||
.app-topbar__actions {
|
.app-topbar__actions {
|
||||||
gap: var(--space-xs);
|
gap: var(--space-xs);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.toast-host {
|
||||||
|
left: var(--space-sm);
|
||||||
|
right: var(--space-sm);
|
||||||
|
bottom: calc(62px + var(--space-sm));
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-item {
|
||||||
|
min-width: auto;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
27
frontend/src/components/common/ConfirmModalHost.vue
Normal file
27
frontend/src/components/common/ConfirmModalHost.vue
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useUIStore } from '@/stores/ui'
|
||||||
|
|
||||||
|
const ui = useUIStore()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
v-if="ui.confirmState.open"
|
||||||
|
class="confirm-backdrop"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
>
|
||||||
|
<div class="confirm-modal card">
|
||||||
|
<h3>{{ ui.confirmState.title }}</h3>
|
||||||
|
<p>{{ ui.confirmState.message }}</p>
|
||||||
|
<div class="confirm-modal__actions">
|
||||||
|
<button type="button" class="btn" @click="ui.resolveConfirm(false)">
|
||||||
|
{{ ui.confirmState.cancelLabel }}
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-danger" @click="ui.resolveConfirm(true)">
|
||||||
|
{{ ui.confirmState.confirmLabel }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
28
frontend/src/components/common/ToastHost.vue
Normal file
28
frontend/src/components/common/ToastHost.vue
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { useUIStore } from '@/stores/ui'
|
||||||
|
|
||||||
|
const ui = useUIStore()
|
||||||
|
const { t } = useI18n()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="toast-host" aria-live="polite" aria-atomic="true">
|
||||||
|
<div
|
||||||
|
v-for="toast in ui.toasts"
|
||||||
|
:key="toast.id"
|
||||||
|
class="toast-item"
|
||||||
|
:class="`toast-item--${toast.type}`"
|
||||||
|
>
|
||||||
|
<span>{{ toast.message }}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="toast-item__close"
|
||||||
|
@click="ui.removeToast(toast.id)"
|
||||||
|
:aria-label="t('common.close')"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@ -71,10 +71,40 @@
|
|||||||
"create": "Vytvořit",
|
"create": "Vytvořit",
|
||||||
"edit": "Upravit",
|
"edit": "Upravit",
|
||||||
"delete": "Smazat",
|
"delete": "Smazat",
|
||||||
|
"close": "Zavřít",
|
||||||
"actions": "Akce",
|
"actions": "Akce",
|
||||||
"loading": "Načítám...",
|
"loading": "Načítám...",
|
||||||
"kcalUnit": "kcal"
|
"kcalUnit": "kcal"
|
||||||
},
|
},
|
||||||
|
"ux": {
|
||||||
|
"toast": {
|
||||||
|
"created": "Položka byla vytvořena.",
|
||||||
|
"saved": "Změny byly uloženy.",
|
||||||
|
"updated": "Změny byly aplikovány.",
|
||||||
|
"deleted": "Položka byla smazána."
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"loadDay": "Nepodařilo se načíst den.",
|
||||||
|
"updateDay": "Nepodařilo se upravit denní jídla.",
|
||||||
|
"loadMeals": "Nepodařilo se načíst jídelníčky.",
|
||||||
|
"createMeal": "Nepodařilo se vytvořit jídelníček.",
|
||||||
|
"loadMeal": "Nepodařilo se načíst jídelníček.",
|
||||||
|
"saveMeal": "Nepodařilo se uložit jídelníček.",
|
||||||
|
"deleteMeal": "Nepodařilo se smazat jídelníček.",
|
||||||
|
"addMealItem": "Nepodařilo se přidat položku jídelníčku.",
|
||||||
|
"updateMealItem": "Nepodařilo se upravit položku jídelníčku.",
|
||||||
|
"deleteMealItem": "Nepodařilo se smazat položku jídelníčku.",
|
||||||
|
"loadIngredients": "Nepodařilo se načíst suroviny.",
|
||||||
|
"saveIngredient": "Nepodařilo se uložit surovinu.",
|
||||||
|
"deleteIngredient": "Nepodařilo se smazat surovinu."
|
||||||
|
},
|
||||||
|
"confirm": {
|
||||||
|
"deleteTitle": "Potvrď smazání",
|
||||||
|
"deleteMeal": "Opravdu chceš smazat tento jídelníček?",
|
||||||
|
"deleteMealItem": "Opravdu chceš smazat tuto položku jídelníčku?",
|
||||||
|
"deleteIngredient": "Opravdu chceš smazat tuto surovinu?"
|
||||||
|
}
|
||||||
|
},
|
||||||
"nutrition": {
|
"nutrition": {
|
||||||
"short": {
|
"short": {
|
||||||
"protein": "B",
|
"protein": "B",
|
||||||
|
|||||||
@ -71,10 +71,40 @@
|
|||||||
"create": "Erstellen",
|
"create": "Erstellen",
|
||||||
"edit": "Bearbeiten",
|
"edit": "Bearbeiten",
|
||||||
"delete": "Löschen",
|
"delete": "Löschen",
|
||||||
|
"close": "Schließen",
|
||||||
"actions": "Aktion",
|
"actions": "Aktion",
|
||||||
"loading": "Lädt...",
|
"loading": "Lädt...",
|
||||||
"kcalUnit": "kcal"
|
"kcalUnit": "kcal"
|
||||||
},
|
},
|
||||||
|
"ux": {
|
||||||
|
"toast": {
|
||||||
|
"created": "Element wurde erstellt.",
|
||||||
|
"saved": "Änderungen wurden gespeichert.",
|
||||||
|
"updated": "Änderungen wurden übernommen.",
|
||||||
|
"deleted": "Element wurde gelöscht."
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"loadDay": "Tag konnte nicht geladen werden.",
|
||||||
|
"updateDay": "Tagesmahlzeiten konnten nicht aktualisiert werden.",
|
||||||
|
"loadMeals": "Mahlzeiten konnten nicht geladen werden.",
|
||||||
|
"createMeal": "Mahlzeitenplan konnte nicht erstellt werden.",
|
||||||
|
"loadMeal": "Mahlzeitenplan konnte nicht geladen werden.",
|
||||||
|
"saveMeal": "Mahlzeitenplan konnte nicht gespeichert werden.",
|
||||||
|
"deleteMeal": "Mahlzeitenplan konnte nicht gelöscht werden.",
|
||||||
|
"addMealItem": "Mahlzeiten-Element konnte nicht hinzugefügt werden.",
|
||||||
|
"updateMealItem": "Mahlzeiten-Element konnte nicht aktualisiert werden.",
|
||||||
|
"deleteMealItem": "Mahlzeiten-Element konnte nicht gelöscht werden.",
|
||||||
|
"loadIngredients": "Zutaten konnten nicht geladen werden.",
|
||||||
|
"saveIngredient": "Zutat konnte nicht gespeichert werden.",
|
||||||
|
"deleteIngredient": "Zutat konnte nicht gelöscht werden."
|
||||||
|
},
|
||||||
|
"confirm": {
|
||||||
|
"deleteTitle": "Löschen bestätigen",
|
||||||
|
"deleteMeal": "Möchtest du diesen Mahlzeitenplan wirklich löschen?",
|
||||||
|
"deleteMealItem": "Möchtest du dieses Mahlzeiten-Element wirklich löschen?",
|
||||||
|
"deleteIngredient": "Möchtest du diese Zutat wirklich löschen?"
|
||||||
|
}
|
||||||
|
},
|
||||||
"nutrition": {
|
"nutrition": {
|
||||||
"short": {
|
"short": {
|
||||||
"protein": "E",
|
"protein": "E",
|
||||||
|
|||||||
@ -71,10 +71,40 @@
|
|||||||
"create": "Create",
|
"create": "Create",
|
||||||
"edit": "Edit",
|
"edit": "Edit",
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
|
"close": "Close",
|
||||||
"actions": "Action",
|
"actions": "Action",
|
||||||
"loading": "Loading...",
|
"loading": "Loading...",
|
||||||
"kcalUnit": "kcal"
|
"kcalUnit": "kcal"
|
||||||
},
|
},
|
||||||
|
"ux": {
|
||||||
|
"toast": {
|
||||||
|
"created": "Item was created.",
|
||||||
|
"saved": "Changes were saved.",
|
||||||
|
"updated": "Changes were applied.",
|
||||||
|
"deleted": "Item was deleted."
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"loadDay": "Failed to load the day.",
|
||||||
|
"updateDay": "Failed to update day meals.",
|
||||||
|
"loadMeals": "Failed to load meals.",
|
||||||
|
"createMeal": "Failed to create meal plan.",
|
||||||
|
"loadMeal": "Failed to load meal plan.",
|
||||||
|
"saveMeal": "Failed to save meal plan.",
|
||||||
|
"deleteMeal": "Failed to delete meal plan.",
|
||||||
|
"addMealItem": "Failed to add meal item.",
|
||||||
|
"updateMealItem": "Failed to update meal item.",
|
||||||
|
"deleteMealItem": "Failed to delete meal item.",
|
||||||
|
"loadIngredients": "Failed to load ingredients.",
|
||||||
|
"saveIngredient": "Failed to save ingredient.",
|
||||||
|
"deleteIngredient": "Failed to delete ingredient."
|
||||||
|
},
|
||||||
|
"confirm": {
|
||||||
|
"deleteTitle": "Confirm delete",
|
||||||
|
"deleteMeal": "Do you really want to delete this meal plan?",
|
||||||
|
"deleteMealItem": "Do you really want to delete this meal item?",
|
||||||
|
"deleteIngredient": "Do you really want to delete this ingredient?"
|
||||||
|
}
|
||||||
|
},
|
||||||
"nutrition": {
|
"nutrition": {
|
||||||
"short": {
|
"short": {
|
||||||
"protein": "P",
|
"protein": "P",
|
||||||
|
|||||||
@ -71,10 +71,40 @@
|
|||||||
"create": "Crear",
|
"create": "Crear",
|
||||||
"edit": "Editar",
|
"edit": "Editar",
|
||||||
"delete": "Eliminar",
|
"delete": "Eliminar",
|
||||||
|
"close": "Cerrar",
|
||||||
"actions": "Acción",
|
"actions": "Acción",
|
||||||
"loading": "Cargando...",
|
"loading": "Cargando...",
|
||||||
"kcalUnit": "kcal"
|
"kcalUnit": "kcal"
|
||||||
},
|
},
|
||||||
|
"ux": {
|
||||||
|
"toast": {
|
||||||
|
"created": "Elemento creado.",
|
||||||
|
"saved": "Cambios guardados.",
|
||||||
|
"updated": "Cambios aplicados.",
|
||||||
|
"deleted": "Elemento eliminado."
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"loadDay": "No se pudo cargar el día.",
|
||||||
|
"updateDay": "No se pudieron actualizar las comidas del día.",
|
||||||
|
"loadMeals": "No se pudieron cargar los menús.",
|
||||||
|
"createMeal": "No se pudo crear el menú.",
|
||||||
|
"loadMeal": "No se pudo cargar el menú.",
|
||||||
|
"saveMeal": "No se pudo guardar el menú.",
|
||||||
|
"deleteMeal": "No se pudo eliminar el menú.",
|
||||||
|
"addMealItem": "No se pudo añadir el elemento del menú.",
|
||||||
|
"updateMealItem": "No se pudo actualizar el elemento del menú.",
|
||||||
|
"deleteMealItem": "No se pudo eliminar el elemento del menú.",
|
||||||
|
"loadIngredients": "No se pudieron cargar los ingredientes.",
|
||||||
|
"saveIngredient": "No se pudo guardar el ingrediente.",
|
||||||
|
"deleteIngredient": "No se pudo eliminar el ingrediente."
|
||||||
|
},
|
||||||
|
"confirm": {
|
||||||
|
"deleteTitle": "Confirmar eliminación",
|
||||||
|
"deleteMeal": "¿De verdad quieres eliminar este menú?",
|
||||||
|
"deleteMealItem": "¿De verdad quieres eliminar este elemento del menú?",
|
||||||
|
"deleteIngredient": "¿De verdad quieres eliminar este ingrediente?"
|
||||||
|
}
|
||||||
|
},
|
||||||
"nutrition": {
|
"nutrition": {
|
||||||
"short": {
|
"short": {
|
||||||
"protein": "P",
|
"protein": "P",
|
||||||
|
|||||||
@ -71,10 +71,40 @@
|
|||||||
"create": "Vytvoriť",
|
"create": "Vytvoriť",
|
||||||
"edit": "Upraviť",
|
"edit": "Upraviť",
|
||||||
"delete": "Zmazať",
|
"delete": "Zmazať",
|
||||||
|
"close": "Zavrieť",
|
||||||
"actions": "Akcia",
|
"actions": "Akcia",
|
||||||
"loading": "Načítavam...",
|
"loading": "Načítavam...",
|
||||||
"kcalUnit": "kcal"
|
"kcalUnit": "kcal"
|
||||||
},
|
},
|
||||||
|
"ux": {
|
||||||
|
"toast": {
|
||||||
|
"created": "Položka bola vytvorená.",
|
||||||
|
"saved": "Zmeny boli uložené.",
|
||||||
|
"updated": "Zmeny boli aplikované.",
|
||||||
|
"deleted": "Položka bola zmazaná."
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"loadDay": "Nepodarilo sa načítať deň.",
|
||||||
|
"updateDay": "Nepodarilo sa upraviť denné jedlá.",
|
||||||
|
"loadMeals": "Nepodarilo sa načítať jedálničky.",
|
||||||
|
"createMeal": "Nepodarilo sa vytvoriť jedálniček.",
|
||||||
|
"loadMeal": "Nepodarilo sa načítať jedálniček.",
|
||||||
|
"saveMeal": "Nepodarilo sa uložiť jedálniček.",
|
||||||
|
"deleteMeal": "Nepodarilo sa zmazať jedálniček.",
|
||||||
|
"addMealItem": "Nepodarilo sa pridať položku jedálnička.",
|
||||||
|
"updateMealItem": "Nepodarilo sa upraviť položku jedálnička.",
|
||||||
|
"deleteMealItem": "Nepodarilo sa zmazať položku jedálnička.",
|
||||||
|
"loadIngredients": "Nepodarilo sa načítať suroviny.",
|
||||||
|
"saveIngredient": "Nepodarilo sa uložiť surovinu.",
|
||||||
|
"deleteIngredient": "Nepodarilo sa zmazať surovinu."
|
||||||
|
},
|
||||||
|
"confirm": {
|
||||||
|
"deleteTitle": "Potvrď zmazanie",
|
||||||
|
"deleteMeal": "Naozaj chceš zmazať tento jedálniček?",
|
||||||
|
"deleteMealItem": "Naozaj chceš zmazať túto položku jedálnička?",
|
||||||
|
"deleteIngredient": "Naozaj chceš zmazať túto surovinu?"
|
||||||
|
}
|
||||||
|
},
|
||||||
"nutrition": {
|
"nutrition": {
|
||||||
"short": {
|
"short": {
|
||||||
"protein": "B",
|
"protein": "B",
|
||||||
|
|||||||
84
frontend/src/stores/ui.ts
Normal file
84
frontend/src/stores/ui.ts
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
|
||||||
|
export type ToastType = 'success' | 'error' | 'info'
|
||||||
|
|
||||||
|
export type ToastItem = {
|
||||||
|
id: number
|
||||||
|
type: ToastType
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ConfirmOptions = {
|
||||||
|
title: string
|
||||||
|
message: string
|
||||||
|
confirmLabel: string
|
||||||
|
cancelLabel: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ConfirmState = ConfirmOptions & {
|
||||||
|
open: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
let toastId = 0
|
||||||
|
let confirmResolver: ((value: boolean) => void) | null = null
|
||||||
|
|
||||||
|
export const useUIStore = defineStore('ui', () => {
|
||||||
|
const toasts = ref<ToastItem[]>([])
|
||||||
|
|
||||||
|
const confirmState = ref<ConfirmState>({
|
||||||
|
open: false,
|
||||||
|
title: '',
|
||||||
|
message: '',
|
||||||
|
confirmLabel: '',
|
||||||
|
cancelLabel: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const removeToast = (id: number) => {
|
||||||
|
toasts.value = toasts.value.filter((item) => item.id !== id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const pushToast = (type: ToastType, message: string, timeoutMs = 3200) => {
|
||||||
|
const id = ++toastId
|
||||||
|
toasts.value = [...toasts.value, { id, type, message }]
|
||||||
|
setTimeout(() => removeToast(id), timeoutMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
const success = (message: string) => pushToast('success', message)
|
||||||
|
const error = (message: string) => pushToast('error', message)
|
||||||
|
const info = (message: string) => pushToast('info', message)
|
||||||
|
|
||||||
|
const confirm = (options: ConfirmOptions): Promise<boolean> => {
|
||||||
|
if (confirmResolver) {
|
||||||
|
confirmResolver(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
confirmState.value = {
|
||||||
|
open: true,
|
||||||
|
...options,
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise<boolean>((resolve) => {
|
||||||
|
confirmResolver = resolve
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveConfirm = (value: boolean) => {
|
||||||
|
confirmState.value.open = false
|
||||||
|
if (confirmResolver) {
|
||||||
|
confirmResolver(value)
|
||||||
|
confirmResolver = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
toasts,
|
||||||
|
confirmState,
|
||||||
|
removeToast,
|
||||||
|
success,
|
||||||
|
error,
|
||||||
|
info,
|
||||||
|
confirm,
|
||||||
|
resolveConfirm,
|
||||||
|
}
|
||||||
|
})
|
||||||
11
frontend/src/utils/error.ts
Normal file
11
frontend/src/utils/error.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
export const toErrorMessage = (error: unknown, fallback: string): string => {
|
||||||
|
if (typeof error === 'string' && error.length > 0) {
|
||||||
|
return error
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof Error && typeof error.message === 'string' && error.message.length > 0) {
|
||||||
|
return error.message
|
||||||
|
}
|
||||||
|
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
@ -3,17 +3,29 @@ import { computed, onMounted, ref } from 'vue'
|
|||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import IngredientForm from '@/components/ingredients/IngredientForm.vue'
|
import IngredientForm from '@/components/ingredients/IngredientForm.vue'
|
||||||
import { useIngredientsStore } from '@/stores/ingredients'
|
import { useIngredientsStore } from '@/stores/ingredients'
|
||||||
|
import { useUIStore } from '@/stores/ui'
|
||||||
import type { Ingredient } from '@/types/domain'
|
import type { Ingredient } from '@/types/domain'
|
||||||
|
import { toErrorMessage } from '@/utils/error'
|
||||||
|
|
||||||
const ingredientsStore = useIngredientsStore()
|
const ingredientsStore = useIngredientsStore()
|
||||||
|
const ui = useUIStore()
|
||||||
const editingId = ref<number | null>(null)
|
const editingId = ref<number | null>(null)
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
|
const formMode = ref<'hidden' | 'create' | 'edit'>('hidden')
|
||||||
|
const loadError = ref('')
|
||||||
|
const actionError = ref('')
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
loadError.value = ''
|
||||||
if (ingredientsStore.items.length <= 0) {
|
if (ingredientsStore.items.length <= 0) {
|
||||||
await ingredientsStore.loadIngredients()
|
await ingredientsStore.loadIngredients()
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
|
loadError.value = toErrorMessage(error, t('ux.errors.loadIngredients'))
|
||||||
|
ui.error(loadError.value)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const editingIngredient = computed<Ingredient | null>(() => {
|
const editingIngredient = computed<Ingredient | null>(() => {
|
||||||
@ -24,15 +36,21 @@ const editingIngredient = computed<Ingredient | null>(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const startCreate = () => {
|
const startCreate = () => {
|
||||||
|
actionError.value = ''
|
||||||
editingId.value = null
|
editingId.value = null
|
||||||
|
formMode.value = 'create'
|
||||||
}
|
}
|
||||||
|
|
||||||
const startEdit = (ingredientId: number) => {
|
const startEdit = (ingredientId: number) => {
|
||||||
|
actionError.value = ''
|
||||||
editingId.value = ingredientId
|
editingId.value = ingredientId
|
||||||
|
formMode.value = 'edit'
|
||||||
}
|
}
|
||||||
|
|
||||||
const cancelEdit = () => {
|
const cancelEdit = () => {
|
||||||
editingId.value = null
|
editingId.value = null
|
||||||
|
formMode.value = 'hidden'
|
||||||
|
actionError.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
const saveIngredient = async (payload: {
|
const saveIngredient = async (payload: {
|
||||||
@ -45,22 +63,48 @@ const saveIngredient = async (payload: {
|
|||||||
kcal_100: number
|
kcal_100: number
|
||||||
}) => {
|
}) => {
|
||||||
saving.value = true
|
saving.value = true
|
||||||
|
actionError.value = ''
|
||||||
try {
|
try {
|
||||||
if (editingIngredient.value) {
|
if (editingIngredient.value) {
|
||||||
await ingredientsStore.updateIngredient(editingIngredient.value.ingredient_id, payload)
|
await ingredientsStore.updateIngredient(editingIngredient.value.ingredient_id, payload)
|
||||||
|
ui.success(t('ux.toast.saved'))
|
||||||
} else {
|
} else {
|
||||||
await ingredientsStore.createIngredient(payload)
|
await ingredientsStore.createIngredient(payload)
|
||||||
|
ui.success(t('ux.toast.created'))
|
||||||
}
|
}
|
||||||
editingId.value = null
|
editingId.value = null
|
||||||
|
formMode.value = 'hidden'
|
||||||
|
} catch (error) {
|
||||||
|
actionError.value = toErrorMessage(error, t('ux.errors.saveIngredient'))
|
||||||
|
ui.error(actionError.value)
|
||||||
} finally {
|
} finally {
|
||||||
saving.value = false
|
saving.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const removeIngredient = async (ingredientId: number) => {
|
const removeIngredient = async (ingredientId: number) => {
|
||||||
|
const confirmed = await ui.confirm({
|
||||||
|
title: t('ux.confirm.deleteTitle'),
|
||||||
|
message: t('ux.confirm.deleteIngredient'),
|
||||||
|
confirmLabel: t('common.delete'),
|
||||||
|
cancelLabel: t('common.cancel'),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!confirmed) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
actionError.value = ''
|
||||||
|
try {
|
||||||
await ingredientsStore.deleteIngredient(ingredientId)
|
await ingredientsStore.deleteIngredient(ingredientId)
|
||||||
|
ui.success(t('ux.toast.deleted'))
|
||||||
if (editingId.value === ingredientId) {
|
if (editingId.value === ingredientId) {
|
||||||
editingId.value = null
|
editingId.value = null
|
||||||
|
formMode.value = 'hidden'
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
actionError.value = toErrorMessage(error, t('ux.errors.deleteIngredient'))
|
||||||
|
ui.error(actionError.value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@ -68,6 +112,7 @@ const removeIngredient = async (ingredientId: number) => {
|
|||||||
<template>
|
<template>
|
||||||
<section class="page">
|
<section class="page">
|
||||||
<IngredientForm
|
<IngredientForm
|
||||||
|
v-if="formMode !== 'hidden'"
|
||||||
:initial="editingIngredient"
|
:initial="editingIngredient"
|
||||||
:submit-label="saving ? t('common.saving') : t('common.save')"
|
:submit-label="saving ? t('common.saving') : t('common.save')"
|
||||||
@save="saveIngredient"
|
@save="saveIngredient"
|
||||||
@ -79,7 +124,11 @@ const removeIngredient = async (ingredientId: number) => {
|
|||||||
<h3>{{ t('ingredients.databaseTitle') }}</h3>
|
<h3>{{ t('ingredients.databaseTitle') }}</h3>
|
||||||
<button class="btn" type="button" @click="startCreate">{{ t('ingredients.newButton') }}</button>
|
<button class="btn" type="button" @click="startCreate">{{ t('ingredients.newButton') }}</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="list" v-if="ingredientsStore.sortedItems.length > 0">
|
|
||||||
|
<p v-if="loadError" class="card-state card-state--error">{{ loadError }}</p>
|
||||||
|
<p v-else-if="ingredientsStore.loading" class="card-state">{{ t('common.loading') }}</p>
|
||||||
|
<p v-else-if="actionError" class="card-state card-state--error">{{ actionError }}</p>
|
||||||
|
<div class="list" v-else-if="ingredientsStore.sortedItems.length > 0">
|
||||||
<div class="list-row" v-for="ingredient in ingredientsStore.sortedItems" :key="ingredient.ingredient_id">
|
<div class="list-row" v-for="ingredient in ingredientsStore.sortedItems" :key="ingredient.ingredient_id">
|
||||||
<div>
|
<div>
|
||||||
<strong>{{ ingredient.name }}</strong>
|
<strong>{{ ingredient.name }}</strong>
|
||||||
|
|||||||
@ -5,18 +5,23 @@ import { useI18n } from 'vue-i18n'
|
|||||||
import MealItemsEditor from '@/components/meals/MealItemsEditor.vue'
|
import MealItemsEditor from '@/components/meals/MealItemsEditor.vue'
|
||||||
import { useIngredientsStore } from '@/stores/ingredients'
|
import { useIngredientsStore } from '@/stores/ingredients'
|
||||||
import { useMealsStore } from '@/stores/meals'
|
import { useMealsStore } from '@/stores/meals'
|
||||||
|
import { useUIStore } from '@/stores/ui'
|
||||||
import type { MealType } from '@/types/domain'
|
import type { MealType } from '@/types/domain'
|
||||||
|
import { toErrorMessage } from '@/utils/error'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const mealsStore = useMealsStore()
|
const mealsStore = useMealsStore()
|
||||||
const ingredientsStore = useIngredientsStore()
|
const ingredientsStore = useIngredientsStore()
|
||||||
|
const ui = useUIStore()
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
const mealName = ref('')
|
const mealName = ref('')
|
||||||
const mealType = ref<MealType>('breakfast')
|
const mealType = ref<MealType>('breakfast')
|
||||||
|
const loadError = ref('')
|
||||||
|
const actionError = ref('')
|
||||||
|
|
||||||
const mealId = computed(() => Number(route.params.mealId))
|
const mealId = computed(() => Number(route.params.mealId))
|
||||||
|
|
||||||
@ -43,11 +48,15 @@ const loadData = async () => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
loading.value = true
|
loading.value = true
|
||||||
|
loadError.value = ''
|
||||||
try {
|
try {
|
||||||
if (ingredientsStore.items.length <= 0) {
|
if (ingredientsStore.items.length <= 0) {
|
||||||
await ingredientsStore.loadIngredients()
|
await ingredientsStore.loadIngredients()
|
||||||
}
|
}
|
||||||
await mealsStore.loadMeal(mealId.value)
|
await mealsStore.loadMeal(mealId.value)
|
||||||
|
} catch (error) {
|
||||||
|
loadError.value = toErrorMessage(error, t('ux.errors.loadMeal'))
|
||||||
|
ui.error(loadError.value)
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
@ -60,12 +69,17 @@ const saveMeal = async () => {
|
|||||||
if (!meal.value) {
|
if (!meal.value) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
actionError.value = ''
|
||||||
saving.value = true
|
saving.value = true
|
||||||
try {
|
try {
|
||||||
await mealsStore.updateMeal(meal.value.meal_id, {
|
await mealsStore.updateMeal(meal.value.meal_id, {
|
||||||
name: mealName.value,
|
name: mealName.value,
|
||||||
meal_type: mealType.value,
|
meal_type: mealType.value,
|
||||||
})
|
})
|
||||||
|
ui.success(t('ux.toast.saved'))
|
||||||
|
} catch (error) {
|
||||||
|
actionError.value = toErrorMessage(error, t('ux.errors.saveMeal'))
|
||||||
|
ui.error(actionError.value)
|
||||||
} finally {
|
} finally {
|
||||||
saving.value = false
|
saving.value = false
|
||||||
}
|
}
|
||||||
@ -75,18 +89,44 @@ const removeMeal = async () => {
|
|||||||
if (!meal.value) {
|
if (!meal.value) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const confirmed = await ui.confirm({
|
||||||
|
title: t('ux.confirm.deleteTitle'),
|
||||||
|
message: t('ux.confirm.deleteMeal'),
|
||||||
|
confirmLabel: t('common.delete'),
|
||||||
|
cancelLabel: t('common.cancel'),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!confirmed) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
actionError.value = ''
|
||||||
|
try {
|
||||||
await mealsStore.deleteMeal(meal.value.meal_id)
|
await mealsStore.deleteMeal(meal.value.meal_id)
|
||||||
|
ui.success(t('ux.toast.deleted'))
|
||||||
await router.replace({ name: 'meals' })
|
await router.replace({ name: 'meals' })
|
||||||
|
} catch (error) {
|
||||||
|
actionError.value = toErrorMessage(error, t('ux.errors.deleteMeal'))
|
||||||
|
ui.error(actionError.value)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const addItem = async (payload: { ingredient_id: number; grams: number }) => {
|
const addItem = async (payload: { ingredient_id: number; grams: number }) => {
|
||||||
if (!meal.value) {
|
if (!meal.value) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
actionError.value = ''
|
||||||
|
try {
|
||||||
await mealsStore.addMealItem(meal.value.meal_id, {
|
await mealsStore.addMealItem(meal.value.meal_id, {
|
||||||
...payload,
|
...payload,
|
||||||
position: (meal.value.items?.length ?? 0) + 1,
|
position: (meal.value.items?.length ?? 0) + 1,
|
||||||
})
|
})
|
||||||
|
ui.success(t('ux.toast.created'))
|
||||||
|
} catch (error) {
|
||||||
|
actionError.value = toErrorMessage(error, t('ux.errors.addMealItem'))
|
||||||
|
ui.error(actionError.value)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateItem = async (payload: {
|
const updateItem = async (payload: {
|
||||||
@ -98,6 +138,8 @@ const updateItem = async (payload: {
|
|||||||
if (!meal.value) {
|
if (!meal.value) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
actionError.value = ''
|
||||||
|
try {
|
||||||
await mealsStore.updateMealItem(
|
await mealsStore.updateMealItem(
|
||||||
payload.meal_item_id,
|
payload.meal_item_id,
|
||||||
{
|
{
|
||||||
@ -107,19 +149,43 @@ const updateItem = async (payload: {
|
|||||||
},
|
},
|
||||||
meal.value.meal_id,
|
meal.value.meal_id,
|
||||||
)
|
)
|
||||||
|
} catch (error) {
|
||||||
|
actionError.value = toErrorMessage(error, t('ux.errors.updateMealItem'))
|
||||||
|
ui.error(actionError.value)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const removeItem = async (mealItemId: number) => {
|
const removeItem = async (mealItemId: number) => {
|
||||||
if (!meal.value) {
|
if (!meal.value) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const confirmed = await ui.confirm({
|
||||||
|
title: t('ux.confirm.deleteTitle'),
|
||||||
|
message: t('ux.confirm.deleteMealItem'),
|
||||||
|
confirmLabel: t('common.delete'),
|
||||||
|
cancelLabel: t('common.cancel'),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!confirmed) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
actionError.value = ''
|
||||||
|
try {
|
||||||
await mealsStore.deleteMealItem(meal.value.meal_id, mealItemId)
|
await mealsStore.deleteMealItem(meal.value.meal_id, mealItemId)
|
||||||
|
ui.success(t('ux.toast.deleted'))
|
||||||
|
} catch (error) {
|
||||||
|
actionError.value = toErrorMessage(error, t('ux.errors.deleteMealItem'))
|
||||||
|
ui.error(actionError.value)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="page">
|
<section class="page">
|
||||||
<div v-if="loading" class="card">{{ t('meals.loadingMeal') }}</div>
|
<div v-if="loading" class="card card-state">{{ t('meals.loadingMeal') }}</div>
|
||||||
|
<div v-else-if="loadError" class="card card-state card-state--error">{{ loadError }}</div>
|
||||||
|
|
||||||
<template v-else-if="meal">
|
<template v-else-if="meal">
|
||||||
<section class="card">
|
<section class="card">
|
||||||
@ -137,6 +203,7 @@ const removeItem = async (mealItemId: number) => {
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
<p v-if="actionError" class="card-state card-state--error">{{ actionError }}</p>
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button class="btn btn-primary" type="button" :disabled="saving" @click="saveMeal">
|
<button class="btn btn-primary" type="button" :disabled="saving" @click="saveMeal">
|
||||||
{{ saving ? t('common.saving') : t('meals.saveChanges') }}
|
{{ saving ? t('common.saving') : t('meals.saveChanges') }}
|
||||||
@ -154,6 +221,6 @@ const removeItem = async (mealItemId: number) => {
|
|||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div v-else class="card">{{ t('meals.notFound') }}</div>
|
<div v-else class="card card-state card-state--error">{{ t('meals.notFound') }}</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -4,21 +4,31 @@ import { useRouter } from 'vue-router'
|
|||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import MealTypeFilter from '@/components/meals/MealTypeFilter.vue'
|
import MealTypeFilter from '@/components/meals/MealTypeFilter.vue'
|
||||||
import { useMealsStore } from '@/stores/meals'
|
import { useMealsStore } from '@/stores/meals'
|
||||||
|
import { useUIStore } from '@/stores/ui'
|
||||||
import type { MealType } from '@/types/domain'
|
import type { MealType } from '@/types/domain'
|
||||||
|
import { toErrorMessage } from '@/utils/error'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const mealsStore = useMealsStore()
|
const mealsStore = useMealsStore()
|
||||||
|
const ui = useUIStore()
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
const filterType = ref<MealType | ''>('')
|
const filterType = ref<MealType | ''>('')
|
||||||
const newMealName = ref('')
|
const newMealName = ref('')
|
||||||
const newMealType = ref<MealType>('breakfast')
|
const newMealType = ref<MealType>('breakfast')
|
||||||
const creating = ref(false)
|
const creating = ref(false)
|
||||||
|
const errorMessage = ref('')
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
errorMessage.value = ''
|
||||||
if (mealsStore.list.length <= 0) {
|
if (mealsStore.list.length <= 0) {
|
||||||
await mealsStore.loadMeals()
|
await mealsStore.loadMeals()
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
|
errorMessage.value = toErrorMessage(error, t('ux.errors.loadMeals'))
|
||||||
|
ui.error(errorMessage.value)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const filteredMeals = computed(() => {
|
const filteredMeals = computed(() => {
|
||||||
@ -33,14 +43,20 @@ const createMeal = async () => {
|
|||||||
if (name.length <= 0) {
|
if (name.length <= 0) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
errorMessage.value = ''
|
||||||
creating.value = true
|
creating.value = true
|
||||||
try {
|
try {
|
||||||
const meal = await mealsStore.createMeal({
|
const meal = await mealsStore.createMeal({
|
||||||
name,
|
name,
|
||||||
meal_type: newMealType.value,
|
meal_type: newMealType.value,
|
||||||
})
|
})
|
||||||
|
ui.success(t('ux.toast.created'))
|
||||||
newMealName.value = ''
|
newMealName.value = ''
|
||||||
await router.push({ name: 'meal-detail', params: { mealId: meal.meal_id } })
|
await router.push({ name: 'meal-detail', params: { mealId: meal.meal_id } })
|
||||||
|
} catch (error) {
|
||||||
|
errorMessage.value = toErrorMessage(error, t('ux.errors.createMeal'))
|
||||||
|
ui.error(errorMessage.value)
|
||||||
} finally {
|
} finally {
|
||||||
creating.value = false
|
creating.value = false
|
||||||
}
|
}
|
||||||
@ -67,7 +83,9 @@ const createMeal = async () => {
|
|||||||
|
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<h3>{{ t('meals.libraryTitle') }}</h3>
|
<h3>{{ t('meals.libraryTitle') }}</h3>
|
||||||
<div class="list" v-if="filteredMeals.length > 0">
|
<p v-if="errorMessage" class="card-state card-state--error">{{ errorMessage }}</p>
|
||||||
|
<p v-else-if="mealsStore.loading" class="card-state">{{ t('common.loading') }}</p>
|
||||||
|
<div class="list" v-else-if="filteredMeals.length > 0">
|
||||||
<RouterLink
|
<RouterLink
|
||||||
v-for="meal in filteredMeals"
|
v-for="meal in filteredMeals"
|
||||||
:key="meal.meal_id"
|
:key="meal.meal_id"
|
||||||
|
|||||||
@ -6,17 +6,21 @@ import DayMealCard from '@/components/today/DayMealCard.vue'
|
|||||||
import DayTotalsCard from '@/components/today/DayTotalsCard.vue'
|
import DayTotalsCard from '@/components/today/DayTotalsCard.vue'
|
||||||
import { useDiaryStore } from '@/stores/diary'
|
import { useDiaryStore } from '@/stores/diary'
|
||||||
import { useMealsStore } from '@/stores/meals'
|
import { useMealsStore } from '@/stores/meals'
|
||||||
|
import { useUIStore } from '@/stores/ui'
|
||||||
import { MEAL_TYPES, type MealType } from '@/types/domain'
|
import { MEAL_TYPES, type MealType } from '@/types/domain'
|
||||||
import { todayISO } from '@/utils/date'
|
import { todayISO } from '@/utils/date'
|
||||||
|
import { toErrorMessage } from '@/utils/error'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const diaryStore = useDiaryStore()
|
const diaryStore = useDiaryStore()
|
||||||
const mealsStore = useMealsStore()
|
const mealsStore = useMealsStore()
|
||||||
|
const ui = useUIStore()
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
const isWorking = ref(false)
|
const isWorking = ref(false)
|
||||||
const selectedDate = ref(todayISO())
|
const selectedDate = ref(todayISO())
|
||||||
|
const errorMessage = ref('')
|
||||||
|
|
||||||
const resolveDate = (): string => {
|
const resolveDate = (): string => {
|
||||||
const dateFromRoute = typeof route.params.date === 'string' ? route.params.date : ''
|
const dateFromRoute = typeof route.params.date === 'string' ? route.params.date : ''
|
||||||
@ -28,12 +32,16 @@ const mealTypeLabel = (mealType: MealType): string => t(`mealTypes.${mealType}`)
|
|||||||
const reloadDay = async (date: string) => {
|
const reloadDay = async (date: string) => {
|
||||||
selectedDate.value = date
|
selectedDate.value = date
|
||||||
diaryStore.ensureCurrentDay(date)
|
diaryStore.ensureCurrentDay(date)
|
||||||
|
errorMessage.value = ''
|
||||||
isWorking.value = true
|
isWorking.value = true
|
||||||
try {
|
try {
|
||||||
if (mealsStore.list.length <= 0) {
|
if (mealsStore.list.length <= 0) {
|
||||||
await mealsStore.loadMeals()
|
await mealsStore.loadMeals()
|
||||||
}
|
}
|
||||||
await diaryStore.loadDay(date)
|
await diaryStore.loadDay(date)
|
||||||
|
} catch (error) {
|
||||||
|
errorMessage.value = toErrorMessage(error, t('ux.errors.loadDay'))
|
||||||
|
ui.error(errorMessage.value)
|
||||||
} finally {
|
} finally {
|
||||||
isWorking.value = false
|
isWorking.value = false
|
||||||
}
|
}
|
||||||
@ -55,11 +63,19 @@ const onDateChange = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onSelectMeal = async (mealType: MealType, mealId: number | null) => {
|
const onSelectMeal = async (mealType: MealType, mealId: number | null) => {
|
||||||
|
try {
|
||||||
|
errorMessage.value = ''
|
||||||
if (mealId === null) {
|
if (mealId === null) {
|
||||||
await diaryStore.unsetMealForType(selectedDate.value, mealType)
|
await diaryStore.unsetMealForType(selectedDate.value, mealType)
|
||||||
|
ui.success(t('ux.toast.updated'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
await diaryStore.setMealForType(selectedDate.value, mealType, mealId)
|
await diaryStore.setMealForType(selectedDate.value, mealType, mealId)
|
||||||
|
ui.success(t('ux.toast.updated'))
|
||||||
|
} catch (error) {
|
||||||
|
errorMessage.value = toErrorMessage(error, t('ux.errors.updateDay'))
|
||||||
|
ui.error(errorMessage.value)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const dayMeals = computed(() => diaryStore.mealsByType)
|
const dayMeals = computed(() => diaryStore.mealsByType)
|
||||||
@ -75,7 +91,8 @@ const dayTotals = computed(() => diaryStore.computedTotals)
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="isWorking || diaryStore.loading" class="card">{{ t('today.loadingDay') }}</div>
|
<div v-if="errorMessage" class="card card-state card-state--error">{{ errorMessage }}</div>
|
||||||
|
<div v-else-if="isWorking || diaryStore.loading" class="card card-state">{{ t('today.loadingDay') }}</div>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div class="grid-three">
|
<div class="grid-three">
|
||||||
|
|||||||
Reference in New Issue
Block a user