Files
duck-prod-manager/notes/stores.md
bartool 6e3d722e69 dodalem pinia store.
dodalem dokumentacje opisujaca dzialanie store
dodalem services do komunikacji z directus
dodalem opis dzialania services
dodalem mocki danych
przygotowania do refaktoryzacji
2026-04-30 16:49:50 +02:00

1072 lines
21 KiB
Markdown

# Frontend Stores
Data: 2026-04-29
## Cel dokumentu
Ten dokument opisuje pięć store Pinia dodanych do frontendu:
- `productsStore`
- `productTimelineStore`
- `productSpecificationStore`
- `dictionariesStore`
- `uiStore`
Store to miejsce, w którym frontend przechowuje stan aplikacji. Stanem są na przykład:
- produkty pobrane z backendu,
- aktualne filtry listy,
- informacja czy trwa ładowanie,
- pełna specyfikacja otwartego produktu,
- pełny timeline otwartego produktu,
- informacja jaki panel drawera jest aktualnie otwarty.
W tym projekcie store są napisane jako Pinia setup stores. To oznacza, że ich składnia jest podobna do Composition API w Vue: używają `ref`, `computed` i zwykłych funkcji.
## Najważniejsza zasada
Frontend nie powinien trzymać jednego ogromnego obiektu produktu, który zawiera wszystko.
Zamiast tego aplikacja używa kilku reprezentacji produktu:
- `ProductListItem` - lekki obiekt do głównej listy produktów.
- `ProductTimeline` - pełna historia produkcji dla jednego produktu.
- `ProductSpecification` - pełna specyfikacja dla jednego produktu.
- `Dictionaries` - słowniki do filtrów i formularzy.
- `UiState` - stan interfejsu, np. otwarty drawer.
To jest celowe. Lista produktów musi działać szybko, szczególnie przy virtual scroll i lazy loading. Dlatego lista dostaje tylko dane potrzebne do pokazania kart produktów. Cięższe dane, takie jak pełna specyfikacja albo pełna historia, są pobierane dopiero wtedy, gdy użytkownik ich potrzebuje.
## Normalizacja danych
W wielu store dane są przechowywane w strukturze:
```js
{
ids: [123, 124],
byId: {
123: { id: 123, model: 'Regius Core 6' },
124: { id: 124, model: 'Duvell Elite 7' },
},
}
```
To nazywa się normalizacją danych.
Zamiast trzymać jedną dużą tablicę i szukać produktu pętlą, store trzyma:
- `ids` - kolejność elementów na liście,
- `byId` - szybki dostęp do elementu po `id`.
Przykład:
```js
const product = productsStore.byId[123]
```
To jest wygodne, gdy trzeba zaktualizować jeden produkt bez przebudowywania całej listy.
## Ważne pojęcia JavaScript
### `ref`
`ref` tworzy reaktywną wartość.
```js
const isLoading = ref(false)
```
Wewnątrz store odczyt i zapis robi się przez `.value`:
```js
isLoading.value = true
```
W komponencie Vue, po użyciu store, zwykle nie trzeba pisać `.value` w template.
### `computed`
`computed` tworzy wartość wyliczaną z innych wartości.
W `productsStore` jest:
```js
const items = computed(() => ids.value.map((id) => byId.value[id]).filter(Boolean))
```
To znaczy:
- `ids` trzyma kolejność produktów,
- `byId` trzyma produkty,
- `items` zwraca gotową tablicę produktów do wyświetlenia.
### `async` i `await`
Funkcje pobierające dane z backendu są asynchroniczne.
```js
await productsStore.fetchFirstPage()
```
`await` oznacza: poczekaj, aż backend odpowie.
## `productsStore`
Plik:
```txt
frontend/src/stores/productsStore.js
```
### Odpowiedzialność
Ten store obsługuje główną listę produktów.
Przechowuje:
- lekkie dane produktów,
- kolejność produktów na liście,
- filtry,
- informacje do lazy loading,
- informację czy trwa pobieranie danych,
- błąd pobierania, jeśli wystąpił.
Ten store powinien dostać z backendu `ProductListItem`, czyli dane przygotowane specjalnie pod listę.
### Czego nie powinien przechowywać
Nie powinien przechowywać:
- pełnej specyfikacji produktu,
- pełnego timeline,
- wszystkich zdjęć,
- pełnych danych technicznych, jeśli nie są pokazywane na liście.
Jeśli na karcie produktu ma być widoczny timeline, ten store powinien trzymać tylko `timelinePreview`, czyli skrót historii.
### Spodziewana struktura stanu
```js
{
ids: [123, 124, 125],
byId: {
123: {
id: 123,
orderId: '0143/2025/1',
model: 'Regius Core 6',
client: 'HIENDGUITAR.COM / INDONESIA',
finish: 'S+M',
productionLists: ['CZE-00'],
timelinePreview: {
body: [
{
id: 901,
code: 'B',
label: 'Bejca',
date: '2026-04-20',
status: 'done',
},
],
neck: [
{
id: 902,
code: 'IZ',
label: 'Izolant',
date: '2026-04-21',
status: 'done',
},
],
},
},
},
filters: {
search: '',
modelId: null,
clientId: null,
finish: null,
productionListId: null,
year: null,
},
limit: 30,
offset: 30,
total: 184,
hasMore: true,
isLoading: false,
error: null,
}
```
### Spodziewana odpowiedź endpointu
Endpoint:
```http
GET /mayo-api/products?limit=30&offset=0&search=regius
```
Odpowiedź:
```json
{
"items": [
{
"id": 123,
"orderId": "0143/2025/1",
"model": "Regius Core 6",
"client": "HIENDGUITAR.COM / INDONESIA",
"finish": "S+M",
"productionLists": ["CZE-00"],
"timelinePreview": {
"body": [
{
"id": 901,
"code": "B",
"label": "Bejca",
"date": "2026-04-20",
"status": "done"
}
],
"neck": []
}
}
],
"pageInfo": {
"limit": 30,
"offset": 0,
"hasMore": true,
"total": 184
}
}
```
### Najważniejsze funkcje
```js
await productsStore.fetchFirstPage()
```
Pobiera pierwszą stronę produktów. Używaj tego:
- przy wejściu na ekran listy,
- po zmianie filtrów,
- po ręcznym odświeżeniu listy.
```js
await productsStore.fetchNextPage()
```
Pobiera kolejną porcję produktów. Używaj tego przy lazy loading, gdy użytkownik zbliża się do końca listy.
```js
await productsStore.applyFilters({ search: 'regius' })
```
Ustawia filtry i pobiera listę od początku.
```js
productsStore.applyTimelinePreviewUpdate(productId, timelinePreview)
```
Aktualizuje skrócony timeline na karcie produktu. Ta funkcja jest użyteczna po dodaniu nowego eventu do pełnego timeline.
### Przykład użycia w komponencie
```vue
<script setup>
import { onMounted } from 'vue'
import { useProductsStore } from 'src/stores/productsStore'
const productsStore = useProductsStore()
onMounted(() => {
productsStore.fetchFirstPage()
})
async function loadMore() {
await productsStore.fetchNextPage()
}
</script>
<template>
<div v-if="productsStore.isLoading">Ladowanie...</div>
<article v-for="product in productsStore.items" :key="product.id">
<h3>{{ product.model }}</h3>
<p>{{ product.orderId }}</p>
</article>
<button v-if="productsStore.hasMore" @click="loadMore">
Zaladuj wiecej
</button>
</template>
```
## `productTimelineStore`
Plik:
```txt
frontend/src/stores/productTimelineStore.js
```
### Odpowiedzialność
Ten store przechowuje pełny timeline produktów.
`productsStore` trzyma tylko `timelinePreview`, bo lista potrzebuje skrótu. `productTimelineStore` trzyma pełną historię, czyli wszystkie operacje, notatki i inne zdarzenia.
### Spodziewana struktura stanu
```js
{
byProductId: {
123: {
productId: 123,
events: [
{
id: 901,
productId: 123,
partId: 10,
partType: 'BODY',
type: 'operation',
operationId: 7,
operationCode: 'B',
operationName: 'Bejca',
date: '2026-04-20',
note: null,
photosCount: 0,
},
{
id: 944,
productId: 123,
partId: 10,
partType: 'BODY',
type: 'note',
operationId: null,
operationCode: null,
operationName: null,
date: '2026-04-23',
note: 'Problem z kolorem po pierwszej warstwie.',
photosCount: 2,
},
],
loadedAt: 1777460000000,
isLoading: false,
error: null,
},
},
}
```
### Spodziewana odpowiedź endpointu
Endpoint:
```http
GET /mayo-api/products/123/timeline
```
Odpowiedź:
```json
{
"productId": 123,
"events": [
{
"id": 901,
"productId": 123,
"partId": 10,
"partType": "BODY",
"type": "operation",
"operationId": 7,
"operationCode": "B",
"operationName": "Bejca",
"date": "2026-04-20",
"note": null,
"photosCount": 0
}
],
"timelinePreview": {
"body": [
{
"id": 901,
"code": "B",
"label": "Bejca",
"date": "2026-04-20",
"status": "done"
}
],
"neck": []
}
}
```
### Najważniejsze funkcje
```js
await timelineStore.fetchTimeline(productId)
```
Pobiera pełny timeline produktu, jeśli nie został wcześniej pobrany.
```js
await timelineStore.fetchTimeline(productId, { force: true })
```
Pobiera timeline ponownie, nawet jeśli jest już w pamięci.
```js
await timelineStore.addEvent(productId, payload)
```
Dodaje nowe zdarzenie do timeline. Po dodaniu eventu store może też zaktualizować `timelinePreview` w `productsStore`, jeśli backend zwróci nowy preview.
### Przykład użycia
```vue
<script setup>
import { computed, onMounted } from 'vue'
import { useProductTimelineStore } from 'src/stores/productTimelineStore'
const props = defineProps({
productId: {
type: Number,
required: true,
},
})
const timelineStore = useProductTimelineStore()
const events = computed(() => timelineStore.getEvents(props.productId))
onMounted(() => {
timelineStore.fetchTimeline(props.productId)
})
async function addOperation() {
await timelineStore.addEvent(props.productId, {
type: 'operation',
partId: 10,
operationId: 7,
date: '2026-04-29',
note: null,
})
}
</script>
```
## `productSpecificationStore`
Plik:
```txt
frontend/src/stores/productSpecificationStore.js
```
### Odpowiedzialność
Ten store przechowuje pełną specyfikację produktu.
Specyfikacja nie jest pobierana razem z główną listą. Powinna być pobrana dopiero wtedy, gdy użytkownik otworzy panel specyfikacji albo ekran szczegółów.
### Spodziewana struktura stanu
```js
{
byProductId: {
123: {
productId: 123,
orderId: '0143/2025/1',
sourceUrl: 'http://10.8.0.6/mayo2/index.php?...',
lastFetchedAt: '2026-04-22T10:30:00Z',
sections: [
{
key: 'szyjka',
label: 'Szyjka',
fields: [
{
key: 'radius',
label: 'Radius',
values: ['GITARA SETIUS/REGIUS/CUSTOM/ 16'],
},
{
key: 'drewno-szyjka',
label: 'Drewno szyjka',
values: [
'Klon amerykanski-Mahon-Wenge-Amazakoe (11 czesci)',
'Regius Core/ Profil laczenia szyjki z korpusem / wyzlobienie schodkowe',
],
},
],
},
],
diff: [
{
path: 'kolor.kolor-top',
type: 'changed',
initial: 'Black',
current: 'Cherry Burst',
},
],
loadedAt: 1777460000000,
isLoading: false,
isRefreshing: false,
error: null,
},
},
}
```
### Spodziewana odpowiedź endpointu
Endpoint:
```http
GET /mayo-api/products/123/specification
```
Odpowiedź:
```json
{
"productId": 123,
"orderId": "0143/2025/1",
"sourceUrl": "http://10.8.0.6/mayo2/index.php?...",
"lastFetchedAt": "2026-04-22T10:30:00Z",
"specification": {
"sections": [
{
"key": "szyjka",
"label": "Szyjka",
"fields": [
{
"key": "radius",
"label": "Radius",
"values": ["GITARA SETIUS/REGIUS/CUSTOM/ 16"]
}
]
}
]
},
"diff": []
}
```
### Najważniejsze funkcje
```js
await specificationStore.fetchSpecification(productId)
```
Pobiera specyfikację, jeśli nie ma jej jeszcze w pamięci.
```js
await specificationStore.fetchSpecification(productId, { force: true })
```
Pobiera specyfikację ponownie.
```js
await specificationStore.refreshSpecification(productId)
```
Wysyła żądanie do backendu, żeby backend pobrał aktualną specyfikację ze starego systemu i zapisał ją w Directus.
### Przykład użycia
```vue
<script setup>
import { computed, onMounted } from 'vue'
import { useProductSpecificationStore } from 'src/stores/productSpecificationStore'
const props = defineProps({
productId: {
type: Number,
required: true,
},
})
const specificationStore = useProductSpecificationStore()
const specification = computed(() =>
specificationStore.getSpecification(props.productId),
)
onMounted(() => {
specificationStore.fetchSpecification(props.productId)
})
</script>
<template>
<div v-if="specification?.isLoading">Ladowanie specyfikacji...</div>
<section v-for="section in specification?.sections" :key="section.key">
<h3>{{ section.label }}</h3>
<div v-for="field in section.fields" :key="field.key">
<strong>{{ field.label }}</strong>
<p v-for="value in field.values" :key="value">{{ value }}</p>
</div>
</section>
</template>
```
## `dictionariesStore`
Plik:
```txt
frontend/src/stores/dictionariesStore.js
```
### Odpowiedzialność
Ten store przechowuje słowniki.
Słownik to lista wartości używana w filtrach, formularzach albo wyborach. Przykłady:
- modele produktów,
- klienci,
- typy wykończenia,
- listy produkcyjne,
- operacje produkcyjne,
- kolory.
Takich danych nie trzeba pobierać przy każdym wejściu na ekran. Zwykle pobiera się je raz i trzyma w pamięci.
### Spodziewana struktura stanu
```js
{
models: [
{
id: 1,
name: 'Regius Core 6',
},
],
clients: [
{
id: 4,
name: 'HIENDGUITAR.COM / INDONESIA',
country: 'INDONESIA',
},
],
finishes: [
{
value: 'GLOSS',
label: 'Gloss',
},
{
value: 'SATIN',
label: 'Satin',
},
],
productionLists: [
{
id: 8,
name: 'CZE-00',
},
],
operations: [
{
id: 7,
code: 'B',
name: 'Bejca',
},
],
colors: [
{
id: 12,
name: 'Trans Natural Satine',
},
],
loadedAt: 1777460000000,
isLoading: false,
error: null,
}
```
### Spodziewana odpowiedź endpointu
Endpoint:
```http
GET /mayo-api/dictionaries
```
Odpowiedź:
```json
{
"models": [],
"clients": [],
"finishes": [],
"productionLists": [],
"operations": [],
"colors": []
}
```
### Najważniejsze funkcje
```js
await dictionariesStore.fetchDictionaries()
```
Pobiera słowniki, jeśli nie są jeszcze załadowane.
```js
await dictionariesStore.fetchDictionaries({ force: true })
```
Wymusza ponowne pobranie słowników.
### Przykład użycia w filtrach
```vue
<script setup>
import { onMounted } from 'vue'
import { useDictionariesStore } from 'src/stores/dictionariesStore'
import { useProductsStore } from 'src/stores/productsStore'
const dictionariesStore = useDictionariesStore()
const productsStore = useProductsStore()
onMounted(() => {
dictionariesStore.fetchDictionaries()
})
function selectModel(modelId) {
productsStore.applyFilters({ modelId })
}
</script>
```
## `uiStore`
Plik:
```txt
frontend/src/stores/uiStore.js
```
### Odpowiedzialność
Ten store przechowuje stan interfejsu użytkownika.
Nie przechowuje danych biznesowych. To znaczy, że nie powinien trzymać specyfikacji, timeline ani produktów.
Przechowuje tylko informacje typu:
- czy drawer jest otwarty,
- jaki panel jest otwarty,
- dla jakiego produktu otwarty jest panel,
- dodatkowe parametry panelu.
### Spodziewana struktura stanu
```js
{
isDrawerOpen: true,
activePanel: 'productSpecification',
activeProductId: 123,
drawerPayload: {
productId: 123,
mode: 'view',
},
drawerInstanceKey: 4,
}
```
### Najważniejsze funkcje
```js
uiStore.openDrawer(UI_PANELS.PRODUCT_SPECIFICATION, {
productId: 123,
mode: 'view',
})
```
Otwiera drawer i ustawia aktywny panel.
```js
uiStore.closeDrawer()
```
Zamyka drawer.
```js
uiStore.replaceDrawer(UI_PANELS.PRODUCT_TIMELINE, {
productId: 123,
})
```
Zamienia aktualny panel na inny.
### Przykład użycia
```vue
<script setup>
import { UI_PANELS, useUiStore } from 'src/stores/uiStore'
const uiStore = useUiStore()
function openSpecification(productId) {
uiStore.openDrawer(UI_PANELS.PRODUCT_SPECIFICATION, {
productId,
})
}
</script>
<template>
<button @click="openSpecification(123)">
Otworz specyfikacje
</button>
</template>
```
## Typowy przepływ aplikacji
## Mock API
Warstwa `services` obsługuje teraz dwa tryby:
- mock API,
- prawdziwe API przez Axios.
Mock API jest domyślnie włączone. To znaczy, że store mogą pobierać dane bez działającego backendu.
Pliki z przykładowymi danymi są tutaj:
```txt
frontend/src/mocks/products.json
frontend/src/mocks/timelines.json
frontend/src/mocks/specifications.json
frontend/src/mocks/dictionaries.json
```
Przełącznik znajduje się w:
```txt
frontend/src/services/apiMode.js
```
Aktualna zasada:
```js
export const USE_MOCK_API = import.meta.env.VITE_USE_MOCK_API !== 'false'
```
To oznacza:
- jeśli nie ustawisz nic, aplikacja używa mocków,
- jeśli ustawisz `VITE_USE_MOCK_API=false`, aplikacja używa prawdziwego backendu.
Przykład uruchomienia z prawdziwym API:
```bash
VITE_USE_MOCK_API=false npm run dev
```
Komponenty i store nie powinny wiedzieć, czy dane pochodzą z mocków czy z backendu. Dla nich API wygląda tak samo:
```txt
komponent -> store -> service -> mock JSON albo Axios
```
Dlatego można spokojnie pracować nad wyglądem strony na danych przykładowych, a później przełączyć się na Directus bez przepisywania komponentów.
### 1. Użytkownik wchodzi na główną stronę
Komponent strony wywołuje:
```js
productsStore.fetchFirstPage()
```
Backend zwraca pierwszą porcję `ProductListItem`.
Frontend zapisuje je w:
```js
productsStore.ids
productsStore.byId
```
Na ekranie widać listę produktów oraz `timelinePreview`.
### 2. Użytkownik przewija listę
Gdy użytkownik zbliża się do końca listy, komponent wywołuje:
```js
productsStore.fetchNextPage()
```
Backend zwraca kolejne produkty. Store dopisuje je do istniejącej listy.
### 3. Użytkownik zmienia filtr
Komponent wywołuje:
```js
productsStore.applyFilters({
search: 'regius',
})
```
Store:
1. zapisuje nowe filtry,
2. resetuje offset,
3. pobiera pierwszą stronę listy od nowa.
### 4. Użytkownik otwiera specyfikację produktu
Komponent wywołuje:
```js
uiStore.openDrawer(UI_PANELS.PRODUCT_SPECIFICATION, {
productId: 123,
})
```
Panel specyfikacji widzi `productId` i wywołuje:
```js
specificationStore.fetchSpecification(123)
```
Specyfikacja trafia do:
```js
productSpecificationStore.byProductId[123]
```
### 5. Użytkownik otwiera pełny timeline
Komponent lub panel wywołuje:
```js
timelineStore.fetchTimeline(123)
```
Pełna historia trafia do:
```js
productTimelineStore.byProductId[123]
```
### 6. Użytkownik dodaje event do timeline
Komponent wywołuje:
```js
timelineStore.addEvent(123, {
type: 'operation',
partId: 10,
operationId: 7,
date: '2026-04-29',
note: null,
})
```
Backend zapisuje event i zwraca:
- utworzony event,
- opcjonalnie nowy `timelinePreview`.
Store:
1. dopisuje event do `productTimelineStore.byProductId[123].events`,
2. jeśli backend zwrócił `timelinePreview`, aktualizuje `productsStore.byId[123].timelinePreview`.
## Dlaczego timeline jest w dwóch miejscach
To może wyglądać jak duplikacja, ale ma konkretny sens.
Na głównej liście potrzebny jest tylko skrót:
```js
timelinePreview
```
Na panelu szczegółów potrzebna jest pełna historia:
```js
events
```
Gdyby lista trzymała pełny timeline każdego produktu, frontend pobierałby dużo danych, których użytkownik często w ogóle nie zobaczy.
Lepszy podział:
```txt
productsStore
ProductListItem
timelinePreview
productTimelineStore
ProductTimeline
events
```
## Kiedy dodawać nowy store
Nie dodawaj nowego store dla każdego nowego endpointu.
Nowy store ma sens wtedy, gdy pojawia się nowy obszar stanu aplikacji.
Przykłady dobrych powodów:
- osobny moduł aplikacji,
- osobny cache danych,
- dane używane w wielu komponentach,
- dane z własnym ładowaniem i błędami,
- stan, który powinien przeżyć zmianę komponentu.
Przykłady złych powodów:
- powstał jeden nowy endpoint,
- jeden komponent ma jedną lokalną zmienną,
- coś da się spokojnie trzymać w `ref` wewnątrz komponentu.
## Kiedy używać store, a kiedy lokalnego `ref`
Użyj store, gdy dane:
- są potrzebne w kilku komponentach,
- mają zostać w pamięci po zamknięciu panelu,
- są pobierane z backendu i cache ma znaczenie,
- opisują ważny stan aplikacji.
Użyj lokalnego `ref` w komponencie, gdy dane:
- są potrzebne tylko w tym jednym komponencie,
- dotyczą chwilowego formularza,
- nie muszą być znane poza komponentem.
Przykład lokalnego `ref`:
```js
const noteText = ref('')
```
Nie ma potrzeby robić osobnego store tylko dla tekstu wpisywanego w jedno pole formularza.
## Najważniejsze reguły dla tego projektu
1. Lista produktów używa `productsStore`.
2. Lista produktów przechowuje tylko lekkie dane i `timelinePreview`.
3. Pełny timeline jest w `productTimelineStore`.
4. Pełna specyfikacja jest w `productSpecificationStore`.
5. Słowniki są w `dictionariesStore`.
6. Drawer i aktywny panel są w `uiStore`.
7. Backend powinien zwracać dane już przygotowane pod ekran, a nie surową strukturę tabel z bazy.
8. `productId` powinien oznaczać wewnętrzne ID produktu z Directusa.
9. `orderId` powinien oznaczać numer starego systemu, np. `0143/2025/1`.
10. Nie pobieraj pełnej specyfikacji i pełnego timeline dla każdego produktu na liście.