From 80bb3a3bbe55f9aa31b80d192a15fee9566cd688 Mon Sep 17 00:00:00 2001 From: Mfuon Leon Date: Sat, 10 Jan 2026 10:49:34 +0300 Subject: [PATCH 01/13] chore: bump version to 0.0.5 --- .version | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.version b/.version index 81340c7..bbdeab6 100644 --- a/.version +++ b/.version @@ -1 +1 @@ -0.0.4 +0.0.5 From 613c49211a709f3126b0a41d2e1e43279b1c6637 Mon Sep 17 00:00:00 2001 From: Mfuon Leon Date: Sat, 10 Jan 2026 11:02:13 +0300 Subject: [PATCH 02/13] chore: bump version to 0.0.6 --- .version | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.version b/.version index bbdeab6..1750564 100644 --- a/.version +++ b/.version @@ -1 +1 @@ -0.0.5 +0.0.6 From 1ef14cbacd0a6c8d5ab5ffaee9f57831fd537d0f Mon Sep 17 00:00:00 2001 From: Mfuon Leon Date: Mon, 12 Jan 2026 07:54:32 +0300 Subject: [PATCH 03/13] chore: bump version to 0.0.8 --- .version | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.version b/.version index 1750564..d169b2f 100644 --- a/.version +++ b/.version @@ -1 +1 @@ -0.0.6 +0.0.8 From db9c76ece33ab031c78d5d430ddbcef2d2a471e1 Mon Sep 17 00:00:00 2001 From: Mfuon Leon Date: Mon, 12 Jan 2026 08:04:17 +0300 Subject: [PATCH 04/13] chore: bump version to 0.0.9 --- .version | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.version b/.version index d169b2f..c5d54ec 100644 --- a/.version +++ b/.version @@ -1 +1 @@ -0.0.8 +0.0.9 From 1504ae29e58a1fa9dada9c0916a19a5b074d90b8 Mon Sep 17 00:00:00 2001 From: Mfuon Leon Date: Mon, 12 Jan 2026 08:18:37 +0300 Subject: [PATCH 05/13] chore: bump version to 0.0.10 --- .version | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.version b/.version index c5d54ec..7c1886b 100644 --- a/.version +++ b/.version @@ -1 +1 @@ -0.0.9 +0.0.10 From ec1846ab8f111d32ef95ddba4b421e3e4542644e Mon Sep 17 00:00:00 2001 From: Mfuon Leon Date: Mon, 12 Jan 2026 08:26:13 +0300 Subject: [PATCH 06/13] chore: bump version to 0.0.11 --- .version | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.version b/.version index 7c1886b..2cfabea 100644 --- a/.version +++ b/.version @@ -1 +1 @@ -0.0.10 +0.0.11 From cbbf88bfb1914337f8aa238806295c78076a6253 Mon Sep 17 00:00:00 2001 From: Mfuon Leon Date: Sun, 8 Feb 2026 17:18:22 +0300 Subject: [PATCH 07/13] chore: bump version to 0.0.13 --- .version | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.version b/.version index 2cfabea..43b2961 100644 --- a/.version +++ b/.version @@ -1 +1 @@ -0.0.11 +0.0.13 From 9c459364e005e119e2a4f9f44b5f341cebabe65a Mon Sep 17 00:00:00 2001 From: Mfuon Leon Date: Sun, 8 Feb 2026 17:39:12 +0300 Subject: [PATCH 08/13] chore: bump version to 0.0.14 --- .version | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.version b/.version index 43b2961..9789c4c 100644 --- a/.version +++ b/.version @@ -1 +1 @@ -0.0.13 +0.0.14 From 809938c840d964fe7b9f23714b1a5bdeac723421 Mon Sep 17 00:00:00 2001 From: Mfuon Leon Date: Tue, 10 Feb 2026 06:28:20 +0300 Subject: [PATCH 09/13] fix-migration issues --- .../20260208_migrate_borrowed_items_v1.sql | 4 + .../20260208_migrate_borrowed_items_v2.sql | 4 + .../20260208_migrate_categories.sql | 32 ++++++ .../20260208_migrate_expenses_v2.sql | 13 +++ .../20260208_migrate_stock_separation.sql | 25 +++++ functions/api/migrate-borrowed-items-v2.js | 38 ------- functions/api/migrate-borrowed-items.js | 40 -------- functions/api/migrate-categories.js | 98 ------------------- functions/api/migrate-expenses-v2.js | 37 ------- functions/api/migrate-stock-separation.js | 92 ----------------- 10 files changed, 78 insertions(+), 305 deletions(-) create mode 100644 drizzle/migrations/20260208_migrate_borrowed_items_v1.sql create mode 100644 drizzle/migrations/20260208_migrate_borrowed_items_v2.sql create mode 100644 drizzle/migrations/20260208_migrate_categories.sql create mode 100644 drizzle/migrations/20260208_migrate_expenses_v2.sql create mode 100644 drizzle/migrations/20260208_migrate_stock_separation.sql delete mode 100644 functions/api/migrate-borrowed-items-v2.js delete mode 100644 functions/api/migrate-borrowed-items.js delete mode 100644 functions/api/migrate-categories.js delete mode 100644 functions/api/migrate-expenses-v2.js delete mode 100644 functions/api/migrate-stock-separation.js diff --git a/drizzle/migrations/20260208_migrate_borrowed_items_v1.sql b/drizzle/migrations/20260208_migrate_borrowed_items_v1.sql new file mode 100644 index 0000000..1d5fa86 --- /dev/null +++ b/drizzle/migrations/20260208_migrate_borrowed_items_v1.sql @@ -0,0 +1,4 @@ +-- Migration: Add returned_quantity tracking to borrowed_items +-- This allows tracking how many borrowed items have been returned + +ALTER TABLE borrowed_items ADD COLUMN returned_quantity INTEGER DEFAULT 0; diff --git a/drizzle/migrations/20260208_migrate_borrowed_items_v2.sql b/drizzle/migrations/20260208_migrate_borrowed_items_v2.sql new file mode 100644 index 0000000..084e914 --- /dev/null +++ b/drizzle/migrations/20260208_migrate_borrowed_items_v2.sql @@ -0,0 +1,4 @@ +-- Migration: Add paid_quantity tracking to borrowed_items +-- This allows tracking how many borrowed items have been paid for + +ALTER TABLE borrowed_items ADD COLUMN paid_quantity INTEGER DEFAULT 0; diff --git a/drizzle/migrations/20260208_migrate_categories.sql b/drizzle/migrations/20260208_migrate_categories.sql new file mode 100644 index 0000000..485c614 --- /dev/null +++ b/drizzle/migrations/20260208_migrate_categories.sql @@ -0,0 +1,32 @@ +-- Migration: Convert product categories from string to foreign key reference +-- This migration: +-- 1. Creates categories for all unique category strings in products +-- 2. Updates products to reference category IDs +-- 3. Drops the legacy category column + +-- Step 1: Insert all unique categories from products table +-- Using INSERT OR IGNORE to handle duplicates safely +INSERT OR IGNORE INTO categories (name) +SELECT DISTINCT TRIM(category) +FROM products +WHERE category IS NOT NULL + AND category != '' + AND category_id IS NULL; + +-- Step 2: Update products to reference the category IDs +-- This matches categories case-insensitively +UPDATE products +SET category_id = ( + SELECT id + FROM categories + WHERE LOWER(TRIM(categories.name)) = LOWER(TRIM(products.category)) + LIMIT 1 +) +WHERE category IS NOT NULL + AND category != '' + AND category_id IS NULL; + +-- Step 3: Drop the legacy category column (if it exists) +-- Note: SQLite doesn't support DROP COLUMN IF EXISTS, so this may error if already dropped +-- The migration runner handles this gracefully +ALTER TABLE products DROP COLUMN category; diff --git a/drizzle/migrations/20260208_migrate_expenses_v2.sql b/drizzle/migrations/20260208_migrate_expenses_v2.sql new file mode 100644 index 0000000..0f43604 --- /dev/null +++ b/drizzle/migrations/20260208_migrate_expenses_v2.sql @@ -0,0 +1,13 @@ +-- Migration: Add incurred_date to expenses table +-- This migration: +-- 1. Adds incurred_date column +-- 2. Backfills the date from created_at timestamp + +-- Step 1: Add the incurred_date column +ALTER TABLE expenses ADD COLUMN incurred_date DATE; + +-- Step 2: Backfill incurred_date from created_at +-- Extract just the date portion (YYYY-MM-DD) from the timestamp +UPDATE expenses +SET incurred_date = date(created_at) +WHERE incurred_date IS NULL OR incurred_date = ''; diff --git a/drizzle/migrations/20260208_migrate_stock_separation.sql b/drizzle/migrations/20260208_migrate_stock_separation.sql new file mode 100644 index 0000000..e8f73dc --- /dev/null +++ b/drizzle/migrations/20260208_migrate_stock_separation.sql @@ -0,0 +1,25 @@ +-- Migration: Separate stock counts from products table +-- This migration: +-- 1. Recreates the stock table with the correct schema +-- 2. Migrates stock data from products table +-- 3. Drops the legacy stock column from products + +-- Step 1: Recreate stock table with proper schema +DROP TABLE IF EXISTS stock; + +CREATE TABLE stock ( + product_id INTEGER PRIMARY KEY REFERENCES products(id) ON DELETE CASCADE, + count INTEGER NOT NULL DEFAULT 0, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +-- Step 2: Migrate stock data from products table +INSERT OR REPLACE INTO stock (product_id, count, updated_at) +SELECT id, stock, CURRENT_TIMESTAMP +FROM products +WHERE stock IS NOT NULL; + +-- Step 3: Drop the legacy stock column from products +-- Note: SQLite doesn't support DROP COLUMN IF EXISTS, so this may error if already dropped +-- The migration runner handles this gracefully +ALTER TABLE products DROP COLUMN stock; diff --git a/functions/api/migrate-borrowed-items-v2.js b/functions/api/migrate-borrowed-items-v2.js deleted file mode 100644 index ec1ab13..0000000 --- a/functions/api/migrate-borrowed-items-v2.js +++ /dev/null @@ -1,38 +0,0 @@ -export async function onRequestPost(context) { - const { env } = context; - - try { - // Add paid_quantity column - try { - await env.DB.prepare( - `ALTER TABLE borrowed_items ADD COLUMN paid_quantity INTEGER DEFAULT 0`, - ).run(); - } catch (e) { - console.warn( - "Could not add paid_quantity (might already exist):", - e.message, - ); - } - - return new Response( - JSON.stringify({ - success: true, - message: "Successfully added paid_quantity to borrowed_items.", - }), - { - headers: { "Content-Type": "application/json" }, - }, - ); - } catch (error) { - return new Response( - JSON.stringify({ - success: false, - error: error.message, - }), - { - status: 500, - headers: { "Content-Type": "application/json" }, - }, - ); - } -} diff --git a/functions/api/migrate-borrowed-items.js b/functions/api/migrate-borrowed-items.js deleted file mode 100644 index d3e3c19..0000000 --- a/functions/api/migrate-borrowed-items.js +++ /dev/null @@ -1,40 +0,0 @@ -export async function onRequestPost(context) { - const { env } = context; - - try { - // 1. Add returned_quantity column - try { - await env.DB.prepare( - `ALTER TABLE borrowed_items ADD COLUMN returned_quantity INTEGER DEFAULT 0`, - ).run(); - } catch (e) { - console.warn( - "Could not add returned_quantity (might already exist):", - e.message, - ); - } - - // 2. Add returned status if not already handled by logic (optional, status is just text) - - return new Response( - JSON.stringify({ - success: true, - message: "Successfully added returned_quantity to borrowed_items.", - }), - { - headers: { "Content-Type": "application/json" }, - }, - ); - } catch (error) { - return new Response( - JSON.stringify({ - success: false, - error: error.message, - }), - { - status: 500, - headers: { "Content-Type": "application/json" }, - }, - ); - } -} diff --git a/functions/api/migrate-categories.js b/functions/api/migrate-categories.js deleted file mode 100644 index 7c74b46..0000000 --- a/functions/api/migrate-categories.js +++ /dev/null @@ -1,98 +0,0 @@ -import { getDb } from "../../drizzle/db"; -import { categories } from "../../drizzle/schema"; - -export async function onRequestPost(context) { - const { env } = context; - const db = getDb(env); - - try { - // 1. Fetch products with old category column using raw SQL - // This allows us to work even after the Drizzle schema is updated - const { results: productsToMigrate } = await env.DB.prepare( - "SELECT id, category, category_id FROM products WHERE category_id IS NULL AND category IS NOT NULL", - ).all(); - - if (productsToMigrate.length === 0) { - // Check if column exists before trying to drop it - try { - await env.DB.prepare("ALTER TABLE products DROP COLUMN category").run(); - } catch (e) { - // Column might already be gone - } - - return new Response( - JSON.stringify({ - success: true, - message: - "No products found requiring migration. Legacy column checked/removed.", - }), - { headers: { "Content-Type": "application/json" } }, - ); - } - - // 2. Get all categories for mapping - const allCategories = await db.select().from(categories); - const categoryMap = new Map( - allCategories.map((c) => [c.name.toLowerCase(), c.id]), - ); - - let migratedCount = 0; - let createdCategoryCount = 0; - - for (const product of productsToMigrate) { - const categoryName = product.category ? product.category.trim() : null; - if (!categoryName) continue; - - const lowerName = categoryName.toLowerCase(); - let categoryId = categoryMap.get(lowerName); - - // 3. If category doesn't exist, create it - if (!categoryId) { - const [newCat] = await db - .insert(categories) - .values({ - name: categoryName, - }) - .returning({ id: categories.id }); - - categoryId = newCat.id; - categoryMap.set(lowerName, categoryId); - createdCategoryCount++; - } - - if (categoryId) { - await env.DB.prepare("UPDATE products SET category_id = ? WHERE id = ?") - .bind(categoryId, product.id) - .run(); - - migratedCount++; - } - } - - // 4. Drop the old category column - await env.DB.prepare("ALTER TABLE products DROP COLUMN category").run(); - - return new Response( - JSON.stringify({ - success: true, - migratedCount, - createdCategoryCount, - message: `Successfully migrated ${migratedCount} products and removed the legacy 'category' column.`, - }), - { - headers: { "Content-Type": "application/json" }, - }, - ); - } catch (error) { - return new Response( - JSON.stringify({ - success: false, - error: error.message, - }), - { - status: 500, - headers: { "Content-Type": "application/json" }, - }, - ); - } -} diff --git a/functions/api/migrate-expenses-v2.js b/functions/api/migrate-expenses-v2.js deleted file mode 100644 index 9e5d08f..0000000 --- a/functions/api/migrate-expenses-v2.js +++ /dev/null @@ -1,37 +0,0 @@ -export async function onRequestPost(context) { - const { env } = context; - - try { - // Add incurred_date column - try { - await env.DB.prepare( - `ALTER TABLE expenses ADD COLUMN incurred_date DATE`, - ).run(); - } catch (e) { - console.log("Column might already exist:", e.message); - } - - // Backfill incurred_date from createdAt - // SQLite: date(created_at) extracts the YYYY-MM-DD part - await env.DB.prepare( - ` - UPDATE expenses - SET incurred_date = date(created_at) - WHERE incurred_date IS NULL OR incurred_date = '' - `, - ).run(); - - return Response.json({ - success: true, - message: "Successfully added and backfilled incurred_date for expenses.", - }); - } catch (error) { - return Response.json( - { - success: false, - error: error.message, - }, - { status: 500 }, - ); - } -} diff --git a/functions/api/migrate-stock-separation.js b/functions/api/migrate-stock-separation.js deleted file mode 100644 index 7efaed7..0000000 --- a/functions/api/migrate-stock-separation.js +++ /dev/null @@ -1,92 +0,0 @@ -import { getNairobiTimestamp } from "../utils/timezone.js"; - -export async function onRequestPost(context) { - const { env } = context; - - try { - // 1. Drop existing stock table if it has wrong schema - // We recreate it to ensure it matches: primary key on product_id, includes updated_at - await env.DB.prepare(`DROP TABLE IF EXISTS stock`).run(); - - await env.DB.prepare( - ` - CREATE TABLE IF NOT EXISTS stock ( - product_id INTEGER PRIMARY KEY REFERENCES products(id), - count INTEGER NOT NULL DEFAULT 0, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP - ) - `, - ).run(); - - // 2. Fetch products with the old stock column - // We use raw SQL to ensure we can read the column even if Drizzle no longer sees it - const { results: productsToMigrate } = await env.DB.prepare( - "SELECT id, stock FROM products WHERE stock IS NOT NULL", - ).all(); - - if (productsToMigrate.length === 0) { - // Check if column exists before trying to drop it - try { - await env.DB.prepare("ALTER TABLE products DROP COLUMN stock").run(); - } catch (e) { - // Column might already be gone - } - - return new Response( - JSON.stringify({ - success: true, - message: - "No products found requiring migration. Legacy column checked/removed.", - }), - { headers: { "Content-Type": "application/json" } }, - ); - } - - const timestamp = getNairobiTimestamp(); - let migratedCount = 0; - - // 3. Migrate data to the new stock table - for (const product of productsToMigrate) { - await env.DB.prepare( - ` - INSERT OR REPLACE INTO stock (product_id, count, updated_at) - VALUES (?, ?, ?) - `, - ) - .bind(product.id, product.stock, timestamp) - .run(); - - migratedCount++; - } - - // 4. Drop the old stock column from products - try { - await env.DB.prepare("ALTER TABLE products DROP COLUMN stock").run(); - } catch (e) { - console.error("Error dropping stock column:", e); - // If drop fails, we still consider data migration successful - } - - return new Response( - JSON.stringify({ - success: true, - migratedCount, - message: `Successfully migrated stock for ${migratedCount} products and attempted to remove the legacy 'stock' column.`, - }), - { - headers: { "Content-Type": "application/json" }, - }, - ); - } catch (error) { - return new Response( - JSON.stringify({ - success: false, - error: error.message, - }), - { - status: 500, - headers: { "Content-Type": "application/json" }, - }, - ); - } -} From 3eb41df54f867cfd178743584f4c6f7020b66eaf Mon Sep 17 00:00:00 2001 From: Mfuon Leon Date: Tue, 10 Feb 2026 07:20:20 +0300 Subject: [PATCH 10/13] Refine product search functionality and synchronize database schemas - Aligned search UI with action buttons in Inventory header - Extended search to all inventory tabs (Products, Restock, Borrowed, Loaned) - Restructured backend search logic to scan entire dataset before pagination - Fixed backend join crash in products count query - Synchronized local and remote D1 schemas (Products, Sales, Expenses) - Added sale_date backfilling migration --- .../20260210_sync_remote_schema.sql | 14 + functions/api/borrowed-items/index.js | 28 +- functions/api/loans/index.js | 45 +-- functions/api/products.js | 102 ++++--- package.json | 4 +- src/stores/borrowedStore.js | 5 +- src/stores/loanStore.js | 5 +- src/stores/productStore.js | 4 + src/views/Inventory.vue | 271 +++++++++++++++++- vite.config.js | 2 +- wrangler.toml | 1 + 11 files changed, 383 insertions(+), 98 deletions(-) create mode 100644 drizzle/migrations/20260210_sync_remote_schema.sql diff --git a/drizzle/migrations/20260210_sync_remote_schema.sql b/drizzle/migrations/20260210_sync_remote_schema.sql new file mode 100644 index 0000000..55a7124 --- /dev/null +++ b/drizzle/migrations/20260210_sync_remote_schema.sql @@ -0,0 +1,14 @@ +-- Migration: Sync remote schema with missing columns +-- This adds columns that are in schema.sql but might be missing in older database instances + +-- Add category_id to products if it doesn't exist +-- Note: SQLite doesn't support ADD COLUMN IF NOT EXISTS easily in a script +-- But we can try to add it, and if it fails because it exists, that's fine for manual run +-- However, wrangler migrations will stop on error. +-- Since I know it's missing on remote, I'll add it. + +ALTER TABLE products ADD COLUMN category_id INTEGER REFERENCES categories(id); +ALTER TABLE sales ADD COLUMN sale_date DATE; + +-- Backfill sale_date +UPDATE sales SET sale_date = date(created_at) WHERE sale_date IS NULL; diff --git a/functions/api/borrowed-items/index.js b/functions/api/borrowed-items/index.js index 0c4668e..555fb85 100644 --- a/functions/api/borrowed-items/index.js +++ b/functions/api/borrowed-items/index.js @@ -3,10 +3,12 @@ import { expenses } from "../../../drizzle/schema"; import { getNairobiTimestamp } from "../../utils/timezone.js"; export async function onRequestGet(context) { - const { env } = context; + const { env, request } = context; + const url = new URL(request.url); + const search = url.searchParams.get("search"); + try { - const { results } = await env.DB.prepare( - ` + let query = ` SELECT bi.*, p.name as product_name, @@ -14,9 +16,23 @@ export async function onRequestGet(context) { p.price as product_price FROM borrowed_items bi LEFT JOIN products p ON bi.product_id = p.id - ORDER BY bi.created_at DESC - `, - ).all(); + `; + + const params = []; + if (search) { + query += ` + WHERE p.name LIKE ? + OR p.barcode LIKE ? + OR bi.borrowed_from LIKE ? + OR bi.reason LIKE ? + `; + const searchPattern = `%${search}%`; + params.push(searchPattern, searchPattern, searchPattern, searchPattern); + } + + query += ` ORDER BY bi.created_at DESC`; + + const { results } = await env.DB.prepare(query).bind(...params).all(); return Response.json(results); } catch (e) { diff --git a/functions/api/loans/index.js b/functions/api/loans/index.js index 25ecf42..68de651 100644 --- a/functions/api/loans/index.js +++ b/functions/api/loans/index.js @@ -1,25 +1,37 @@ export async function onRequestGet(context) { - const { env } = context; + const { env, request } = context; + const url = new URL(request.url); + const search = url.searchParams.get("search"); + try { - // Join loans with items for a complete view - // Since D1 doesn't support complex JSON_GROUP_ARRAY easily in all versions, - // we might fetch loans then fetch items, or just fetch flat structure. - // Let's fetch loans and then their items or use a join. - // For simplicity in UI, we often want "Loan #123 (Shop X) - 3 items". - - // Fetch all loans first - const { results: loans } = await env.DB.prepare( - ` - SELECT * FROM loans ORDER BY created_at DESC - `, - ).all(); + let loanQuery = `SELECT * FROM loans`; + const loanParams = []; + + if (search) { + loanQuery += ` + WHERE id IN ( + SELECT l.id FROM loans l + WHERE l.borrower_name LIKE ? OR l.collateral LIKE ? + UNION + SELECT li.loan_id FROM loan_items li + JOIN products p ON li.product_id = p.id + WHERE p.name LIKE ? OR p.barcode LIKE ? + ) + `; + const pattern = `%${search}%`; + loanParams.push(pattern, pattern, pattern, pattern); + } + + loanQuery += ` ORDER BY created_at DESC`; + + const { results: loans } = await env.DB.prepare(loanQuery) + .bind(...loanParams) + .all(); if (loans.length === 0) { return Response.json([]); } - // This loop might be slightly N+1 but D1 is fast locally. - // Optimized: get all loan items for these loans const loanIds = loans.map((l) => l.id).join(","); const { results: items } = await env.DB.prepare( ` @@ -31,8 +43,6 @@ export async function onRequestGet(context) { `, ).all(); - // Fetch return history/substitutions - // We can do this efficiently by getting all returns for these items const itemIds = items.map((i) => i.id).join(","); let returns = []; if (itemIds) { @@ -48,7 +58,6 @@ export async function onRequestGet(context) { returns = returnData; } - // Attach items to loans, and returns to items const loansWithItems = loans.map((loan) => ({ ...loan, items: items diff --git a/functions/api/products.js b/functions/api/products.js index f8bdee9..be683df 100644 --- a/functions/api/products.js +++ b/functions/api/products.js @@ -1,7 +1,7 @@ import { getNairobiTimestamp } from "../utils/timezone.js"; import { getDb } from "../../drizzle/db"; import { products, categories, stock } from "../../drizzle/schema"; -import { count, desc, lt, eq } from "drizzle-orm"; +import { count, desc, lt, eq, or, like } from "drizzle-orm"; export async function onRequestGet(context) { const { env, request } = context; @@ -9,48 +9,65 @@ export async function onRequestGet(context) { const pageParam = url.searchParams.get("page"); const limitParam = url.searchParams.get("limit"); const lowStockParam = url.searchParams.get("low_stock"); + const searchParam = url.searchParams.get("search"); try { const db = getDb(env); + // Initial query blocks + let countQuery = db + .select({ total: count() }) + .from(products) + .leftJoin(categories, eq(products.categoryId, categories.id)) + .leftJoin(stock, eq(products.id, stock.productId)); + + let dataQuery = db + .select({ + id: products.id, + name: products.name, + barcode: products.barcode, + price: products.price, + cost: products.cost, + stock: stock.count, + category: categories.name, + categoryId: products.categoryId, + image: products.image, + deleted_at: products.deletedAt, + created_at: products.createdAt, + }) + .from(products) + .leftJoin(categories, eq(products.categoryId, categories.id)) + .leftJoin(stock, eq(products.id, stock.productId)); + + // Apply Filter: Low Stock + if (lowStockParam === "true") { + const lowStockFilter = lt(stock.count, 1); + countQuery = countQuery.where(lowStockFilter); + dataQuery = dataQuery.where(lowStockFilter); + } + + // Apply Filter: Search + if (searchParam) { + const searchFilter = or( + like(products.name, `%${searchParam}%`), + like(products.barcode, `%${searchParam}%`), + like(categories.name, `%${searchParam}%`), + ); + countQuery = countQuery.where(searchFilter); + dataQuery = dataQuery.where(searchFilter); + } + if (pageParam) { const page = parseInt(pageParam) || 1; const limit = parseInt(limitParam) || 20; const offset = (page - 1) * limit; - let countQuery = db - .select({ total: count() }) - .from(products) - .leftJoin(stock, eq(products.id, stock.productId)); - - let dataQuery = db - .select({ - id: products.id, - name: products.name, - barcode: products.barcode, - price: products.price, - cost: products.cost, - stock: stock.count, - category: categories.name, - categoryId: products.categoryId, - image: products.image, - deleted_at: products.deletedAt, - created_at: products.createdAt, - }) - .from(products) - .leftJoin(categories, eq(products.categoryId, categories.id)) - .leftJoin(stock, eq(products.id, stock.productId)) + const results = await dataQuery .orderBy(desc(products.createdAt)) .limit(limit) .offset(offset); - if (lowStockParam === "true") { - countQuery = countQuery.where(lt(stock.count, 1)); - dataQuery = dataQuery.where(lt(stock.count, 1)); - } - const [{ total }] = await countQuery; - const results = await dataQuery; return new Response( JSON.stringify({ @@ -67,31 +84,8 @@ export async function onRequestGet(context) { }, ); } else { - let query = db - .select({ - id: products.id, - name: products.name, - barcode: products.barcode, - price: products.price, - cost: products.cost, - stock: stock.count, - category: categories.name, - categoryId: products.categoryId, - image: products.image, - deleted_at: products.deletedAt, - created_at: products.createdAt, - }) - .from(products) - .leftJoin(categories, eq(products.categoryId, categories.id)) - .leftJoin(stock, eq(products.id, stock.productId)) - .orderBy(desc(products.createdAt)); - - if (lowStockParam === "true") { - query = query.where(lt(stock.count, 1)); - } - - const results = await query; - + // Non-paginated (e.g., for export or bulk views) + const results = await dataQuery.orderBy(desc(products.createdAt)); return new Response(JSON.stringify(results), { headers: { "Content-Type": "application/json" }, }); diff --git a/package.json b/package.json index 0980961..d9f1f33 100644 --- a/package.json +++ b/package.json @@ -5,8 +5,8 @@ "type": "module", "scripts": { "dev": "vite", - "dev:backend": "CI=true wrangler pages dev --proxy 5173 --port 8788", - "dev:remote": "CI=true wrangler pages dev --proxy 5173 --port 8788 --remote", + "dev:backend": "CI=true wrangler pages dev --proxy 5173 --port 8789", + "dev:remote": "CI=true wrangler pages dev --proxy 5173 --port 8789 --remote", "dev:all": "concurrently \"bun run dev\" \"wait-on http://localhost:5173 && bun run dev:backend\" --names \"vite,wrangler\" --prefix-colors \"cyan,magenta\"", "build": "vite build", "preview": "vite preview", diff --git a/src/stores/borrowedStore.js b/src/stores/borrowedStore.js index 743af88..d86c06b 100644 --- a/src/stores/borrowedStore.js +++ b/src/stores/borrowedStore.js @@ -7,10 +7,11 @@ export const useBorrowedStore = defineStore("borrowed", () => { const loading = ref(false); const error = ref(null); - async function fetchBorrowedItems() { + async function fetchBorrowedItems(search = "") { loading.value = true; try { - const response = await apiFetch("/api/borrowed-items"); + const url = search ? `/api/borrowed-items?search=${encodeURIComponent(search)}` : "/api/borrowed-items"; + const response = await apiFetch(url); if (response.ok) { borrowedItems.value = await response.json(); } else { diff --git a/src/stores/loanStore.js b/src/stores/loanStore.js index bd9c4cc..6594255 100644 --- a/src/stores/loanStore.js +++ b/src/stores/loanStore.js @@ -7,10 +7,11 @@ export const useLoanStore = defineStore('loan', () => { const loading = ref(false) const error = ref(null) - async function fetchLoans() { + async function fetchLoans(search = "") { loading.value = true try { - const response = await apiFetch('/api/loans') + const url = search ? `/api/loans?search=${encodeURIComponent(search)}` : '/api/loans' + const response = await apiFetch(url) if (response.ok) { loans.value = await response.json() } else { diff --git a/src/stores/productStore.js b/src/stores/productStore.js index a77c074..a44c8f4 100644 --- a/src/stores/productStore.js +++ b/src/stores/productStore.js @@ -30,6 +30,10 @@ export const useProductStore = defineStore('product', () => { queryParams.append('low_stock', 'true') } + if (params.search) { + queryParams.append('search', params.search) + } + const queryString = queryParams.toString() if (queryString) { url += `?${queryString}` diff --git a/src/views/Inventory.vue b/src/views/Inventory.vue index 202fc5e..fd47962 100644 --- a/src/views/Inventory.vue +++ b/src/views/Inventory.vue @@ -59,19 +59,55 @@ }}
+ + +
+ + + + @@ -153,6 +189,44 @@

Borrowed Inventory

+
+ +
+
+ + +
@@ -248,6 +322,44 @@

Loaned Inventory

+
+ +
+
+ + +
@@ -833,6 +945,7 @@ import { Package, Download, Upload, + Search, Image as ImageIcon, Camera, ArrowDownLeft, @@ -859,29 +972,62 @@ const products = computed(() => productStore.products); const categories = computed(() => categoryStore.categories); const pagination = computed(() => productStore.pagination); const borrowedItems = computed(() => borrowedStore.borrowedItems); - +const loans = computed(() => loanStore.loans); const activeTab = ref("inventory"); // inventory, low_stock, borrowed, loaned const exporting = ref(false); +const searchQuery = ref(""); +let searchTimeout = null; + +// Watch for search query changes with debounce +watch(searchQuery, (newQuery) => { + if (searchTimeout) clearTimeout(searchTimeout); + searchTimeout = setTimeout(async () => { + if (activeTab.value === "inventory" || activeTab.value === "low_stock") { + await productStore.fetchProducts({ + page: 1, + limit: 20, + low_stock: activeTab.value === "low_stock", + search: newQuery, + }); + } else if (activeTab.value === "borrowed") { + await borrowedStore.fetchBorrowedItems(newQuery); + } else if (activeTab.value === "loaned") { + await loanStore.fetchLoans(newQuery); + } + }, 300); +}); // Watch for tab changes to fetch appropriate data watch(activeTab, async (newTab) => { if (newTab === "inventory") { - await productStore.fetchProducts({ page: 1, limit: 20 }); + await productStore.fetchProducts({ + page: 1, + limit: 20, + search: searchQuery.value, + }); } else if (newTab === "low_stock") { - await productStore.fetchProducts({ page: 1, limit: 20, low_stock: true }); + await productStore.fetchProducts({ + page: 1, + limit: 20, + low_stock: true, + search: searchQuery.value, + }); } else if (newTab === "borrowed") { - await borrowedStore.fetchBorrowedItems(); + await borrowedStore.fetchBorrowedItems(searchQuery.value); } else if (newTab === "loaned") { - await loanStore.fetchLoans(); + await loanStore.fetchLoans(searchQuery.value); } }); async function handlePageChange(page) { + const params = { page, limit: 20 }; if (activeTab.value === "low_stock") { - await productStore.fetchProducts({ page, limit: 20, low_stock: true }); - } else { - await productStore.fetchProducts({ page, limit: 20 }); + params.low_stock = true; } + if (searchQuery.value) { + params.search = searchQuery.value; + } + await productStore.fetchProducts(params); } async function exportToExcel() { @@ -1056,7 +1202,6 @@ async function handleMarkAsPaid() { } // Loan Edit Logic -const loans = computed(() => loanStore.loans); const showEditLoanModal = ref(false); const showManageLoanModal = ref(false); const editingLoanDetail = ref(null); @@ -1477,7 +1622,107 @@ function formatDate(dateString) { .header-actions { display: flex; - gap: 1rem; + gap: 0.75rem; + align-items: center; +} + +/* Search Bar Styling */ +.search-input-wrapper { + position: relative; + display: flex; + align-items: center; +} + +.desktop-search { + flex: 1; + min-width: 250px; + max-width: 350px; +} + +.mobile-search { + display: none; + margin-bottom: var(--spacing-md); +} + +.search-icon { + position: absolute; + left: 10px; + width: 16px; + height: 16px; + color: var(--text-secondary); + pointer-events: none; +} + +.search-input { + width: 100%; + padding: 0.45rem 2.2rem 0.45rem 2.2rem; + border: var(--border-width) solid var(--border-color); + border-radius: var(--radius-md); + font-size: var(--font-size-sm); + background: var(--bg-white); + transition: all 0.2s ease; + height: 36px; +} + +.search-input:focus { + outline: none; + border-color: var(--primary-color); + box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1); +} + +.clear-search { + position: absolute; + right: 8px; + background: none; + border: none; + color: var(--text-secondary); + cursor: pointer; + padding: 4px; + font-size: 14px; + display: flex; + align-items: center; + justify-content: center; + transition: color 0.2s; +} + +.clear-search:hover { + color: var(--danger-bg); +} + +.add-btn, +.export-btn, +.upload-btn { + height: 36px; + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + white-space: nowrap; +} + +@media (max-width: 1024px) { + .btn-text { + display: none; + } + .desktop-search { + min-width: 200px; + } +} + +@media (max-width: 768px) { + .desktop-search { + display: none; + } + .mobile-search { + display: flex; + } + .section-header { + flex-direction: column; + align-items: stretch; + } + .header-actions { + justify-content: flex-end; + } } .add-btn { diff --git a/vite.config.js b/vite.config.js index 2fe08a4..772f44d 100644 --- a/vite.config.js +++ b/vite.config.js @@ -6,7 +6,7 @@ export default defineConfig({ server: { proxy: { '/api': { - target: 'http://localhost:8788', + target: 'http://localhost:8789', changeOrigin: true } } diff --git a/wrangler.toml b/wrangler.toml index 894b701..a759861 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -7,6 +7,7 @@ pages_build_output_dir = "dist" binding = "DB" database_name = "pos_database" database_id = "f76d66b5-3c71-4d46-9a1c-2d24c191bb8e" +migrations_dir = "drizzle/migrations" # Cloudflare R2 Bucket for product images [[r2_buckets]] From 194497a8c7811b638641a8cb10d516c0b1a0bcd4 Mon Sep 17 00:00:00 2001 From: Mfuon Leon Date: Tue, 10 Feb 2026 07:30:15 +0300 Subject: [PATCH 11/13] chore: bump version to 0.0.15 --- .version | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.version b/.version index 9789c4c..ceddfb2 100644 --- a/.version +++ b/.version @@ -1 +1 @@ -0.0.14 +0.0.15 From 8fa90846c754e18d944338ea43fa544cb442ed9d Mon Sep 17 00:00:00 2001 From: Mfuon Leon Date: Tue, 10 Feb 2026 13:30:31 +0300 Subject: [PATCH 12/13] mixed u[p permissions --- functions/_middleware.js | 4 +-- functions/utils/rbac.js | 59 ++++++++++++++++++++++++++++------------ 2 files changed, 44 insertions(+), 19 deletions(-) diff --git a/functions/_middleware.js b/functions/_middleware.js index 4dab9b6..028c341 100644 --- a/functions/_middleware.js +++ b/functions/_middleware.js @@ -65,7 +65,7 @@ export async function onRequest(context) { } // 2. AUTHENTICATION - if (!isPublicRoute(path)) { + if (!isPublicRoute(path, request.method)) { const token = getTokenFromRequest(request); if (!token) { @@ -79,7 +79,7 @@ export async function onRequest(context) { } // 3. RBAC - if (!hasPermission(session, path)) { + if (!hasPermission(session, path, request.method)) { return applyHeaders(createForbiddenResponse()); } diff --git a/functions/utils/rbac.js b/functions/utils/rbac.js index a07121d..620370e 100644 --- a/functions/utils/rbac.js +++ b/functions/utils/rbac.js @@ -15,13 +15,23 @@ const ROUTE_PERMISSIONS = { // Sales - accessible by cashiers and admins "/api/sales": ["admin", "cashier"], + // Admin-only routes with read access for cashiers + "/api/products": { + GET: ["admin", "cashier"], + POST: ["admin"], + PUT: ["admin"], + DELETE: ["admin"], + }, + "/api/settings": { + GET: ["admin", "cashier"], + PUT: ["admin"], + }, + // Admin-only routes - "/api/products": ["admin"], "/api/categories": ["admin"], "/api/suppliers": ["admin"], "/api/expenses": ["admin"], "/api/users": ["admin"], - "/api/settings": ["admin"], "/api/reports": ["admin"], "/api/purchase-orders": ["admin"], }; @@ -29,49 +39,64 @@ const ROUTE_PERMISSIONS = { /** * Check if a route is public (no auth required) * @param {string} path - Request path + * @param {string} method - Request method * @returns {boolean} */ -export function isPublicRoute(path) { - const permissions = getRoutePermissions(path); +export function isPublicRoute(path, method = "GET") { + const permissions = getRoutePermissions(path, method); return permissions.includes("*"); } /** * Get permissions for a route * @param {string} path - Request path + * @param {string} method - Request method * @returns {string[]} Array of allowed roles */ -export function getRoutePermissions(path) { +export function getRoutePermissions(path, method) { + let permissions = null; + // Check for exact match first if (ROUTE_PERMISSIONS[path]) { - return ROUTE_PERMISSIONS[path]; - } - - // Check for pattern match (remove dynamic segments like IDs) - for (const [routePattern, roles] of Object.entries(ROUTE_PERMISSIONS)) { - // Match route prefix (e.g., /api/sales matches /api/sales/123) - if (path.startsWith(routePattern)) { - return roles; + permissions = ROUTE_PERMISSIONS[path]; + } else { + // Check for pattern match (remove dynamic segments like IDs) + for (const [routePattern, roles] of Object.entries(ROUTE_PERMISSIONS)) { + // Match route prefix (e.g., /api/sales matches /api/sales/123) + if (path.startsWith(routePattern)) { + permissions = roles; + break; + } } } // Default: require admin for any undefined routes - return ["admin"]; + if (!permissions) { + return ["admin"]; + } + + // Handle method-specific permissions + if (!Array.isArray(permissions) && typeof permissions === "object") { + return permissions[method] || ["admin"]; + } + + return permissions; } /** * Check if a user has permission to access a route * @param {Object} session - User session with role * @param {string} path - Request path + * @param {string} method - HTTP method * @returns {boolean} */ -export function hasPermission(session, path) { +export function hasPermission(session, path, method) { if (!session || !session.role) { return false; } - const allowedRoles = getRoutePermissions(path); - return allowedRoles.includes(session.role); + const allowedRoles = getRoutePermissions(path, method); + return allowedRoles.includes(session.role) || allowedRoles.includes("*"); } /** From 1932f32b6c2e73058e266ff54e63b417df3e9cd0 Mon Sep 17 00:00:00 2001 From: Mfuon Leonard Date: Wed, 18 Mar 2026 12:50:24 +0300 Subject: [PATCH 13/13] Enable D1 Migrations in deploy workflow edited --- .github/workflows/deploy.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 59c3ce7..514fd3e 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -28,11 +28,11 @@ jobs: - name: Build project run: npm run build - # - name: Run D1 Migrations - # env: - # CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} - # CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} - # run: npx wrangler d1 migrations apply pos_database --remote + - name: Run D1 Migrations + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + run: npx wrangler d1 migrations apply pos_database --remote - name: Deploy to Cloudflare Pages uses: cloudflare/pages-action@v1