Files
duck-prod-manager/notes/stores.md
bartool 93778065ce refaktoryzacja service api.
wydzielenie mockow.
dodanie parsowania wyszukiwania
2026-05-02 05:49:24 +02:00

22 KiB

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:

{
  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:

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ść.

const isLoading = ref(false)

Wewnątrz store odczyt i zapis robi się przez .value:

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:

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.

await productsStore.fetchFirstPage()

await oznacza: poczekaj, aż backend odpowie.

productsStore

Plik:

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

{
  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: {
    modelId: null,
    clientId: null,
    finish: null,
    productionListId: null,
    year: null,
  },

  searchQuery: '123/1',
  orderSearch: [
    {
      raw: '123/1',
      type: 'orderNumberIndex',
      match: 'exact',
      orderNumber: 123,
      orderNumberPrefix: null,
      orderYear: null,
      orderIndex: 1,
    },
  ],

  limit: 30,
  offset: 30,
  total: 184,
  hasMore: true,
  isLoading: false,
  error: null,
}

Spodziewana odpowiedź endpointu

Endpoint:

GET /mayo-api/products?limit=30&offset=0&search=123/1

Odpowiedź:

{
  "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

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.
await productsStore.fetchNextPage()

Pobiera kolejną porcję produktów. Używaj tego przy lazy loading, gdy użytkownik zbliża się do końca listy.

await productsStore.applyFilters({ finish: 'GLOSS' })

Ustawia filtry i pobiera listę od początku. Searchbar nie jest zwyklym filtrem, bo ma osobny parser numerow zamowien.

productsStore.setSearchQuery('123/1')

Zapisuje tekst z searchbara i parsuje go do orderSearch.

await productsStore.applySearch('123/1')

Zapisuje tekst searchbara, parsuje go i pobiera listę od początku.

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

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

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

{
  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:

GET /mayo-api/products/123/timeline

Odpowiedź:

{
  "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

await timelineStore.fetchTimeline(productId)

Pobiera pełny timeline produktu, jeśli nie został wcześniej pobrany.

await timelineStore.fetchTimeline(productId, { force: true })

Pobiera timeline ponownie, nawet jeśli jest już w pamięci.

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

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

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

{
  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:

GET /mayo-api/products/123/specification

Odpowiedź:

{
  "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

await specificationStore.fetchSpecification(productId)

Pobiera specyfikację, jeśli nie ma jej jeszcze w pamięci.

await specificationStore.fetchSpecification(productId, { force: true })

Pobiera specyfikację ponownie.

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

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

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

{
  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:

GET /mayo-api/dictionaries

Odpowiedź:

{
  "models": [],
  "clients": [],
  "finishes": [],
  "productionLists": [],
  "operations": [],
  "colors": []
}

Najważniejsze funkcje

await dictionariesStore.fetchDictionaries()

Pobiera słowniki, jeśli nie są jeszcze załadowane.

await dictionariesStore.fetchDictionaries({ force: true })

Wymusza ponowne pobranie słowników.

Przykład użycia w filtrach

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

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

{
  isDrawerOpen: true,
  activePanel: 'productSpecification',
  activeProductId: 123,
  drawerPayload: {
    productId: 123,
    mode: 'view',
  },
  drawerInstanceKey: 4,
}

Najważniejsze funkcje

uiStore.openDrawer(UI_PANELS.PRODUCT_SPECIFICATION, {
  productId: 123,
  mode: 'view',
})

Otwiera drawer i ustawia aktywny panel.

uiStore.closeDrawer()

Zamyka drawer.

uiStore.replaceDrawer(UI_PANELS.PRODUCT_TIMELINE, {
  productId: 123,
})

Zamienia aktualny panel na inny.

Przykład użycia

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

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:

frontend/src/services/apiMode.js

Aktualna zasada:

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:

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:

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:

productsStore.fetchFirstPage()

Backend zwraca pierwszą porcję ProductListItem.

Frontend zapisuje je w:

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:

productsStore.fetchNextPage()

Backend zwraca kolejne produkty. Store dopisuje je do istniejącej listy.

3. Użytkownik zmienia filtr

Komponent wywołuje:

productsStore.applyFilters({
  finish: 'GLOSS',
})

Store:

  1. zapisuje nowe filtry,
  2. resetuje offset,
  3. pobiera pierwszą stronę listy od nowa.

Searchbar jest obslugiwany osobno:

productsStore.applySearch('123/1')

To jest celowe, bo wyszukiwanie po numerze zamowienia ma wlasny parser i nie powinno mieszac sie z filtrami typu model, klient, finish albo lista produkcyjna.

4. Użytkownik otwiera specyfikację produktu

Komponent wywołuje:

uiStore.openDrawer(UI_PANELS.PRODUCT_SPECIFICATION, {
  productId: 123,
})

Panel specyfikacji widzi productId i wywołuje:

specificationStore.fetchSpecification(123)

Specyfikacja trafia do:

productSpecificationStore.byProductId[123]

5. Użytkownik otwiera pełny timeline

Komponent lub panel wywołuje:

timelineStore.fetchTimeline(123)

Pełna historia trafia do:

productTimelineStore.byProductId[123]

6. Użytkownik dodaje event do timeline

Komponent wywołuje:

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:

timelinePreview

Na panelu szczegółów potrzebna jest pełna historia:

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ł:

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:

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.