Compare commits
10 Commits
d578340636
...
fb08705883
| Author | SHA1 | Date | |
|---|---|---|---|
| fb08705883 | |||
| 2005e327f1 | |||
| 93778065ce | |||
| 045c65c363 | |||
| e03257f6fb | |||
| 6e3d722e69 | |||
| 5725c024dc | |||
| 2acdb09bfa | |||
| 32f126161e | |||
| a6ed643434 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -0,0 +1 @@
|
||||
.codex
|
||||
131
db_schema
131
db_schema
@@ -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
156
db_schema.dbml
Normal 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
63
directus-api.http
Normal 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
|
||||
3
directus/extensions/directus-extension-mayo-api/.gitignore
vendored
Normal file
3
directus/extensions/directus-extension-mayo-api/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
.DS_Store
|
||||
node_modules
|
||||
dist
|
||||
16
directus/extensions/directus-extension-mayo-api/README.md
Normal file
16
directus/extensions/directus-extension-mayo-api/README.md
Normal 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
|
||||
```
|
||||
@@ -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}}
|
||||
252
directus/extensions/directus-extension-mayo-api/old/index.js.old
Normal file
252
directus/extensions/directus-extension-mayo-api/old/index.js.old
Normal 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 });
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
},
|
||||
}
|
||||
8401
directus/extensions/directus-extension-mayo-api/package-lock.json
generated
Normal file
8401
directus/extensions/directus-extension-mayo-api/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
31
directus/extensions/directus-extension-mayo-api/package.json
Normal file
31
directus/extensions/directus-extension-mayo-api/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
11
directus/extensions/directus-extension-mayo-api/src/index.js
Normal file
11
directus/extensions/directus-extension-mayo-api/src/index.js
Normal 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)
|
||||
},
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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('+')
|
||||
}
|
||||
@@ -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))
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export function asyncHandler(handler) {
|
||||
return async (req, res, next) => {
|
||||
try {
|
||||
await handler(req, res, next)
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 []
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: [],
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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">→</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">→</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>
|
||||
|
||||
91
frontend/src/components/ProductionPreview.vue
Normal file
91
frontend/src/components/ProductionPreview.vue
Normal 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">→</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>
|
||||
48
frontend/src/components/right-drawer/RightDrawerHost.vue
Normal file
48
frontend/src/components/right-drawer/RightDrawerHost.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
10
frontend/src/components/right-drawer/panels/index.js
Normal file
10
frontend/src/components/right-drawer/panels/index.js
Normal 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,
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
19
frontend/src/mocks/api/dictionariesMockApi.js
Normal file
19
frontend/src/mocks/api/dictionariesMockApi.js
Normal 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 ?? [],
|
||||
}
|
||||
}
|
||||
43
frontend/src/mocks/api/productSpecificationMockApi.js
Normal file
43
frontend/src/mocks/api/productSpecificationMockApi.js
Normal 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 ?? [],
|
||||
}
|
||||
}
|
||||
81
frontend/src/mocks/api/productTimelineMockApi.js
Normal file
81
frontend/src/mocks/api/productTimelineMockApi.js
Normal 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()
|
||||
}
|
||||
83
frontend/src/mocks/api/productsMockApi.js
Normal file
83
frontend/src/mocks/api/productsMockApi.js
Normal 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
|
||||
}
|
||||
109
frontend/src/mocks/dictionaries.json
Normal file
109
frontend/src/mocks/dictionaries.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
117
frontend/src/mocks/products.json
Normal file
117
frontend/src/mocks/products.json
Normal 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": []
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
93
frontend/src/mocks/specifications.json
Normal file
93
frontend/src/mocks/specifications.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
111
frontend/src/mocks/timelines.json
Normal file
111
frontend/src/mocks/timelines.json
Normal 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": []
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
7
frontend/src/services/apiMode.js
Normal file
7
frontend/src/services/apiMode.js
Normal 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)
|
||||
})
|
||||
}
|
||||
5
frontend/src/services/dictionariesApi.js
Normal file
5
frontend/src/services/dictionariesApi.js
Normal 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
|
||||
18
frontend/src/services/dictionariesHttpApi.js
Normal file
18
frontend/src/services/dictionariesHttpApi.js
Normal 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 ?? [],
|
||||
}
|
||||
}
|
||||
17
frontend/src/services/productSpecificationApi.js
Normal file
17
frontend/src/services/productSpecificationApi.js
Normal 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
|
||||
24
frontend/src/services/productSpecificationHttpApi.js
Normal file
24
frontend/src/services/productSpecificationHttpApi.js
Normal 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 ?? [],
|
||||
}
|
||||
}
|
||||
17
frontend/src/services/productTimelineApi.js
Normal file
17
frontend/src/services/productTimelineApi.js
Normal 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
|
||||
16
frontend/src/services/productTimelineHttpApi.js
Normal file
16
frontend/src/services/productTimelineHttpApi.js
Normal 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,
|
||||
}
|
||||
}
|
||||
5
frontend/src/services/productsApi.js
Normal file
5
frontend/src/services/productsApi.js
Normal 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
|
||||
19
frontend/src/services/productsHttpApi.js
Normal file
19
frontend/src/services/productsHttpApi.js
Normal 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,
|
||||
}
|
||||
}
|
||||
73
frontend/src/stores/dictionariesStore.js
Normal file
73
frontend/src/stores/dictionariesStore.js
Normal 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))
|
||||
}
|
||||
141
frontend/src/stores/productSpecificationStore.js
Normal file
141
frontend/src/stores/productSpecificationStore.js
Normal 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))
|
||||
}
|
||||
126
frontend/src/stores/productTimelineStore.js
Normal file
126
frontend/src/stores/productTimelineStore.js
Normal 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))
|
||||
}
|
||||
173
frontend/src/stores/productsStore.js
Normal file
173
frontend/src/stores/productsStore.js
Normal 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))
|
||||
}
|
||||
54
frontend/src/stores/uiStore.js
Normal file
54
frontend/src/stores/uiStore.js
Normal 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))
|
||||
}
|
||||
141
frontend/src/utils/orderSearchParser.js
Normal file
141
frontend/src/utils/orderSearchParser.js
Normal 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)
|
||||
}
|
||||
134
frontend/src/utils/orderSearchParser.test.js
Normal file
134
frontend/src/utils/orderSearchParser.test.js
Normal 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
600
notes/api_services.md
Normal 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
437
notes/arch_struct.md
Normal 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
358
notes/directus-api.md
Normal 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
1103
notes/stores.md
Normal file
File diff suppressed because it is too large
Load Diff
48
project.md
Normal file
48
project.md
Normal 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
40
question.md
Normal 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
26
questions/serachbar.md
Normal 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
104
responses/0143_2025_1.json
Normal 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
101
responses/0367_2025_1.json
Normal 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
4242
snapshot(6).json
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user