Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion backend/db/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -54,12 +54,14 @@ CREATE TABLE IF NOT EXISTS products (
shopify_inventory_item_id TEXT,
manufacturer_id INTEGER REFERENCES manufacturers(id) ON DELETE SET NULL,
unit_cost NUMERIC(10,2),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_synced_at TIMESTAMPTZ
);

-- For databases created before the column existed:
ALTER TABLE products ADD COLUMN IF NOT EXISTS shopify_inventory_item_id TEXT;
ALTER TABLE products ADD COLUMN IF NOT EXISTS unit_cost NUMERIC(10,2);
ALTER TABLE products ADD COLUMN IF NOT EXISTS last_synced_at TIMESTAMPTZ;

CREATE TABLE IF NOT EXISTS reorder_thresholds (
id SERIAL PRIMARY KEY,
Expand Down
44 changes: 40 additions & 4 deletions backend/src/routes/products.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,22 @@ const syncRateLimit = rateLimit(
'Sync was run too recently. Please wait a minute and try again.'
);

function matchProducts(hubProducts, shopifyProducts) {
const results = hubProducts.map((hubProduct) => {
const shopifyProduct = shopifyProducts.find((shopifyProduct) =>
(hubProduct.shopify_inventory_item_id &&
shopifyProduct.inventory_item_id === hubProduct.shopify_inventory_item_id) ||
(hubProduct.sku &&
shopifyProduct.sku === hubProduct.sku)
);

return [hubProduct.id,
shopifyProduct ? 'matched' : 'unmatched']
});

return Object.fromEntries(results);
}

router.get('/', asyncRoute(async (req, res) => {
const sql =
'SELECT p.*, m.name AS manufacturer_name, rt.threshold ' +
Expand All @@ -34,8 +50,27 @@ router.get('/', asyncRoute(async (req, res) => {
'LIMIT 1000';

const result = await query(sql);
const hubProducts = result.rows;
let shopifyProducts;
try {
shopifyProducts = await shopify.getStockLevels();
} catch (err) {
console.error('Unable to retrieve Shopify products: ' + err.message);
const products = hubProducts.map((product) => ({
...product,
shopify_match: null
}));
return res.json({ products: products });
}

const matches = matchProducts(hubProducts, shopifyProducts);

const products = hubProducts.map((product) => ({
...product,
shopify_match: matches[product.id]
}));

res.json({ products: result.rows });
res.json({ products: products });
}));

router.get('/:id', asyncRoute(async (req, res) => {
Expand Down Expand Up @@ -116,11 +151,12 @@ router.post('/sync-shopify', syncRateLimit, asyncRoute(async (req, res) => {

try {
await query(
'INSERT INTO products (sku, name, shopify_inventory_item_id) ' +
'VALUES ($1, $2, $3) ' +
'INSERT INTO products (sku, name, shopify_inventory_item_id, last_synced_at) ' +
'VALUES ($1, $2, $3, NOW()) ' +
'ON CONFLICT (sku) DO UPDATE SET ' +
' name = EXCLUDED.name, ' +
' shopify_inventory_item_id = COALESCE(products.shopify_inventory_item_id, EXCLUDED.shopify_inventory_item_id)',
' shopify_inventory_item_id = COALESCE(products.shopify_inventory_item_id, EXCLUDED.shopify_inventory_item_id), ' +
' last_synced_at = EXCLUDED.last_synced_at',
[item.sku, item.name, item.inventory_item_id || null]
);
synced += 1;
Expand Down
24 changes: 16 additions & 8 deletions backend/src/services/sampleData/shopify.json
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,8 @@
"unit_cost": 14.00,
"stock_on_hand": 34,
"reorder_level": 50,
"image": "https://placehold.co/300x300?text=Serum"
"image": "https://placehold.co/300x300?text=Serum",
"inventory_item_id": "INV_01"
},
{
"sku": "ER-OIL-002",
Expand All @@ -179,7 +180,8 @@
"unit_cost": 11.00,
"stock_on_hand": 120,
"reorder_level": 40,
"image": "https://placehold.co/300x300?text=Face+Oil"
"image": "https://placehold.co/300x300?text=Face+Oil",
"inventory_item_id": "INV_02"
},
{
"sku": "ER-CRM-003",
Expand All @@ -189,7 +191,8 @@
"unit_cost": 13.00,
"stock_on_hand": 58,
"reorder_level": 60,
"image": "https://placehold.co/300x300?text=Day+Cream"
"image": "https://placehold.co/300x300?text=Day+Cream",
"inventory_item_id": "INV_03"
},
{
"sku": "ER-CRM-004",
Expand All @@ -199,7 +202,8 @@
"unit_cost": 16.00,
"stock_on_hand": 210,
"reorder_level": 40,
"image": "https://placehold.co/300x300?text=Night+Cream"
"image": "https://placehold.co/300x300?text=Night+Cream",
"inventory_item_id": "INV_04"
},
{
"sku": "ER-BLM-005",
Expand All @@ -209,7 +213,8 @@
"unit_cost": 3.00,
"stock_on_hand": 95,
"reorder_level": 100,
"image": "https://placehold.co/300x300?text=Lip+Balm"
"image": "https://placehold.co/300x300?text=Lip+Balm",
"inventory_item_id": "INV_05"
},
{
"sku": "ER-MSK-006",
Expand All @@ -219,7 +224,8 @@
"unit_cost": 12.00,
"stock_on_hand": 77,
"reorder_level": 30,
"image": "https://placehold.co/300x300?text=Detox+Mask"
"image": "https://placehold.co/300x300?text=Detox+Mask",
"inventory_item_id": "INV_06"
},
{
"sku": "ER-TNR-007",
Expand All @@ -229,7 +235,8 @@
"unit_cost": 7.00,
"stock_on_hand": 160,
"reorder_level": 50,
"image": "https://placehold.co/300x300?text=Toner"
"image": "https://placehold.co/300x300?text=Toner",
"inventory_item_id": "INV_07"
},
{
"sku": "ER-KIT-008",
Expand All @@ -239,7 +246,8 @@
"unit_cost": 35.00,
"stock_on_hand": 12,
"reorder_level": 20,
"image": "https://placehold.co/300x300?text=Ritual+Kit"
"image": "https://placehold.co/300x300?text=Ritual+Kit",
"inventory_item_id": "INV_08"
}
],
"reviews": [
Expand Down
39 changes: 37 additions & 2 deletions frontend/src/pages/Products.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,13 @@ import { useTableView, SortHeader, SearchBox, onEnter } from '../ui.jsx';

const EMPTY_FORM = { sku: '', name: '', manufacturer_id: '', threshold: '', unit_cost: '' };

const MATCH_STATES = [
{ value: 'matched', label: 'Matched', className: 'ok' },
{ value: 'unmatched', label: 'Unmatched', className: 'warn' },
];

const UNKNOWN_MATCH = { label: 'Unable to Verify', className: 'low' }

export default function Products() {
const [products, setProducts] = useState(null);
const [manufacturers, setManufacturers] = useState([]);
Expand Down Expand Up @@ -150,7 +157,7 @@ export default function Products() {
const r = await api('/products/sync-shopify', { method: 'POST' });
const skippedNote = r.skipped ? `, ${r.skipped} skipped` : '';
setSyncMsg(`Synced ${r.synced} of ${r.total} Shopify items${skippedNote}.`);
load();
await load();
} catch (e) {
setSyncMsg('');
setError(e.message);
Expand Down Expand Up @@ -191,7 +198,15 @@ export default function Products() {
));
}

function formatDate(value) {
return new Date(value).toLocaleString('en-GB');
}

function renderRow(product) {
const matchState =
MATCH_STATES.find(state => state.value === product.shopify_match)
?? UNKNOWN_MATCH;

return (
<tr key={product.id}>
{writable && (
Expand All @@ -206,6 +221,12 @@ export default function Products() {
)}
<td>{product.sku}</td>
<td><Link to={`/products/${product.id}`}>{product.name}</Link></td>
<td>
<span className={`pill ${matchState.className}`}>
{matchState.label}
</span>
</td>
<td>{product.last_synced_at ? formatDate(product.last_synced_at) : 'Never Recorded'}</td>
<td>
<select
value={product.manufacturer_id || ''}
Expand Down Expand Up @@ -361,6 +382,20 @@ export default function Products() {
)}
<SortHeader label="SKU / Item ID" sortKey="sku" sort={sort} toggleSort={toggleSort} />
<SortHeader label="Product" sortKey="name" sort={sort} toggleSort={toggleSort} />
<SortHeader label="Shopify Match" sortKey="shopify_match" sort={sort} toggleSort={toggleSort} />
<SortHeader
label={
<span
style={{ whiteSpace: 'nowrap' }}
title="Last synced records the latest successful product sync to the Hub and may use cached Shopify data."
>
Last Synced to Hub ⓘ
</span>
}
sortKey="last_synced_at"
sort={sort}
toggleSort={toggleSort}
/>
<SortHeader label="Manufacturer" sortKey="manufacturer_name" sort={sort} toggleSort={toggleSort} />
<SortHeader label="Reorder threshold" sortKey="threshold" sort={sort} toggleSort={toggleSort} className="num" />
<SortHeader label="Unit cost ($)" sortKey="unit_cost" sort={sort} toggleSort={toggleSort} className="num" />
Expand All @@ -369,7 +404,7 @@ export default function Products() {
</thead>
<tbody>
{view.length === 0 ? (
<tr><td colSpan={writable ? 7 : 5} className="empty">No products match “{query}”.</td></tr>
<tr><td colSpan={writable ? 9 : 7} className="empty">No products match “{query}”.</td></tr>
) : view.map(renderRow)}
</tbody>
</table>
Expand Down