diff --git a/frontend/quasar.config.js b/frontend/quasar.config.js index da65eda..414b15a 100644 --- a/frontend/quasar.config.js +++ b/frontend/quasar.config.js @@ -11,14 +11,10 @@ export default defineConfig((/* ctx */) => { // app boot file (/src/boot) // --> boot files are part of "main.js" // https://v2.quasar.dev/quasar-cli-vite/boot-files - boot: [ - 'axios' - ], + boot: ['axios'], // https://v2.quasar.dev/quasar-cli-vite/quasar-config-file#css - css: [ - 'app.scss' - ], + css: ['app.scss'], // https://github.com/quasarframework/quasar/tree/dev/extras extras: [ @@ -38,7 +34,7 @@ export default defineConfig((/* ctx */) => { build: { target: { browser: 'baseline-widely-available', - node: 'node22' + node: 'node22', }, vueRouterMode: 'hash', // available values: 'hash', 'history' @@ -59,21 +55,27 @@ export default defineConfig((/* ctx */) => { // extendViteConf (viteConf) {}, // viteVuePluginOptions: {}, - + vitePlugins: [ - ['vite-plugin-checker', { - eslint: { - lintCommand: 'eslint -c ./eslint.config.js "./src*/**/*.{js,mjs,cjs,vue}"', - useFlatConfig: true - } - }, { server: false }] - ] + [ + 'vite-plugin-checker', + { + eslint: { + lintCommand: 'eslint -c ./eslint.config.js "./src*/**/*.{js,mjs,cjs,vue}"', + useFlatConfig: true, + }, + }, + { server: false }, + ], + ], }, // Full list of options: https://v2.quasar.dev/quasar-cli-vite/quasar-config-file#devserver devServer: { // https: true, - open: true // opens browser window automatically + open: false, // opens browser window automatically + host: '0.0.0.0', + port: 9000, }, // https://v2.quasar.dev/quasar-cli-vite/quasar-config-file#framework @@ -91,7 +93,7 @@ export default defineConfig((/* ctx */) => { // directives: [], // Quasar plugins - plugins: [] + plugins: [], }, // animations: 'all', // --- includes all animations @@ -114,10 +116,10 @@ export default defineConfig((/* ctx */) => { // https://v2.quasar.dev/quasar-cli-vite/developing-ssr/configuring-ssr ssr: { prodPort: 3000, // The default port that the production server should use - // (gets superseded if process.env.PORT is specified at runtime) + // (gets superseded if process.env.PORT is specified at runtime) middlewares: [ - 'render' // keep this as last one + 'render', // keep this as last one ], // extendPackageJson (json) {}, @@ -128,7 +130,7 @@ export default defineConfig((/* ctx */) => { // manualStoreHydration: true, // manualPostHydrationTrigger: true, - pwa: false + pwa: false, // pwaOfflineHtmlFilename: 'offline.html', // do NOT use index.html as name! // pwaExtendGenerateSWOptions (cfg) {}, @@ -137,7 +139,7 @@ export default defineConfig((/* ctx */) => { // https://v2.quasar.dev/quasar-cli-vite/developing-pwa/configuring-pwa pwa: { - workboxMode: 'GenerateSW' // 'GenerateSW' or 'InjectManifest' + workboxMode: 'GenerateSW', // 'GenerateSW' or 'InjectManifest' // swFilename: 'sw.js', // manifestFilename: 'manifest.json', // extendManifestJson (json) {}, @@ -155,7 +157,7 @@ export default defineConfig((/* ctx */) => { // Full list of options: https://v2.quasar.dev/quasar-cli-vite/developing-capacitor-apps/configuring-capacitor capacitor: { - hideSplashscreen: true + hideSplashscreen: true, }, // Full list of options: https://v2.quasar.dev/quasar-cli-vite/developing-electron-apps/configuring-electron @@ -166,7 +168,7 @@ export default defineConfig((/* ctx */) => { // extendPackageJson (json) {}, // Electron preload scripts (if any) from /src-electron, WITHOUT file extension - preloadScripts: [ 'electron-preload' ], + preloadScripts: ['electron-preload'], // specify the debugging port to use for the Electron app when running in development mode inspectPort: 5858, @@ -175,13 +177,11 @@ export default defineConfig((/* ctx */) => { packager: { // https://github.com/electron-userland/electron-packager/blob/master/docs/api.md#options - // OS X / Mac App Store // appBundleId: '', // appCategoryType: '', // osxSign: '', // protocol: 'myapp://path', - // Windows only // win32metadata: { ... } }, @@ -189,8 +189,8 @@ export default defineConfig((/* ctx */) => { builder: { // https://www.electron.build/configuration - appId: 'package.json' - } + appId: 'package.json', + }, }, // Full list of options: https://v2.quasar.dev/quasar-cli-vite/developing-browser-extensions/configuring-bex @@ -206,7 +206,7 @@ export default defineConfig((/* ctx */) => { * * @example [ 'my-script.ts', 'sub-folder/my-other-script.js' ] */ - extraScripts: [] - } + extraScripts: [], + }, } }) diff --git a/frontend/src/components/right-drawer/panels/index.js b/frontend/src/components/right-drawer/panels/index.js new file mode 100644 index 0000000..69ebe60 --- /dev/null +++ b/frontend/src/components/right-drawer/panels/index.js @@ -0,0 +1,10 @@ +import AdvancedSearchPanel from './AdvancedSearchPanel.vue' +import ProductSpecificationPanel from './ProductSpecificationPanel.vue' +import ProductionStatusesPanel from './ProductionStatusesPanel.vue' +import { UI_PANELS } from 'src/stores/uiStore' + +export const rightDrawerPanels = { + [UI_PANELS.ADVANCED_SEARCH]: AdvancedSearchPanel, + [UI_PANELS.PRODUCT_SPECIFICATION]: ProductSpecificationPanel, + [UI_PANELS.PRODUCTION_STATUSES]: ProductionStatusesPanel, +} diff --git a/frontend/src/mocks/dictionaries.json b/frontend/src/mocks/dictionaries.json new file mode 100644 index 0000000..ba3cf8b --- /dev/null +++ b/frontend/src/mocks/dictionaries.json @@ -0,0 +1,109 @@ +{ + "models": [ + { + "id": 1, + "name": "Regius Core 6" + }, + { + "id": 2, + "name": "Duvell Elite 7 B26,5" + }, + { + "id": 3, + "name": "Legend 6" + } + ], + "clients": [ + { + "id": 1, + "name": "HIENDGUITAR.COM / INDONESIA", + "country": "INDONESIA" + }, + { + "id": 2, + "name": "USA | Sebastopol | USA", + "country": "USA" + }, + { + "id": 3, + "name": "Mayo Stock", + "country": "POLAND" + } + ], + "finishes": [ + { + "value": "GLOSS", + "label": "Gloss" + }, + { + "value": "SATIN", + "label": "Satin" + }, + { + "value": "MAT", + "label": "Mat" + }, + { + "value": "S+M", + "label": "Satin + Mat" + }, + { + "value": "G+M", + "label": "Gloss + Mat" + } + ], + "productionLists": [ + { + "id": 1, + "name": "CZE-00" + }, + { + "id": 2, + "name": "LIS-25" + }, + { + "id": 3, + "name": "STY-25" + }, + { + "id": 4, + "name": "SUMMIT" + } + ], + "operations": [ + { + "id": 1, + "code": "B", + "name": "Bejca" + }, + { + "id": 2, + "code": "IZ", + "name": "Izolant" + }, + { + "id": 3, + "code": "AK", + "name": "Akryl" + }, + { + "id": 4, + "code": "M", + "name": "Monolith" + } + ], + "colors": [ + { + "id": 1, + "name": "Trans Natural Satine" + }, + { + "id": 2, + "name": "Trans Natural Matt" + }, + { + "id": 3, + "name": "Fluo Orange" + } + ] +} diff --git a/frontend/src/mocks/products.json b/frontend/src/mocks/products.json new file mode 100644 index 0000000..48049ff --- /dev/null +++ b/frontend/src/mocks/products.json @@ -0,0 +1,117 @@ +{ + "items": [ + { + "id": 101, + "orderId": "0143/2025/1", + "orderNumber": "0143", + "orderYear": 2025, + "orderIndex": 1, + "model": "Regius Core 6", + "client": "HIENDGUITAR.COM / INDONESIA", + "finish": "S+M", + "productionLists": ["CZE-00"], + "timelinePreview": { + "body": [ + { + "id": 9001, + "code": "B", + "label": "Bejca", + "date": "2026-04-20", + "status": "done" + }, + { + "id": 9002, + "code": "IZ", + "label": "Izolant", + "date": "2026-04-22", + "status": "done" + }, + { + "id": 9003, + "code": "AK", + "label": "Akryl", + "date": "2026-04-24", + "status": "done" + } + ], + "neck": [ + { + "id": 9004, + "code": "B", + "label": "Bejca", + "date": "2026-04-21", + "status": "done" + }, + { + "id": 9005, + "code": "IZ", + "label": "Izolant", + "date": "2026-04-23", + "status": "done" + } + ] + } + }, + { + "id": 102, + "orderId": "0367/2025/1", + "orderNumber": "0367", + "orderYear": 2025, + "orderIndex": 1, + "model": "Duvell Elite 7 B26,5", + "client": "USA | Sebastopol | USA", + "finish": "G+M", + "productionLists": ["LIS-25"], + "timelinePreview": { + "body": [ + { + "id": 9011, + "code": "B", + "label": "Bejca", + "date": "2026-04-18", + "status": "done" + }, + { + "id": 9012, + "code": "M", + "label": "Monolith", + "date": "2026-04-25", + "status": "in_progress" + } + ], + "neck": [ + { + "id": 9013, + "code": "IZ", + "label": "Izolant", + "date": "2026-04-19", + "status": "done" + } + ] + } + }, + { + "id": 103, + "orderId": "0029/2024/12", + "orderNumber": "0029", + "orderYear": 2024, + "orderIndex": 12, + "model": "Legend 6", + "client": "Mayo Stock", + "finish": "MAT", + "productionLists": ["STY-25", "SUMMIT"], + "timelinePreview": { + "body": [ + { + "id": 9021, + "code": "B", + "label": "Bejca", + "date": "2026-04-14", + "status": "done" + } + ], + "neck": [] + } + } + ] +} diff --git a/frontend/src/mocks/specifications.json b/frontend/src/mocks/specifications.json new file mode 100644 index 0000000..0c7aa6d --- /dev/null +++ b/frontend/src/mocks/specifications.json @@ -0,0 +1,93 @@ +{ + "101": { + "productId": 101, + "orderId": "0143/2025/1", + "sourceUrl": "http://10.8.0.6/mayo2/index.php?&modul=14&id_zamowienia=8055&id_zestawu=35994", + "lastFetchedAt": "2026-04-22T10:30:00Z", + "specification": { + "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" + ] + } + ] + }, + { + "key": "kolor", + "label": "Kolor", + "fields": [ + { + "key": "kolor-top", + "label": "Kolor top", + "values": ["T-NAT-S/ Trans Natural Satine"] + }, + { + "key": "kolor-korpus", + "label": "Kolor korpus", + "values": ["T-NAT-M/ Trans Natural Matt"] + } + ] + } + ] + }, + "diff": [] + }, + "102": { + "productId": 102, + "orderId": "0367/2025/1", + "sourceUrl": "http://10.8.0.6/mayo2/index.php?id_zestawu=37692&id_zamowienia=8286&modul=14&pozycja=", + "lastFetchedAt": "2026-04-22T10:35:00Z", + "specification": { + "sections": [ + { + "key": "konstrukcja", + "label": "Konstrukcja", + "fields": [ + { + "key": "wersja", + "label": "Wersja", + "values": ["BARYTON 26.5"] + }, + { + "key": "konstrukcja", + "label": "Konstrukcja", + "values": ["bolt-on (gryf przykrecany)"] + } + ] + }, + { + "key": "elektronika", + "label": "Elektronika", + "fields": [ + { + "key": "przetworniki-gitara", + "label": "Przetworniki gitara", + "values": ["SEYMOUR DUNCAN / Pegasus 7 / Bridge Humbucker"] + } + ] + } + ] + }, + "diff": [ + { + "path": "kolor.kolor-top", + "type": "changed", + "initial": "Black", + "current": "Fluo Orange" + } + ] + } +} diff --git a/frontend/src/mocks/timelines.json b/frontend/src/mocks/timelines.json new file mode 100644 index 0000000..deedd9b --- /dev/null +++ b/frontend/src/mocks/timelines.json @@ -0,0 +1,111 @@ +{ + "101": { + "productId": 101, + "events": [ + { + "id": 9001, + "productId": 101, + "partId": 501, + "partType": "BODY", + "type": "operation", + "operationId": 1, + "operationCode": "B", + "operationName": "Bejca", + "date": "2026-04-20", + "note": null, + "photosCount": 0 + }, + { + "id": 9002, + "productId": 101, + "partId": 501, + "partType": "BODY", + "type": "operation", + "operationId": 2, + "operationCode": "IZ", + "operationName": "Izolant", + "date": "2026-04-22", + "note": null, + "photosCount": 1 + }, + { + "id": 9006, + "productId": 101, + "partId": 501, + "partType": "BODY", + "type": "note", + "operationId": null, + "operationCode": null, + "operationName": null, + "date": "2026-04-23", + "note": "Do sprawdzenia rownomiernosc koloru na krawedzi topu.", + "photosCount": 2 + } + ], + "timelinePreview": { + "body": [ + { + "id": 9001, + "code": "B", + "label": "Bejca", + "date": "2026-04-20", + "status": "done" + }, + { + "id": 9002, + "code": "IZ", + "label": "Izolant", + "date": "2026-04-22", + "status": "done" + } + ], + "neck": [ + { + "id": 9004, + "code": "B", + "label": "Bejca", + "date": "2026-04-21", + "status": "done" + } + ] + } + }, + "102": { + "productId": 102, + "events": [ + { + "id": 9011, + "productId": 102, + "partId": 511, + "partType": "BODY", + "type": "operation", + "operationId": 1, + "operationCode": "B", + "operationName": "Bejca", + "date": "2026-04-18", + "note": null, + "photosCount": 0 + } + ], + "timelinePreview": { + "body": [ + { + "id": 9011, + "code": "B", + "label": "Bejca", + "date": "2026-04-18", + "status": "done" + } + ], + "neck": [] + } + }, + "103": { + "productId": 103, + "events": [], + "timelinePreview": { + "body": [], + "neck": [] + } + } +} diff --git a/frontend/src/services/apiMode.js b/frontend/src/services/apiMode.js new file mode 100644 index 0000000..bc73364 --- /dev/null +++ b/frontend/src/services/apiMode.js @@ -0,0 +1,7 @@ +export const USE_MOCK_API = import.meta.env.VITE_USE_MOCK_API !== 'false' + +export function waitForMockApi() { + return new Promise((resolve) => { + globalThis.setTimeout(resolve, 250) + }) +} diff --git a/frontend/src/services/dictionariesApi.js b/frontend/src/services/dictionariesApi.js new file mode 100644 index 0000000..5205e71 --- /dev/null +++ b/frontend/src/services/dictionariesApi.js @@ -0,0 +1,26 @@ +import { api } from 'src/boot/axios' +import mockDictionaries from 'src/mocks/dictionaries.json' +import { USE_MOCK_API, waitForMockApi } from 'src/services/apiMode' + +export async function fetchDictionaries() { + if (USE_MOCK_API) { + await waitForMockApi() + + return normalizeDictionaries(mockDictionaries) + } + + const response = await api.get('/mayo-api/dictionaries') + + return normalizeDictionaries(response.data) +} + +function normalizeDictionaries(data) { + return { + models: data.models ?? [], + clients: data.clients ?? [], + finishes: data.finishes ?? [], + productionLists: data.productionLists ?? [], + operations: data.operations ?? [], + colors: data.colors ?? [], + } +} diff --git a/frontend/src/services/productSpecificationApi.js b/frontend/src/services/productSpecificationApi.js new file mode 100644 index 0000000..a920b27 --- /dev/null +++ b/frontend/src/services/productSpecificationApi.js @@ -0,0 +1,56 @@ +import { api } from 'src/boot/axios' +import mockSpecifications from 'src/mocks/specifications.json' +import { USE_MOCK_API, waitForMockApi } from 'src/services/apiMode' + +function normalizeSpecificationResponse(data) { + return { + productId: data.productId, + orderId: data.orderId ?? null, + sourceUrl: data.sourceUrl ?? null, + lastFetchedAt: data.lastFetchedAt ?? null, + sections: data.specification?.sections ?? data.sections ?? [], + diff: data.diff ?? [], + } +} + +export async function fetchProductSpecification(productId) { + if (USE_MOCK_API) { + await waitForMockApi() + + return normalizeSpecificationResponse(getMockSpecification(productId)) + } + + const response = await api.get(`/mayo-api/products/${productId}/specification`) + + return normalizeSpecificationResponse(response.data) +} + +export async function refreshProductSpecification(productId) { + if (USE_MOCK_API) { + await waitForMockApi() + + return normalizeSpecificationResponse({ + ...getMockSpecification(productId), + lastFetchedAt: new Date().toISOString(), + }) + } + + const response = await api.post(`/mayo-api/products/${productId}/specification/refresh`) + + return normalizeSpecificationResponse(response.data) +} + +function getMockSpecification(productId) { + return ( + mockSpecifications[productId] ?? { + productId, + orderId: null, + sourceUrl: null, + lastFetchedAt: null, + specification: { + sections: [], + }, + diff: [], + } + ) +} diff --git a/frontend/src/services/productTimelineApi.js b/frontend/src/services/productTimelineApi.js new file mode 100644 index 0000000..27e3412 --- /dev/null +++ b/frontend/src/services/productTimelineApi.js @@ -0,0 +1,104 @@ +import { api } from 'src/boot/axios' +import mockTimelines from 'src/mocks/timelines.json' +import { USE_MOCK_API, waitForMockApi } from 'src/services/apiMode' + +const timelinesByProductId = structuredClone(mockTimelines) + +export async function fetchProductTimeline(productId) { + if (USE_MOCK_API) { + await waitForMockApi() + + return timelinesByProductId[productId] ?? { + productId, + events: [], + timelinePreview: { + body: [], + neck: [], + }, + } + } + + const response = await api.get(`/mayo-api/products/${productId}/timeline`) + + return response.data +} + +export async function createProductTimelineEvent(productId, payload) { + if (USE_MOCK_API) { + await waitForMockApi() + + const timeline = ensureMockTimeline(productId) + const event = createMockEvent(productId, payload) + + timeline.events.push(event) + timeline.timelinePreview = createTimelinePreview(timeline.events) + + return { + event, + timelinePreview: timeline.timelinePreview, + } + } + + const response = await api.post(`/mayo-api/products/${productId}/timeline/events`, payload) + + return { + event: response.data.event ?? response.data, + timelinePreview: response.data.timelinePreview, + } +} + +function ensureMockTimeline(productId) { + if (!timelinesByProductId[productId]) { + timelinesByProductId[productId] = { + productId, + events: [], + timelinePreview: { + body: [], + neck: [], + }, + } + } + + return timelinesByProductId[productId] +} + +function createMockEvent(productId, payload) { + const now = Date.now() + + return { + id: now, + productId, + partId: payload.partId ?? null, + partType: normalizePartType(payload.partType), + type: payload.type ?? 'operation', + operationId: payload.operationId ?? null, + operationCode: payload.operationCode ?? payload.code ?? 'NEW', + operationName: payload.operationName ?? payload.label ?? 'Nowa operacja', + date: payload.date ?? new Date().toISOString().slice(0, 10), + note: payload.note ?? null, + photosCount: payload.photosCount ?? 0, + } +} + +function createTimelinePreview(events) { + return { + body: createPartPreview(events, 'BODY'), + neck: createPartPreview(events, 'NECK'), + } +} + +function createPartPreview(events, partType) { + return events + .filter((event) => event.type === 'operation' && event.partType === partType) + .map((event) => ({ + id: event.id, + code: event.operationCode, + label: event.operationName, + date: event.date, + status: 'done', + })) +} + +function normalizePartType(partType) { + return String(partType ?? 'BODY').toUpperCase() +} diff --git a/frontend/src/services/productsApi.js b/frontend/src/services/productsApi.js new file mode 100644 index 0000000..bb4589d --- /dev/null +++ b/frontend/src/services/productsApi.js @@ -0,0 +1,64 @@ +import { api } from 'src/boot/axios' +import mockProducts from 'src/mocks/products.json' +import { USE_MOCK_API, waitForMockApi } from 'src/services/apiMode' + +const mockItems = [...mockProducts.items] + +export async function fetchProducts(params) { + if (USE_MOCK_API) { + await waitForMockApi() + + const filteredItems = filterMockProducts(mockItems, params) + const offset = Number(params?.offset ?? 0) + const limit = Number(params?.limit ?? filteredItems.length) + const items = filteredItems.slice(offset, offset + limit) + + return { + items, + pageInfo: { + limit, + offset, + hasMore: offset + limit < filteredItems.length, + total: filteredItems.length, + }, + } + } + + const response = await api.get('/mayo-api/products', { params }) + + return { + items: response.data.items ?? response.data, + pageInfo: response.data.pageInfo ?? {}, + } +} + +function filterMockProducts(items, params = {}) { + return items.filter((product) => { + const search = params.search?.toLowerCase() + const productionList = params.productionList + + if (search && !matchesSearch(product, search)) { + return false + } + + if (params.finish && product.finish !== params.finish) { + return false + } + + if (params.year && product.orderYear !== Number(params.year)) { + return false + } + + if (productionList && !product.productionLists.includes(productionList)) { + return false + } + + return true + }) +} + +function matchesSearch(product, search) { + return [product.orderId, product.model, product.client, product.finish] + .filter(Boolean) + .some((value) => value.toLowerCase().includes(search)) +} diff --git a/frontend/src/stores/dictionariesStore.js b/frontend/src/stores/dictionariesStore.js new file mode 100644 index 0000000..21e6f9a --- /dev/null +++ b/frontend/src/stores/dictionariesStore.js @@ -0,0 +1,73 @@ +import { computed, ref } from 'vue' +import { acceptHMRUpdate, defineStore } from 'pinia' +import { fetchDictionaries as fetchDictionariesFromApi } from 'src/services/dictionariesApi' + +export const useDictionariesStore = defineStore('dictionaries', () => { + const models = ref([]) + const clients = ref([]) + const finishes = ref([]) + const productionLists = ref([]) + const operations = ref([]) + const colors = ref([]) + const loadedAt = ref(null) + const isLoading = ref(false) + const error = ref(null) + + const isLoaded = computed(() => loadedAt.value !== null) + + async function fetchDictionaries({ force = false } = {}) { + if (isLoading.value || (isLoaded.value && !force)) { + return + } + + isLoading.value = true + error.value = null + + try { + const data = await fetchDictionariesFromApi() + + models.value = data.models + clients.value = data.clients + finishes.value = data.finishes + productionLists.value = data.productionLists + operations.value = data.operations + colors.value = data.colors + loadedAt.value = Date.now() + } catch (err) { + error.value = err + throw err + } finally { + isLoading.value = false + } + } + + function clear() { + models.value = [] + clients.value = [] + finishes.value = [] + productionLists.value = [] + operations.value = [] + colors.value = [] + loadedAt.value = null + error.value = null + } + + return { + models, + clients, + finishes, + productionLists, + operations, + colors, + loadedAt, + isLoading, + error, + isLoaded, + fetchDictionaries, + clear, + } +}) + +if (import.meta.hot) { + import.meta.hot.accept(acceptHMRUpdate(useDictionariesStore, import.meta.hot)) +} diff --git a/frontend/src/stores/productSpecificationStore.js b/frontend/src/stores/productSpecificationStore.js new file mode 100644 index 0000000..1bf0f76 --- /dev/null +++ b/frontend/src/stores/productSpecificationStore.js @@ -0,0 +1,141 @@ +import { computed, ref } from 'vue' +import { acceptHMRUpdate, defineStore } from 'pinia' +import { + fetchProductSpecification, + refreshProductSpecification, +} from 'src/services/productSpecificationApi' + +function createEmptySpecification(productId) { + return { + productId, + orderId: null, + sourceUrl: null, + lastFetchedAt: null, + sections: [], + diff: [], + loadedAt: null, + isLoading: false, + isRefreshing: false, + error: null, + } +} + +export const useProductSpecificationStore = defineStore('productSpecification', () => { + const byProductId = ref({}) + + const loadedProductIds = computed(() => Object.keys(byProductId.value).map(Number)) + + function ensureSpecification(productId) { + if (!byProductId.value[productId]) { + byProductId.value = { + ...byProductId.value, + [productId]: createEmptySpecification(productId), + } + } + + return byProductId.value[productId] + } + + function setSpecification(productId, patch) { + const current = ensureSpecification(productId) + + byProductId.value = { + ...byProductId.value, + [productId]: { + ...current, + ...patch, + }, + } + } + + function getSpecification(productId) { + return byProductId.value[productId] ?? null + } + + async function fetchSpecification(productId, { force = false } = {}) { + const current = ensureSpecification(productId) + + if (current.isLoading || (current.loadedAt && !force)) { + return current + } + + setSpecification(productId, { + isLoading: true, + error: null, + }) + + try { + const specification = await fetchProductSpecification(productId) + + setSpecification(productId, { + productId, + orderId: specification.orderId, + sourceUrl: specification.sourceUrl, + lastFetchedAt: specification.lastFetchedAt, + sections: specification.sections, + diff: specification.diff, + loadedAt: Date.now(), + isLoading: false, + }) + + return byProductId.value[productId] + } catch (err) { + setSpecification(productId, { + isLoading: false, + error: err, + }) + throw err + } + } + + async function refreshSpecification(productId) { + ensureSpecification(productId) + setSpecification(productId, { + isRefreshing: true, + error: null, + }) + + try { + const specification = await refreshProductSpecification(productId) + + setSpecification(productId, { + productId, + orderId: specification.orderId, + sourceUrl: specification.sourceUrl, + lastFetchedAt: specification.lastFetchedAt, + sections: specification.sections, + diff: specification.diff, + loadedAt: Date.now(), + isRefreshing: false, + }) + + return byProductId.value[productId] + } catch (err) { + setSpecification(productId, { + isRefreshing: false, + error: err, + }) + throw err + } + } + + function clearProduct(productId) { + const next = { ...byProductId.value } + delete next[productId] + byProductId.value = next + } + + return { + byProductId, + loadedProductIds, + ensureSpecification, + getSpecification, + fetchSpecification, + refreshSpecification, + clearProduct, + } +}) + +if (import.meta.hot) { + import.meta.hot.accept(acceptHMRUpdate(useProductSpecificationStore, import.meta.hot)) +} diff --git a/frontend/src/stores/productTimelineStore.js b/frontend/src/stores/productTimelineStore.js new file mode 100644 index 0000000..8353752 --- /dev/null +++ b/frontend/src/stores/productTimelineStore.js @@ -0,0 +1,126 @@ +import { computed, ref } from 'vue' +import { acceptHMRUpdate, defineStore } from 'pinia' +import { + createProductTimelineEvent, + fetchProductTimeline, +} from 'src/services/productTimelineApi' +import { useProductsStore } from 'src/stores/productsStore' + +function createEmptyTimeline(productId) { + return { + productId, + events: [], + loadedAt: null, + isLoading: false, + error: null, + } +} + +export const useProductTimelineStore = defineStore('productTimeline', () => { + const byProductId = ref({}) + + const loadedProductIds = computed(() => Object.keys(byProductId.value).map(Number)) + + function ensureTimeline(productId) { + if (!byProductId.value[productId]) { + byProductId.value = { + ...byProductId.value, + [productId]: createEmptyTimeline(productId), + } + } + + return byProductId.value[productId] + } + + function setTimeline(productId, patch) { + const current = ensureTimeline(productId) + + byProductId.value = { + ...byProductId.value, + [productId]: { + ...current, + ...patch, + }, + } + } + + function getEvents(productId) { + return byProductId.value[productId]?.events ?? [] + } + + async function fetchTimeline(productId, { force = false } = {}) { + const current = ensureTimeline(productId) + + if (current.isLoading || (current.loadedAt && !force)) { + return current + } + + setTimeline(productId, { + isLoading: true, + error: null, + }) + + try { + const timeline = await fetchProductTimeline(productId) + + setTimeline(productId, { + events: timeline.events ?? [], + timelinePreview: timeline.timelinePreview, + loadedAt: Date.now(), + isLoading: false, + }) + + if (timeline.timelinePreview) { + useProductsStore().applyTimelinePreviewUpdate(productId, timeline.timelinePreview) + } + + return byProductId.value[productId] + } catch (err) { + setTimeline(productId, { + isLoading: false, + error: err, + }) + throw err + } + } + + async function addEvent(productId, payload) { + const { event: createdEvent, timelinePreview } = await createProductTimelineEvent( + productId, + payload, + ) + + const current = ensureTimeline(productId) + + setTimeline(productId, { + events: [...current.events, createdEvent], + loadedAt: Date.now(), + }) + + if (timelinePreview) { + useProductsStore().applyTimelinePreviewUpdate(productId, timelinePreview) + } + + return createdEvent + } + + function clearProduct(productId) { + const next = { ...byProductId.value } + delete next[productId] + byProductId.value = next + } + + return { + byProductId, + loadedProductIds, + ensureTimeline, + getEvents, + fetchTimeline, + addEvent, + clearProduct, + } +}) + +if (import.meta.hot) { + import.meta.hot.accept(acceptHMRUpdate(useProductTimelineStore, import.meta.hot)) +} diff --git a/frontend/src/stores/productsStore.js b/frontend/src/stores/productsStore.js new file mode 100644 index 0000000..4522996 --- /dev/null +++ b/frontend/src/stores/productsStore.js @@ -0,0 +1,156 @@ +import { computed, ref } from 'vue' +import { acceptHMRUpdate, defineStore } from 'pinia' +import { fetchProducts } from 'src/services/productsApi' + +const DEFAULT_LIMIT = 30 + +function createDefaultFilters() { + return { + search: '', + modelId: null, + clientId: null, + finish: null, + productionListId: null, + year: null, + } +} + +export const useProductsStore = defineStore('products', () => { + const ids = ref([]) + const byId = ref({}) + const filters = ref(createDefaultFilters()) + const limit = ref(DEFAULT_LIMIT) + const offset = ref(0) + const total = ref(null) + const hasMore = ref(true) + const isLoading = ref(false) + const error = ref(null) + + const items = computed(() => ids.value.map((id) => byId.value[id]).filter(Boolean)) + const count = computed(() => ids.value.length) + + function buildListParams() { + return { + limit: limit.value, + offset: offset.value, + search: filters.value.search || undefined, + model: filters.value.modelId || undefined, + client: filters.value.clientId || undefined, + finish: filters.value.finish || undefined, + productionList: filters.value.productionListId || undefined, + year: filters.value.year || undefined, + } + } + + function setProducts(products, { append = false } = {}) { + const nextIds = append ? [...ids.value] : [] + const nextById = append ? { ...byId.value } : {} + + for (const product of products) { + nextById[product.id] = product + + if (!nextIds.includes(product.id)) { + nextIds.push(product.id) + } + } + + ids.value = nextIds + byId.value = nextById + } + + async function fetchFirstPage() { + offset.value = 0 + hasMore.value = true + return fetchNextPage({ append: false }) + } + + async function fetchNextPage({ append = true } = {}) { + if (isLoading.value || !hasMore.value) { + return + } + + isLoading.value = true + error.value = null + + try { + const { items: products, pageInfo } = await fetchProducts(buildListParams()) + + setProducts(products, { append }) + + offset.value = append ? offset.value + products.length : products.length + total.value = pageInfo.total ?? total.value + hasMore.value = pageInfo.hasMore ?? products.length === limit.value + } catch (err) { + error.value = err + throw err + } finally { + isLoading.value = false + } + } + + function setFilters(nextFilters) { + filters.value = { + ...filters.value, + ...nextFilters, + } + } + + async function applyFilters(nextFilters) { + setFilters(nextFilters) + await fetchFirstPage() + } + + function updateProduct(productId, patch) { + const current = byId.value[productId] + + if (!current) { + return + } + + byId.value = { + ...byId.value, + [productId]: { + ...current, + ...patch, + }, + } + } + + function applyTimelinePreviewUpdate(productId, timelinePreview) { + updateProduct(productId, { timelinePreview }) + } + + function clear() { + ids.value = [] + byId.value = {} + offset.value = 0 + total.value = null + hasMore.value = true + error.value = null + } + + return { + ids, + byId, + filters, + limit, + offset, + total, + hasMore, + isLoading, + error, + items, + count, + fetchFirstPage, + fetchNextPage, + setFilters, + applyFilters, + updateProduct, + applyTimelinePreviewUpdate, + clear, + } +}) + +if (import.meta.hot) { + import.meta.hot.accept(acceptHMRUpdate(useProductsStore, import.meta.hot)) +} diff --git a/frontend/src/stores/uiStore.js b/frontend/src/stores/uiStore.js new file mode 100644 index 0000000..a2d9fcc --- /dev/null +++ b/frontend/src/stores/uiStore.js @@ -0,0 +1,54 @@ +import { computed, ref } from 'vue' +import { acceptHMRUpdate, defineStore } from 'pinia' + +export const UI_PANELS = { + ADVANCED_SEARCH: 'advancedSearch', + PRODUCT_SPECIFICATION: 'productSpecification', + PRODUCTION_STATUSES: 'productionStatuses', + PRODUCT_TIMELINE: 'productTimeline', +} + +export const useUiStore = defineStore('ui', () => { + const isDrawerOpen = ref(false) + const activePanel = ref(null) + const activeProductId = ref(null) + const drawerPayload = ref({}) + const drawerInstanceKey = ref(0) + + const hasActivePanel = computed(() => activePanel.value !== null) + + function openDrawer(panel, payload = {}) { + isDrawerOpen.value = true + activePanel.value = panel + activeProductId.value = payload.productId ?? null + drawerPayload.value = payload + drawerInstanceKey.value += 1 + } + + function replaceDrawer(panel, payload = {}) { + openDrawer(panel, payload) + } + + function closeDrawer() { + isDrawerOpen.value = false + activePanel.value = null + activeProductId.value = null + drawerPayload.value = {} + } + + return { + isDrawerOpen, + activePanel, + activeProductId, + drawerPayload, + drawerInstanceKey, + hasActivePanel, + openDrawer, + replaceDrawer, + closeDrawer, + } +}) + +if (import.meta.hot) { + import.meta.hot.accept(acceptHMRUpdate(useUiStore, import.meta.hot)) +} diff --git a/notes/api_services.md b/notes/api_services.md new file mode 100644 index 0000000..ef581d9 --- /dev/null +++ b/notes/api_services.md @@ -0,0 +1,502 @@ +# API Services I Mocki + +Data: 2026-04-29 + +## Cel dokumentu + +Ten dokument opisuje warstwe API we frontendzie. + +Warstwa API znajduje sie w: + +```txt +frontend/src/services/ +``` + +Jej zadaniem jest odseparowanie store od szczegolow komunikacji HTTP. + +Store nie powinien wiedziec: + +- jaki dokladnie jest URL endpointu, +- czy komunikacja idzie przez Axios, +- czy dane pochodza z prawdziwego backendu, +- czy dane pochodza z mockow JSON. + +Store powinien wywolac funkcje domenowa, np.: + +```js +fetchProducts(params) +``` + +a service powinien zajac sie reszta. + +## Aktualne pliki services + +```txt +frontend/src/services/apiMode.js +frontend/src/services/productsApi.js +frontend/src/services/productTimelineApi.js +frontend/src/services/productSpecificationApi.js +frontend/src/services/dictionariesApi.js +``` + +## Przeplyw danych + +Docelowy przeplyw wyglada tak: + +```txt +komponent Vue + -> store Pinia + -> service API + -> mock JSON albo Axios + -> backend Directus +``` + +Przyklad: + +```txt +IndexPage.vue + -> productsStore.fetchFirstPage() + -> fetchProducts(params) + -> products.json albo GET /mayo-api/products +``` + +## Dlaczego services sa osobna warstwa + +Bez services store musialby robic cos takiego: + +```js +api.get('/mayo-api/products', { params }) +``` + +Wtedy store zna: + +- Axios, +- endpoint, +- strukture odpowiedzi HTTP. + +To miesza odpowiedzialnosci. + +Po wydzieleniu services store robi: + +```js +const { items, pageInfo } = await fetchProducts(params) +``` + +To jest lepsze, bo: + +- store skupia sie na stanie aplikacji, +- services skupiaja sie na komunikacji, +- endpoint mozna zmienic w jednym miejscu, +- latwiej dodac mocki, +- latwiej testowac logike store. + +## `apiMode.js` + +Plik: + +```txt +frontend/src/services/apiMode.js +``` + +Zawiera przelacznik miedzy mock API i prawdziwym API: + +```js +export const USE_MOCK_API = import.meta.env.VITE_USE_MOCK_API !== 'false' +``` + +To oznacza: + +- domyslnie mocki sa wlaczone, +- jesli ustawisz `VITE_USE_MOCK_API=false`, aplikacja uzyje prawdziwego backendu. + +Uruchomienie z prawdziwym backendem: + +```bash +VITE_USE_MOCK_API=false npm run dev +``` + +Uruchomienie z mockami: + +```bash +npm run dev +``` + +Mocki maja sztuczne opoznienie: + +```js +waitForMockApi() +``` + +Dzieki temu podczas pracy nad UI widac stany ladowania podobne do prawdziwego API. + +## Pliki mockow + +Mocki sa zwyklymi plikami JSON: + +```txt +frontend/src/mocks/products.json +frontend/src/mocks/timelines.json +frontend/src/mocks/specifications.json +frontend/src/mocks/dictionaries.json +``` + +Sa uzywane tylko przez warstwe `services`. + +Komponenty i store nie importuja mockow bezposrednio. + +To jest wazne, bo komponent nie powinien wiedziec, skad przyszly dane. Dla komponentu dane z mocka i z backendu powinny wygladac identycznie. + +## `productsApi.js` + +Plik: + +```txt +frontend/src/services/productsApi.js +``` + +Eksportuje: + +```js +fetchProducts(params) +``` + +Odpowiedzialnosc: + +- pobranie listy produktow, +- obsluga filtrow, +- obsluga `limit` i `offset`, +- zwrocenie danych w formacie oczekiwanym przez `productsStore`. + +Zwraca: + +```js +{ + items: [], + pageInfo: { + limit: 30, + offset: 0, + hasMore: true, + total: 184, + }, +} +``` + +W trybie mock: + +- czyta `frontend/src/mocks/products.json`, +- filtruje po `search`, `finish`, `year`, `productionList`, +- ucina wynik wedlug `limit` i `offset`, +- zwraca `items` i `pageInfo`. + +W trybie prawdziwego API: + +```http +GET /mayo-api/products +``` + +z parametrami: + +```js +{ + limit, + offset, + search, + model, + client, + finish, + productionList, + year, +} +``` + +## `productTimelineApi.js` + +Plik: + +```txt +frontend/src/services/productTimelineApi.js +``` + +Eksportuje: + +```js +fetchProductTimeline(productId) +createProductTimelineEvent(productId, payload) +``` + +### `fetchProductTimeline` + +Pobiera pelny timeline produktu. + +W trybie mock: + +- czyta `frontend/src/mocks/timelines.json`, +- zwraca timeline dla `productId`, +- jesli brak danych, zwraca pusty timeline. + +W trybie prawdziwego API: + +```http +GET /mayo-api/products/:id/timeline +``` + +### `createProductTimelineEvent` + +Dodaje event do timeline. + +W trybie mock: + +- tworzy sztuczny event, +- dodaje go do pamieci mockow, +- przelicza `timelinePreview`, +- zwraca nowy event i nowy preview. + +W trybie prawdziwego API: + +```http +POST /mayo-api/products/:id/timeline/events +``` + +Zwracany format: + +```js +{ + event: {}, + timelinePreview: { + body: [], + neck: [], + }, +} +``` + +To pozwala store od razu zaktualizowac: + +- pelny timeline, +- preview na glownej karcie produktu. + +## `productSpecificationApi.js` + +Plik: + +```txt +frontend/src/services/productSpecificationApi.js +``` + +Eksportuje: + +```js +fetchProductSpecification(productId) +refreshProductSpecification(productId) +``` + +### `fetchProductSpecification` + +Pobiera specyfikacje produktu. + +W trybie mock: + +- czyta `frontend/src/mocks/specifications.json`. + +W trybie prawdziwego API: + +```http +GET /mayo-api/products/:id/specification +``` + +### `refreshProductSpecification` + +Odswieza specyfikacje produktu. + +W trybie mock: + +- zwraca dane z mocka, +- aktualizuje `lastFetchedAt` na aktualny czas. + +W trybie prawdziwego API: + +```http +POST /mayo-api/products/:id/specification/refresh +``` + +### Normalizacja odpowiedzi + +Ten plik zawiera funkcje: + +```js +normalizeSpecificationResponse(data) +``` + +Jej zadaniem jest zamiana odpowiedzi backendu na prosty format dla store: + +```js +{ + productId, + orderId, + sourceUrl, + lastFetchedAt, + sections, + diff, +} +``` + +Service akceptuje dwa ksztalty: + +```js +data.specification.sections +``` + +albo: + +```js +data.sections +``` + +Dzieki temu store nie musi znac szczegolow odpowiedzi HTTP. + +## `dictionariesApi.js` + +Plik: + +```txt +frontend/src/services/dictionariesApi.js +``` + +Eksportuje: + +```js +fetchDictionaries() +``` + +Odpowiedzialnosc: + +- pobranie slownikow do filtrow i formularzy, +- normalizacja pustych pol do pustych tablic. + +W trybie mock: + +- czyta `frontend/src/mocks/dictionaries.json`. + +W trybie prawdziwego API: + +```http +GET /mayo-api/dictionaries +``` + +Zwraca: + +```js +{ + models: [], + clients: [], + finishes: [], + productionLists: [], + operations: [], + colors: [], +} +``` + +## Jak dodac nowy endpoint + +Przyklad: chcesz dodac API do zdjec produktu. + +Nie dodawaj Axiosa bezposrednio w komponencie ani w store. + +Zrob nowy service: + +```txt +frontend/src/services/productPhotosApi.js +``` + +W nim: + +```js +import { api } from 'src/boot/axios' +import { USE_MOCK_API, waitForMockApi } from 'src/services/apiMode' + +export async function fetchProductPhotos(productId) { + if (USE_MOCK_API) { + await waitForMockApi() + return [] + } + + const response = await api.get(`/mayo-api/products/${productId}/photos`) + return response.data +} +``` + +Dopiero potem store importuje: + +```js +import { fetchProductPhotos } from 'src/services/productPhotosApi' +``` + +## Zasady pracy z mockami + +Mocki powinny miec taki sam ksztalt jak prawdziwe API. + +To jest najwazniejsza zasada. + +Jesli prawdziwy endpoint ma zwrocic: + +```js +{ + items: [], + pageInfo: {} +} +``` + +to mock tez powinien zwrocic: + +```js +{ + items: [], + pageInfo: {} +} +``` + +Nie nalezy robic osobnych struktur danych tylko dla widoku, jesli nie beda zgodne z przyszlym API. + +## Co zostalo juz zrobione + +Utworzono: + +```txt +frontend/src/services/apiMode.js +frontend/src/services/productsApi.js +frontend/src/services/productTimelineApi.js +frontend/src/services/productSpecificationApi.js +frontend/src/services/dictionariesApi.js +``` + +Utworzono mocki: + +```txt +frontend/src/mocks/products.json +frontend/src/mocks/timelines.json +frontend/src/mocks/specifications.json +frontend/src/mocks/dictionaries.json +``` + +Zaktualizowano store tak, aby uzywaly services zamiast Axiosa bezposrednio. + +Zaktualizowano widok glownej strony tak, aby pobieral dane ze store zamiast trzymac lokalne mocki w komponencie. + +## Najwazniejsza regula + +Komponenty nie powinny importowac: + +```js +api +``` + +ani: + +```js +src/mocks/*.json +``` + +Komponent powinien uzywac store albo dostawac dane przez props. + +Store powinien uzywac services. + +Services powinny uzywac Axiosa albo mockow. diff --git a/notes/arch_struct.md b/notes/arch_struct.md new file mode 100644 index 0000000..9fd5773 --- /dev/null +++ b/notes/arch_struct.md @@ -0,0 +1,437 @@ +# Architektura Danych I Endpointow + +Data: 2026-04-29 + +## Cel dokumentu + +Ten dokument podsumowuje ustalenia dotyczace architektury danych pomiedzy: + +- frontendem Quasar/Vue, +- Directusem jako glownym backendem dla frontendu, +- FastAPI jako integracja ze starym systemem Mayo. + +Najwazniejszy problem, ktory rozwiazywalismy: + +> Ile danych frontend powinien pobierac i trzymac w pamieci oraz jaki ksztalt powinny miec endpointy Directusa, zeby aplikacja byla szybka, czytelna i latwa do dalszej rozbudowy. + +## Glowne zalozenie + +Frontend nie powinien pobierac pelnych danych kazdego produktu na glowna liste. + +Glowna lista produktow powinna dostawac lekki obiekt przygotowany specjalnie pod widok listy. Pelna specyfikacja i pelny timeline powinny byc pobierane dopiero wtedy, gdy uzytkownik ich potrzebuje. + +To jest dobre rozwiazanie, bo: + +- lista produktow bedzie szybciej sie ladowac, +- virtual scroll i lazy loading beda prostsze, +- frontend nie bedzie trzymal w pamieci danych, ktorych uzytkownik moze nigdy nie otworzyc, +- backend moze przygotowac dane dokladnie pod ekran, +- komponenty Vue nie musza znac struktury tabel w Directusie. + +## Trzy rozne reprezentacje produktu + +Ten sam produkt moze miec kilka reprezentacji w aplikacji. To nie jest blad ani niepotrzebna duplikacja. To jest normalny podzial danych pod konkretne widoki. + +### `ProductListItem` + +Lekki obiekt do glownej listy produktow. + +Zawiera tylko dane potrzebne do pokazania karty produktu, filtrowania i szybkiej pracy listy: + +```js +{ + id: 101, + orderId: '0143/2025/1', + orderNumber: '0143', + orderYear: 2025, + orderIndex: 1, + model: 'Regius Core 6', + client: 'HIENDGUITAR.COM / INDONESIA', + finish: 'S+M', + productionLists: ['CZE-00'], + timelinePreview: { + body: [ + { + id: 9001, + code: 'B', + label: 'Bejca', + date: '2026-04-20', + status: 'done', + }, + ], + neck: [], + }, +} +``` + +To jest format, z ktorego korzysta karta produktu na glownej stronie. + +### `ProductTimeline` + +Pelny timeline jednego produktu. + +Jest pobierany osobno, np. do prawego drawera albo widoku szczegolowego: + +```js +{ + productId: 101, + events: [ + { + id: 9001, + productId: 101, + partId: 501, + partType: 'BODY', + type: 'operation', + operationId: 1, + operationCode: 'B', + operationName: 'Bejca', + date: '2026-04-20', + note: null, + photosCount: 0, + }, + { + id: 9006, + productId: 101, + partId: 501, + partType: 'BODY', + type: 'note', + operationId: null, + operationCode: null, + operationName: null, + date: '2026-04-23', + note: 'Do sprawdzenia rownomiernosc koloru.', + photosCount: 2, + }, + ], + timelinePreview: { + body: [], + neck: [], + }, +} +``` + +Pelny timeline zawiera operacje, notatki i pozniej moze zawierac informacje o zdjeciach. + +### `ProductSpecification` + +Pelna specyfikacja produktu ze starego systemu Mayo. + +Nie powinna byc czescia `ProductListItem`. Jest pobierana dopiero po otwarciu panelu specyfikacji: + +```js +{ + productId: 101, + 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'], + }, + ], + }, + ], + diff: [], +} +``` + +## Dlaczego timeline jest w dwoch miejscach + +Na glownej liscie produktow potrzebny jest tylko skrot timeline: + +```js +timelinePreview +``` + +W prawym drawerze albo widoku szczegolowym potrzebna jest pelna historia: + +```js +events +``` + +Dlatego mamy dwa poziomy danych: + +```txt +ProductListItem.timelinePreview + szybki skrot na karte produktu + +ProductTimeline.events + pelny timeline do szczegolow +``` + +To jest dobre rozwiazanie, bo na liscie moze byc duzo produktow. Gdyby kazdy produkt mial pelny timeline, frontend pobieralby i przechowywal duzo danych, ktore czesto nie beda uzyte. + +## Czy produkt na liscie powinien miec pelna specyfikacje + +Nie. + +Produkt na liscie nie powinien miec pelnej specyfikacji. Powinien miec tylko informacje potrzebne do pokazania karty: + +- numer zamowienia, +- model, +- klient, +- finish, +- listy produkcyjne, +- skrot timeline. + +Pelna specyfikacja powinna byc pobierana przez osobny endpoint po kliknieciu produktu albo otwarciu drawera. + +## Rola backendu Directus + +Directus powinien byc glownym API dla frontendu. + +Frontend nie powinien skladac produktu z wielu tabel Directusa. Backend powinien przygotowac gotowy ksztalt odpowiedzi pod konkretny ekran. + +To oznacza, ze zamiast zmuszac frontend do pobierania: + +```txt +products +orders +clients +models +parts +events +lists +``` + +lepiej utworzyc custom endpoint: + +```http +GET /mayo-api/products +``` + +ktory zwroci gotowe `ProductListItem`. + +To jest dobre rozwiazanie, bo: + +- frontend jest prostszy, +- mniej logiki laczenia danych jest w Vue, +- backend moze zoptymalizowac zapytania SQL, +- jeden endpoint odpowiada jednemu widokowi aplikacji, +- latwiej utrzymac stabilny kontrakt API. + +## Rola FastAPI + +FastAPI nie powinien byc glownym API dla frontendu. + +Jego rola to integracja ze starym systemem Mayo: + +- pobranie specyfikacji po numerze zamowienia, +- parsowanie strony starego systemu, +- zwrocenie danych Directusowi albo procesowi importu. + +Docelowy przeplyw: + +```txt +frontend -> Directus -> FastAPI -> stary system Mayo +``` + +Frontend powinien jak najczesciej rozmawiac tylko z Directusem. + +## Planowane endpointy + +### Lista produktow + +```http +GET /mayo-api/products +``` + +Parametry: + +```http +?limit=30&offset=0&search=regius&finish=GLOSS&year=2025&productionList=CZE-00 +``` + +Cel: + +- pobieranie danych do glownej listy, +- obsluga virtual scroll i lazy loading, +- filtrowanie, +- zwrocenie `timelinePreview`. + +Przykladowa odpowiedz: + +```json +{ + "items": [], + "pageInfo": { + "limit": 30, + "offset": 0, + "hasMore": true, + "total": 184 + } +} +``` + +### Pelny timeline produktu + +```http +GET /mayo-api/products/:id/timeline +``` + +Cel: + +- pobranie pelnej historii produkcji jednego produktu, +- uzycie w prawym drawerze albo widoku szczegolowym. + +### Dodanie eventu do timeline + +```http +POST /mayo-api/products/:id/timeline/events +``` + +Cel: + +- dodanie operacji, notatki albo innego wpisu produkcyjnego. + +Backend powinien zwrocic: + +- utworzony event, +- opcjonalnie nowy `timelinePreview`. + +Dzieki temu frontend moze od razu: + +- dopisac event do pelnego timeline, +- zaktualizowac skrot na karcie produktu. + +### Pelna specyfikacja produktu + +```http +GET /mayo-api/products/:id/specification +``` + +Cel: + +- pobranie pelnej specyfikacji jednego produktu, +- uzycie w panelu specyfikacji. + +### Odswiezenie specyfikacji + +```http +POST /mayo-api/products/:id/specification/refresh +``` + +Cel: + +- Directus prosi FastAPI o pobranie aktualnych danych ze starego systemu, +- backend liczy hash/diff, +- zapisuje nowa wersje tylko jesli dane sie zmienily, +- zwraca aktualny stan specyfikacji. + +### Slowniki + +```http +GET /mayo-api/dictionaries +``` + +Cel: + +- pobranie danych do filtrow i formularzy. + +Przyklady: + +- modele, +- klienci, +- finisze, +- listy produkcyjne, +- operacje, +- kolory. + +## Virtual scroll i lazy loading + +Glowna lista powinna dzialac na porcjach danych. + +Przyklad: + +```txt +limit = 30 +offset = 0 +``` + +Nastepne pobranie: + +```txt +limit = 30 +offset = 30 +``` + +Frontend nie powinien pobierac calej bazy tylko dlatego, ze lista nie ma paginacji widocznej dla uzytkownika. + +Lista moze wygladac jak jedna ciagla lista, ale technicznie powinna pobierac dane porcjami. + +## Nazewnictwo `productId` i `orderId` + +Wazne rozroznienie: + +```js +productId +``` + +To wewnetrzne ID produktu w Directusie. + +```js +orderId +``` + +To numer starego systemu Mayo, np.: + +```txt +0143/2025/1 +``` + +Nie nalezy uzywac `orderId` jako `productId`. + +## Podzial odpowiedzialnosci + +Docelowy przeplyw: + +```txt +komponent Vue + -> store Pinia + -> service API + -> Axios albo mock JSON + -> Directus +``` + +Komponent: + +- renderuje UI, +- dostaje dane przez props, +- emituje zdarzenia. + +Store: + +- trzyma stan, +- trzyma loading/error, +- zarzadza cache, +- decyduje kiedy pobrac dane. + +Service API: + +- zna endpointy, +- zna Axios, +- normalizuje odpowiedzi backendu. + +Backend: + +- laczy dane z tabel, +- przygotowuje odpowiedz pod frontend, +- pilnuje spojnosci danych. + +## Dlaczego to jest dobre rozwiazanie + +Ten podzial jest dobry, bo utrzymuje osobne odpowiedzialnosci: + +- komponenty nie wiedza nic o Directusie, +- store nie musza znac szczegolow Axiosa, +- services nie przechowuja stanu, +- backend moze zmieniac strukture bazy bez przepisywania calego frontendu, +- mock API pozwala pracowac nad wygladem bez gotowego backendu. + +To jest praktyczny kompromis dla MVP: nie jest przesadnie skomplikowany, ale od razu porzadkuje najwazniejsze granice aplikacji. diff --git a/notes/stores.md b/notes/stores.md new file mode 100644 index 0000000..f50b759 --- /dev/null +++ b/notes/stores.md @@ -0,0 +1,1071 @@ +# 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 + + + +``` + +## `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 + +``` + +## `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 + + + +``` + +## `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 + +``` + +## `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 + + + +``` + +## 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.