diff --git a/services/targo-hub/lib/store.js b/services/targo-hub/lib/store.js
index f275fc5..a87d9ed 100644
--- a/services/targo-hub/lib/store.js
+++ b/services/targo-hub/lib/store.js
@@ -15,6 +15,7 @@
* dollar-accolade dans le HTML/JS interne (Vue utilise les doubles accolades).
*/
const { json } = require('./helpers')
+const erp = require('./erp')
const PAGE = `
-
⚙️ Page modèle — données démo. Branchement au catalogue ERPNext (Items + bundles + prix) à l'étape suivante.
+
⚙️ Page modèle — données démo (catalogue ERPNext vide ou indisponible). Cochez « Afficher dans la boutique » sur des Items pour les voir ici.
+
✓ Catalogue ERPNext en direct — produits cochés « Afficher dans la boutique » · prix Item Price · stock live.
@@ -311,10 +313,11 @@ const DEMO=[
];
Vue.createApp({
- data(){return{cat:'Tous',sel:null,picked:{},qty:1,cart:[],cartOpen:false,toast:null,added:false,bump:false,_t:0,_b:0}},
+ data(){return{cat:'Tous',sel:null,picked:{},qty:1,cart:[],cartOpen:false,toast:null,added:false,bump:false,items:DEMO,live:false,_t:0,_b:0}},
+ mounted(){this.load()},
computed:{
- cats(){return ['Tous'].concat([...new Set(DEMO.map(p=>p.cat))])},
- shown(){return this.cat==='Tous'?DEMO:DEMO.filter(p=>p.cat===this.cat)},
+ cats(){return ['Tous'].concat([...new Set(this.items.map(p=>p.cat))])},
+ shown(){return this.cat==='Tous'?this.items:this.items.filter(p=>p.cat===this.cat)},
count(){return this.cart.reduce((s,l)=>s+l.qty,0)},
total(){return this.cart.reduce((s,l)=>s+l.price*l.qty,0)},
curPrice(){
@@ -324,12 +327,14 @@ Vue.createApp({
},
curStock(){
if(!this.sel)return 1;if(this.sel.bundle)return 5;
+ if(!this.sel.options)return this.sel.stock!==undefined?this.sel.stock:99;
let st=99;(this.sel.options||[]).forEach(o=>{const v=this.valOf(o);if(v&&v.stock!==undefined)st=Math.min(st,v.stock)});return st;
},
stockClass(){const s=this.curStock;return s===0?'no':(s<=6?'low':'ok')},
stockLabel(){const s=this.curStock;return s===0?'Rupture de stock':(s<=6?('Plus que '+s+' en stock'):'En stock')}
},
methods:{
+ load(){fetch('/store/catalog').then(r=>r.json()).then(d=>{if(d&&d.products&&d.products.length){this.items=d.products;this.live=true}}).catch(()=>{})},
money(n){return (Math.round(n*100)/100).toFixed(2).replace('.',',')+' $'},
colorOf(p){return (p.options||[]).find(o=>o.type==='color')},
valOf(o){return o.values.find(v=>v.v===this.picked[o.name])},
@@ -369,12 +374,99 @@ Vue.createApp({
`
+// ── Catalogue live depuis ERPNext ──────────────────────────────────────────
+// Curation = champ Item.show_in_store (case à cocher). Catégorie = item_group.
+// Prix = Item Price (Standard Selling). Stock = somme Bin.actual_qty. Bundles =
+// Product Bundle. Variantes mono-attribut → options additives (add + stock/valeur).
+const ERP_HOST = 'https://erp.gigafibre.ca'
+const PRICE_LIST = 'Standard Selling'
+const COLOR_MAP = { blanc: '#f3f4f6', noir: '#1f2937', gris: '#9ca3af', bleu: '#2563eb', rouge: '#dc2626', vert: '#16a34a', jaune: '#eab308', orange: '#f97316', argent: '#cbd5e1' }
+const GROUP_ICON = { 'Caméras': '📷', 'Boosters WiFi': '📶', 'Câbles réseau': '🔌', 'TP-Link': '📡', 'Accessoires': '⚡', 'Bundles': '📦' }
+const round2 = n => Math.round((Number(n) || 0) * 100) / 100
+const stripHtml = s => (s || '').replace(/<[^>]+>/g, ' ').replace(/ /g, ' ').replace(/\s+/g, ' ').trim()
+const imgUrl = img => (img && img.startsWith('/files')) ? ERP_HOST + img : ''
+
+async function priceOf (code) {
+ const rows = await erp.list('Item Price', { filters: [['item_code', '=', code], ['price_list', '=', PRICE_LIST], ['selling', '=', 1]], fields: ['price_list_rate'], limit: 1 })
+ return rows && rows[0] ? round2(rows[0].price_list_rate) : 0
+}
+async function stockOf (code) {
+ const bins = await erp.list('Bin', { filters: [['item_code', '=', code]], fields: ['actual_qty'], limit: 50 })
+ return Math.round((bins || []).reduce((s, b) => s + (Number(b.actual_qty) || 0), 0))
+}
+async function nameOf (code) {
+ const it = await erp.get('Item', code, { fields: ['item_name'] })
+ return (it && it.item_name) || code
+}
+
+async function buildCatalog () {
+ const items = await erp.list('Item', {
+ filters: [['show_in_store', '=', 1], ['disabled', '=', 0]],
+ fields: ['name', 'item_name', 'item_group', 'image', 'description', 'standard_rate', 'has_variants', 'variant_of'],
+ limit: 200,
+ })
+ const bundles = await erp.list('Product Bundle', { fields: ['name'], limit: 200 })
+ const bundleSet = new Set((bundles || []).map(b => b.name))
+ const out = []
+ for (const it of (items || [])) {
+ if (it.variant_of) continue // les variantes enfant sont exposées via leur template
+ const p = {
+ id: it.name, cat: it.item_group || 'Boutique', name: it.item_name || it.name,
+ desc: stripHtml(it.description), icon: GROUP_ICON[it.item_group] || '📦', image: imgUrl(it.image),
+ }
+ if (bundleSet.has(it.name)) {
+ const pb = await erp.get('Product Bundle', it.name)
+ const comps = (pb && pb.items) || []
+ let sum = 0; const list = []
+ for (const c of (comps || [])) {
+ const q = Number(c.qty) || 1; sum += (await priceOf(c.item_code)) * q
+ list.push({ q, n: await nameOf(c.item_code) })
+ }
+ p.bundle = true; p.sum = round2(sum); p.price = (await priceOf(it.name)) || p.sum; p.items = list
+ } else if (it.has_variants) {
+ const vs = await erp.list('Item', { filters: [['variant_of', '=', it.name], ['disabled', '=', 0]], fields: ['name'], limit: 100 })
+ const info = []
+ for (const v of (vs || [])) {
+ const vd = await erp.get('Item', v.name)
+ const attrs = (vd && vd.attributes) || []
+ info.push({ attrs, price: await priceOf(v.name), stock: await stockOf(v.name) })
+ }
+ const prices = info.map(v => v.price).filter(n => n > 0)
+ const baseP = prices.length ? Math.min(...prices) : (round2(it.standard_rate) || 0)
+ p.base = baseP
+ const attrNames = [...new Set(info.flatMap(v => v.attrs.map(a => a.attribute)))]
+ p.options = attrNames.map(an => {
+ const isColor = /couleur|color/i.test(an)
+ const vals = [...new Set(info.flatMap(v => v.attrs.filter(a => a.attribute === an).map(a => a.attribute_value)))]
+ return {
+ name: an, type: isColor ? 'color' : 'pill',
+ values: vals.map(val => {
+ const match = info.filter(v => v.attrs.some(a => a.attribute === an && a.attribute_value === val))
+ const mp = match.map(v => v.price).filter(n => n > 0)
+ const o = { v: val, add: round2((mp.length ? Math.min(...mp) : baseP) - baseP), stock: match.reduce((s, v) => s + v.stock, 0) }
+ if (isColor) o.hex = COLOR_MAP[String(val).toLowerCase()] || '#9ca3af'
+ return o
+ }),
+ }
+ })
+ } else {
+ p.base = (await priceOf(it.name)) || round2(it.standard_rate) || 0
+ p.stock = await stockOf(it.name)
+ }
+ out.push(p)
+ }
+ return out
+}
+
async function handle (req, res, method, path) {
if (path === '/store' && method === 'GET') {
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
return res.end(PAGE)
}
- // À venir : GET /store/catalog → Items + Product Bundle d'ERPNext
+ if (path === '/store/catalog' && method === 'GET') {
+ try { return json(res, 200, { products: await buildCatalog() }) }
+ catch (e) { return json(res, 200, { products: [], error: e.message }) }
+ }
return json(res, 404, { error: 'not found' })
}