diff --git a/frontend/package.json b/frontend/package.json index 94b641c..2426e24 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,7 +9,7 @@ "scripts": { "lint": "eslint -c ./eslint.config.js \"./src*/**/*.{js,cjs,mjs,vue}\"", "format": "prettier --write \"**/*.{js,vue,scss,html,md,json}\" --ignore-path .gitignore", - "test": "echo \"No test specified\" && exit 0", + "test": "node --test \"src/**/*.test.js\"", "dev": "quasar dev", "build": "quasar build", "postinstall": "quasar prepare" @@ -41,4 +41,4 @@ "yarn": ">= 1.21.1", "pnpm": ">= 10.0.0" } -} \ No newline at end of file +} diff --git a/frontend/src/mocks/api/dictionariesMockApi.js b/frontend/src/mocks/api/dictionariesMockApi.js new file mode 100644 index 0000000..349a199 --- /dev/null +++ b/frontend/src/mocks/api/dictionariesMockApi.js @@ -0,0 +1,19 @@ +import mockDictionaries from 'src/mocks/dictionaries.json' +import { waitForMockApi } from 'src/services/apiMode' + +export async function fetchDictionaries() { + await waitForMockApi() + + return normalizeDictionaries(mockDictionaries) +} + +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/mocks/api/productSpecificationMockApi.js b/frontend/src/mocks/api/productSpecificationMockApi.js new file mode 100644 index 0000000..5276fd9 --- /dev/null +++ b/frontend/src/mocks/api/productSpecificationMockApi.js @@ -0,0 +1,43 @@ +import mockSpecifications from 'src/mocks/specifications.json' +import { waitForMockApi } from 'src/services/apiMode' + +export async function fetchProductSpecification(productId) { + await waitForMockApi() + + return normalizeSpecificationResponse(getMockSpecification(productId)) +} + +export async function refreshProductSpecification(productId) { + await waitForMockApi() + + return normalizeSpecificationResponse({ + ...getMockSpecification(productId), + lastFetchedAt: new Date().toISOString(), + }) +} + +function getMockSpecification(productId) { + return ( + mockSpecifications[productId] ?? { + productId, + orderId: null, + sourceUrl: null, + lastFetchedAt: null, + specification: { + sections: [], + }, + diff: [], + } + ) +} + +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 ?? [], + } +} diff --git a/frontend/src/mocks/api/productTimelineMockApi.js b/frontend/src/mocks/api/productTimelineMockApi.js new file mode 100644 index 0000000..5f34528 --- /dev/null +++ b/frontend/src/mocks/api/productTimelineMockApi.js @@ -0,0 +1,81 @@ +import mockTimelines from 'src/mocks/timelines.json' +import { waitForMockApi } from 'src/services/apiMode' + +const timelinesByProductId = structuredClone(mockTimelines) + +export async function fetchProductTimeline(productId) { + await waitForMockApi() + + return getMockTimeline(productId) +} + +export async function createProductTimelineEvent(productId, payload) { + await waitForMockApi() + + const timeline = getMockTimeline(productId) + const event = createMockEvent(productId, payload) + + timeline.events.push(event) + timeline.timelinePreview = createTimelinePreview(timeline.events) + + return { + event, + timelinePreview: timeline.timelinePreview, + } +} + +function getMockTimeline(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/mocks/api/productsMockApi.js b/frontend/src/mocks/api/productsMockApi.js new file mode 100644 index 0000000..b568a55 --- /dev/null +++ b/frontend/src/mocks/api/productsMockApi.js @@ -0,0 +1,83 @@ +import mockProducts from 'src/mocks/products.json' +import { waitForMockApi } from 'src/services/apiMode' + +const mockItems = [...mockProducts.items] + +export async function fetchProducts(params) { + 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, + }, + } +} + +function filterMockProducts(items, params = {}) { + return items.filter((product) => { + if (!matchesOrderSearch(product, params)) { + return false + } + + if (params.finish && product.finish !== params.finish) { + return false + } + + if (params.year && product.orderYear !== Number(params.year)) { + return false + } + + if (params.productionList && !product.productionLists.includes(params.productionList)) { + return false + } + + return true + }) +} + +function matchesOrderSearch(product, params = {}) { + const search = params.search?.trim() + const orderSearch = params.orderSearch ?? [] + + if (!search) { + return true + } + + if (!orderSearch.length) { + return false + } + + return orderSearch.some((condition) => matchesOrderCondition(product, condition)) +} + +function matchesOrderCondition(product, condition) { + const productOrderNumber = Number(product.orderNumber) + const productOrderNumberText = String(productOrderNumber) + + if (condition.type === 'orderNumberPrefix') { + return productOrderNumberText.startsWith(condition.orderNumberPrefix) + } + + if (condition.orderNumber !== null && productOrderNumber !== condition.orderNumber) { + return false + } + + if (condition.orderYear !== null && product.orderYear !== condition.orderYear) { + return false + } + + if (condition.orderIndex !== null && product.orderIndex !== condition.orderIndex) { + return false + } + + return true +} diff --git a/frontend/src/pages/IndexPage.vue b/frontend/src/pages/IndexPage.vue index e774508..85eaf22 100644 --- a/frontend/src/pages/IndexPage.vue +++ b/frontend/src/pages/IndexPage.vue @@ -78,7 +78,7 @@ import { useProductsStore } from 'src/stores/productsStore' const productsStore = useProductsStore() const uiStore = useUiStore() -const searchQuery = ref(productsStore.filters.search) +const searchQuery = ref(productsStore.searchQuery) const activeMonth = ref('all') const monthFilters = [ @@ -98,7 +98,7 @@ onMounted(() => { }) watch(searchQuery, (value) => { - productsStore.setFilters({ search: value }) + productsStore.setSearchQuery(value) }) function openAdvancedSearch() { @@ -135,7 +135,7 @@ function openProductionStatuses({ product, partType } = {}) { } function applySearch() { - productsStore.applyFilters({ search: searchQuery.value }) + productsStore.applySearch(searchQuery.value) } function selectMonth(month) { diff --git a/frontend/src/services/dictionariesApi.js b/frontend/src/services/dictionariesApi.js index 5205e71..707c300 100644 --- a/frontend/src/services/dictionariesApi.js +++ b/frontend/src/services/dictionariesApi.js @@ -1,26 +1,5 @@ -import { api } from 'src/boot/axios' -import mockDictionaries from 'src/mocks/dictionaries.json' -import { USE_MOCK_API, waitForMockApi } from 'src/services/apiMode' +import { fetchDictionaries as fetchDictionariesFromHttp } from 'src/services/dictionariesHttpApi' +import { fetchDictionaries as fetchDictionariesFromMock } from 'src/mocks/api/dictionariesMockApi' +import { USE_MOCK_API } 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 ?? [], - } -} +export const fetchDictionaries = USE_MOCK_API ? fetchDictionariesFromMock : fetchDictionariesFromHttp diff --git a/frontend/src/services/dictionariesHttpApi.js b/frontend/src/services/dictionariesHttpApi.js new file mode 100644 index 0000000..a170be6 --- /dev/null +++ b/frontend/src/services/dictionariesHttpApi.js @@ -0,0 +1,18 @@ +import { api } from 'src/boot/axios' + +export async function fetchDictionaries() { + 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 index a920b27..e7e3944 100644 --- a/frontend/src/services/productSpecificationApi.js +++ b/frontend/src/services/productSpecificationApi.js @@ -1,56 +1,17 @@ -import { api } from 'src/boot/axios' -import mockSpecifications from 'src/mocks/specifications.json' -import { USE_MOCK_API, waitForMockApi } from 'src/services/apiMode' +import { + fetchProductSpecification as fetchProductSpecificationFromHttp, + refreshProductSpecification as refreshProductSpecificationFromHttp, +} from 'src/services/productSpecificationHttpApi' +import { + fetchProductSpecification as fetchProductSpecificationFromMock, + refreshProductSpecification as refreshProductSpecificationFromMock, +} from 'src/mocks/api/productSpecificationMockApi' +import { USE_MOCK_API } 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 const fetchProductSpecification = USE_MOCK_API + ? fetchProductSpecificationFromMock + : fetchProductSpecificationFromHttp -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: [], - } - ) -} +export const refreshProductSpecification = USE_MOCK_API + ? refreshProductSpecificationFromMock + : refreshProductSpecificationFromHttp diff --git a/frontend/src/services/productSpecificationHttpApi.js b/frontend/src/services/productSpecificationHttpApi.js new file mode 100644 index 0000000..6efad26 --- /dev/null +++ b/frontend/src/services/productSpecificationHttpApi.js @@ -0,0 +1,24 @@ +import { api } from 'src/boot/axios' + +export async function fetchProductSpecification(productId) { + const response = await api.get(`/mayo-api/products/${productId}/specification`) + + return normalizeSpecificationResponse(response.data) +} + +export async function refreshProductSpecification(productId) { + const response = await api.post(`/mayo-api/products/${productId}/specification/refresh`) + + return normalizeSpecificationResponse(response.data) +} + +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 ?? [], + } +} diff --git a/frontend/src/services/productTimelineApi.js b/frontend/src/services/productTimelineApi.js index 27e3412..a54567b 100644 --- a/frontend/src/services/productTimelineApi.js +++ b/frontend/src/services/productTimelineApi.js @@ -1,104 +1,17 @@ -import { api } from 'src/boot/axios' -import mockTimelines from 'src/mocks/timelines.json' -import { USE_MOCK_API, waitForMockApi } from 'src/services/apiMode' +import { + createProductTimelineEvent as createProductTimelineEventFromHttp, + fetchProductTimeline as fetchProductTimelineFromHttp, +} from 'src/services/productTimelineHttpApi' +import { + createProductTimelineEvent as createProductTimelineEventFromMock, + fetchProductTimeline as fetchProductTimelineFromMock, +} from 'src/mocks/api/productTimelineMockApi' +import { USE_MOCK_API } from 'src/services/apiMode' -const timelinesByProductId = structuredClone(mockTimelines) +export const fetchProductTimeline = USE_MOCK_API + ? fetchProductTimelineFromMock + : fetchProductTimelineFromHttp -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() -} +export const createProductTimelineEvent = USE_MOCK_API + ? createProductTimelineEventFromMock + : createProductTimelineEventFromHttp diff --git a/frontend/src/services/productTimelineHttpApi.js b/frontend/src/services/productTimelineHttpApi.js new file mode 100644 index 0000000..d92056a --- /dev/null +++ b/frontend/src/services/productTimelineHttpApi.js @@ -0,0 +1,16 @@ +import { api } from 'src/boot/axios' + +export async function fetchProductTimeline(productId) { + const response = await api.get(`/mayo-api/products/${productId}/timeline`) + + return response.data +} + +export async function createProductTimelineEvent(productId, payload) { + const response = await api.post(`/mayo-api/products/${productId}/timeline/events`, payload) + + return { + event: response.data.event ?? response.data, + timelinePreview: response.data.timelinePreview, + } +} diff --git a/frontend/src/services/productsApi.js b/frontend/src/services/productsApi.js index bb4589d..833799c 100644 --- a/frontend/src/services/productsApi.js +++ b/frontend/src/services/productsApi.js @@ -1,64 +1,5 @@ -import { api } from 'src/boot/axios' -import mockProducts from 'src/mocks/products.json' -import { USE_MOCK_API, waitForMockApi } from 'src/services/apiMode' +import { fetchProducts as fetchProductsFromHttp } from 'src/services/productsHttpApi' +import { fetchProducts as fetchProductsFromMock } from 'src/mocks/api/productsMockApi' +import { USE_MOCK_API } 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)) -} +export const fetchProducts = USE_MOCK_API ? fetchProductsFromMock : fetchProductsFromHttp diff --git a/frontend/src/services/productsHttpApi.js b/frontend/src/services/productsHttpApi.js new file mode 100644 index 0000000..58361e3 --- /dev/null +++ b/frontend/src/services/productsHttpApi.js @@ -0,0 +1,19 @@ +import { api } from 'src/boot/axios' + +export async function fetchProducts(params) { + const response = await api.get('/mayo-api/products', { + params: serializeProductsParams(params), + }) + + return { + items: response.data.items ?? response.data, + pageInfo: response.data.pageInfo ?? {}, + } +} + +function serializeProductsParams(params = {}) { + return { + ...params, + orderSearch: params.orderSearch?.length ? JSON.stringify(params.orderSearch) : undefined, + } +} diff --git a/frontend/src/stores/productsStore.js b/frontend/src/stores/productsStore.js index 4522996..ef06062 100644 --- a/frontend/src/stores/productsStore.js +++ b/frontend/src/stores/productsStore.js @@ -1,12 +1,12 @@ import { computed, ref } from 'vue' import { acceptHMRUpdate, defineStore } from 'pinia' import { fetchProducts } from 'src/services/productsApi' +import { parseOrderSearchQuery } from 'src/utils/orderSearchParser' const DEFAULT_LIMIT = 30 function createDefaultFilters() { return { - search: '', modelId: null, clientId: null, finish: null, @@ -19,6 +19,8 @@ export const useProductsStore = defineStore('products', () => { const ids = ref([]) const byId = ref({}) const filters = ref(createDefaultFilters()) + const searchQuery = ref('') + const orderSearch = ref([]) const limit = ref(DEFAULT_LIMIT) const offset = ref(0) const total = ref(null) @@ -33,7 +35,8 @@ export const useProductsStore = defineStore('products', () => { return { limit: limit.value, offset: offset.value, - search: filters.value.search || undefined, + search: searchQuery.value || undefined, + orderSearch: orderSearch.value.length ? orderSearch.value : undefined, model: filters.value.modelId || undefined, client: filters.value.clientId || undefined, finish: filters.value.finish || undefined, @@ -95,11 +98,21 @@ export const useProductsStore = defineStore('products', () => { } } + function setSearchQuery(value) { + searchQuery.value = value + orderSearch.value = parseOrderSearchQuery(value) + } + async function applyFilters(nextFilters) { setFilters(nextFilters) await fetchFirstPage() } + async function applySearch(value) { + setSearchQuery(value) + await fetchFirstPage() + } + function updateProduct(productId, patch) { const current = byId.value[productId] @@ -133,6 +146,8 @@ export const useProductsStore = defineStore('products', () => { ids, byId, filters, + searchQuery, + orderSearch, limit, offset, total, @@ -144,7 +159,9 @@ export const useProductsStore = defineStore('products', () => { fetchFirstPage, fetchNextPage, setFilters, + setSearchQuery, applyFilters, + applySearch, updateProduct, applyTimelinePreviewUpdate, clear, diff --git a/frontend/src/utils/orderSearchParser.js b/frontend/src/utils/orderSearchParser.js new file mode 100644 index 0000000..2b19268 --- /dev/null +++ b/frontend/src/utils/orderSearchParser.js @@ -0,0 +1,141 @@ +const TOKEN_SEPARATOR_PATTERN = /[\s,.]+/ +const ORDER_NUMBER_PATTERN = /^\d{1,4}$/ +const YEAR_PATTERN = /^\d{4}$/ +const ORDER_INDEX_PATTERN = /^\d{1,2}$/ +const MIN_ORDER_YEAR = 2021 + +export function parseOrderSearchQuery(query) { + return tokenizeSearchQuery(query).map(parseOrderSearchToken).filter(Boolean) +} + +export function tokenizeSearchQuery(query) { + return String(query ?? '') + .trim() + .split(TOKEN_SEPARATOR_PATTERN) + .map((token) => token.trim()) + .filter(Boolean) +} + +export function parseOrderSearchToken(token) { + const parts = token.split('/').map((part) => part.trim()) + + if (parts.length === 1) { + return parseOrderNumberOnly(parts[0], token) + } + + if (parts.length === 2) { + return parseTwoPartOrderToken(parts, token) + } + + if (parts.length === 3) { + return parseFullOrderToken(parts, token) + } + + return null +} + +function parseOrderNumberOnly(orderNumberText, raw) { + if (!isValidOrderNumber(orderNumberText)) { + return null + } + + const normalizedOrderNumber = normalizeNumber(orderNumberText) + const hasLeadingZeros = orderNumberText.length > 1 && orderNumberText.startsWith('0') + const isFullWidthOrderNumber = orderNumberText.length === 4 + + if (hasLeadingZeros || isFullWidthOrderNumber) { + return { + raw, + type: 'orderNumber', + match: 'exact', + orderNumber: normalizedOrderNumber, + orderNumberPrefix: null, + orderYear: null, + orderIndex: null, + } + } + + return { + raw, + type: 'orderNumberPrefix', + match: 'prefix', + orderNumber: null, + orderNumberPrefix: orderNumberText, + orderYear: null, + orderIndex: null, + } +} + +function parseTwoPartOrderToken(parts, raw) { + const [orderNumberText, secondPart] = parts + + if (!isValidOrderNumber(orderNumberText)) { + return null + } + + if (isValidOrderYear(secondPart)) { + return { + raw, + type: 'orderNumberYear', + match: 'exact', + orderNumber: normalizeNumber(orderNumberText), + orderNumberPrefix: null, + orderYear: normalizeNumber(secondPart), + orderIndex: null, + } + } + + if (isValidOrderIndex(secondPart)) { + return { + raw, + type: 'orderNumberIndex', + match: 'exact', + orderNumber: normalizeNumber(orderNumberText), + orderNumberPrefix: null, + orderYear: null, + orderIndex: normalizeNumber(secondPart), + } + } + + return null +} + +function parseFullOrderToken(parts, raw) { + const [orderNumberText, orderYearText, orderIndexText] = parts + + if ( + !isValidOrderNumber(orderNumberText) || + !isValidOrderYear(orderYearText) || + !isValidOrderIndex(orderIndexText) + ) { + return null + } + + return { + raw, + type: 'fullOrderId', + match: 'exact', + orderNumber: normalizeNumber(orderNumberText), + orderNumberPrefix: null, + orderYear: normalizeNumber(orderYearText), + orderIndex: normalizeNumber(orderIndexText), + } +} + +function isValidOrderNumber(value) { + return ORDER_NUMBER_PATTERN.test(value) && normalizeNumber(value) >= 1 +} + +function isValidOrderYear(value) { + return YEAR_PATTERN.test(value) && normalizeNumber(value) >= MIN_ORDER_YEAR +} + +function isValidOrderIndex(value) { + const normalized = normalizeNumber(value) + + return ORDER_INDEX_PATTERN.test(value) && normalized >= 1 && normalized <= 99 +} + +function normalizeNumber(value) { + return Number.parseInt(value, 10) +} diff --git a/frontend/src/utils/orderSearchParser.test.js b/frontend/src/utils/orderSearchParser.test.js new file mode 100644 index 0000000..d108347 --- /dev/null +++ b/frontend/src/utils/orderSearchParser.test.js @@ -0,0 +1,134 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' +import { + parseOrderSearchQuery, + parseOrderSearchToken, + tokenizeSearchQuery, +} from './orderSearchParser.js' + +describe('tokenizeSearchQuery', () => { + it('splits tokens by spaces, commas and dots', () => { + assert.deepEqual(tokenizeSearchQuery('123/1 0200/2,333/2025/3.444/2024'), [ + '123/1', + '0200/2', + '333/2025/3', + '444/2024', + ]) + }) + + it('ignores empty input', () => { + assert.deepEqual(tokenizeSearchQuery(' , . '), []) + }) +}) + +describe('parseOrderSearchToken', () => { + it('parses full order id', () => { + assert.deepEqual(parseOrderSearchToken('0012/2025/01'), { + raw: '0012/2025/01', + type: 'fullOrderId', + match: 'exact', + orderNumber: 12, + orderNumberPrefix: null, + orderYear: 2025, + orderIndex: 1, + }) + }) + + it('parses padded order number as exact order number', () => { + assert.deepEqual(parseOrderSearchToken('0012'), { + raw: '0012', + type: 'orderNumber', + match: 'exact', + orderNumber: 12, + orderNumberPrefix: null, + orderYear: null, + orderIndex: null, + }) + }) + + it('parses unpadded order number as prefix search', () => { + assert.deepEqual(parseOrderSearchToken('12'), { + raw: '12', + type: 'orderNumberPrefix', + match: 'prefix', + orderNumber: null, + orderNumberPrefix: '12', + orderYear: null, + orderIndex: null, + }) + }) + + it('parses order number with year', () => { + assert.deepEqual(parseOrderSearchToken('444/2024'), { + raw: '444/2024', + type: 'orderNumberYear', + match: 'exact', + orderNumber: 444, + orderNumberPrefix: null, + orderYear: 2024, + orderIndex: null, + }) + }) + + it('parses order number with product index', () => { + assert.deepEqual(parseOrderSearchToken('12/01'), { + raw: '12/01', + type: 'orderNumberIndex', + match: 'exact', + orderNumber: 12, + orderNumberPrefix: null, + orderYear: null, + orderIndex: 1, + }) + }) + + it('rejects invalid values', () => { + assert.equal(parseOrderSearchToken('0000'), null) + assert.equal(parseOrderSearchToken('12/2019/1'), null) + assert.equal(parseOrderSearchToken('12/2025/100'), null) + assert.equal(parseOrderSearchToken('12/2025/1/2'), null) + }) +}) + +describe('parseOrderSearchQuery', () => { + it('parses many order numbers in one query', () => { + assert.deepEqual(parseOrderSearchQuery('123/1 0200/2 333/2025/3 444/2024'), [ + { + raw: '123/1', + type: 'orderNumberIndex', + match: 'exact', + orderNumber: 123, + orderNumberPrefix: null, + orderYear: null, + orderIndex: 1, + }, + { + raw: '0200/2', + type: 'orderNumberIndex', + match: 'exact', + orderNumber: 200, + orderNumberPrefix: null, + orderYear: null, + orderIndex: 2, + }, + { + raw: '333/2025/3', + type: 'fullOrderId', + match: 'exact', + orderNumber: 333, + orderNumberPrefix: null, + orderYear: 2025, + orderIndex: 3, + }, + { + raw: '444/2024', + type: 'orderNumberYear', + match: 'exact', + orderNumber: 444, + orderNumberPrefix: null, + orderYear: 2024, + orderIndex: null, + }, + ]) + }) +}) diff --git a/notes/api_services.md b/notes/api_services.md index ef581d9..abc9c0e 100644 --- a/notes/api_services.md +++ b/notes/api_services.md @@ -34,9 +34,26 @@ a service powinien zajac sie reszta. ```txt frontend/src/services/apiMode.js frontend/src/services/productsApi.js +frontend/src/services/productsHttpApi.js frontend/src/services/productTimelineApi.js +frontend/src/services/productTimelineHttpApi.js frontend/src/services/productSpecificationApi.js +frontend/src/services/productSpecificationHttpApi.js frontend/src/services/dictionariesApi.js +frontend/src/services/dictionariesHttpApi.js +``` + +Pliki `*Api.js` sa fasadami. Wybieraja implementacje na podstawie `USE_MOCK_API`. + +Pliki `*HttpApi.js` sa implementacja prawdziwego API przez Axios. + +Implementacje mockow sa poza katalogiem `services`: + +```txt +frontend/src/mocks/api/productsMockApi.js +frontend/src/mocks/api/productTimelineMockApi.js +frontend/src/mocks/api/productSpecificationMockApi.js +frontend/src/mocks/api/dictionariesMockApi.js ``` ## Przeplyw danych @@ -57,6 +74,7 @@ Przyklad: IndexPage.vue -> productsStore.fetchFirstPage() -> fetchProducts(params) + -> productsMockApi albo productsHttpApi -> products.json albo GET /mayo-api/products ``` @@ -90,6 +108,20 @@ To jest lepsze, bo: - latwiej dodac mocki, - latwiej testowac logike store. +Po ostatnim porzadkowaniu services sa fasadami. Przyklad: + +```js +export const fetchProducts = USE_MOCK_API ? fetchProductsFromMock : fetchProductsFromHttp +``` + +Dzieki temu `productsStore` zawsze importuje to samo: + +```js +import { fetchProducts } from 'src/services/productsApi' +``` + +ale realna implementacja moze byc mockowa albo HTTP. + ## `apiMode.js` Plik: @@ -140,18 +172,19 @@ frontend/src/mocks/specifications.json frontend/src/mocks/dictionaries.json ``` -Sa uzywane tylko przez warstwe `services`. +Sa uzywane tylko przez implementacje mock API w `frontend/src/mocks/api/`. -Komponenty i store nie importuja mockow bezposrednio. +Komponenty, store i fasady `services/*Api.js` nie importuja plikow JSON 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` +## `productsApi.js` i `productsHttpApi.js` Plik: ```txt frontend/src/services/productsApi.js +frontend/src/services/productsHttpApi.js ``` Eksportuje: @@ -167,6 +200,14 @@ Odpowiedzialnosc: - obsluga `limit` i `offset`, - zwrocenie danych w formacie oczekiwanym przez `productsStore`. +`productsApi.js` jest fasada. + +`productsHttpApi.js` zna Axios i endpoint: + +```http +GET /mayo-api/products +``` + Zwraca: ```js @@ -181,14 +222,20 @@ Zwraca: } ``` +W trybie mock implementacja jest w: + +```txt +frontend/src/mocks/api/productsMockApi.js +``` + W trybie mock: - czyta `frontend/src/mocks/products.json`, -- filtruje po `search`, `finish`, `year`, `productionList`, +- filtruje po `orderSearch`, `finish`, `year`, `productionList`, - ucina wynik wedlug `limit` i `offset`, - zwraca `items` i `pageInfo`. -W trybie prawdziwego API: +W trybie prawdziwego API implementacja jest w `productsHttpApi.js`: ```http GET /mayo-api/products @@ -201,6 +248,7 @@ z parametrami: limit, offset, search, + orderSearch, model, client, finish, @@ -209,12 +257,21 @@ z parametrami: } ``` -## `productTimelineApi.js` +`orderSearch` powstaje w `productsStore` przez parser: + +```txt +frontend/src/utils/orderSearchParser.js +``` + +Do HTTP `orderSearch` jest serializowane jako JSON string, bo endpoint listy jest typu `GET`. + +## `productTimelineApi.js` i `productTimelineHttpApi.js` Plik: ```txt frontend/src/services/productTimelineApi.js +frontend/src/services/productTimelineHttpApi.js ``` Eksportuje: @@ -228,13 +285,19 @@ createProductTimelineEvent(productId, payload) Pobiera pelny timeline produktu. +W trybie mock implementacja jest w: + +```txt +frontend/src/mocks/api/productTimelineMockApi.js +``` + W trybie mock: - czyta `frontend/src/mocks/timelines.json`, - zwraca timeline dla `productId`, - jesli brak danych, zwraca pusty timeline. -W trybie prawdziwego API: +W trybie prawdziwego API implementacja jest w `productTimelineHttpApi.js`: ```http GET /mayo-api/products/:id/timeline @@ -274,12 +337,13 @@ To pozwala store od razu zaktualizowac: - pelny timeline, - preview na glownej karcie produktu. -## `productSpecificationApi.js` +## `productSpecificationApi.js` i `productSpecificationHttpApi.js` Plik: ```txt frontend/src/services/productSpecificationApi.js +frontend/src/services/productSpecificationHttpApi.js ``` Eksportuje: @@ -293,11 +357,17 @@ refreshProductSpecification(productId) Pobiera specyfikacje produktu. +W trybie mock implementacja jest w: + +```txt +frontend/src/mocks/api/productSpecificationMockApi.js +``` + W trybie mock: - czyta `frontend/src/mocks/specifications.json`. -W trybie prawdziwego API: +W trybie prawdziwego API implementacja jest w `productSpecificationHttpApi.js`: ```http GET /mayo-api/products/:id/specification @@ -353,12 +423,13 @@ data.sections Dzieki temu store nie musi znac szczegolow odpowiedzi HTTP. -## `dictionariesApi.js` +## `dictionariesApi.js` i `dictionariesHttpApi.js` Plik: ```txt frontend/src/services/dictionariesApi.js +frontend/src/services/dictionariesHttpApi.js ``` Eksportuje: @@ -372,11 +443,17 @@ Odpowiedzialnosc: - pobranie slownikow do filtrow i formularzy, - normalizacja pustych pol do pustych tablic. +W trybie mock implementacja jest w: + +```txt +frontend/src/mocks/api/dictionariesMockApi.js +``` + W trybie mock: - czyta `frontend/src/mocks/dictionaries.json`. -W trybie prawdziwego API: +W trybie prawdziwego API implementacja jest w `dictionariesHttpApi.js`: ```http GET /mayo-api/dictionaries @@ -405,20 +482,28 @@ Zrob nowy service: ```txt frontend/src/services/productPhotosApi.js +frontend/src/services/productPhotosHttpApi.js +frontend/src/mocks/api/productPhotosMockApi.js ``` -W nim: +Fasada: + +```js +import { fetchProductPhotos as fetchProductPhotosFromHttp } from 'src/services/productPhotosHttpApi' +import { fetchProductPhotos as fetchProductPhotosFromMock } from 'src/mocks/api/productPhotosMockApi' +import { USE_MOCK_API } from 'src/services/apiMode' + +export const fetchProductPhotos = USE_MOCK_API + ? fetchProductPhotosFromMock + : fetchProductPhotosFromHttp +``` + +Implementacja HTTP: ```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 } @@ -463,12 +548,16 @@ Utworzono: ```txt frontend/src/services/apiMode.js frontend/src/services/productsApi.js +frontend/src/services/productsHttpApi.js frontend/src/services/productTimelineApi.js +frontend/src/services/productTimelineHttpApi.js frontend/src/services/productSpecificationApi.js +frontend/src/services/productSpecificationHttpApi.js frontend/src/services/dictionariesApi.js +frontend/src/services/dictionariesHttpApi.js ``` -Utworzono mocki: +Utworzono dane mockow: ```txt frontend/src/mocks/products.json @@ -477,6 +566,15 @@ frontend/src/mocks/specifications.json frontend/src/mocks/dictionaries.json ``` +Utworzono implementacje mock API: + +```txt +frontend/src/mocks/api/productsMockApi.js +frontend/src/mocks/api/productTimelineMockApi.js +frontend/src/mocks/api/productSpecificationMockApi.js +frontend/src/mocks/api/dictionariesMockApi.js +``` + 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. diff --git a/notes/stores.md b/notes/stores.md index f50b759..d2aa14a 100644 --- a/notes/stores.md +++ b/notes/stores.md @@ -182,7 +182,6 @@ Jeśli na karcie produktu ma być widoczny timeline, ten store powinien trzymać }, filters: { - search: '', modelId: null, clientId: null, finish: null, @@ -190,6 +189,19 @@ Jeśli na karcie produktu ma być widoczny timeline, ten store powinien trzymać year: null, }, + searchQuery: '123/1', + orderSearch: [ + { + raw: '123/1', + type: 'orderNumberIndex', + match: 'exact', + orderNumber: 123, + orderNumberPrefix: null, + orderYear: null, + orderIndex: 1, + }, + ], + limit: 30, offset: 30, total: 184, @@ -204,7 +216,7 @@ Jeśli na karcie produktu ma być widoczny timeline, ten store powinien trzymać Endpoint: ```http -GET /mayo-api/products?limit=30&offset=0&search=regius +GET /mayo-api/products?limit=30&offset=0&search=123/1 ``` Odpowiedź: @@ -261,10 +273,22 @@ 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' }) +await productsStore.applyFilters({ finish: 'GLOSS' }) ``` -Ustawia filtry i pobiera listę od początku. +Ustawia filtry i pobiera listę od początku. Searchbar nie jest zwyklym filtrem, bo ma osobny parser numerow zamowien. + +```js +productsStore.setSearchQuery('123/1') +``` + +Zapisuje tekst z searchbara i parsuje go do `orderSearch`. + +```js +await productsStore.applySearch('123/1') +``` + +Zapisuje tekst searchbara, parsuje go i pobiera listę od początku. ```js productsStore.applyTimelinePreviewUpdate(productId, timelinePreview) @@ -914,7 +938,7 @@ Komponent wywołuje: ```js productsStore.applyFilters({ - search: 'regius', + finish: 'GLOSS', }) ``` @@ -924,6 +948,14 @@ Store: 2. resetuje offset, 3. pobiera pierwszą stronę listy od nowa. +Searchbar jest obslugiwany osobno: + +```js +productsStore.applySearch('123/1') +``` + +To jest celowe, bo wyszukiwanie po numerze zamowienia ma wlasny parser i nie powinno mieszac sie z filtrami typu model, klient, finish albo lista produkcyjna. + ### 4. Użytkownik otwiera specyfikację produktu Komponent wywołuje: diff --git a/questions/serachbar.md b/questions/serachbar.md new file mode 100644 index 0000000..9294689 --- /dev/null +++ b/questions/serachbar.md @@ -0,0 +1,26 @@ +# zachowanie paska wyszukiwania + +Pasek wyszukiwnia sluzy do wyszukiwania produktow na liscie produktow. +wyszukiwanie odbywa sie głównie po numerze zamoiwnia. Kazdy produkt ma unikalny numer zamowiania w postaci XXXX/YYYY/ZZ, gdzie: + +- XXXX to numer zamowinia i przyjmuje wartosci od 1 do 1000. moze miec wiądoące zera. +- YYYY to rok zlozenia zamówniea i przyjmuje wartosci wieksze od 2020. +- ZZ to numer produktu w zamowieniu. moze miec wiadace zero, ale nie musi. + +1. wyszukiwanie moze odbywac sie po pelnym nuemrze (0012/2025/1). lsita produktow powinna zaweirac jeden (gdy jest pasujace dopasowanie) lub zero produktów, jeżeli żaden produkt nie ma takiego numeru. +2. wyszukiwanie moze odbywac sie po samym numerze zamowienai (0012). lista produktów poinna zawierac wszystkie produkty o numerze 12, z każdego roku. +3. wyszukiwanie moze odbywac sie po skroconym zapisie (0012/1). lista produktow powinna zawierac liste produktow o pasujacym numerze i pozycji w zamowieniu z kazdego roku. +4. wyszukiwanie moze odbywac sie po numerze zamowinia bez wiadacych zer. na przykład: + - 1 wszystkie produkty gdzie numer zamowienia zaczyna sie liczba 1 + - 12 wszystkie produkty gdzie numer zamowienia zaczyna sie liczba 12 + - 123 wszystkie produkty gdzie numer zamowienia zaczyna sie liczba 123 +5. wyszukiwanie moze odbywac sie po numerze zamowinia bez wiadacych zer wraz z pozycja porduktu w zamoieniu. na przykład: + - 1/1 wszystkie produkty gdzie numer zamowienia to 1, a pozycja produktu to 1. rok dowolny + - 12/1 wszystkie produkty gdzie numer zamowienia to 12, a pozycja produktu to 1. rok dowolny + - 123/1 wszystkie produkty gdzie numer zamowienia to 123, a pozycja produktu to 1. rok dowolny + - 1/01 wszystkie produkty gdzie numer zamowienia to 1, a pozycja produktu to 1. rok dowolny + - 12/01 wszystkie produkty gdzie numer zamowienia to 12, a pozycja produktu to 1. rok dowolny + - 123/01 wszystkie produkty gdzie numer zamowienia to 123, a pozycja produktu to 1. rok dowolny +6. wyszukiwanie moze odbywac podajac wiele numerow zamowien. separatorem może być spacja, kropka czy przecinek. na przykład: + "123/1 0200/2 333/2025/3 444/2024" +7. zawartosc pola wyszukiwania nie wplywa na filtry. filrtry takie jak model, klient, finish, lista prukdcyjna czy rok, sa ustawiane niezaleznie.