Compare commits

...

10 Commits

Author SHA1 Message Date
fb08705883 feat: Implement initial structure for Directus Mayo API extension
- Added main router in src/index.js to register endpoints.
- Implemented GET /mayo-api/products to fetch product list with pagination and filters.
- Implemented GET /mayo-api/dictionaries to fetch various dictionaries for frontend use.
- Created separate files for routes, repositories, serializers, and utilities to maintain clean architecture.
- Added utility functions for async handling, pagination, and order search parsing.
- Introduced serializers for products and dictionaries to format data for frontend consumption.
- Established repository functions for database queries related to products and dictionaries.
- Updated package.json to include license information.
- Created documentation for the API extension detailing current state and future implementation plans.
2026-05-20 22:38:51 +02:00
2005e327f1 refaktoryzacja komponentów: dodanie interakcji i poprawa wyświetlania specyfikacji produktów 2026-05-20 19:26:21 +02:00
93778065ce refaktoryzacja service api.
wydzielenie mockow.
dodanie parsowania wyszukiwania
2026-05-02 05:49:24 +02:00
045c65c363 proof-of-concept endpointow dla directusa 2026-04-30 16:49:50 +02:00
e03257f6fb refaktoryzacja paneli i widokow 2026-04-30 16:49:50 +02:00
6e3d722e69 dodalem pinia store.
dodalem dokumentacje opisujaca dzialanie store
dodalem services do komunikacji z directus
dodalem opis dzialania services
dodalem mocki danych
przygotowania do refaktoryzacji
2026-04-30 16:49:50 +02:00
5725c024dc przygotowanie na refaktor. dodanie plikow zawierajace stan projektu 2026-04-30 16:29:36 +02:00
2acdb09bfa wersja 5 db schema
dodalem wersjonowanie specyfikacji
2026-04-22 22:34:40 +02:00
32f126161e wersja 4 db schema
uproszczenie tabeli events
2026-04-22 22:16:11 +02:00
a6ed643434 wersja 3 db scheme 2026-04-22 21:37:55 +02:00
67 changed files with 19097 additions and 320 deletions

1
.gitignore vendored
View File

@@ -0,0 +1 @@
.codex

131
db_schema
View File

@@ -1,131 +0,0 @@
// Use DBML to define your database structure
// Docs: https://dbml.dbdiagram.io/docs
Table models {
id int [pk]
name varchar [not null, unique]
strings int [not null, default: 6, note: "ilosc strun"]
scale int [not null, default: 645, note: "menzura w mm"]
}
Table colors {
id int [pk]
name varchar [not null, unique]
burst bool [not null, default: false, note: "czy ma cien"]
monolith bool [not null, default: false, note: "czy nie jest transparetny"]
}
Enum finish_type {
GLOSS
SATIN
MAT
NITRO
}
Enum part_type {
BODY
NECK
}
Table parts {
id int [pk]
product_id int [ref: > products.id, not null]
type part_type_type [not null]
color_top int [ref: > colors.id, not null]
color_back int [ref: > colors.id, not null]
finish_top finish_type [not null]
finish_back finish_type [not null]
indexes {
(product_id, type) [unique]
}
}
Table products {
id int [pk]
model int [ref: > models.id, not null]
note text
}
Table detail_products {
id int [pk]
product_id int [ref: - products.id, not null]
spec jsonb [note: "specyfikacja pobrana ze starego systemu w formie json"]
url varchar [note: "adres url strony ze specyfikacja ze starego systemu" ]
}
Table clients {
id int [pk]
name varchar [not null, note: "Nazwa klienta"]
country varchar [note: "Kraj klienta"]
}
Table orders {
id int [pk]
order_number int [not null, note: "XXXX, eg 0027"]
order_year int [not null, note: "YYYY, eg 2025"]
client_id int [ref: > clients.id ,not null]
indexes {
(order_number, order_year) [unique]
}
}
Table order_products {
id int [pk]
product_id int [ref: > products.id, not null, unique]
order_id int [ref: > orders.id, not null]
product_order_idx int [not null]
indexes {
(order_id, product_order_idx) [unique]
}
}
Table operations {
id int [pk]
operation varchar [not null, unique]
description text
}
Enum event_type {
OPERATION
INFO
ERROR
}
Table event_info {
id int [pk]
event_id int [ref: > events.id, not null]
comment text [default: null]
photo_url varchar [default: null]
}
Table events {
id int [pk]
part_id int [ref: > parts.id, not null]
operation_id int [ref: > operations.id]
ordinal int [not null, note: "liczba porzadkowa, wielokrotnosc 32, zeby mozna bylo dodac pomiedzy"]
type event_type [not null, default: "OPERATION"]
date date [not null, note: "data jest ustawiana po wykonaniu operacji, niekonicznie w ten sam dzien. mozna edytowac"]
indexes {
(part_id, ordinal) [unique]
}
}
Table production_lists {
id int [pk]
name varchar [not null, unique]
description text
}
Table production_list_products {
id int [pk]
product_id int [ref: > products.id, not null]
prod_list_id int [ref: > production_lists.id, not null]
indexes {
(product_id, prod_list_id) [unique]
}
}

156
db_schema.dbml Normal file
View File

@@ -0,0 +1,156 @@
// Directus Database Schema for Mayo
// Revised schema proposal
// Updated: 2026-05-20
Table mayo_clients {
id integer [primary key]
name "character varying" [unique, not null]
country "character varying"
}
Table mayo_colors {
id integer [primary key]
name "character varying" [unique, not null]
}
Table mayo_finishes {
id integer [primary key]
code "character varying" [unique, not null]
name "character varying" [not null]
}
Table mayo_neck_constructions {
id integer [primary key]
code "character varying" [unique, not null]
name "character varying" [not null]
}
Table mayo_part_types {
id integer [primary key]
code "character varying" [unique, not null]
name "character varying" [not null]
}
Table mayo_product_models {
id integer [primary key]
name "character varying" [unique, not null]
neck_construction_id integer [not null]
}
Table mayo_products {
id integer [primary key]
user_created uuid
date_created timestamp
user_updated uuid
date_updated timestamp
source_url "character varying"
specification json
specification_last_fetched_at timestamp
product_model_id integer [not null]
}
Table mayo_orders {
id integer [primary key]
user_created uuid
date_created timestamp
user_updated uuid
date_updated timestamp
order_number "character varying" [not null]
order_year integer [not null]
client_id integer [not null]
}
Table mayo_order_items {
id integer [primary key]
user_created uuid
date_created timestamp
user_updated uuid
date_updated timestamp
product_id integer [unique, not null]
order_id integer [not null]
order_index integer [not null]
}
Table mayo_production_lists {
id integer [primary key]
user_created uuid
date_created timestamp
user_updated uuid
date_updated timestamp
name "character varying" [unique, not null]
description text
}
Table mayo_product_production_lists {
id integer [primary key]
user_created uuid
date_created timestamp
user_updated uuid
date_updated timestamp
order_item_id integer [not null]
production_list_id integer [not null]
}
Table mayo_product_parts {
id integer [primary key]
product_id integer [not null]
part_type_id integer [not null]
top_color_id integer
back_color_id integer
top_finish_id integer
back_finish_id integer
}
Table mayo_production_operations {
id integer [primary key]
code "character varying" [unique, not null]
name "character varying" [unique, not null]
description text
}
Table mayo_production_events {
id integer [primary key]
user_created uuid
date_created timestamp
user_updated uuid
date_updated timestamp
product_part_id integer [not null]
ordinal integer [not null]
event_date date [not null]
operation_id integer
note text
}
// Relationships
Ref: mayo_product_models.neck_construction_id > mayo_neck_constructions.id
Ref: mayo_products.product_model_id > mayo_product_models.id
Ref: mayo_orders.client_id > mayo_clients.id
Ref: mayo_order_items.product_id - mayo_products.id
Ref: mayo_order_items.order_id > mayo_orders.id
Ref: mayo_product_production_lists.order_item_id > mayo_order_items.id
Ref: mayo_product_production_lists.production_list_id > mayo_production_lists.id
Ref: mayo_product_parts.product_id > mayo_products.id
Ref: mayo_product_parts.part_type_id > mayo_part_types.id
Ref: mayo_product_parts.top_color_id > mayo_colors.id
Ref: mayo_product_parts.back_color_id > mayo_colors.id
Ref: mayo_product_parts.top_finish_id > mayo_finishes.id
Ref: mayo_product_parts.back_finish_id > mayo_finishes.id
Ref: mayo_production_events.product_part_id > mayo_product_parts.id
Ref: mayo_production_events.operation_id > mayo_production_operations.id
// Suggested seed values
//
// mayo_neck_constructions:
// - NTB: Neck-through-body
// - BOLT_ON: Bolt-on
// - SET_IN: Set-in
//
// mayo_part_types:
// - BODY: Body
// - NECK: Neck
//
// mayo_finishes:
// - GLOSS: Gloss
// - SATIN: Satin
// - MAT: Mat
// - NITRO: Nitro

63
directus-api.http Normal file
View File

@@ -0,0 +1,63 @@
@baseUrl = https://bartool.ovh/dpm-api
@token = _gEp4JyW6uBY7xyKVDo2vKUtCLfPGr3D
# @token = K3YEINVfAh1q0EfSPO7YFz_2Zx_CLbSt
@webhook = 879074dc-d25a-462e-b693-b994ad8435d5
### Logowanie
# @name login
POST {{baseUrl}}/auth/login
Content-Type: application/json
{
"email": "bartoolina@gmail.com",
"password": "20madafaka"
}
###
@auth_token = {{login.response.body.data.access_token}}
### Products
GET {{baseUrl}}/items/mayo_products
Authorization: Bearer {{auth_token}}
### Products
GET {{baseUrl}}/collections
Authorization: Bearer {{auth_token}}
### Products with model
GET {{baseUrl}}/items/mayo_products?fields=*,model_id.*,order.product_order_index,order.order_id.*
Authorization: Bearer {{token}}
### Orders by number
GET {{baseUrl}}/items/mayo_orders?filter[order_number][_contains]=1&filter[order_year][_eq]=2025
Authorization: Bearer {{token}}
### moj test
GET {{baseUrl}}/items/mayo_order_products?fields=product_id.*,order_id.*
Authorization: Bearer {{token}}
### test custom endpoint
GET {{baseUrl}}/mayo-api/products
Authorization: Bearer {{token}}
### test custom endpoint
GET {{baseUrl}}/mayo-api/orders
Authorization: Bearer {{token}}
### test custom endpoint
GET {{baseUrl}}/mayo-api/my-products
Authorization: Bearer {{token}}
### test custom endpoint
GET {{baseUrl}}/collections
Authorization: Bearer {{token}}
### webhook
GET {{baseUrl}}/flows/trigger/{{webhook}}?order=0027&year=2025&index=01
Authorization: Bearer {{auth_token}}
### dane
@order = 0367
@year = 2025
@index = 2
### mayo parse
GET http://192.168.1.101:8001/orders/?order_id={{order}}%2F{{year}}%2F{{index}}
>res.json

View File

@@ -0,0 +1,3 @@
.DS_Store
node_modules
dist

View File

@@ -0,0 +1,16 @@
# Directus Mayo API Extension
Custom Directus endpoint extension for Duck Prod Manager.
The extension exposes frontend-facing API routes under:
```txt
/mayo-api
```
Current implemented routes:
```txt
GET /mayo-api/products
GET /mayo-api/dictionaries
```

View File

@@ -0,0 +1,56 @@
@baseUrl = https://bartool.ovh/dpm-api
@token = _gEp4JyW6uBY7xyKVDo2vKUtCLfPGr3D
### Health check - Directus server
GET {{baseUrl}}/server/health
### Logowanie
# @name login
POST {{baseUrl}}/auth/login
Content-Type: application/json
{
"email": "bartoolina@gmail.com",
"password": "20madafaka"
}
###
@auth_token = {{login.response.body.data.access_token}}
### Mayo API - products
GET {{baseUrl}}/mayo-api/products
Authorization: Bearer {{auth_token}}
### Mayo API - products with pagination
GET {{baseUrl}}/mayo-api/products?limit=30&offset=0
Authorization: Bearer {{auth_token}}
### Mayo API - products by order search
# orderSearch is a JSON string. This example matches order number 0143/2025/1.
GET {{baseUrl}}/mayo-api/products?search=0143/2025/1&orderSearch=[{"raw":"0143/2025/1","type":"fullOrderId","match":"exact","orderNumber":143,"orderNumberPrefix":null,"orderYear":2025,"orderIndex":1}]
Authorization: Bearer {{auth_token}}
### Mayo API - products by year
GET {{baseUrl}}/mayo-api/products?year=2025
Authorization: Bearer {{auth_token}}
### Mayo API - products by model id
GET {{baseUrl}}/mayo-api/products?model=2
Authorization: Bearer {{auth_token}}
### Mayo API - products by client id
GET {{baseUrl}}/mayo-api/products?client=1
Authorization: Bearer {{auth_token}}
### Mayo API - products by production list id
GET {{baseUrl}}/mayo-api/products?productionList=1
Authorization: Bearer {{auth_token}}
### Mayo API - products by finish
GET {{baseUrl}}/mayo-api/products?finish=MAT
Authorization: Bearer {{auth_token}}
### Mayo API - dictionaries
GET {{baseUrl}}/mayo-api/dictionaries
Authorization: Bearer {{auth_token}}

View File

@@ -0,0 +1,252 @@
export default {
id: "mayo-api",
handler: (router, context) => {
const { services, getSchema, database } = context;
const { ItemsService } = services;
router.get("/products", async (req, res) => {
const productsService = new ItemsService("mayo_products", {
schema: await getSchema(),
accountability: req.accountability,
});
const partsService = new ItemsService("mayo_parts", {
schema: await getSchema(),
accountability: req.accountability,
});
try {
const products = await productsService.readByQuery({
fields: [
"id",
"note",
"model_id.name",
"model_id.strings_count",
"model_id.scale",
],
limit: -1,
});
const productIds = products.map((product) => product.id);
const parts = await partsService.readByQuery({
fields: [
"id",
"product_id",
"part_type",
"top_color_id.name",
"back_color_id.name",
"top_finish",
"back_finish",
],
filter: {
product_id: {
_in: productIds,
},
},
limit: -1,
});
const partsByProductId = new Map();
for (const part of parts) {
const productId =
typeof part.product_id === "object"
? part.product_id.id
: part.product_id;
if (!partsByProductId.has(productId)) {
partsByProductId.set(productId, []);
}
partsByProductId.get(productId).push(part);
}
const response = products.map((product) => ({
...product,
parts: partsByProductId.get(product.id) ?? [],
}));
res.json(response);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
router.get("/products-new", async (req, res) => {
try {
const products = await database("mayo_products as product")
.select(
"product.id",
"product.note",
"model.name as model_name",
"model.strings_count as model_strings_count",
"model.scale as model_scale",
)
.leftJoin("mayo_models as model", "product.model_id", "model.id");
const productIds = products.map((product) => product.id);
const parts = productIds.length
? await database("mayo_parts as part")
.select(
"part.id",
"part.product_id",
"part.part_type",
"part.top_finish",
"part.back_finish",
"top_color.name as top_color_name",
"back_color.name as back_color_name",
)
.leftJoin(
"mayo_colors as top_color",
"part.top_color_id",
"top_color.id",
)
.leftJoin(
"mayo_colors as back_color",
"part.back_color_id",
"back_color.id",
)
.whereIn("part.product_id", productIds)
: [];
const partsByProductId = new Map();
for (const part of parts) {
if (!partsByProductId.has(part.product_id)) {
partsByProductId.set(part.product_id, []);
}
partsByProductId.get(part.product_id).push({
id: part.id,
product_id: part.product_id,
part_type: part.part_type,
top_color_id: {
name: part.top_color_name,
},
back_color_id: {
name: part.back_color_name,
},
top_finish: part.top_finish,
back_finish: part.back_finish,
});
}
const response = products.map((product) => ({
id: product.id,
note: product.note,
model_id: {
name: product.model_name,
strings_count: product.model_strings_count,
scale: product.model_scale,
},
parts: partsByProductId.get(product.id) ?? [],
}));
res.json(response);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
router.get("/orders", async (req, res) => {
const itemsService = new ItemsService("mayo_order_products", {
schema: await getSchema(),
accountability: req.accountability,
});
try {
const items = await itemsService.readByQuery({
fields: [
"id",
"product_order_index",
"order_id.order_number",
"order_id.order_year",
"order_id.client_id.name",
"product_id.id",
"product_id.note",
"product_id.model_id.name",
"product_id.model_id.strings_count",
"product_id.model_id.scale",
],
limit: -1,
});
res.json(items);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
router.get("/my-products", async (req, res) => {
try {
const products = await database("mayo_products as mp")
.select(
"mp.id",
"mm.name as model",
"mo.order_number",
"mo.order_year",
"mop.product_order_index as order_index",
)
.leftJoin("mayo_order_products as mop", "mop.product_id", "mp.id")
.leftJoin("mayo_orders as mo", "mo.id", "mop.order_id")
.leftJoin("mayo_models as mm", "mm.id", "mp.model_id");
const productIds = products.map((product) => product.id);
const parts = productIds.length
? await database("mayo_parts as part")
.select("part.product_id", "part.part_type")
.whereIn("part.product_id", productIds)
: [];
const partTypesByProductId = new Map();
for (const part of parts) {
if (!partTypesByProductId.has(part.product_id)) {
partTypesByProductId.set(part.product_id, []);
}
if (part.part_type) {
partTypesByProductId.get(part.product_id).push(part.part_type);
}
}
const response = products.map((product) => ({
...product,
part_types: partTypesByProductId.get(product.id) ?? [],
}));
res.json(response);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
router.get("/my-products2", async (req, res) => {
const productsService = new ItemsService("mayo_products", {
schema: await getSchema(),
accountability: req.accountability,
});
const partsService = new ItemsService("mayo_parts", {
schema: await getSchema(),
accountability: req.accountability,
});
try {
const products = await productsService.readByQuery({
fields: [
"id",
"note",
"model_id.name",
"model_id.strings_count",
"model_id.scale",
],
limit: -1,
});
res.json(products);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
},
};

View File

@@ -0,0 +1,128 @@
export default (router) => {
router.get('/', (req, res) => res.send('Hello, World!'));
};
export default {
id: 'mayo-api',
handler: (router, { database }) => {
router.get('/products/by-order/:orderNumber/:orderYear/:productIndex/timeline', async (req, res) => {
if (!req.accountability?.user) {
return res.status(401).json({ error: 'Unauthorized' })
}
const { orderNumber, orderYear, productIndex } = req.params
const normalizedOrderNumber = Number(orderNumber)
const normalizedOrderYear = Number(orderYear)
const normalizedProductIndex = Number(productIndex)
if (
Number.isNaN(normalizedOrderNumber) ||
Number.isNaN(normalizedOrderYear) ||
Number.isNaN(normalizedProductIndex)
) {
return res.status(400).json({ error: 'Invalid order code' })
}
const orderProduct = await database('mayo_order_products as op')
.select(
'op.id as order_product_id',
'op.product_id',
'op.produc_order_idx',
'o.id as order_id',
'o.order_number',
'o.order_year',
'p.note',
'm.id as model_id',
'm.name as model_name'
)
.join('mayo_orders as o', 'op.order_id', 'o.id')
.join('mayo_products as p', 'op.product_id', 'p.id')
.leftJoin('mayo_models as m', 'p.model_id', 'm.id')
.where('o.order_number', normalizedOrderNumber)
.where('o.order_year', normalizedOrderYear)
.where('op.produc_order_idx', normalizedProductIndex)
.first()
if (!orderProduct) {
return res.status(404).json({ error: 'Product not found' })
}
const events = await database('mayo_events as e')
.select(
'e.id',
'e.ordinal',
'e.event_kind',
'e.event_date',
'p.id as part_id',
'p.part_type'
)
.join('mayo_parts as p', 'e.part_id', 'p.id')
.where('p.product_id', orderProduct.product_id)
.orderBy('e.ordinal', 'asc')
const eventIds = events.map((event) => event.id)
const [operations, notes, photos] = await Promise.all([
database('mayo_event_operations as eo')
.select(
'eo.event_id',
'o.id as operation_id',
'o.name',
'o.description'
)
.leftJoin('mayo_operations as o', 'eo.operation_id', 'o.id')
.whereIn('eo.event_id', eventIds),
database('mayo_event_notes')
.select('event_id', 'note_type', 'note')
.whereIn('event_id', eventIds),
database('mayo_event_photos')
.select('event_id', 'photo_url')
.whereIn('event_id', eventIds),
])
const operationsByEventId = new Map(operations.map((operation) => [operation.event_id, operation]))
const notesByEventId = new Map(notes.map((note) => [note.event_id, note]))
const photosByEventId = photos.reduce((acc, photo) => {
if (!acc.has(photo.event_id)) acc.set(photo.event_id, [])
acc.get(photo.event_id).push({
file_id: photo.photo_url,
url: photo.photo_url ? `/assets/${photo.photo_url}` : null,
})
return acc
}, new Map())
const timeline = events.map((event) => ({
id: event.id,
ordinal: event.ordinal,
kind: event.event_kind,
date: event.event_date,
part: {
id: event.part_id,
type: event.part_type,
},
operation: operationsByEventId.get(event.id) ?? null,
note: notesByEventId.get(event.id) ?? null,
photos: photosByEventId.get(event.id) ?? [],
}))
return res.json({
product: {
id: orderProduct.product_id,
order_code: `${String(orderProduct.order_number).padStart(4, '0')}/${orderProduct.order_year}/${orderProduct.produc_order_idx}`,
model: {
id: orderProduct.model_id,
name: orderProduct.model_name,
},
note: orderProduct.note,
},
timeline,
})
})
},
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,31 @@
{
"name": "directus-extension-mayo-api",
"description": "Please enter a description for your extension",
"icon": "extension",
"version": "1.0.1",
"license": "UNLICENSED",
"keywords": [
"directus",
"directus-extension",
"directus-extension-endpoint"
],
"type": "module",
"files": [
"dist"
],
"directus:extension": {
"type": "endpoint",
"path": "dist/index.js",
"source": "src/index.js",
"host": "^10.10.0"
},
"scripts": {
"build": "directus-extension build",
"dev": "directus-extension build -w --no-minify",
"link": "directus-extension link",
"validate": "directus-extension validate"
},
"devDependencies": {
"@directus/extensions-sdk": "17.1.3"
}
}

View File

@@ -0,0 +1,11 @@
import { registerGetDictionaries } from './routes/dictionaries.get.js'
import { registerGetProducts } from './routes/products.get.js'
export default {
id: 'mayo-api',
handler: (router, context) => {
registerGetProducts(router, context)
registerGetDictionaries(router, context)
},
}

View File

@@ -0,0 +1,19 @@
export async function getDictionaries({ database }) {
const [models, clients, productionLists, operations, colors, finishes] = await Promise.all([
database('mayo_product_models').select('id', 'name').orderBy('name', 'asc'),
database('mayo_clients').select('id', 'name', 'country').orderBy('name', 'asc'),
database('mayo_production_lists').select('id', 'name').orderBy('name', 'asc'),
database('mayo_production_operations').select('id', 'code', 'name').orderBy('name', 'asc'),
database('mayo_colors').select('id', 'name').orderBy('name', 'asc'),
database('mayo_finishes').select('id', 'code', 'name').orderBy('name', 'asc'),
])
return {
models,
clients,
productionLists,
operations,
colors,
finishes,
}
}

View File

@@ -0,0 +1,250 @@
import { serializeProduct } from '../serializers/product.serializer.js'
import { createTimelinePreview } from '../serializers/timeline.serializer.js'
import { parseOrderSearch } from '../utils/order-search.js'
import { createPageInfo, parsePagination } from '../utils/pagination.js'
export async function getProducts({ database }, query = {}) {
const { limit, offset } = parsePagination(query)
const orderSearch = parseOrderSearch(query.orderSearch)
const baseQuery = createProductsBaseQuery(database, query, orderSearch)
const [{ total }] = await baseQuery
.clone()
.clearSelect()
.clearOrder()
.countDistinct({ total: 'order_item.id' })
const rows = await baseQuery
.clone()
.select(
'order_item.id as order_item_id',
'order_item.order_index',
'product.id as product_id',
'ord.order_number',
'ord.order_year',
'model.name as model_name',
'client.name as client_name',
)
.orderBy('ord.order_year', 'desc')
.orderBy('ord.order_number', 'desc')
.orderBy('order_item.order_index', 'asc')
.limit(limit)
.offset(offset)
const productIds = rows.map((row) => row.product_id)
const orderItemIds = rows.map((row) => row.order_item_id)
const [productionListsByOrderItemId, finishesByProductId, eventsByProductId] = await Promise.all([
readProductionListsByOrderItemId(database, orderItemIds),
readFinishesByProductId(database, productIds),
readEventsByProductId(database, productIds),
])
const items = rows.map((row) =>
serializeProduct(row, {
finish: serializeFinish(finishesByProductId.get(row.product_id)),
productionLists: productionListsByOrderItemId.get(row.order_item_id) ?? [],
timelinePreview: createTimelinePreview(eventsByProductId.get(row.product_id) ?? []),
}),
)
return {
items,
pageInfo: createPageInfo({
limit,
offset,
total: Number(total),
}),
}
}
function createProductsBaseQuery(database, query, orderSearch) {
const productsQuery = database('mayo_order_items as order_item')
.join('mayo_products as product', 'order_item.product_id', 'product.id')
.join('mayo_orders as ord', 'order_item.order_id', 'ord.id')
.leftJoin('mayo_product_models as model', 'product.product_model_id', 'model.id')
.leftJoin('mayo_clients as client', 'ord.client_id', 'client.id')
applyFilters(productsQuery, query, orderSearch)
return productsQuery
}
function applyFilters(productsQuery, query, orderSearch) {
if (query.model) {
productsQuery.where('product.product_model_id', query.model)
}
if (query.client) {
productsQuery.where('ord.client_id', query.client)
}
if (query.year) {
productsQuery.where('ord.order_year', Number(query.year))
}
if (query.productionList) {
productsQuery.whereExists(function existsProductionList() {
this.select(1)
.from('mayo_product_production_lists as product_list')
.whereRaw('product_list.order_item_id = order_item.id')
.where('product_list.production_list_id', query.productionList)
})
}
if (query.finish) {
productsQuery.whereExists(function existsFinish() {
this.select(1)
.from('mayo_product_parts as part')
.leftJoin('mayo_finishes as top_finish', 'part.top_finish_id', 'top_finish.id')
.leftJoin('mayo_finishes as back_finish', 'part.back_finish_id', 'back_finish.id')
.whereRaw('part.product_id = product.id')
.where((finishQuery) => {
finishQuery.where('top_finish.code', query.finish).orWhere('back_finish.code', query.finish)
})
})
}
applyOrderSearchFilter(productsQuery, query.search, orderSearch)
}
function applyOrderSearchFilter(productsQuery, search, orderSearch) {
if (!String(search ?? '').trim()) {
return
}
if (!orderSearch.length) {
productsQuery.whereRaw('1 = 0')
return
}
productsQuery.where((conditionsQuery) => {
for (const condition of orderSearch) {
conditionsQuery.orWhere((conditionQuery) => {
applyOrderSearchCondition(conditionQuery, condition)
})
}
})
}
function applyOrderSearchCondition(conditionQuery, condition) {
if (condition.type === 'orderNumberPrefix' && condition.orderNumberPrefix) {
conditionQuery.where('ord.order_number', 'like', `${condition.orderNumberPrefix}%`)
return
}
if (condition.orderNumber !== null && condition.orderNumber !== undefined) {
conditionQuery.whereRaw('CAST("ord"."order_number" AS INTEGER) = ?', [condition.orderNumber])
}
if (condition.orderYear !== null && condition.orderYear !== undefined) {
conditionQuery.where('ord.order_year', condition.orderYear)
}
if (condition.orderIndex !== null && condition.orderIndex !== undefined) {
conditionQuery.where('order_item.order_index', condition.orderIndex)
}
}
async function readProductionListsByOrderItemId(database, orderItemIds) {
const result = new Map()
if (!orderItemIds.length) {
return result
}
const rows = await database('mayo_product_production_lists as product_list')
.select('product_list.order_item_id', 'production_list.name')
.join(
'mayo_production_lists as production_list',
'product_list.production_list_id',
'production_list.id',
)
.whereIn('product_list.order_item_id', orderItemIds)
.orderBy('production_list.name', 'asc')
for (const row of rows) {
if (!result.has(row.order_item_id)) {
result.set(row.order_item_id, [])
}
result.get(row.order_item_id).push(row.name)
}
return result
}
async function readFinishesByProductId(database, productIds) {
const result = new Map()
if (!productIds.length) {
return result
}
const rows = await database('mayo_product_parts as part')
.select(
'part.product_id',
'top_finish.code as top_finish_code',
'back_finish.code as back_finish_code',
)
.leftJoin('mayo_finishes as top_finish', 'part.top_finish_id', 'top_finish.id')
.leftJoin('mayo_finishes as back_finish', 'part.back_finish_id', 'back_finish.id')
.whereIn('part.product_id', productIds)
for (const row of rows) {
if (!result.has(row.product_id)) {
result.set(row.product_id, new Set())
}
const finishes = result.get(row.product_id)
if (row.top_finish_code) finishes.add(row.top_finish_code)
if (row.back_finish_code) finishes.add(row.back_finish_code)
}
return result
}
async function readEventsByProductId(database, productIds) {
const result = new Map()
if (!productIds.length) {
return result
}
const rows = await database('mayo_production_events as event')
.select(
'event.id',
'event.event_date as date',
'part.product_id',
'part_type.code as part_type',
'operation.id as operation_id',
'operation.code as operation_code',
'operation.name as operation_name',
'operation.description as operation_description',
)
.join('mayo_product_parts as part', 'event.product_part_id', 'part.id')
.join('mayo_part_types as part_type', 'part.part_type_id', 'part_type.id')
.leftJoin('mayo_production_operations as operation', 'event.operation_id', 'operation.id')
.whereIn('part.product_id', productIds)
.whereNotNull('event.operation_id')
.orderBy('event.event_date', 'asc')
.orderBy('event.ordinal', 'asc')
for (const row of rows) {
if (!result.has(row.product_id)) {
result.set(row.product_id, [])
}
result.get(row.product_id).push(row)
}
return result
}
function serializeFinish(finishes) {
if (!finishes?.size) {
return null
}
return [...finishes].sort().join('+')
}

View File

@@ -0,0 +1,13 @@
import { getDictionaries } from '../repositories/dictionaries.repository.js'
import { serializeDictionaries } from '../serializers/dictionaries.serializer.js'
import { asyncHandler } from '../utils/async-handler.js'
export function registerGetDictionaries(router, context) {
router.get(
'/dictionaries',
asyncHandler(async (req, res) => {
const dictionaries = await getDictionaries(context)
res.json(serializeDictionaries(dictionaries))
}),
)
}

View File

@@ -0,0 +1,12 @@
import { getProducts } from '../repositories/products.repository.js'
import { asyncHandler } from '../utils/async-handler.js'
export function registerGetProducts(router, context) {
router.get(
'/products',
asyncHandler(async (req, res) => {
const result = await getProducts(context, req.query)
res.json(result)
}),
)
}

View File

@@ -0,0 +1,54 @@
export function serializeDictionaries(dictionaries) {
return {
models: dictionaries.models.map(serializeModel),
clients: dictionaries.clients.map(serializeClient),
finishes: dictionaries.finishes.map(serializeFinish),
productionLists: dictionaries.productionLists.map(serializeProductionList),
operations: dictionaries.operations.map(serializeOperation),
colors: dictionaries.colors.map(serializeColor),
}
}
function serializeModel(model) {
return {
id: model.id,
name: model.name,
}
}
function serializeClient(client) {
return {
id: client.id,
name: client.name,
country: client.country,
}
}
function serializeFinish(finish) {
return {
value: finish.code,
label: finish.name,
}
}
function serializeProductionList(productionList) {
return {
id: productionList.id,
name: productionList.name,
}
}
function serializeOperation(operation) {
return {
id: operation.id,
code: operation.code,
name: operation.name,
}
}
function serializeColor(color) {
return {
id: color.id,
name: color.name,
}
}

View File

@@ -0,0 +1,22 @@
export function serializeProduct(row, { finish, productionLists, timelinePreview }) {
const orderNumber = formatOrderNumber(row.order_number)
const orderIndex = Number(row.order_index)
const orderYear = Number(row.order_year)
return {
id: row.product_id,
orderId: `${orderNumber}/${orderYear}/${orderIndex}`,
orderNumber,
orderYear,
orderIndex,
model: row.model_name ?? null,
client: row.client_name ?? null,
finish,
productionLists,
timelinePreview,
}
}
function formatOrderNumber(value) {
return String(value ?? '').padStart(4, '0')
}

View File

@@ -0,0 +1,29 @@
export function createTimelinePreview(events) {
return {
body: createPartTimelinePreview(events, 'BODY'),
neck: createPartTimelinePreview(events, 'NECK'),
}
}
function createPartTimelinePreview(events, partType) {
return events
.filter((event) => event.part_type === partType)
.map((event) => ({
id: event.id,
code: createOperationCode(event),
label: event.operation_name,
date: event.date,
status: 'done',
}))
}
function createOperationCode(event) {
if (event.operation_code) {
return event.operation_code
}
const source = event.operation_description || event.operation_name || ''
const firstWord = source.trim().split(/\s+/)[0] ?? ''
return firstWord.slice(0, 3).toUpperCase()
}

View File

@@ -0,0 +1,9 @@
export function asyncHandler(handler) {
return async (req, res, next) => {
try {
await handler(req, res, next)
} catch (error) {
next(error)
}
}
}

View File

@@ -0,0 +1,16 @@
export function parseOrderSearch(value) {
if (!value) {
return []
}
if (Array.isArray(value)) {
return value
}
try {
const parsed = JSON.parse(value)
return Array.isArray(parsed) ? parsed : []
} catch {
return []
}
}

View File

@@ -0,0 +1,28 @@
const DEFAULT_LIMIT = 30
const MAX_LIMIT = 100
export function parsePagination(query = {}) {
const limit = clampNumber(query.limit, DEFAULT_LIMIT, 1, MAX_LIMIT)
const offset = clampNumber(query.offset, 0, 0, Number.MAX_SAFE_INTEGER)
return { limit, offset }
}
export function createPageInfo({ limit, offset, total }) {
return {
limit,
offset,
hasMore: offset + limit < total,
total,
}
}
function clampNumber(value, fallback, min, max) {
const parsed = Number.parseInt(value, 10)
if (!Number.isFinite(parsed)) {
return fallback
}
return Math.min(Math.max(parsed, min), max)
}

View File

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

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

@@ -2,61 +2,46 @@
<div class="my-order-card">
<div class="mark"></div>
<div class="card-head">
<div class="order-info">
<span class="order-id">{{ order.orderId }}</span>
<h3 class="model">{{ order.model }}</h3>
<p class="client">{{ order.client }}</p>
<div class="order-info" @click="emit('showProductSpec', { product })">
<span class="order-id">{{ product.orderId }}</span>
<h3 class="model">{{ product.model }}</h3>
<p class="client">{{ product.client }}</p>
<div class="order-lists">
<span
v-for="assignedOrder in order.assignedOrders"
:key="assignedOrder"
v-for="productionList in product.productionLists"
:key="productionList"
class="order-list"
>
{{ assignedOrder }}
{{ productionList }}
</span>
</div>
<span class="finish">{{ order.finish }}</span>
</div>
</div>
<div class="flow">
<div class="flow-head">
<span class="label">Deska</span>
<div class="line"></div>
</div>
<div class="steps no-scrollbar">
<template v-for="(step, index) in order.steps.body" :key="`${step}-${index}`">
<div class="step">{{ step }}</div>
<span class="arrow">&#x2192;</span>
</template>
<button class="step add">
<q-icon class="add-icon" name="add" />
</button>
</div>
</div>
<div class="flow" v-if="order.steps.neck">
<div class="flow-head">
<span class="label">Gryf</span>
<div class="line"></div>
</div>
<div class="steps no-scrollbar">
<template v-for="(step, index) in order.steps.neck" :key="`${step}-${index}`">
<div class="step">{{ step }}</div>
<span class="arrow">&#x2192;</span>
</template>
<button class="step add">
<q-icon class="add-icon" name="add" />
</button>
<span class="finish">{{ product.finish }}</span>
</div>
</div>
<production-preview
label="Deska"
:steps="product.timelinePreview.body"
@add="emit('addProductionEvent', { product, partType: 'BODY' })"
/>
<production-preview
v-if="product.timelinePreview.neck.length"
label="Gryf"
:steps="product.timelinePreview.neck"
@add="emit('addProductionEvent', { product, partType: 'NECK' })"
/>
</div>
</template>
<script setup>
import ProductionPreview from 'src/components/ProductionPreview.vue'
defineProps({
order: {
product: {
type: Object,
required: true,
},
})
const emit = defineEmits(['addProductionEvent', 'showProductSpec'])
</script>
<style lang="scss" scoped>
.my-order-card {
@@ -155,62 +140,5 @@ defineProps({
border-radius: var(--my-radius-sm);
}
}
.flow {
margin-top: 1rem;
.flow-head {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.75rem;
.label {
color: var(--my-on-surface-variant);
font-size: 0.625rem;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.line {
flex: 1;
height: 1px;
background: color-mix(in srgb, var(--my-outline-variant) 30%, transparent);
}
}
.steps {
display: flex;
align-items: center;
gap: 0.5rem;
overflow-x: auto;
.arrow {
color: var(--my-outline-variant);
font-size: 0.75rem;
}
.step {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
font-size: 0.75rem;
font-weight: 700;
color: var(--my-primary);
background: color-mix(in srgb, var(--my-primary-dim) 20%, transparent);
border: 1px solid color-mix(in srgb, var(--my-primary) 30%, transparent);
border-radius: var(--my-radius-sm);
&.add {
background: var(--my-surface-container-lowest);
border-style: dashed;
border-color: var(--my-outline-variant);
color: var(--my-on-surface-variant);
font-size: 0.875rem;
}
}
}
}
}
</style>

View File

@@ -0,0 +1,91 @@
<template>
<div class="flow">
<div class="flow-head">
<span class="label">{{ label }}</span>
<div class="line"></div>
</div>
<div class="steps no-scrollbar">
<template v-for="step in steps" :key="step.id">
<div class="step">{{ step.code }}</div>
<span class="arrow">&#x2192;</span>
</template>
<button class="step add" type="button" @click="emit('add')">
<q-icon class="add-icon" name="add" />
</button>
</div>
</div>
</template>
<script setup>
defineProps({
label: {
type: String,
required: true,
},
steps: {
type: Array,
default: () => [],
},
})
const emit = defineEmits(['add'])
</script>
<style lang="scss" scoped>
.flow {
margin-top: 1rem;
.flow-head {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.75rem;
.label {
color: var(--my-on-surface-variant);
font-size: 0.625rem;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.line {
flex: 1;
height: 1px;
background: color-mix(in srgb, var(--my-outline-variant) 30%, transparent);
}
}
.steps {
display: flex;
align-items: center;
gap: 0.5rem;
overflow-x: auto;
.arrow {
color: var(--my-outline-variant);
font-size: 0.75rem;
}
.step {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
font-size: 0.75rem;
font-weight: 700;
color: var(--my-primary);
background: color-mix(in srgb, var(--my-primary-dim) 20%, transparent);
border: 1px solid color-mix(in srgb, var(--my-primary) 30%, transparent);
border-radius: var(--my-radius-sm);
&.add {
background: var(--my-surface-container-lowest);
border-style: dashed;
border-color: var(--my-outline-variant);
color: var(--my-on-surface-variant);
font-size: 0.875rem;
}
}
}
}
</style>

View File

@@ -0,0 +1,48 @@
<template>
<div class="right-drawer-host">
<component
:is="activePanelComponent"
v-if="activePanelComponent"
:key="uiStore.drawerInstanceKey"
v-bind="uiStore.drawerPayload"
@cancel="closeDrawer"
@close="closeDrawer"
@saved="closeDrawer"
/>
<div v-else class="right-drawer-host__empty">
Brak aktywnego panelu.
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { useUiStore } from 'src/stores/uiStore'
import { rightDrawerPanels } from './panels'
const uiStore = useUiStore()
const activePanelComponent = computed(() => {
if (!uiStore.activePanel) {
return null
}
return rightDrawerPanels[uiStore.activePanel] ?? null
})
function closeDrawer() {
uiStore.closeDrawer()
}
</script>
<style lang="scss" scoped>
.right-drawer-host {
min-height: 100%;
}
.right-drawer-host__empty {
padding: 1.25rem;
color: var(--my-on-surface-variant);
}
</style>

View File

@@ -0,0 +1,105 @@
<template>
<section class="drawer-panel">
<header class="drawer-panel__header">
<div>
<div class="drawer-panel__eyebrow">Search</div>
<h2 class="drawer-panel__title">Advanced Search</h2>
</div>
<q-btn
flat
round
dense
icon="close"
aria-label="Close advanced search panel"
@click="emit('close')"
/>
</header>
<div class="drawer-panel__body">
<p class="drawer-panel__description">
Ten panel bedzie trzymal robocza konfiguracje filtrow i zapisze ja dopiero po
zatwierdzeniu przez uzytkownika.
</p>
<div class="drawer-panel__placeholder">
Tu trafia kontrolki filtrow wybieranych bez wpisywania tekstu.
</div>
</div>
<footer class="drawer-panel__footer">
<q-btn flat label="Cancel" @click="emit('cancel')" />
<q-btn color="primary" label="Apply" @click="applyFilters" />
</footer>
</section>
</template>
<script setup>
const emit = defineEmits(['cancel', 'close', 'apply'])
function applyFilters() {
emit('apply')
}
</script>
<style lang="scss" scoped>
.drawer-panel {
display: grid;
grid-template-rows: auto 1fr auto;
min-height: 100%;
background: var(--my-surface);
}
.drawer-panel__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
padding: 1.25rem 1.25rem 1rem;
border-bottom: 1px solid color-mix(in srgb, var(--my-outline-variant) 60%, transparent);
}
.drawer-panel__eyebrow {
margin-bottom: 0.35rem;
color: var(--my-on-surface-variant);
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.drawer-panel__title {
margin: 0;
font-size: 1.1rem;
font-weight: 700;
}
.drawer-panel__body {
display: grid;
align-content: start;
gap: 1rem;
padding: 1.25rem;
}
.drawer-panel__description {
margin: 0;
color: var(--my-on-surface-variant);
line-height: 1.5;
}
.drawer-panel__placeholder {
padding: 1rem;
background: var(--my-surface-container-low);
border: 1px dashed var(--my-outline-variant);
border-radius: var(--my-radius-md);
color: var(--my-on-surface-variant);
}
.drawer-panel__footer {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
padding: 1rem 1.25rem 1.25rem;
border-top: 1px solid color-mix(in srgb, var(--my-outline-variant) 60%, transparent);
}
</style>

View File

@@ -0,0 +1,229 @@
<template>
<section class="drawer-panel">
<header class="header">
<div>
<h3 class="header-title">Edit Instrument</h3>
<p class="header-info">{{ spec?.orderId ?? props.productId ?? 'brak produktu' }}</p>
</div>
<q-btn
flat
round
dense
icon="close"
aria-label="Close product specification panel"
@click="emit('close')"
/>
</header>
<section class="spec">
<q-inner-loading :showing="isLoading" />
<q-banner v-if="error" rounded class="spec-state spec-state--error">
Nie udalo sie wczytac specyfikacji produktu.
</q-banner>
<div v-else-if="!sections.length && !isLoading" class="spec-state">
Brak specyfikacji dla tego produktu.
</div>
<template v-else>
<div v-for="section in sections" :key="section.key" class="spec-section">
<div class="spec-header">
<q-icon name="label" class="spec-icon" />
<h4 class="spec-title">{{ section.label }}</h4>
</div>
<div class="spec-details">
<div v-for="field in section.fields" :key="field.key" class="spec-record">
<span class="spec-label">{{ field.label }}:</span>
<span class="spec-value">{{ formatValues(field.values) }}</span>
</div>
</div>
</div>
</template>
</section>
<footer class="drawer-panel__footer">
<q-btn flat label="Cancel" @click="emit('cancel')" />
<q-btn color="primary" label="Save" @click="saveSpecification" />
</footer>
</section>
</template>
<script setup>
import { computed, watch } from 'vue'
import { useProductSpecificationStore } from 'src/stores/productSpecificationStore'
const productSpecificationStore = useProductSpecificationStore()
const props = defineProps({
productId: {
type: [Number, String],
default: null,
},
mode: {
type: String,
default: 'view',
},
})
const spec = computed(() => {
if (!props.productId) {
return null
}
return productSpecificationStore.getSpecification(props.productId)
})
const sections = computed(() => spec.value?.sections ?? [])
const isLoading = computed(() => spec.value?.isLoading ?? false)
const error = computed(() => spec.value?.error ?? null)
const emit = defineEmits(['cancel', 'close', 'saved'])
watch(
() => props.productId,
(productId) => {
if (productId) {
void loadSpecification(productId)
}
},
{ immediate: true },
)
async function loadSpecification(productId) {
try {
await productSpecificationStore.fetchSpecification(productId)
} catch {
// Error state is stored in productSpecificationStore and rendered above.
}
}
function formatValues(values) {
return values?.length ? values.join(', ') : '-'
}
function saveSpecification() {
emit('saved', {
productId: props.productId,
})
}
</script>
<style lang="scss" scoped>
.drawer-panel {
display: grid;
grid-template-rows: auto 1fr auto;
min-height: 100%;
background: var(--my-surface-container);
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1.5rem;
// border-bottom: 1px solid color-mix(in srgb, var(--my-outline-variant) 60%, transparent);
.header-title {
margin: 0;
font-size: 1.1rem;
font-weight: 700;
line-height: normal;
}
.header-info {
margin: 0;
color: var(--my-primary);
font-size: 0.7rem;
font-weight: 700;
// letter-spacing: 0.08em;
// letter-spacing: -0.05em;
text-transform: uppercase;
}
}
.spec {
position: relative;
padding: 1rem;
.spec-section {
margin-bottom: 1.5rem;
}
.spec-state {
color: var(--my-on-surface-variant);
}
.spec-state--error {
color: var(--my-error);
background: var(--my-error-container);
}
.spec-header {
display: flex;
justify-content: start;
align-items: center;
gap: 10px;
text-transform: uppercase;
margin-bottom: 16px;
.spec-icon {
color: var(--my-primary);
}
.spec-title {
color: var(--my-on-surface-variant);
margin: 0;
font-size: 0.75rem;
font-weight: 900;
line-height: 1rem;
letter-spacing: 0.1rem;
}
}
.spec-details {
.spec-record {
padding: 0.5rem;
margin-bottom: 0.5rem;
background-color: var(--my-surface-container-high);
display: flex;
justify-content: space-between;
flex-wrap: wrap;
.spec-label {
color: var(--my-on-surface-variant);
}
.spec-value {
margin-left: auto;
}
}
}
}
.body {
display: grid;
align-content: start;
gap: 1rem;
padding: 1.25rem;
}
.description {
margin: 0;
color: var(--my-on-surface-variant);
line-height: 1.5;
}
.placeholder {
display: grid;
gap: 0.5rem;
padding: 1rem;
background: var(--my-surface-container-low);
border: 1px dashed var(--my-outline-variant);
border-radius: var(--my-radius-md);
color: var(--my-on-surface-variant);
}
.drawer-panel__footer {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
padding: 1rem 1.25rem 1.25rem;
border-top: 1px solid color-mix(in srgb, var(--my-outline-variant) 60%, transparent);
}
}
</style>

View File

@@ -0,0 +1,122 @@
<template>
<section class="drawer-panel">
<header class="drawer-panel__header">
<div>
<div class="drawer-panel__eyebrow">Production</div>
<h2 class="drawer-panel__title">Production Statuses</h2>
</div>
<q-btn
flat
round
dense
icon="close"
aria-label="Close production statuses panel"
@click="emit('close')"
/>
</header>
<div class="drawer-panel__body">
<p class="drawer-panel__description">
Ten panel dostanie identyfikator kontekstu produkcyjnego i utworzy lokalny draft do
przegladania oraz edycji statusow.
</p>
<div class="drawer-panel__placeholder">
<div><strong>orderId:</strong> {{ orderId ?? 'brak' }}</div>
<div><strong>productId:</strong> {{ productId ?? 'brak' }}</div>
</div>
</div>
<footer class="drawer-panel__footer">
<q-btn flat label="Cancel" @click="emit('cancel')" />
<q-btn color="primary" label="Save" @click="saveStatuses" />
</footer>
</section>
</template>
<script setup>
const props = defineProps({
orderId: {
type: [Number, String],
default: null,
},
productId: {
type: [Number, String],
default: null,
},
})
const emit = defineEmits(['cancel', 'close', 'saved'])
function saveStatuses() {
emit('saved', {
orderId: props.orderId,
productId: props.productId,
})
}
</script>
<style lang="scss" scoped>
.drawer-panel {
display: grid;
grid-template-rows: auto 1fr auto;
min-height: 100%;
background: var(--my-surface);
}
.drawer-panel__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
padding: 1.25rem 1.25rem 1rem;
border-bottom: 1px solid color-mix(in srgb, var(--my-outline-variant) 60%, transparent);
}
.drawer-panel__eyebrow {
margin-bottom: 0.35rem;
color: var(--my-on-surface-variant);
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.drawer-panel__title {
margin: 0;
font-size: 1.1rem;
font-weight: 700;
}
.drawer-panel__body {
display: grid;
align-content: start;
gap: 1rem;
padding: 1.25rem;
}
.drawer-panel__description {
margin: 0;
color: var(--my-on-surface-variant);
line-height: 1.5;
}
.drawer-panel__placeholder {
display: grid;
gap: 0.5rem;
padding: 1rem;
background: var(--my-surface-container-low);
border: 1px dashed var(--my-outline-variant);
border-radius: var(--my-radius-md);
color: var(--my-on-surface-variant);
}
.drawer-panel__footer {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
padding: 1rem 1.25rem 1.25rem;
border-top: 1px solid color-mix(in srgb, var(--my-outline-variant) 60%, transparent);
}
</style>

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

@@ -2,14 +2,13 @@
<q-layout view="lHh Lpr lFf">
<q-header>
<q-toolbar class="topbar">
<q-btn flat dense round icon="menu" aria-label="Menu" @click="toggleLeftDrawer" />
<!-- <q-btn flat dense round icon="menu" aria-label="Menu" @click="toggleLeftDrawer" /> -->
<q-toolbar-title> Quasar App </q-toolbar-title>
<q-toolbar-title> DuckProductionManager </q-toolbar-title>
<q-btn :color="theme ? 'accent' : 'dark'" @click="theme = !theme">
{{ theme ? 'DARK' : 'LIGHT' }}
</q-btn>
<div>Quasar v{{ $q.version }}</div>
</q-toolbar>
</q-header>
@@ -21,6 +20,16 @@
</q-list>
</q-drawer>
<q-drawer
v-model="rightDrawerOpen"
side="right"
overlay
:width="$q.screen.width"
class="right-drawer"
>
<RightDrawerHost />
</q-drawer>
<q-page-container>
<router-view />
</q-page-container>
@@ -30,7 +39,9 @@
<script setup>
import { ref, computed } from 'vue'
import EssentialLink from 'components/EssentialLink.vue'
import RightDrawerHost from 'src/components/right-drawer/RightDrawerHost.vue'
import { useQuasar } from 'quasar'
import { useUiStore } from 'src/stores/uiStore'
const linksList = [
{
@@ -78,19 +89,30 @@ const linksList = [
]
const $q = useQuasar()
const leftDrawerOpen = ref(false)
const uiStore = useUiStore()
const theme = computed({
get: () => $q.dark.isActive,
set: (val) => $q.dark.set(val),
})
function toggleLeftDrawer() {
leftDrawerOpen.value = !leftDrawerOpen.value
}
const rightDrawerOpen = computed({
get: () => uiStore.isDrawerOpen,
set: (value) => {
if (!value) {
uiStore.closeDrawer()
}
},
})
// function toggleLeftDrawer() {
// leftDrawerOpen.value = !leftDrawerOpen.value
// }
</script>
<style lang="scss" scoped>
.topbar {
background: var(--my-background);
color: var(--my-on-background);
}
</style>

View File

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

View File

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

View File

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

View File

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

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

@@ -1,28 +1,55 @@
<template>
<q-page class="content">
<section class="drawer-test-actions">
<q-btn color="primary" label="Open Advanced Search" unelevated @click="openAdvancedSearch" />
<q-btn
color="secondary"
label="Open Product Specification"
unelevated
@click="openProductSpecification"
/>
<q-btn
color="accent"
label="Open Production Statuses"
unelevated
@click="openProductionStatuses"
/>
</section>
<section class="filters">
<div class="search-field">
<input class="input" placeholder="Search orders or models..." type="text" />
<input
v-model="searchQuery"
class="input"
placeholder="Search orders or models..."
type="text"
@keyup.enter="applySearch"
/>
<div class="icon-wrap">
<span class="material-symbols-outlined" data-icon="tune">tune</span>
<q-icon class="icon" name="close" @click="clearSearch" />
<span class="v-line"></span>
<q-icon class="icon" name="camera_alt" />
<q-icon class="icon" name="search" @click="applySearch" />
</div>
</div>
<div class="month-tabs no-scrollbar">
<button class="tab active">All</button>
<button class="tab">Jan</button>
<button class="tab">Feb</button>
<button class="tab">Mar</button>
<button class="tab">Apr</button>
<button class="tab">May</button>
<button class="tab">Jun</button>
<button
v-for="month in monthFilters"
:key="month.value"
class="tab"
:class="{ active: activeMonth === month.value }"
@click="selectMonth(month.value)"
>
{{ month.label }}
</button>
</div>
</section>
<div class="stats">
<div class="stat-card primary">
<span class="label">In Progress</span>
<span class="value">14 Units</span>
<span class="value">{{ loadedProductsCount }} Units</span>
</div>
<div class="stat-card tertiary">
<span class="label">Avg Lead Time</span>
@@ -31,49 +58,93 @@
</div>
<div class="orders">
<order-card :order="order[0]" />
<order-card :order="order[1]" />
<order-card :order="order[2]" />
<order-card
v-for="product in productsStore.items"
:key="product.id"
:product="product"
@add-production-event="openProductionStatuses"
@show-product-spec="openProductSpecification"
/>
</div>
</q-page>
</template>
<script setup>
import { computed, onMounted, ref, watch } from 'vue'
import OrderCard from 'src/components/OrderCard.vue'
const order = [
{
orderId: '0112/2025/12',
model: 'Duvell 6',
client: 'Sleek Elite',
assignedOrders: ['PAZ-25'],
finish: 'GLOSS',
steps: {
body: ['B', 'IZ', 'AK', 'LD', 'LD', 'LD', 'UV'],
neck: ['B', 'IZ', 'AK'],
},
},
{
orderId: '0029/2024/1',
model: 'Legend 6',
client: 'Heindeburs Indonesia',
assignedOrders: ['STY-25'],
finish: 'MAT',
steps: {
body: ['B', 'M'],
},
},
{
orderId: '0001/2025/20',
model: 'Regius 6 Core',
client: 'Mayo Stock',
assignedOrders: ['KWI-25', 'SUMMIT'],
finish: 'MIX',
steps: {
body: ['B', 'IZ', 'AK', 'LD'],
},
},
import { UI_PANELS, useUiStore } from 'src/stores/uiStore'
import { useProductsStore } from 'src/stores/productsStore'
const productsStore = useProductsStore()
const uiStore = useUiStore()
const searchQuery = ref(productsStore.searchQuery)
const activeMonth = ref('all')
const monthFilters = [
{ label: 'All', value: 'all' },
{ label: 'Jan', value: 1 },
{ label: 'Feb', value: 2 },
{ label: 'Mar', value: 3 },
{ label: 'Apr', value: 4 },
{ label: 'May', value: 5 },
{ label: 'Jun', value: 6 },
]
//
const loadedProductsCount = computed(() => productsStore.count)
onMounted(() => {
productsStore.fetchFirstPage()
})
watch(searchQuery, (value) => {
productsStore.setSearchQuery(value)
})
function openAdvancedSearch() {
uiStore.openDrawer(UI_PANELS.ADVANCED_SEARCH, {
source: 'index-page',
})
}
function openProductSpecification(product) {
const selectedProduct = product ?? productsStore.items[0]
console.log(selectedProduct)
if (!selectedProduct) {
return
}
uiStore.openDrawer(UI_PANELS.PRODUCT_SPECIFICATION, {
productId: selectedProduct.id,
mode: 'edit',
})
}
function openProductionStatuses({ product, partType } = {}) {
const selectedProduct = product ?? productsStore.items[0]
if (!selectedProduct) {
return
}
uiStore.openDrawer(UI_PANELS.PRODUCTION_STATUSES, {
orderId: selectedProduct.orderId,
productId: selectedProduct.id,
partType,
})
}
function clearSearch() {
searchQuery.value = ''
productsStore.applySearch(searchQuery.value)
}
function applySearch() {
productsStore.applySearch(searchQuery.value)
}
function selectMonth(month) {
activeMonth.value = month
}
</script>
<style lang="scss" scoped>
@@ -88,26 +159,41 @@ const order = [
padding: 1rem;
}
.drawer-test-actions {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
margin-bottom: 1.5rem;
}
.filters {
display: grid;
gap: 1rem;
margin-bottom: 1.5rem;
.search-field {
position: relative;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
color: var(--my-on-surface);
background: var(--my-surface-container-highest);
border-bottom: 2px solid var(--my-outline-variant);
transition: border-color var(--my-transition);
&:focus-within {
border-bottom-color: var(--my-primary);
}
.input {
width: 100%;
padding: 0.75rem 3rem 0.75rem 1rem;
min-width: 0;
padding: 0.75rem 0.5rem 0.75rem 1rem;
color: var(--my-on-surface);
background: var(--my-surface-container-highest);
background: transparent;
border: 0;
border-bottom: 2px solid var(--my-outline-variant);
transition: border-color var(--my-transition);
&:focus {
outline: none;
border-bottom-color: var(--my-primary);
}
&::placeholder {
@@ -116,11 +202,23 @@ const order = [
}
.icon-wrap {
position: absolute;
top: 50%;
right: 1rem;
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0 1rem 0 0.5rem;
color: var(--my-on-surface-variant);
transform: translateY(-50%);
.v-line {
// align-self: stretch; /* 🔥 kluczowe */
height: 2rem;
width: 1px;
background: var(--my-on-surface-variant);
}
.icon {
// color: var(--my-on-surface);
font-size: 1.5rem;
cursor: pointer;
}
}
}

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,5 @@
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 const fetchDictionaries = USE_MOCK_API ? fetchDictionariesFromMock : fetchDictionariesFromHttp

View File

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

View File

@@ -0,0 +1,17 @@
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'
export const fetchProductSpecification = USE_MOCK_API
? fetchProductSpecificationFromMock
: fetchProductSpecificationFromHttp
export const refreshProductSpecification = USE_MOCK_API
? refreshProductSpecificationFromMock
: refreshProductSpecificationFromHttp

View File

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

View File

@@ -0,0 +1,17 @@
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'
export const fetchProductTimeline = USE_MOCK_API
? fetchProductTimelineFromMock
: fetchProductTimelineFromHttp
export const createProductTimelineEvent = USE_MOCK_API
? createProductTimelineEventFromMock
: createProductTimelineEventFromHttp

View File

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

View File

@@ -0,0 +1,5 @@
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'
export const fetchProducts = USE_MOCK_API ? fetchProductsFromMock : fetchProductsFromHttp

View File

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

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,173 @@
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 {
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 searchQuery = ref('')
const orderSearch = ref([])
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: 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,
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,
}
}
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]
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,
searchQuery,
orderSearch,
limit,
offset,
total,
hasMore,
isLoading,
error,
items,
count,
fetchFirstPage,
fetchNextPage,
setFilters,
setSearchQuery,
applyFilters,
applySearch,
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))
}

View File

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

View File

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

600
notes/api_services.md Normal file
View File

@@ -0,0 +1,600 @@
# 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/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
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)
-> productsMockApi albo productsHttpApi
-> 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.
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:
```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 implementacje mock API w `frontend/src/mocks/api/`.
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` i `productsHttpApi.js`
Plik:
```txt
frontend/src/services/productsApi.js
frontend/src/services/productsHttpApi.js
```
Eksportuje:
```js
fetchProducts(params)
```
Odpowiedzialnosc:
- pobranie listy produktow,
- obsluga filtrow,
- 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
{
items: [],
pageInfo: {
limit: 30,
offset: 0,
hasMore: true,
total: 184,
},
}
```
W trybie mock implementacja jest w:
```txt
frontend/src/mocks/api/productsMockApi.js
```
W trybie mock:
- czyta `frontend/src/mocks/products.json`,
- filtruje po `orderSearch`, `finish`, `year`, `productionList`,
- ucina wynik wedlug `limit` i `offset`,
- zwraca `items` i `pageInfo`.
W trybie prawdziwego API implementacja jest w `productsHttpApi.js`:
```http
GET /mayo-api/products
```
z parametrami:
```js
{
limit,
offset,
search,
orderSearch,
model,
client,
finish,
productionList,
year,
}
```
`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:
```js
fetchProductTimeline(productId)
createProductTimelineEvent(productId, payload)
```
### `fetchProductTimeline`
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 implementacja jest w `productTimelineHttpApi.js`:
```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` i `productSpecificationHttpApi.js`
Plik:
```txt
frontend/src/services/productSpecificationApi.js
frontend/src/services/productSpecificationHttpApi.js
```
Eksportuje:
```js
fetchProductSpecification(productId)
refreshProductSpecification(productId)
```
### `fetchProductSpecification`
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 implementacja jest w `productSpecificationHttpApi.js`:
```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` i `dictionariesHttpApi.js`
Plik:
```txt
frontend/src/services/dictionariesApi.js
frontend/src/services/dictionariesHttpApi.js
```
Eksportuje:
```js
fetchDictionaries()
```
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 implementacja jest w `dictionariesHttpApi.js`:
```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
frontend/src/services/productPhotosHttpApi.js
frontend/src/mocks/api/productPhotosMockApi.js
```
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'
export async function fetchProductPhotos(productId) {
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/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 dane mockow:
```txt
frontend/src/mocks/products.json
frontend/src/mocks/timelines.json
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.
## 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.

437
notes/arch_struct.md Normal file
View File

@@ -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.

358
notes/directus-api.md Normal file
View File

@@ -0,0 +1,358 @@
# Directus Mayo API Extension
Data aktualizacji: 2026-05-20
## Cel
Ten dokument opisuje aktualny stan budowy custom API w Directus dla frontendu Duck Prod Manager.
Extension znajduje sie w:
```txt
directus/extensions/directus-extension-mayo-api
```
Endpoint Directus extension ma miec identyfikator:
```txt
mayo-api
```
Dlatego publiczne sciezki beda dostepne pod prefiksem:
```txt
/mayo-api
```
## Aktualny stan
Katalog extension jest przygotowany i zawiera scaffold Directus endpoint extension.
Istniejace pliki:
```txt
directus/extensions/directus-extension-mayo-api/README.md
directus/extensions/directus-extension-mayo-api/package.json
directus/extensions/directus-extension-mayo-api/src/index.js
directus/extensions/directus-extension-mayo-api/old/index.js.old
directus/extensions/directus-extension-mayo-api/old/old_index.js.old
```
`src/index.js` rejestruje obecnie pierwsze dwa endpointy:
- `GET /mayo-api/products`
- `GET /mayo-api/dictionaries`
Stare pliki w `old/` sa tylko materialem referencyjnym. Nie traktujemy ich jako aktywnej implementacji.
## Zalozona architektura
Nie umieszczamy wszystkich endpointow w jednym pliku.
`src/index.js` ma pelnic role glownego routera:
- eksportuje Directus endpoint extension,
- ustawia `id: 'mayo-api'`,
- importuje pliki endpointow,
- rejestruje endpointy,
- nie zawiera zapytan SQL ani logiki biznesowej.
Kazdy endpoint ma miec osobny plik w katalogu `routes/`.
Logika wspolna powinna byc rozbita na:
- `repositories/` - zapytania do bazy Directus,
- `serializers/` - mapowanie danych z DB na format frontendu,
- `utils/` - drobne helpery techniczne.
## Proponowana struktura plikow
```txt
directus/extensions/directus-extension-mayo-api/src/
index.js
routes/
products.get.js
dictionaries.get.js
product-timeline.get.js # do zrobienia
product-timeline-event.post.js # do zrobienia
product-specification.get.js # do zrobienia
product-specification-refresh.post.js # do zrobienia
repositories/
products.repository.js
dictionaries.repository.js
timeline.repository.js # do zrobienia
specification.repository.js # do zrobienia
serializers/
product.serializer.js
timeline.serializer.js
specification.serializer.js # do zrobienia
dictionaries.serializer.js
utils/
async-handler.js
http-error.js # do zrobienia, jesli bedzie potrzebny
pagination.js
order-search.js
```
## Endpointy wymagane przez frontend
### 1. Lista produktow
```http
GET /mayo-api/products
```
Status: zaimplementowany w pierwszym etapie.
Parametry query:
```js
{
limit,
offset,
search,
orderSearch,
model,
client,
finish,
productionList,
year,
}
```
`orderSearch` przychodzi z frontendu jako JSON string.
Oczekiwana odpowiedz:
```js
{
items: [],
pageInfo: {
limit,
offset,
hasMore,
total,
},
}
```
Element `items` powinien miec ksztalt produktu uzywany przez frontend:
```js
{
id,
orderId,
orderNumber,
orderYear,
orderIndex,
model,
client,
finish,
productionLists,
timelinePreview: {
body: [],
neck: [],
},
}
```
### 2. Slowniki
```http
GET /mayo-api/dictionaries
```
Status: zaimplementowany w pierwszym etapie.
Oczekiwana odpowiedz:
```js
{
models: [],
clients: [],
finishes: [],
productionLists: [],
operations: [],
colors: [],
}
```
### 3. Timeline produktu
```http
GET /mayo-api/products/:id/timeline
```
Status: do zrobienia.
Oczekiwana odpowiedz:
```js
{
productId,
events: [],
timelinePreview: {
body: [],
neck: [],
},
}
```
### 4. Dodanie eventu timeline
```http
POST /mayo-api/products/:id/timeline/events
```
Status: do zrobienia.
Oczekiwana odpowiedz:
```js
{
event: {},
timelinePreview: {
body: [],
neck: [],
},
}
```
### 5. Specyfikacja produktu
```http
GET /mayo-api/products/:id/specification
```
Status: do zrobienia.
Preferowana odpowiedz:
```js
{
productId,
orderId,
sourceUrl,
lastFetchedAt,
sections: [],
diff: [],
}
```
Frontend akceptuje tez ksztalt:
```js
{
productId,
orderId,
sourceUrl,
lastFetchedAt,
specification: {
sections: [],
},
diff: [],
}
```
### 6. Odswiezenie specyfikacji produktu
```http
POST /mayo-api/products/:id/specification/refresh
```
Status: do zrobienia.
Odpowiedz powinna miec taki sam ksztalt jak `GET /specification`, ale z aktualnym `lastFetchedAt` i ewentualnym `diff`.
## Kolejnosc implementacji
Rekomendowana kolejnosc:
1. `GET /mayo-api/products`
2. `GET /mayo-api/dictionaries`
3. `GET /mayo-api/products/:id/timeline`
4. `POST /mayo-api/products/:id/timeline/events`
5. `GET /mayo-api/products/:id/specification`
6. `POST /mayo-api/products/:id/specification/refresh`
Pierwsze dwa endpointy odblokowuja glowny ekran frontendu.
## Co zostalo zrobione
- Przeanalizowano `notes/api_services.md`.
- Przeanalizowano frontendowe implementacje HTTP w `frontend/src/services/*HttpApi.js`.
- Przeanalizowano store Pinia, zeby ustalic realne parametry i ksztalt danych.
- Ustalono liste 6 endpointow wymaganych przez obecny frontend.
- Ustalono, ze `index.js` bedzie tylko routerem/rejestratorem endpointow.
- Ustalono docelowa hierarchie plikow extension.
- Utworzono ten dokument jako aktualny opis stanu prac nad Directus extension.
- Utworzono katalogi `routes/`, `repositories/`, `serializers/`, `utils/`.
- Wypelniono `src/index.js` rejestracja pierwszych endpointow.
- Zaimplementowano `GET /mayo-api/products`.
- Zaimplementowano `GET /mayo-api/dictionaries`.
- Zweryfikowano nazwy kolekcji na podstawie `snapshot(6).json` i `db_schema.dbml`.
- Uruchomiono `npm run build` w katalogu extension. Build zakonczyl sie poprawnie.
- Dodano `README.md` dla extension.
- Dodano `license: "UNLICENSED"` w `package.json`, zeby extension bylo oznaczone jako pakiet wewnetrzny.
- Uruchomiono `npm run validate` w katalogu extension. Walidacja zakonczyla sie poprawnie.
- Dodano `mayo-api.http` z requestami REST Client do manualnego testowania endpointow.
- Przebudowano `db_schema.dbml` pod nowa baze Directus: poprawione nazwy, tabele slownikowe zamiast enumow.
- Dostosowano aktywne repository extension do nowego schematu.
- Uwzgledniono relacje `mayo_product_production_lists.order_item_id -> mayo_order_items.id`.
## Decyzje implementacyjne
- Aktywna implementacja ignoruje katalog `directus/extensions/directus-extension-mayo-api/old`.
- Pierwsza implementacja byla oparta o stary schemat Directus, ktory zawieral literowki:
- `mayo_order_porducts`
- `mayo_lisst_products`
- `mayo_color`
- `db_schema.dbml` zostal przebudowany jako propozycja nowego schematu:
- enumy zostaly zamienione na tabele slownikowe,
- literowki zostaly poprawione,
- nazwy kolekcji zostaly doprecyzowane pod Directus.
- Nowa baza Directus zostala utworzona zgodnie z `db_schema.dbml`, z jedna celowa zmiana:
- `mayo_product_production_lists.order_item_id` wskazuje na `mayo_order_items.id`.
- Extension zostal dostosowany do nowych nazw kolekcji i relacji `order_item_id`.
- `GET /products` buduje `timelinePreview` z `mayo_production_events`, `mayo_product_parts`, `mayo_part_types` i `mayo_production_operations`.
- `GET /products` filtruje po `model`, `client`, `year`, `productionList`, `finish` i `orderSearch`.
- `GET /dictionaries` pobiera finish z tabeli slownikowej `mayo_finishes`.
- Kod operacji pochodzi z `mayo_production_operations.code`.
- Uruchomiono `npm run build` po dostosowaniu do nowego schematu. Build zakonczyl sie poprawnie.
- Uruchomiono `npm run validate` po dostosowaniu do nowego schematu. Walidacja zakonczyla sie poprawnie.
## Proponowane nowe nazwy kolekcji
Stare nazwy i ich proponowane odpowiedniki:
```txt
mayo_color -> mayo_colors
mayo_lisst_products -> mayo_product_production_lists
mayo_lists -> mayo_production_lists
mayo_models -> mayo_product_models
mayo_operations -> mayo_production_operations
mayo_order_porducts -> mayo_order_items
mayo_part_events -> mayo_production_events
mayo_parts -> mayo_product_parts
```
Enumy zostaly zastapione tabelami:
```txt
mayo_models_neck_construction -> mayo_neck_constructions
mayo_parts_part_type -> mayo_part_types
mayo_parts_finish -> mayo_finishes
```
## Do zrobienia
- Zaimplementowac `GET /mayo-api/products/:id/timeline`.
- Zaimplementowac `POST /mayo-api/products/:id/timeline/events`.
- Zaimplementowac `GET /mayo-api/products/:id/specification`.
- Zaimplementowac `POST /mayo-api/products/:id/specification/refresh`.
- Sprawdzic na realnych danych, czy `finish` na karcie produktu ma byc pojedynczym enumem, czy agregatem z `top_finish` i `back_finish`.
- Przetestowac frontend z `VITE_USE_MOCK_API=false`.

1103
notes/stores.md Normal file

File diff suppressed because it is too large Load Diff

48
project.md Normal file
View File

@@ -0,0 +1,48 @@
# Project: Duck Production Manager
System zarządzania produkcją (prawdopodobnie gitar) zintegrowany z systemem Mayo oraz bazą Directus. Projekt składa się z frontendu (Quasar), backendu (FastAPI) oraz rozszerzeń Directus.
## Struktura Katalogów i Plików
### Root
* `db_schema.dbml` - Dokumentacja struktury bazy danych Directus (tabele z prefiksem `mayo_`).
* `snapshot(6).json` - Zrzut konfiguracji Directus (kolekcje, pola, relacje).
* `directus-api.http` - Plik z żądaniami HTTP do testowania API Directus.
* `question.md` - Notatki lub pytania dotyczące projektu.
* `project.md` - (Ten plik) Dokumentacja struktury i stanu projektu.
### Frontend (`/frontend`)
Aplikacja webowa oparta na frameworku Quasar (Vue 3).
* `quasar.config.js` - Główna konfiguracja frameworka Quasar i Vite.
* `package.json` - Zależności i skrypty frontendu (m.in. `quasar dev`, `quasar build`).
* `src/` - Kod źródłowy aplikacji.
* `App.vue` - Główny komponent aplikacji.
* `pages/` - Komponenty stron (np. `IndexPage.vue`).
* `layouts/` - Układy stron (np. `MainLayout.vue`).
* `components/` - Współdzielone komponenty Vue (np. `OrderCard.vue`, panele bocznego menu).
* `stores/` - Zarządzanie stanem aplikacji (Pinia).
* `boot/` - Skrypty inicjalizacyjne (np. konfiguracja axios).
### Backend (`/backend`)
Serwis integracyjny pośredniczący między Directus a systemem Mayo.
* `my_fastapi.py` - Główna aplikacja FastAPI wystawiająca punkty końcowe do pobierania danych z Mayo.
* `mayo/` - Moduł kliencki do komunikacji z systemem Mayo.
* `client.py` - Logika klienta HTTP (MayoClient) obsługująca sesje i żądania.
* `parser.py` - Narzędzia do parsowania danych otrzymanych z Mayo.
* `models.py` - Modele danych Pydantic dla odpowiedzi z Mayo.
* `exceptions.py` - Definicje specyficznych wyjątków dla integracji Mayo.
### Directus Extension (`/directus/extensions/directus-extension-mayo-api`)
Rozszerzenie Directus (Endpoint) dodające dedykowane punkty końcowe API.
* `src/index.js` - Implementacja endpointów (np. `/products`, `/orders`, `/my-products`) korzystająca z wewnętrznych usług Directus (ItemsService) lub bezpośrednich zapytań Knex.
* `package.json` - Konfiguracja rozszerzenia i zależności SDK Directus.
### Inne
* `/responses` - Przykładowe odpowiedzi JSON z zewnętrznych systemów.
* `/notes` - Dodatkowa dokumentacja (np. `frontend-api-handoff.md`).
## Odpowiedzialności komponentów
1. **Directus**: Główna baza danych i system zarządzania treścią. Przechowuje dane o produktach, częściach, kolorach i zamówieniach.
2. **Backend (FastAPI)**: Odpowiada za pobieranie "żywych" danych z zewnętrznego systemu Mayo, do których Directus nie ma bezpośredniego dostępu.
3. **Frontend (Quasar)**: Interfejs użytkownika, który agreguje dane z Directus (poprzez standardowe API i dedykowane rozszerzenie) oraz z backendu FastAPI.
4. **Directus Extension**: Optymalizuje zapytania do bazy danych Directus dla potrzeb frontendu, łącząc dane z wielu tabel w dedykowane formaty odpowiedzi.

40
question.md Normal file
View File

@@ -0,0 +1,40 @@
# CEL PROJEKTU
Pracuje nad projektem, ktory ma pomóc w zarządzaniu produkcja.
# STOS TECHNOLOGICZNY
## frontend:
Frontend jest oparty o quasar. Chce wykorzystać mozliwosc stwowrzenia aplikacji na telefon. quasar jest oparty o vue, ktory jest przyjemny w programowaniu. Jezyk to Javascript.
## backend:
Backend jest podzielony na dwie czesci. Frontend bedzie sie komunikowal z directus, ktory zarzadza baza danych i udostepnia api dla frontendu.
Jest tez drugi backend oparty o fastapi, ktorym jedymym zadaniem jest pobranie danych ze starego systemu bazowego. stary system nie ma wystawionego api do bazy danych, dlatego przy uzyciu fastapi stworzylem endpoint, ktory scrapuje dane z strony w php. w tym momencie wystawiony jest jeden endpoint, ktory przyjmuje numer zamowienia i zwraca pelna specyfikacje produktu. numer zamowienia jest specyficzny i zawiera trzy liczby, ma postac XXXX/YYYY/ZZ, gdzie XXXX jest numerem wewnetrznym zamowienia, YYYY jest rok przyjecia zamowienia, a ZZ jest liczba w zakresie 1-99, ktore opisuje pozucje produktu w zamowieniu. moze sie zdazyc, ze jest kilka zamowien o tm samym numerze XXXX, ale z innego roku YYYY.
# WIEDZA PROGRAMISTY
developer projektu jest jednoosobowy programista, ktory nie pracuje w tej branzy i wszystko co wie, to sam sie nauczyl, metoda prob i bledow. wspomaga sie scztuczna inteligencja. Porgramista preferuje zrozumienie dzialania kodu i aplikacji, bardziej niz gotowe rozwiazania. Programista chce poznac rozwiazania stosowane w profesjonalnych aplikacjach, a nie tylko "dzialajacy" kod. Programista ma dosc slabe rozeznanie w jezyku javasript, ale swietnie rozumie jezyk C i python, wiec rozumie jak dziala kod, ale moze miec problemy ze zroumieniem skladni javascript, trzeba czasem mu to wyjasnic. Programista preferuje clean code i paradygmat SOLID. Z checia pozna wzorce projektowe stosowane w profesjonalnych pordukcjach.
# APLIAKCJA
Aplikacja ma za zadanie ulatwic zarzadzniem produkcji jak i stworzyc baze produktow, ktore sa juz w produkcji i te ktore juz jakis czas temu zostaluy zrobione. da to mozliwosc oceny stosowanych metod produkcyjnych jak i weryfikowanie produktow wykorzystanych w produkcji.
# MVP
W pierwszej kolekjnsci chce stworzyc aplikacje webowa na telefon. Bieżacy cel, to wystartowanie pierwszej dzialajace wersji. Strona web ma wyswietlac liste produktow dodanych do bazy danych. kazdy produkt, na tej liscie, ma miec podstawowe informacje takie jak: nr zamowienia (XXXX/YYYY/ZZ), Model produktu, odbiorca (klient), przypisane listy produkcyjne, forme malowania (finish) i historie wykonaia i uzytch do tego srodkow chemicznych. koniczna jest mozliwosc wyszukiwanie i filtrowania listy po numerze zamowinia, odbiorcy, sposobie wykonczenia, listy produkcyjnej, modelu. lista ma byc "ciagla" lista, bez paginacji. pelna specyfikacja bedzie wyswietlana w dodtakowym oknie. waznym elementem ma byc latwosc dodawamnia kolejnego elementu do historii produkcji. jezeli ten proces bedzie wymagal za duzo od uzytkownika, to nie bedzie wykorzystywany i ta apliakcja starci sens.
## obency stan projektu
frontend ma stworziny wyglad na urzadzenia mobilne. ma stworzona liste produktow z docelowym wygladem. brakuje funkcjonalnosci. kod jest w tym momencie chaotyczny. Developer skupil sie na oczekiwanym wygladzie. powoli bedzie refaktoryzowal kod. jest stworzonuy layout quazar z przewidzianym oknem (right-drawer) na dodatkowe informacje. sa stworzony trzy podstawowe panale, ktore sa wyswietlane w prawym drawerze.
Backend (fastapi) do scrapowania danych jest dzialajacy i wystawia jeden endpoint, ktory przeszukuje stary system i zwraca pelna specyfikacje produktu. mozliwe, ze beda potrzeben dodatkowe endpointy.
Backend (directus) ma stworzona baze danych. Scheme bazy danych nie jest ostateczna i bedzie podlegac modyfikacjom. Planowane sa customowe endpointy jak i wykorzystanie flow directuasa. Plan jest taki, zeby directus komunikowal sie z backendem fastapi gdy dostanie informacje o stworzeniu nowego recodu produktu o numerze zamowinia, ktorego jescze nie ma w bazie danych.
wiele plikow w katalogu projektu jest utworzona w ramach testowania rozwiazna (proof of concept).
plik project.md ma opis co zawiera projekt.
plik db_schema.dbml ma auktualna strukture bazy danych

26
questions/serachbar.md Normal file
View File

@@ -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.

104
responses/0143_2025_1.json Normal file
View File

@@ -0,0 +1,104 @@
{
"order_number": "0143/2025/1",
"completion_date": "2025-03-31",
"prod_list": "CZE-00",
"url": "http://10.8.0.6/mayo2/index.php?&modul=14&id_zamowienia=8055&id_zestawu=35994",
"client": "HIENDGUITAR.COM / INDONESIA | INDONESIA",
"model": "Regius Core 6",
"spec": {
"szyjka": {
"radius": ["GITARA SETIUS/REGIUS/CUSTOM/ 16"],
"drewno-szyjka": [
"Klon amerykanski-Mahon-Wenge-Amazakoe (11 czesci)",
"Regius Core/ Profil laczenia szyjki z korpusem / wyzlobienie schodkowe"
],
"drewno-podstrunnica": ["BEM-MAST/ Birdseye Maple Master Grade"],
"drewno-glowka": ["BYB-MASTER/ BUCKEYE BURL MASTER (top)"],
"wymiary": [
"GITARA REGIUS 6/PRO 6/BARI/ Szyjka szerokosc I : 43 mm",
"GITARA REGIUS 6/PRO 6/BARI/ Szyjka szerokosc XII : 53,5 mm",
"GITARA REGIUS 6/PRO 6/BARI/ Szyjka szerokosc XXIV : 58,5 mm",
"GITARA SETIUS/REGIUS/CUSTOM/ Szyjka grubosc I : 20 mm",
"GITARA SETIUS/REGIUS/CUSTOM/ Szyjka grubosc XII : 21 mm"
],
"markery-bok": [
"SB-20/ Kropeczki (boczne) / 2mm / Fluorescencyjne / Niebieskie / Nie do jasnych krawedzi"
],
"markery-front": [
"Custom markerBIRD OF PARADISE !!!!BIRD OF PARADISE Custom Inlay"
],
"progi": [
"FERD WAGNER /FW 9685 / 667851/ Stainless Steel / 2,80x3,34 (1,45) / EXTRA JUMBO [JD brak] /STANDARD"
]
},
"konstrukcja": {
"wersja": ["V1/ CORE V1"],
"konstrukcja": ["neck-thru-body (szyjka przez korpus)"],
"skala": ["GITARA STANDARD/ 645mm (25,4\")"]
},
"ogolne": {
"lamowka(binding)": [
"Korpus / Szyjka / Glowka",
"3 czesciowa / 2 x ABS Czarny + Akryl Czarny Perloid"
]
},
"korpus": {
"drewno-top": [
"BYB-MASTER/ BUCKEYE BURL MASTER",
"Hand-picked wood/UWAGA!Dwa topy od wyboru"
],
"drewno-korpus": [
"V1/ - Korpus profilowany, wersja z ostrymi przezlobieniami / V1",
"ASH-SW/ ASH, SWAMP (Jesion Bagienny)"
],
"konfiguracja-przetworniki": ["H-H"],
"konfiguracja-potencjometry": [
"1 x Volume / 1 x Tone",
"Przelacznik Slizgowy"
]
},
"kolor": {
"kolor-top": [
"T-NAT-S/ Trans Natural Satine (transparent naturalny satynowy | zalane pory)"
],
"kolor-korpus": [
"T-NAT-M/ Trans Natural Matt (transparent naturalny matowy)"
],
"kolor-szyjka": [
"T-NAT-M/ Trans Natural Matt (transparent naturalny matowy)"
],
"kolor-glowka": [
"T-NAT-S/ Trans Natural Satine (transparent naturalny satynowy | zalane pory)"
],
"kolor-wykonczenie[k/c]": ["S+M/ Satin+Matt"]
},
"elektronika": {
"przetworniki-gitara": [
"/SPECIAL/ - PINS: GOLD SCREWS",
"VELVETRONE /H15+H13-6-SET / STD/ Ironside + Solium | 6 string | Humbucker Set"
],
"elektronika": [
"MAYONES / Treble Bleed Mod",
"BOURNS /PDA241-HTR01-504A2/ Potencjometr / Tone / 500kA",
"BOURNS /PDA241-HTR01-504A2 | STD/ Potencjometr / Volume / 500kA"
],
"przelacznikiigniazda": [
"ELECTROSWITCH /5-way | STD/ Przelacznik / Electroswitch 5-poz Multipole (slizgowy) - Konieczna REZERWACJA [wyklucza split na volume push-pull]",
"SWITCHCRAFT /C12B/ Gniazdo Jack"
]
},
"akcesoria": {
"mostek-gitara": ["HIPSHOT /41065G/ 6 String Fixed / 0.175 / Gold"],
"stroiki-gitara": ["HIPSHOT /6G000-D07-G/ GripLock 3+3 / Gold / Regius"],
"siodelko": ["BT-6010-00/ Grafitowe / Graph-Tech BT-6010-00"],
"struny": ["Nickel 010 - 046 (6)"],
"akcesoria-kolor": ["GLD (Gold)"]
},
"dodatki": {
"wyposazeniedodatkowe": [
"SCHALLER /ZACZEPY/ Zaczep Schaller Security Lock Gold / GLD (2 szt)",
"/CANTO-HYBRID-DD-GUITAR/ Mayones Hybrid Guitar Double Deck"
]
}
}
}

101
responses/0367_2025_1.json Normal file
View File

@@ -0,0 +1,101 @@
{
"order_number": "0367/2025/1",
"completion_date": "2025-11-03",
"prod_list": "LIS-25",
"url": "http://10.8.0.6/mayo2/index.php?id_zestawu=37692&id_zamowienia=8286&modul=14&pozycja=",
"client": "USA | Sebastopol | USA",
"model": "Duvell Elite 7 B26,5",
"spec": {
"szyjka": {
"radius": ["GITARA SETIUS/REGIUS/CUSTOM/ 16"],
"drewno-szyjka": [
"5ply Wenge/Purple/ 5-cz | Wenge-Purpleheart",
"Carbon Rods/ Dodatkowe Prety Grafitowe / Carbon Rods"
],
"drewno-podstrunnica": ["EBN/ EBONY (Heban)"],
"wymiary": [
"GITARA SETIUS/REGIUS/CUSTOM/ Szyjka grubosc I : 20 mm",
"DUVELL 7/ Szyjka szerokosc I: 48,5mm",
"DUVELL 7/ Szyjka szerokosc XII : 61mm",
"DUVELL 7/ Szyjka szerokosc XXIV : 66mm",
"GITARA SETIUS/REGIUS/CUSTOM/ Szyjka grubosc XII : 21 mm"
],
"markery-bok": [
"SGM-23/ Kropeczki (boczne) / 2+1mm / Fluorescencyjne / Zielone z czarna obwodka"
],
"markery-front": [
"SG-40/ Kropki (FRONT) / 4mm / Fluorescencyjne / Zielone / Nie do jasnych krawedzi",
"UKLAD / BASS SIDE 7/ Bass side / Ulozenie pomiedzy struna E6 i H7"
],
"progi": [
"FERD WAGNER /FW 9685 / 667851/ Stainless Steel / 2,80x3,34 (1,45) / EXTRA JUMBO [JD brak] /STANDARD"
]
},
"konstrukcja": {
"wersja": ["BARYTON 26.5"],
"konstrukcja": ["bolt-on (gryf przykrecany)"],
"skala": ["GITARA BARYTON26.5/ 673mm (26.5\")"]
},
"ogolne": {
"lamowka(binding)": [
"1PWH/ 1 czesciowa ABS / Biala",
"BEZ LAMOWKI (krawedz w kolorze topu)",
"/ Szyjka / Glowka"
]
},
"korpus": {
"drewno-top": ["NO-TOP/ Bez topu"],
"drewno-korpus": ["ASH-SW/ ASH, SWAMP (Jesion Bagienny)"],
"konfiguracja-przetworniki": [
"H (mostek) - tylko 1 przetwonik -dopasowac grubosc lakieru do wysokosci podstrunnicy!"
],
"konfiguracja-potencjometry": [
"CG-05T/ 1 x Volume - W MIEJSCU TONE",
"INNE/ --- UWAGA BEZ FREZOWANIA PRZELACZNIKA ---"
]
},
"kolor": {
"kolor-top": [
"M-CST-G/ MONOLITH CUSTOM COLOUR | GLOSSNajpierw SAMPLE!!! Fluo Orange | 29F02 | Z info Mate - jak najbardziej intensywny, neon!"
],
"kolor-korpus": [
"M-CST-G/ MONOLITH CUSTOM COLOUR | GLOSSNajpierw SAMPLE!!! Fluo Orange | 29F02 | Z info Mate - jak najbardziej intensywny, neon!"
],
"kolor-szyjka": [
"T-NAT-M/ Trans Natural Matt (transparent naturalny matowy)"
],
"kolor-glowka": [
"M-CST-G/ MONOLITH CUSTOM COLOUR | GLOSSNajpierw SAMPLE!!! Fluo Orange | 29F02 | Z info Mate - jak najbardziej intensywny, neon!"
],
"kolor-wykonczenie[k/c]": ["G/ Gloss"],
"kolor-wykonczenie[s]": ["G+M/ Gloss+Matt"]
},
"elektronika": {
"przetworniki-gitara": [
"/KOLOR/ - KOLOR : CZARNYopen",
"SEYMOUR DUNCAN / Pegasus 7 / Bridge Humbucker"
],
"elektronika": [
"BOURNS /PDB 183-GTR01-504A2 PP/ Potencjometr / Volume / 500A PushPull",
"MAYONES / Treble Bleed Mod"
],
"przelacznikiigniazda": ["SWITCHCRAFT /C12B/ Gniazdo Jack"]
},
"akcesoria": {
"mostek-gitara": [
"HIPSHOT /41075B/ Hipshot 7 String Fixed .175 Guitar Bridge Black"
],
"mostek-kostki": ["Standard"],
"stroiki-gitara": ["HIPSHOT /6G000-HS-B/ GripLock 4+3 / Black / Duvell"],
"siodelko": ["BT-6700-00/ Grafitowe / Graph-Tech BT-6700-00 (do 7-ki)"],
"galki": ["SAMWOO/ NS-030 BLK"],
"akcesoria-kolor": ["BLK (Black)"]
},
"dodatki": {
"wyposazeniedodatkowe": [
"/CANTO-MAYONES/ Mayones HYBRID Case",
"SCHALLER /ZACZEPY/ Zaczep Schaller Security Lock BLK (2 szt)"
]
}
}
}

4242
snapshot(6).json Normal file

File diff suppressed because it is too large Load Diff