dodalem pinia store.

dodalem dokumentacje opisujaca dzialanie store
dodalem services do komunikacji z directus
dodalem opis dzialania services
dodalem mocki danych
przygotowania do refaktoryzacji
This commit is contained in:
2026-04-30 16:35:53 +02:00
parent 5725c024dc
commit 6e3d722e69
19 changed files with 3286 additions and 29 deletions

View File

@@ -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: [],
},
}
})

View File

@@ -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,
}

View File

@@ -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"
}
]
}

View File

@@ -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": []
}
}
]
}

View File

@@ -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"
}
]
}
}

View File

@@ -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": []
}
}
}

View File

@@ -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)
})
}

View File

@@ -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 ?? [],
}
}

View File

@@ -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: [],
}
)
}

View File

@@ -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()
}

View File

@@ -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))
}

View File

@@ -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))
}

View File

@@ -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))
}

View File

@@ -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))
}

View File

@@ -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))
}

View File

@@ -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))
}