Compare commits
1 commit
main
...
offers_imp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d7a588a529 |
648 changed files with 36605 additions and 76697 deletions
|
|
@ -1,89 +0,0 @@
|
|||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Shell(npx tsc --noEmit --skipLibCheck src/trpc/apis/common-apis/common.ts 2 >& 1)",
|
||||
"Shell(npx tsc --noEmit 2 >& 1)",
|
||||
"Shell(grep:*)",
|
||||
"Shell(npx tsc --noEmit -p packages/shared/tsconfig.json 2 >& 1)",
|
||||
"Shell(cp:*)",
|
||||
"Shell(rm:*)",
|
||||
"Shell(ls:*)",
|
||||
"Shell(cd /Users/mohammedshafiuddin/WebDev/freshyo/apps/admin-ui && ls \"app/(drawer)\" 2>/dev/null; echo \"---\"; find app -name \"create.tsx\" -path \"*coupon*\" 2>/dev/null)",
|
||||
"Shell(cd /Users/mohammedshafiuddin/WebDev/freshyo/apps/user-ui && echo \"=== verify zero external refs for each dead file ===\" && for f in \"ProfileChecker\" \"UserAddressHeader\" \"AddressSelector\" \"OrderAgainIcon\" \"usePhonepeSdk\" \"useHideDrawerHeader\" \"useCurrentUserId\" \"useThemeColor\" \"IconSymbol\" \"TabBarBackground\" \"axios-user-ui\"; do refs=$(grep -rn \"$f\" --include=\"*.tsx\" --include=\"*.ts\" . 2>/dev/null | grep -v node_modules | grep -vE \"components/ProfileChecker|components/UserAddressHeader|components/AddressSelector|icons/OrderAgainIcon|hooks/usePhonepeSdk|hooks/useHideDrawerHeader|hooks/useCurrentUserId|hooks/useThemeColor|components/ui/IconSymbol|components/ui/TabBarBackground|services/axios-user-ui\" | wc -l | tr -d ' '); echo \"$f: $refs external refs\"; done)",
|
||||
"Shell(cd /Users/mohammedshafiuddin/WebDev/freshyo && echo \"=== usage count per export across apps (excluding packages/ui itself) ===\" && for name in RolesDropdown StorageService ROLE_NAMES ROLE_DISPLAY_NAMES ROLE_OPTIONS BUSINESS_ROLE_OPTIONS REFUND_STATUS MyButton MyTextButton MyTextInput BottomDialog LoadingDialog MyText MyTouchableOpacity ConfirmationDialog RawBottomDialog DatePicker BottomDropdown ImageViewerURI ImageCarousel ImageGallery ImageGalleryWithDelete ImageUploader ImageUploaderNeo ProfileImage Checkbox AppContainer SearchBar DataTable Quantifier MiniQuantifier TabViewWrapper MyFlatList useFocusCallback useManualRefresh useDrawerTitle useMarkDataFetchers useIsDevMode usePagination DateTimePickerMod RefreshProvider useRefresh MyStatusBar updateStatusBarColor useStatusBarStore; do count=$(grep -rn \"\\b$name\\b\" apps/user-ui apps/admin-ui apps/web-ui apps/fallback-ui --include=\"*.tsx\" --include=\"*.ts\" 2>/dev/null | grep -v node_modules | wc -l | tr -d ' '); echo \"$name: $count\"; done)",
|
||||
"Shell(cd /Users/mohammedshafiuddin/WebDev/freshyo && echo \"=== check usage inside packages/ui (internal use) ===\" && for name in RolesDropdown ROLE_DISPLAY_NAMES ROLE_OPTIONS BUSINESS_ROLE_OPTIONS MyTextButton ImageGallery ImageGalleryWithDelete DataTable TabViewWrapper useIsDevMode usePagination useRefresh useStatusBarStore; do count=$(grep -rn \"\\b$name\\b\" packages/ui --include=\"*.tsx\" --include=\"*.ts\" 2>/dev/null | grep -v node_modules | grep -v \"index.ts\" | wc -l | tr -d ' '); echo \"$name: $count internal\"; done)",
|
||||
"Shell(cd /Users/mohammedshafiuddin/WebDev/freshyo/apps/backend/dumps && echo \"=== lines 7890-7910 (product_skus start) ===\" && sed -n '7890,7910p' local_8_aug.sql && echo \"=== lines 8330-8345 (product_info start) ===\" && sed -n '8330,8345p' local_8_aug.sql && echo \"=== total lines ===\" && wc -l local_8_aug.sql && echo \"=== where does product_info block end? next CREATE/other marker ===\" && grep -nE \"CREATE TABLE|^INSERT INTO \\\"product_info\\\"|^INSERT INTO \\`product_info\\`|^INSERT INTO product_info\" local_8_aug.sql | sed -n '25,40p')",
|
||||
"Shell(cd /Users/mohammedshafiuddin/WebDev/freshyo/apps/backend/dumps && echo \"=== product_info block: from 8334 to next CREATE TABLE after it ===\" && awk 'NR>=8334 && /^CREATE TABLE/ {print NR\": \"$0; exit}' local_8_aug.sql && echo \"=== where do product_skus INSERTs start/end ===\" && grep -nE \"^INSERT INTO \\`?product_skus\\`?|^INSERT INTO \\\"product_skus\\\"\" local_8_aug.sql | head && echo \"=== where do sku_features/product_market_stats/product_info INSERTs start ===\" && grep -nE \"^INSERT INTO \\`?(product_skus|sku_features|product_market_stats|product_info)\\`?\" local_8_aug.sql | head && echo \"=== what comes right after product_info INSERTs (next table or marker) ===\" && awk 'NR>8334 && /^CREATE TABLE/ {print NR\": \"$0}' local_8_aug.sql | head)",
|
||||
"Shell(awk:*)",
|
||||
"Shell(echo:*)",
|
||||
"Shell(cd /Users/mohammedshafiuddin/WebDev/freshyo/apps/backend/dumps && python3 - <<'PY' # Move the product_info block (CREATE + INSERTs) above product_skus. with open('local_8_aug.sql', 'r') as f: lines = f.readlines() # 1-indexed boundaries pi_start = 8334 - 1 # CREATE TABLE product_info pi_end = 8478 # exclusive (up to line 8478 inclusive) -> slice [8333:8478] sku_start = 7900 - 1 # line where product_skus CREATE begins block = lines[pi_start:pi_end] # Remove the product_info block new_lines = lines[:pi_start] + lines[pi_end:] # Re-insert before product_skus (its index may have shifted by -len(block)) insert_at = sku_start new_lines = new_lines[:insert_at] + block + new_lines[insert_at:] with open('local_8_aug.sql', 'w') as f: f.writelines(new_lines) print(f\"Moved product_info block (lines {pi_start+1}-{pi_end}) to before line {sku_start+1}\") PY echo \"=== verify new order ===\" && grep -nE 'CREATE TABLE .(product_info|product_skus|sku_features|product_market_stats|cart_items).' local_8_aug.sql)",
|
||||
"Shell(cd /Users/mohammedshafiuddin/WebDev/freshyo/apps/backend/dumps && echo \"=== order: product_info BEFORE product_skus? ===\" && grep -nE '^CREATE TABLE' local_8_aug.sql | grep -E \"product_info|product_skus|sku_features|product_market_stats|cart_items\" && echo \"=== doc check: every REFERENCES has parent CREATE before it ===\" && grep -nE '^CREATE TABLE|REFERENCES' local_8_aug.sql | grep -E \"REFERENCES\" | grep -oE \"REFERENCES \\`?[a-z_]+\" | sort | uniq -c)",
|
||||
"Shell(sqlite3:*)",
|
||||
"Shell(cd /Users/mohammedshafiuddin/WebDev/freshyo/apps/backend/dumps && sqlite3 :memory: <<'EOF' PRAGMA foreign_keys=ON; BEGIN; .read local_8_aug.sql COMMIT; EOF echo \"exit: $?\")",
|
||||
"Shell(cd:*)",
|
||||
"Shell(mv:*)",
|
||||
"Shell(npm:*)",
|
||||
"Shell(curl -s http://localhost:4174/home 2 >& 1)",
|
||||
"Shell(curl -s https://assets.freshyo.in/api-cache/v-690/products.json 2 >& 1)",
|
||||
"Shell(python3 -c import sys,json; d=json.load(sys.stdin); print('products:', len(d.get('products',[]))); print('tags:', [t['tagName'] for t in d.get('tags',[])][:10]) 2 >& 1)",
|
||||
"Shell(lsof:*)",
|
||||
"Shell(curl -s http://localhost:8787/api/trpc/common.essentialConsts 2 >& 1)",
|
||||
"Shell(curl -s https://assets2.freshyo.in/api-cache-dev/v-684/products.json 2 >& 1)",
|
||||
"Shell(python3 -c import sys,json; d=json.load(sys.stdin); print('products:', len(d.get('products',[]))); print('tags:', [t['tagName'] for t in d.get('tags',[])][:12]) 2 >& 1)",
|
||||
"Shell(curl -s http://localhost:4174/src/styles.css 2 > /dev/null)",
|
||||
"Shell(printf:*)",
|
||||
"Shell(cd /Users/mohammedshafiuddin/WebDev/freshyo/apps/backend && python3 - <<'EOF' import re with open('dumps/local_8_aug.sql') as f: content = f.read() # Find all CREATE TABLE positions and all REFERENCES target tables create_positions = {} # table name -> line for m in re.finditer(r'CREATE TABLE(?: IF NOT EXISTS)? \"?([A-Za-z_]+)\"? \\(', content): create_positions[m.group(1)] = content.count('\\n', 0, m.start()) + 1 # Verify each REFERENCES target is defined before the referencing CREATE errors = [] for m in re.finditer(r'CREATE TABLE(?: IF NOT EXISTS)? \"?([A-Za-z_]+)\"? \\(([^;]*?)\\)', content, re.S): table = m.group(1) table_line = content.count('\\n', 0, m.start()) + 1 body = m.group(2) for ref in re.findall(r'REFERENCES `?([A-Za-z_]+)`?\\(', body): if ref in create_positions and create_positions[ref] > table_line: errors.append(f\"{table} (line {table_line}) references {ref} (defined line {create_positions[ref]})\") if errors: print(\"FAIL - out of order:\") for e in errors: print(\" \", e) else: print(\"PASS - all REFERENCES resolve to tables defined earlier\") EOF)",
|
||||
"Shell(find packages/db_helper_sqlite/src packages/db_helper_postgres/src -name \"*.ts\" | xargs wc -l | tail -5 && echo \"=== apps imports of db_helper ===\" && grep -rn \"db_helper\" apps packages --include=\"*.ts\" --include=\"*.tsx\" --include=\"*.json\" -l | grep -v node_modules | grep -v \"db_helper_sqlite/\\|db_helper_postgres/\\|\\.turbo\\|dist\" | sort -u)",
|
||||
"Shell(cat apps/backend/wrangler.toml 2>/dev/null | head -30; echo \"=== package.json ===\"; cat apps/backend/package.json; echo \"=== how build/dev resolves ===\"; ls apps/backend/.wrangler/tmp/dev-*/worker.js 2>/dev/null | head -1 | xargs grep -o \"sqliteService\\|getAllSlotsWithProductsForCache\" 2>/dev/null | head -3; echo \"=== tsx alias config? ===\"; grep -rn \"tsx\\|alias\" apps/backend/package.json apps/backend/wrangler.toml 2>/dev/null | head; echo \"=== node_modules links ===\"; ls -la apps/backend/node_modules/ 2>/dev/null | grep -i \"db_helper\\|sqlite\\|postgres\" | head)",
|
||||
"Shell(cd /Users/mohammedshafiuddin/WebDev/freshyo && for pkg in packages/db_helper_sqlite packages/db_helper_postgres; do echo \"########## $pkg ##########\"; for f in $(find $pkg/src -name \"*.ts\" | sort); do # relative imports only grep -oE \"from '(\\.\\.?/[^']+)'\" $f | sed \"s/from '//;s/'//\" | while read imp; do resolved=$(cd $(dirname $f) && node -e \"const p=require('path');try{const r=p.resolve('$imp');console.log(r.startsWith(process.cwd())?p.relative(process.cwd(),r):'')}catch(e){}\" 2>/dev/null) if [ -n \"$resolved\" ] && [ -f \"$resolved.ts\" ] || [ -n \"$resolved\" ] && [ -f \"$resolved\" ]; then echo \"$(echo $f | sed 's|^packages/||') -> $resolved\"; fi done; done; done 2>/dev/null | sort -u | grep -v \"db_index\\|schema.ts ->\" | head -80)",
|
||||
"Shell(node -e const fs = require(\"fs\"), path = require(\"path\"); for (const pkg of [\"packages/db_helper_sqlite\", \"packages/db_helper_postgres\"]) { console.log(\"########## \" + pkg + \" ##########\"); const files = []; (function walk(d){ for (const e of fs.readdirSync(d)) { const p = path.join(d,e); if (fs.statSync(p).isDirectory()) walk(p); else if (e.endsWith(\".ts\")) files.push(p); } })(pkg); const edges = {}; for (const f of files) { const src = fs.readFileSync(f, \"utf8\"); const re = /from\\s+[\\x27\"](\\.\\.?\\/[^\\x27\"]+)[\\x27\"]/g; let m; while ((m = re.exec(src))) { const base = path.dirname(f); let target = path.resolve(base, m[1]); if (!fs.existsSync(target) && fs.existsSync(target + \".ts\")) target += \".ts\"; if (target.startsWith(process.cwd()) && fs.existsSync(target)) { const rel = path.relative(pkg, target); if (!rel.startsWith(\"..\")) { (edges[f] = edges[f] || []).push(rel); } } } } const allRel = files.map(f => path.relative(pkg, f)); const imported = new Set(Object.values(edges).flat()); const deadFiles = allRel.filter(r => r !== \"index.ts\" && !imported.has(r) && !r.includes(\"/db/seed\") && !r.includes(\"drizzle.config\")); // internal cross-file usage counts: who imports this rel path (excluding itself) for (const f of files) { const rel = path.relative(pkg, f); const importers = Object.entries(edges).filter(([src, tgts]) => tgts.includes(rel) && src !== f).map(([src]) => path.relative(pkg, src)); if (rel.endsWith(\".ts\") && importers.length) console.log(rel + \" <== imported by: \" + importers.join(\", \")); } console.log(\"--- files with NO internal importers (candidates for dead file): ---\"); console.log(deadFiles.join(\"\\n\")); } )",
|
||||
"Shell(node -e const fs = require(\"fs\"), path = require(\"path\"); for (const pkg of [\"packages/db_helper_sqlite\", \"packages/db_helper_postgres\"]) { console.log(\"########## \" + pkg + \" ##########\"); const files = []; (function walk(d){ if (d.includes(\"node_modules\")) return; for (const e of fs.readdirSync(d)) { const p = path.join(d,e); if (fs.statSync(p).isDirectory()) walk(p); else if (e.endsWith(\".ts\")) files.push(p); } })(pkg); const imported = new Set(); const importers = {}; // target -> [sources] for (const f of files) { const src = fs.readFileSync(f, \"utf8\"); const re = /from\\s+[\\x27\"](\\.\\.?\\/[^\\x27\"]+)[\\x27\"]/g; let m; while ((m = re.exec(src))) { let target = path.resolve(path.dirname(f), m[1]); if (!fs.existsSync(target) && fs.existsSync(target + \".ts\")) target += \".ts\"; if (target.startsWith(pkg) && fs.existsSync(target)) { const rel = path.relative(pkg, target); imported.add(rel); (importers[rel] = importers[rel] || []).push(path.relative(pkg, f)); } } } const excluded = new Set([\"index.ts\",\"drizzle.config.ts\",\"src/db/seed.ts\",\"src/db/db_index.ts\",\"src/db/schema.ts\"]); const noImporter = files.map(f=>path.relative(pkg,f)).filter(r => !imported.has(r) && !excluded.has(r) && !r.includes(\"drizzle.config\")); console.log(\"Files never imported internally (incl. index.ts not importing them? checked separately):\"); for (const r of noImporter) console.log(\" \" + r); // now check which of those are imported by index.ts explicitly const idx = fs.readFileSync(path.join(pkg,\"index.ts\"),\"utf8\"); for (const r of noImporter) { const basename = path.basename(r, \".ts\"); if (!idx.includes(basename)) console.log(\" [also NOT in index.ts] \" + r); } } )",
|
||||
"Shell(node -e const fs = require(\"fs\"), path = require(\"path\"); const pkg = \"packages/db_helper_sqlite\"; const idx = fs.readFileSync(path.join(pkg,\"index.ts\"),\"utf8\"); const re = /from\\s+[\\x27\"](\\.\\.?\\/[^\\x27\"]+)[\\x27\"]/g; let m; while ((m = re.exec(idx))) { const raw = m[1]; let target = path.resolve(pkg, raw); if (!fs.existsSync(target) && fs.existsSync(target+\".ts\")) target += \".ts\"; console.log(JSON.stringify(raw), \"exists:\", fs.existsSync(target), \"rel:\", path.relative(pkg, target), \"startsWithPkg:\", target.startsWith(pkg), \"target:\", target); } )",
|
||||
"Shell(node -e const fs = require(\"fs\"), path = require(\"path\"); const cwd = process.cwd(); for (const pkg of [\"packages/db_helper_sqlite\", \"packages/db_helper_postgres\"]) { console.log(\"########## \" + pkg + \" ##########\"); const pkgAbs = path.resolve(pkg); const files = []; (function walk(d){ if (d.includes(\"node_modules\")) return; for (const e of fs.readdirSync(d)) { const p = path.join(d,e); if (fs.statSync(p).isDirectory()) walk(p); else if (e.endsWith(\".ts\")) files.push(p); } })(pkgAbs); const imported = new Set(); for (const f of files) { const src = fs.readFileSync(f, \"utf8\"); const re = /from\\s+[\\x27\"](\\.\\.?\\/[^\\x27\"]+)[\\x27\"]/g; let m; while ((m = re.exec(src))) { let target = path.resolve(path.dirname(f), m[1]); if (!fs.existsSync(target) && fs.existsSync(target + \".ts\")) target += \".ts\"; if (target.startsWith(pkgAbs) && fs.existsSync(target) && target !== f) { imported.add(path.relative(pkgAbs, target)); } } } const allRel = files.map(f => path.relative(pkgAbs, f)).sort(); console.log(\"Total ts files:\", allRel.length); for (const r of allRel) { if (r === \"index.ts\" || r.includes(\"drizzle.config\")) continue; if (!imported.has(r)) console.log(\" NEVER-IMPORTED FILE: \" + r); } } )",
|
||||
"Shell(for:*)",
|
||||
"Shell(do:*)",
|
||||
"Shell(done:*)",
|
||||
"Shell(cd /Users/mohammedshafiuddin/WebDev/freshyo && echo \"=== product.ts trpc import line & usage count ===\" && sed -n '1,40p' apps/backend/src/trpc/apis/admin-apis/apis/product.ts && echo \"=== count occurrences of each beyond import ===\" && for fn in checkUnitExists getProductImagesById replaceProductTags; do echo \"$fn: total=$(grep -c \"$fn\" apps/backend/src/trpc/apis/admin-apis/apis/product.ts)\"; done)",
|
||||
"Shell(node -e const fs = require(\"fs\"); const parse = (p) => { const src = fs.readFileSync(p,\"utf8\"); const names = new Set(); // export { a, b as c, ... } from / re-export lists const re = /\\bexport\\s*\\{([^}]*)\\}/g; let m; while ((m = re.exec(src))) { const body = m[1]; for (let line of body.split(\",\")) { line = line.trim().replace(/\\/\\/.*$/,\"\").trim(); if (!line) continue; const asMatch = line.match(/^(.+?)\\s+as\\s+(.+)$/); if (asMatch) names.add(asMatch[2].trim()); else if (line.match(/^[A-Za-z_$][\\w$]*$/)) names.add(line); } } return names; }; const sqlite = parse(\"packages/db_helper_sqlite/index.ts\"); const pg = parse(\"packages/db_helper_postgres/index.ts\"); const onlySqlite = [...sqlite].filter(x=>!pg.has(x)).sort(); const onlyPg = [...pg].filter(x=>!sqlite.has(x)).sort(); console.log(\"ONLY IN SQLITE INDEX:\", onlySqlite.join(\", \")); console.log(\"ONLY IN POSTGRES INDEX:\", onlyPg.join(\", \")); )",
|
||||
"Shell(cd /Users/mohammedshafiuddin/WebDev/freshyo/packages/db_helper_postgres npx tsc --noEmit > /tmp/pg_tsc.log 2>&1 echo \"tsc exit: $?\" echo \"=== error count ===\"; grep -c \"error TS\" /tmp/pg_tsc.log echo \"=== errors touching shared types / banner / coupon / store / const / complaint / staff-user ===\" grep -E \"admin-apis/(banner|coupon|store|const|complaint|staff-user|slots|vendor-snippets|order)\\.ts\" /tmp/pg_tsc.log | head -20 echo \"=== total error files ===\" grep \"error TS\" /tmp/pg_tsc.log | grep -oE \"^[^(]+\" | sort -u)",
|
||||
"Shell(cd /Users/mohammedshafiuddin/WebDev/freshyo/apps/web-ui && npx tsc --noEmit > /tmp/me_tsc.txt 2>&1; echo \"exit=$?\"; wc -l /tmp/me_tsc.txt; head -20 /tmp/me_tsc.txt)",
|
||||
"Shell(sleep:*)",
|
||||
"Shell(for p in /me /me/orders /me/addresses /me/coupons /me/complaints /me/edit-profile /me/terms; do code=$(curl -s -o /dev/null -w \"%{http_code}\" \"http://localhost:4175$p\"); echo \"$p -> $code\"; done)",
|
||||
"Shell(curl -s http://localhost:4175/me)",
|
||||
"Shell(pkill -f \"vite dev\" 2>/dev/null; pkill -f \"web-ui\" 2>/dev/null; echo \"stopped\")",
|
||||
"Shell(npx playwright install chromium 2 >& 1)",
|
||||
"Shell(npx playwright --version)",
|
||||
"Shell(npx playwright test --list 2 >& 1)",
|
||||
"Shell(curl -s http://localhost:4175/login)",
|
||||
"Shell(curl -s -o /dev/null -w %{http_code}\\n http://localhost:4175/login)",
|
||||
"Shell(grep -aiE \"error|fail\" /tmp/adminweb_dev.log | head -10 || echo \"no errors\"; pkill -f \"vite dev\" 2>/dev/null; pkill -f \"admin-web\" 2>/dev/null; echo \"stopped\")",
|
||||
"Shell(git:*)",
|
||||
"Shell(bun:*)",
|
||||
"Shell(bunx playwright test --list -c ../playwright.config.ts 2 >& 1)",
|
||||
"Shell([:*)",
|
||||
"Shell(sed:*)",
|
||||
"Shell(curl -s -o /dev/null -w %{http_code}\\n --max-time 4 http://localhost:4175/login 2 > /dev/null)",
|
||||
"Shell(cd /Users/mohammedshafiuddin/WebDev/freshyo && bun run test:e2e > /tmp/e2e_run.log 2>&1; echo \"exit=$?\")",
|
||||
"Shell(ps:*)",
|
||||
"Shell(npx tsc --noEmit -p tests 2 >& 1)",
|
||||
"Shell(cd /Users/mohammedshafiuddin/WebDev/freshyo && bun run test:e2e > /tmp/e2e_run2.log 2>&1; echo \"exit=$?\" >> /tmp/e2e_run2.log)",
|
||||
"Shell(cd /Users/mohammedshafiuddin/WebDev/freshyo && sed -n '/```yaml/,/```/p' \"test-results/auth.setup.ts-authenticate-staff-user-setup/error-context.md\")",
|
||||
"Shell(cd /Users/mohammedshafiuddin/WebDev/freshyo && bun run test:e2e > /tmp/e2e_run3.log 2>&1; echo \"exit=$?\" >> /tmp/e2e_run3.log)",
|
||||
"Shell(cd /Users/mohammedshafiuddin/WebDev/freshyo && npx tsc --noEmit -p tests 2>&1 | tail -5; echo \"tsc_exit=$?\"; bun run test:e2e > /tmp/e2e_run4.log 2>&1; echo \"exit=$?\" >> /tmp/e2e_run4.log)",
|
||||
"Shell(cd /Users/mohammedshafiuddin/WebDev/freshyo && sed -n '/```yaml/,/```/p' \"test-results/specs-product-lifecycle-pr-e8d1e-e-→-update-→-slot-→-suspend-chromium/error-context.md\" | head -60)",
|
||||
"Shell(cd /Users/mohammedshafiuddin/WebDev/freshyo && npx tsc --noEmit -p tests 2>&1 | tail -5; echo \"tsc_exit=$?\"; bun run test:e2e > /tmp/e2e_run5.log 2>&1; echo \"exit=$?\" >> /tmp/e2e_run5.log)",
|
||||
"Shell(cd /Users/mohammedshafiuddin/WebDev/freshyo && sed -n '/```yaml/,/```/p' \"test-results/specs-product-lifecycle-pr-e8d1e-e-→-update-→-slot-→-suspend-chromium/error-context.md\" | head -50)",
|
||||
"Shell(cd /Users/mohammedshafiuddin/WebDev/freshyo && bun run test:e2e > /tmp/e2e_run6.log 2>&1; echo \"exit=$?\" >> /tmp/e2e_run6.log)",
|
||||
"Shell(cd /Users/mohammedshafiuddin/WebDev/freshyo && npx tsc --noEmit -p tests 2>&1 | tail -5; echo \"tsc_exit=$?\"; bun run test:e2e > /tmp/e2e_run7.log 2>&1; echo \"exit=$?\" >> /tmp/e2e_run7.log)",
|
||||
"Shell(cd /Users/mohammedshafiuddin/WebDev/freshyo && sed -n '/```yaml/,/```/p' \"test-results/specs-product-lifecycle-pr-e8d1e-e-→-update-→-slot-→-suspend-chromium/error-context.md\" | head -45)",
|
||||
"Shell(cd /Users/mohammedshafiuddin/WebDev/freshyo && grep -c \"add-slot-fab\" \"test-results/specs-product-lifecycle-pr-e8d1e-e-→-update-→-slot-→-suspend-chromium/error-context.md\"; grep -ao \"add-slot-fab\" \"test-results/specs-product-lifecycle-pr-e8d1e-e-→-update-→-slot-→-suspend-chromium/error-context.md\" | head; echo \"--- tail of snapshot:\"; sed -n '/```yaml/,/```/p' \"test-results/specs-product-lifecycle-pr-e8d1e-e-→-update-→-slot-→-suspend-chromium/error-context.md\" | tail -25)",
|
||||
"Shell(cd /Users/mohammedshafiuddin/WebDev/freshyo && npx tsc --noEmit -p tests 2>&1 | tail -5; echo \"tsc_exit=$?\"; bun run test:e2e > /tmp/e2e_run8.log 2>&1; echo \"exit=$?\" >> /tmp/e2e_run8.log)",
|
||||
"Shell(cd /Users/mohammedshafiuddin/WebDev/freshyo && npx tsc --noEmit -p tests 2>&1 | tail -5; echo \"tsc_exit=$?\"; bun run test:e2e > /tmp/e2e_run9.log 2>&1; echo \"exit=$?\" >> /tmp/e2e_run9.log)",
|
||||
"Shell(DEBUG=pw:browser npx playwright test tests/specs/smoke.spec.ts 2 >& 1)",
|
||||
"Shell(cd /Users/mohammedshafiuddin/WebDev/freshyo && git diff product_to_sku_c1 -- apps/user-ui/hooks/cart-query-hooks.tsx apps/user-ui/hooks/useUploadToObjectStore.ts apps/user-ui/eas.json apps/user-ui/'(drawer)' 2>/dev/null; git diff product_to_sku_c1 -- \"apps/user-ui/app/(drawer)/(tabs)/me/addresses/index.tsx\" \"apps/user-ui/app/(drawer)/(tabs)/me/my-orders/index.tsx\" | head -160)",
|
||||
"Shell(cd /Users/mohammedshafiuddin/WebDev/freshyo/packages/db_helper_sqlite && npx tsc --noEmit 2>&1 | tail -8; echo \"sqlite_exit=$?\"; echo \"=== user-ui ===\"; cd /Users/mohammedshafiuddin/WebDev/freshyo/apps/user-ui && npx tsc --noEmit 2>&1 | tee /tmp/uui_tsc.txt | tail -5; echo \"userui_errors=$(grep -cE 'error TS' /tmp/uui_tsc.txt)\")"
|
||||
],
|
||||
"deny": [],
|
||||
"defaultMode": "default"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
# Taste
|
||||
See [taste/taste.md](taste/taste.md)
|
||||
|
|
@ -1,144 +0,0 @@
|
|||
# Taste
|
||||
- Defines tunable/magic numbers as named module-level constants in the SAME file where they are used (e.g., `const TOKENS_PER_QUEUE_MESSAGE = 5` next to the code it configures, with a short explanatory comment) instead of inline literals or a shared/global config — explicitly stated: "Define 5 as a constant in the same file." Confidence: 0.7
|
||||
- Prefers that the agent not run typechecks; instead, read the project's AGENTS.md for instructions on what to do/not do before finalizing work. Confidence: 0.9
|
||||
- Judges completion by seeing the feature live in the running app ("I still don't see it on the /home route page"), so for data-driven features the agent must verify the app's actual runtime data source (API base URL, cache/backend) has the required data — not just that code builds and typechecks. Confirmed when the user signaled success only after the fix (pointing web-ui at the local backend with the tags data) made the section visible in the running app, and reconfirmed with the admin-ui product selector showing no products because it points at the production worker. Confidence: 0.9
|
||||
- Prefers detailed analysis and explicit error/mistake checking before executing destructive operations, rather than just performing the action — e.g., before a git merge, or before deleting a row directly from the DB (asked "Can I simply delete the row from db. Will it cause any problems" about an unclaimed reserved coupon, expecting the agent to trace all referencing tables/FKs — couponUsages, couponApplicableUsers/Products — before answering). Confidence: 0.7
|
||||
- Do not run `git stash` or other destructive git operations that would discard or modify working-tree changes without explicit permission. Confidence: 0.9
|
||||
- When removing unused code (e.g., an unused tRPC procedure), prefers a complete cleanup: also remove internal helper methods used only by it, associated types, re-exports, orphaned hook/API files and their shared types, and even commented-out imports, leaving zero remaining references. Confidence: 0.9
|
||||
- Before removing or deciding on code, likes to first verify where it is actually used across the codebase (asks questions like "where is X used"), rather than removing based on assumption. Confidence: 0.4
|
||||
- Prefers light/pastel colors for randomized or accent UI elements (e.g., tabs, chips). Confidence: 0.7
|
||||
- Prefers minimal, flat tab/navigation UI: active state shown with a thick underline, no pill/chip backgrounds or rounded fills; communicates desired visual styles by sharing reference images/screenshots. Confidence: 0.8
|
||||
- Wants the agent to come up with a plan first before implementing code changes. Confidence: 0.9
|
||||
- Appreciates being asked clarifying design questions with concrete options during planning. Confidence: 0.7
|
||||
- Prefers embedding related data into existing cache files over creating or calling separate API endpoints. Confidence: 0.9
|
||||
- Prefers using icons from Hicons (rather than custom/hand-authored SVGs or other icon libraries). Confidence: 0.9
|
||||
- When a feature or page is repurposed, wants file names, route names, and code references renamed to match the new purpose (not just UI content/icons). Confidence: 0.8
|
||||
- When replicating a UI pattern (e.g., home page tabs) onto a different page/context, adapt the copied colors to that page's own brand accent (e.g., the flash page's tabs use the flash pink `text-flash-600`/`bg-flash-500` instead of the home page's brand blue) while keeping the layout and styling identical. Confidence: 0.6
|
||||
- Wants analysis findings and plans persisted in a markdown file before any implementation begins, with explicit confirmation that implementation is paused until instructed. Confidence: 0.8
|
||||
- When changing cache or storage keys, wants both the read and write paths verified to use the same single source of truth (e.g., a shared `CACHE_STORAGE_KEYS` constant). Confidence: 0.8
|
||||
- In React Native with react-native-paper's `Text` component (wrapped as `MyText`), prefers avoiding mixed string/expression children; use template literals to produce a single string child to prevent "Text strings must be rendered within a <Text> component" warnings. Confidence: 0.8
|
||||
- When requesting test plans, expects exhaustive coverage across all relevant apps/screens/routes, including every functionality and every edge case. Confidence: 0.9
|
||||
- Verifies that changed code introduces no NEW type errors beyond a project's pre-existing baseline (e.g., by temporarily stashing changes to compare typecheck output), and fixes even pre-existing type errors in files he heavily edits. Confidence: 0.8
|
||||
- Prefers test case documentation to be structured with preconditions, steps, explicit "things to test" checklist, and expected results. Confidence: 0.7
|
||||
- Prefers test plans written for a non-technical audience, using plain-language, click-by-click instructions ("tap this", "type that", "check there") rather than technical terms or API names. Confidence: 0.9
|
||||
- When updating or creating a document, prefers the agent first compare it against existing source documents, identify missing items or gaps, and add them in the same established format. Confidence: 0.9
|
||||
- Dislikes nested headers in mobile/drawer navigation; prefers a single, shared header (e.g., the drawer header) and relies on device back buttons or gestures for returning to previous screens. Confidence: 0.9
|
||||
- After reviewing a presented plan, prefers brief, action-oriented approval (e.g., "nice. go ahead", "go ahead and implement") before implementation proceeds, expecting the agent to autonomously execute the already-presented plan. Confidence: 0.9
|
||||
- Wants feature parity maintained between the web-ui and user-ui apps (port logic/data patterns, rebuild UI per platform) — e.g., when he asks for a link in the cart page "at the coupons section" of @apps/user-ui/components/cart-page.tsx, he means mirror user-ui's coupons section (card, header, icon) in web-ui and add the requested link there; and conversely asks for features added in one app to be added to the sibling app "too" (e.g., "add the coupon redirection on the user-ui cart-page too"), expecting the agent to locate the equivalent component/route and replicate it. Confidence: 0.98
|
||||
- Wants the web-ui home page section/component order to match the reference user-ui home page exactly (e.g., "on the home page have the order same as that of @apps/user-ui/.../home/index.tsx"), including when deciding placement of sections like Explore, Stores, Slots, and All Products. Confidence: 0.7
|
||||
- Wants brand/logo spots to use the single real brand logo asset (shared across apps, e.g., the Freshyo splash logo) displayed as an image, not a generic/decorative icon (e.g., a "meat piece" Meat/Beef icon) — web-ui should be on par with user-ui and show the same logo; applies to favicon, PWA icons, and every in-UI branding spot (sidebar, topbar, login, home banner). Confidence: 0.85
|
||||
- Prefers dead-code audits to be documented in a markdown file. Confidence: 0.9
|
||||
- Prefers detailed technical documentation of system architecture, data models, flows, and integrations in markdown format. Confidence: 0.8
|
||||
- Wants edge cases explicitly enumerated when documenting or analyzing existing code/systems. Confidence: 0.8
|
||||
- Values end-to-end analysis of features (architecture, data model, flows, integrations, and edge cases) when asked to explain how something works. Confidence: 0.7
|
||||
- When creating a clone/mirror of an app on a different platform (e.g., a web version of a React Native app), expects EXACT replication — functionality and looks must match the reference app with zero changes ("not even a slight change is acceptable"). The tech stack is inherited from the existing clone, not the original. The exactness requirement applies per section and down to every sub-page: naming a section of the RN app (e.g., "the 'me' section and all its sub pages") means the entire subtree must be an exact replica of the mobile screens, and he will flag remaining gaps ("I see a lot of gaps") if any sub-screen diverges — so the agent should audit every sub-page, not just the section hub, against the RN source of truth. Confidence: 0.95
|
||||
- Prefers unifying parallel data structures into a single instance rather than maintaining separate ones (e.g., one cart for flash and regular items instead of separate flash/regular carts). Confidence: 0.9
|
||||
- Prefers using sentinel values in existing fields (e.g., slotId = 0) to distinguish special-case items rather than creating separate fields or structures. Confidence: 0.9
|
||||
- Prefers handling special-case logic locally in the relevant component/file rather than globally or via parallel flows. Confidence: 0.85
|
||||
- Prefers lazy/virtualized list rendering (e.g., FlatList) for long lists, rather than rendering all items at once. Confidence: 0.8
|
||||
- Wants true responsive web design for desktop, not just color/theme changes or mobile layouts stretched to desktop. Confidence: 0.95
|
||||
- Specifies layout proportions in relative terms rather than fixed pixel sizes — e.g., the left sidebar/menu should be 20% of the viewport width at most ("the left menu should be 20% of width at max"), implemented as a viewport-relative track (20vw) rather than a hard-coded px column. Confidence: 0.7
|
||||
- On the web-ui, wants the topbar (logo/search/cart) hidden on mobile screens and only rendered from the tablet breakpoint (`md`, 768px) upward, since mobile gets its own bottom navigation; when hiding it, the surrounding grid layout must be cleaned up so no empty reserved space remains. Confidence: 0.8
|
||||
- The search bar must be independent of the topbar and always visible on every screen size; decoupling it from the topbar's tablet-only visibility — hiding the topbar must not hide search ("the search bar... should be independent of the top bar. I want it visible always"). When a page already has its own search UI (e.g., the search route's sticky header), the global always-visible search row should be suppressed there to avoid stacked duplicate bars. Confidence: 0.8
|
||||
- Wants the search bar (and other persistent page-level UI like the topbar) to be sticky, pinning to the top of the viewport while the page scrolls ("I see it's not sticky. I want it sticky"); to make sticky actually work, the header group should live in a full-height flex column rather than a bounded grid row that scrolls away. Confidence: 0.9
|
||||
- Does not want color/theme changes during redesigns — keep colors and theme consistent with the existing apps (e.g., @apps/user-ui): reuse the shared theme tokens (Untitled UI blue brand scale, flash pink accent, gray neutrals) rather than introducing new brand palettes; approval of a redesign's layout/composition does not imply approval of color or theme changes. Confidence: 0.95
|
||||
roducing new brand palettes; approval of a redesign's layout/composition does not imply approval of color or theme changes. Confidence: 0.95
|
||||
er-ui/app/(drawer)/(tabs)/home/index.tsx, I want similar here") and expects the agent to mirror that pattern exactly, including details like item counts and container styling. Confidence: 0.95
|
||||
- Combo-only SKUs (`isComboOnly` on `product_skus`) must be hidden from all user-facing product surfaces — home page, offers page, and any other list where they could appear ("Don't display them. We're hiding them from home page too"). Confidence: 0.75
|
||||
- Expects a filter/business rule already applied on one page to be applied consistently on every other page showing the same data, not just the page he happened to notice ("we need to hide them here too"). Confidence: 0.85
|
||||
- Debugs by adding temporary console.log debug prints to inspect runtime data (e.g., `console.log({skusData})` to check a query result) and reports the observed values to the agent as ground truth (e.g., "the database is same. I've checked"). Confidence: 0.75
|
||||
- When a bug reproduces only on a deployed API (e.g., devapi) but not against the local backend, expects the agent to investigate the environment difference — which API base URL the app actually points to, wrangler/D1 bindings, and the deployed bundle version — rather than assuming identical code paths. Confidence: 0.65
|
||||
- Keeps operational runbooks / command documentation (e.g., `apps/backend/wrangler-commands.md` documenting the D1 export table-ordering trap) in repo markdown files and expects the agent to read them and apply the documented procedure when performing the related operation (e.g., fixing dump table order), treating the doc as the source of truth for the fix. Confidence: 0.65
|
||||
- Prefers monetary amounts (totals, discounts, savings) displayed as whole numbers only — explicitly truncates decimals (e.g., `Math.trunc()`) rather than rounding, keeping internal calculations at full precision and truncating only at the render site ("Just truncate and show only the whole number"). Confidence: 0.7
|
||||
- When displaying savings/benefit lines, hides the row entirely unless the amount exceeds a meaningful threshold (e.g., "show only if the savings is greater than rs 1") so the truncated whole-number display never shows a negligible ₹0/₹1 saving. Confidence: 0.55
|
||||
- Prefers out-of-stock items pushed to the end of product list sections (e.g., home page tab sections) while preserving the existing/admin-curated order — a stable partition (in-stock first, out-of-stock last, relative order kept within each group), reusing the same out-of-stock detection rule used elsewhere on the page ("preserve the order but push the out of stock items to last"). Confidence: 0.7
|
||||
- Wants product grid sections (e.g., Explore Products, All Available Products on the home page) to render at least 3 columns even on the smallest screens, scaling up on larger breakpoints — prefers the minimum column count on mobile to not be reduced to 2. Confidence: 0.7
|
||||
- Tracks previously discussed/planned fixes and follows up to verify they were actually applied (e.g., "did we take care of the math.trunc"); expects the agent to check the current state of the code and apply any outstanding planned changes rather than assume they were completed. Confidence: 0.8
|
||||
- Prevents contradictory/invalid states in admin forms by hiding the inapplicable control entirely — including its label/text row — rather than leaving it visible but disabled (e.g., the entire "Combo Only SKU" checkbox row is hidden when the SKU itself is a combo — has combo items — not just disabled). Confidence: 0.6
|
||||
- Wants savings figures kept consistent across all order surfaces: a savings concept shown in the cart/checkout bill (market price − offered price) must also be reflected in the order confirmation summary ("you saved n on this order"), aggregating all savings sources (coupon discount, market-price savings, free delivery) rather than only the coupon/discount component. Confidence: 0.85
|
||||
- Cares about the exact user-facing wording of labels and explicitly dictates renames (e.g., "Total Savings" → "Total Discount" on the cart page), expecting the change to be applied exactly as specified — including new labels dictated verbatim in the request (e.g., a link literally named "Add Coupons to account", a button that "should say Add Multiple", not paraphrased). Confidence: 0.8
|
||||
- When a button/feature is broken on one page but works on a sibling page (e.g., "Edit Notes" in the order-list three-dot menu vs. the order detail page), expects it fixed to behave like the working page — including calling the proper API with the correct parameter types (numeric order id matching the backend's zod schema, not a stringified id) rather than a no-op or wrong call. Confidence: 0.7
|
||||
- Reports UI bugs by pointing at the specific file and describing the intended action plus the visible symptom (e.g., "submit not happening", "I see [id]/index instead of product name in the top bar"), often with a hypothesis about the cause ("maybe we're not fulfilling conditions to enable"); expects the agent to trace the actual code path and find the root cause rather than just making the UI appear to work. Confidence: 0.65
|
||||
- Expects form validation and submit-enable conditions to match the current UI mode: fields hidden by a mode toggle (e.g., the single coupon-code field when "reserved coupon" is selected) must not be required, and discount-type toggles should not pre-fill placeholder values (like 0) that fail the `> 0` validation — hidden/placeholder state must never block submission. Confidence: 0.55
|
||||
- On mobile screens, prefers abbreviated button labels to conserve horizontal space — e.g., the add-to-cart button shows just the cart icon + "Add" on mobile and the full "Add to Cart" label from the `md` breakpoint up ("if the screen is mobile one just show cart symbol and Add instead of Add to cart"), implemented with responsive `md:hidden` / `hidden md:inline` spans. Confidence: 0.8
|
||||
- Expects the UI to reflect state changes immediately after an action, without a manual page refresh — e.g., items added to the cart must show up in the cart/badge right away; reports staleness bugs as "added items are not visible unless page is refreshed. They are added but not shown in cart," distinguishing that data was persisted (localStorage) but the UI didn't update. Confidence: 0.7
|
||||
- Wants the same UI component variant used consistently across every section/page in the app: when the user designates one section as having the "correct" version of a component, that exact section is the reference and its variant must be replicated everywhere else. Confirmed by an explicit correction: the Explore tabs section's mini floating cart icon (`miniView={true}`) was the correct one — "what was first present in tabs section, should've been replicated elsewhere" — i.e., replicated to All Products, search results, offers, and store detail, not the other way around. He expects the agent to follow the stated direction precisely and will correct a swapped interpretation. Confidence: 0.9
|
||||
- When the user asks for "similar tabs" on a page (e.g., the home page's category tabs on the 1-hr flash page), they mean the tabs model should REPLACE the page's existing filter/navigation mechanism — e.g., the store selector sidebar — entirely, not be layered on top of it ("I meant, replace the store selector with tabs model"). The page should use a single, consistent navigation/filter model like the home page, not two stacked filter controls; expects the agent to remove the superseded component and its now-unused imports/state. Confidence: 0.85
|
||||
- Dislikes transparent backgrounds on UI controls that overlay content — e.g., the quantity stepper floating over product images must have a solid background (white with a subtle shadow) so it reads clearly over any image, not a transparent one ("the background of the quantifier is transparent. Don't do that."). Confidence: 0.7
|
||||
- Modal dialogs (e.g., the add-to-cart dialog) must always layer above persistent fixed UI — the floating cart bar and the bottom navigation — never behind them; states the rule explicitly: the bar "shouldn't have more z index than the dialog," on both mobile and desktop. On mobile, interactive dialog content (the Add/Update button) must also clear the bottom nav (higher z-index than the nav's z-50 plus mobile-only bottom padding) while desktop behavior stays unchanged ("Move it a little top in case of mobile view"). Confidence: 0.75
|
||||
- In any cart surface, decrementing a quantity stepper down to 0 must remove the item from the cart entirely (make it disappear), never leave it persisted at quantity 0 — expected both on the cart page and in the cart slide-over opened from the floating cart bar, and the user will explicitly correct which surface he meant if the fix targets the wrong one ("I'm unable to click - to make it 0 and disappear" / "I clicked the cart bar to open cart view. There I'm not able to make '0' and remove"). Confidence: 0.7
|
||||
- In Tailwind v4 apps, arbitrary-value classes (e.g., `z-[60]`) authored in external/shared packages (like `packages/web-components`) can be silently dropped by the content scanner even when sibling classes from the same file compile — so for critical layering/positioning, prefer built-in scale values or an inline `style={{ zIndex: ... }}` and verify the compiled output (CSS/JS bundle) actually contains it rather than trusting the class source. Confidence: 0.7
|
||||
- In modal dialogs that contain a list plus a primary action (e.g., the add-to-cart dialog with delivery slots and an Add to Cart button), only the list region should scroll — the header and footer (quantity + action buttons) stay pinned and always visible, so reaching the primary action never requires scrolling ("only the slots should be scrollable... I need to scroll down to see the add to cart button"); implemented as a flex column with a fixed header, a `flex-1 min-h-0 overflow-y-auto` scrollable middle, and a `shrink-0` footer. Confidence: 0.75
|
||||
- On narrow/small screens, prefers text to scale DOWN to avoid crowding — prices/amounts and general card text alike: e.g., cart item prices `text-sm` on mobile bumping to `text-base` at the `sm` breakpoint, product card prices `text-base` → `text-lg`, and tightened vertical gaps near the quantity row ("if the width is small, reduce the price size and also the gap below the quantity"). Explicitly generalized to all card text (name, price, unit/quantity) with the direct request "when the screen width is small, reduce the font size too" — implemented as smaller arbitrary sizes (e.g., `text-[13px]`, `text-[15px]`, `text-[11px]`) below the `sm` breakpoint returning to normal sizes at `sm` and up. Explicitly requested and then repeatedly reinforced ("I still see large blue text in price"). Confidence: 0.8
|
||||
- When he reports a visual issue persisting ("I still see the same"), he is referring to the same visual pattern (e.g., "large blue text in price" = any `text-brand-600` price across cart pages, product cards) wherever it appears — expects the agent to grep the whole codebase for every occurrence of the matching styling and fix them all, not just the one surface initially identified. Confidence: 0.7
|
||||
- When a spacing/visual complaint persists ("I still see the gap below the quantity"), the same issue may live in a different rendering mode of the component than the one already fixed — e.g., product cards in `miniView` where the quantity control overlays the image and the gap comes from the unit-line bottom margin plus the card's bottom padding, not the cart slide-over that was edited first. Expects the agent to re-inspect the actual rendered layout of each variant/mode and tighten spacing everywhere the pattern appears. Confidence: 0.6
|
||||
- Prefers vertical tab/sidebar lists for store/category selection on ALL screen sizes — a vertical sidebar layout must not be swapped to a horizontal scrolling chip strip on mobile ("on the flash screen, the store tabs are horizontal, they should have been vertical"); instead keep the vertical list, stacked full-width above the content on small screens and as a sticky rail on desktop. Confidence: 0.7
|
||||
- When adopting the tabs model to replace a store filter, the tabs must be driven by the same dimension as the replaced filter — one tab per store, labeled by the store name, with each tab showing only that store's items (per-tab scoped content), not category/product tags. Explicitly corrected after a misinterpretation ("the tab labels should be store names. In every tab we need to show the items of that store"). Confidence: 0.8
|
||||
- When replicating a horizontal scrollable tab strip (store/category tabs) from user-ui to web-ui, expects identical interactive behavior, not just styling — the active tab must auto-scroll into view on selection so clicking a right-most tab brings it fully into view (matching user-ui's scroll-to-tab-position behavior, e.g., scroll to `offsetLeft - 16`), and the same auto-scroll must be applied consistently to every page using that tab strip pattern ("the tabs aren't behaving same as user-ui. They aren't auto scrolling when I try to click a right most tab"). Confidence: 0.8
|
||||
- For compact product cards (price, strike-through market price, quantity), dictates a specific vertical stacking layout rather than a side-by-side row: show the price first, then the struck market price on the same line, and the quantity on a new line below ("first show the price, then the striked price, in new line show the quantity") — this supersedes earlier attempts to pin the quantity to the right of the same row. Confidence: 0.7
|
||||
- For text that wraps next to a leading icon in a chip/label (e.g., the delivery-date chip with a truck icon), constrains multiline text to a maximum of 2 lines (`line-clamp-2`) and pins the icon to the top of the first line (`items-start` + `shrink-0` + top margin on the icon) so it never drops to a second line when the text wraps ("which takes at most 2 lines, with the truck on the first line"). Confidence: 0.6
|
||||
- When a fixed N-line block (e.g., the delivery-date chip capped at 2 lines) must carry an icon on line 1 only, he rejects relying on natural text wrapping: if the text naturally fits on one line, the second line renders empty ("still the same. I see truck and space below it in next line empty"). Prefers a DETERMINISTIC structure that explicitly fills every line — split the content into two deliberate lines (e.g., date on line 1 next to the icon, time on its own line 2) so line 2 always has content, rather than wrap/clamp heuristics that can leave a gap. Confidence: 0.75
|
||||
- Prefers minimal, chrome-free presentation for informational text like the delivery-date line: drop the background, border, and padding entirely and show just the text (and any leading icon) in the brand color at 70% opacity (`text-brand-700/70`), rather than a colored/bordered chip, after the layout was finalized ("remove the blue background and the padding. Just show the text in brand color but 70% opacity"). Confidence: 0.7
|
||||
- When a pinned leading icon reserves its own column in a chip, a subsequent wrapped text line must NOT carry over that reserved gap — the second line should span the full inner width with proper spacing, not leave an "open gap" where the icon column sits on the first line ("in the second line I don't see truck but the gap is open. It should have space"). Confidence: 0.45
|
||||
- When he wants a UI element/feature hidden for now (e.g., a delivery-date block), prefers the agent to comment the code out (wrapping in `{/* ... */}`) rather than delete it, so it can be easily restored later ("just hide the delivery date. just comment it out"). Confidence: 0.6
|
||||
- Prefers the product-card quantity line to carry an explicit "Qty:" label prefix before the formatted value — e.g., "Qty: 12Kg" — rather than showing the bare formatted quantity (e.g., just `12Kg`). Confidence: 0.7
|
||||
- Prefers a lighter, more compact look for product-card text: the product name rendered at `font-medium` (not `font-bold`) and the card's content padding tightened (e.g., `px-3 pt-2.5 pb-2.5` instead of `px-4 pt-3 pb-3`) — explicit instruction: "for the text of product name, reduce the weight and it's padding." He then continued tightening the horizontal padding by directly dictating the value ("make the padding px-1.5"), so expects the agent to apply exact spacing values he specifies. Confidence: 0.7
|
||||
- Prefers secondary/informational product-card text (e.g., the quantity line) in the brand color at 70% opacity (`text-brand-700/70`) rather than muted gray — "show the quantity in the branch [brand] color 70% opacity" — the same `text-brand-700/70` treatment he chose for the delivery-date line, suggesting a recurring card-text color treatment for secondary info. When the line carries a label prefix (e.g., "Qty:"), only the VALUE part gets the brand color; the label stays muted gray and the value gets a touch more weight (`font-semibold`) to stand out — "only the value part. not the 'Qty'". Confidence: 0.7
|
||||
- During product-card decluttering passes, wants promo/discount badges (e.g., the "n% OFF" label) hidden too — prefers a clean, uncluttered product card with no discount badge overlays ("hide the n% off label too"), the same treatment previously applied to hiding the delivery-date block. Confidence: 0.6
|
||||
- Prefers bottom-sheet dialogs (the app's `BottomDialog`) for collecting simple input values (e.g., a count for bulk coupon generation) rather than separate screens or inline form sections — explicitly requested "it should open a bottom dialog and should take input of the desired count". Confidence: 0.4
|
||||
- Expects newly generated/derived data to be merged into existing state structures rather than managed separately — e.g., bulk-generated coupon codes appended to the existing `couponCodes` array because "we already have coupon codes as array". Confidence: 0.5
|
||||
- Expects back navigation in the mobile app to return to the screen he navigated from, not to an unrelated tab root — when a link from the cart page to the coupons page (which lives in another tab's stack) made pressing back land on the "Me" page instead of the cart, he reported it as a bug ("when I try to press back I don't come to the cart page again. I come to me page. why is that"); cross-tab navigation flows must preserve a way back to the origin screen. Confidence: 0.6
|
||||
- When the same screen can be reached from multiple entry points, wants its UI to adapt based on the entry point — e.g., the coupons page opened from the cart page (via a `?from=cart` param) should hide the bottom tab bar while focused and restore it on leaving, while the same page opened from its own tab keeps the tab bar visible ("if coupons page is accessed from cart page, I want to hide the bottom nav bar"). Confidence: 0.7
|
||||
- For bulk/sequential generation from a code pattern (e.g., ABC12 → ABC13...), expects numbering to continue from the HIGHEST existing value so regeneration never re-emits already-entered codes — after entering ABC12, the next batch must start at ABC13, not restart at 12 (the first matching code). Confidence: 0.6
|
||||
- Dislikes UI that renders transient loading states as definitive business states — e.g., the home page showed every item as "out of stock" until slot data loaded, because the out-of-stock check treated "no slot yet" (data not loaded) as genuinely out of stock. Expects a "data loaded" guard so the UI never flashes a wrong definitive status (out of stock) before data arrives, while still applying real out-of-stock detection once loaded. Confidence: 0.7
|
||||
- When deleting a record, prefers the existing soft-delete/invalidate path over hard-deleting rows: the supported delete already sets `isInvalidated: true` (keeping history/audit and avoiding broken FK joins), and a raw hard delete should only be considered when confirmed nothing references the row (no usage records), with related rows (couponApplicableUsers/Products) removed transactionally to avoid orphans. Confidence: 0.75
|
||||
- When commissioning a dead-code audit of an app/package, expects a "close analysis" that is exhaustive across every category — dead, unreachable, and unused code: orphaned files, unreachable/unlinked routes, dormant or disabled-by-flag features, dead branches and commented-out blocks, and unused imports/exports — finding "every such code which serves no purpose" rather than only obviously-unreferenced files; findings should distinguish verified-dead items from merely dormant/judgment-call items. Confidence: 0.65
|
||||
- When an analysis/audit has been produced for one app in the monorepo, expects the same analysis run for the sibling apps too — after the user-ui dead-code audit he immediately asked "now do the same for the @apps/admin-ui" — applying the identical exhaustive methodology and document style to each named app. Confidence: 0.85
|
||||
- For analysis documents, expects a consistent naming pattern following the established convention (`user_ui_dpsk.md` → `admin_ui_dpsk.md`, i.e., `{app}_dpsk.md` at the repo root) rather than an ad-hoc filename — "make another md file with same naming pattern"; the `_dpsk.md` suffix also applies to cross-cutting topic analyses (e.g., `repeat_types_dpsk.md` for the repeated-types scan). Confidence: 0.9
|
||||
- When commissioning a cross-cutting audit/analysis, expects the scan to span the entire monorepo in one pass — every app, the backend, AND the shared packages (e.g., "scan the entire @apps/user-ui/ @apps/admin-ui/ @apps/backend/ and also entire @packages/") — not just one app or one area. Confidence: 0.65
|
||||
- Values type-level hygiene alongside runtime structures: wants repeated type declarations across the monorepo hunted down and classified (identical duplicates, near-identical/unifiable shapes, same-name-different-shape collisions) and documented in a report before any refactor, rather than fixing types piecemeal. Confidence: 0.6
|
||||
|
||||
- Requires the parallel DB-helper packages (db_helper_sqlite and db_helper_postgres) to not duplicate type definitions: they must share one canonical set of types and emit exactly the same data shape, since they represent the same business entities on different backends — stated directly: "they shouldn't duplicate types. They should emit exactly same type of data. They should share the types." Confidence: 0.9
|
||||
|
||||
- When the two DB-helper implementations diverge at the data-model level (sqlite is SKU-based with skuIds; the dormant postgres collapsed the SKU tier into productIds), the live/canonical model wins: the sqlite/SKU model — which packages/shared already standardizes on — is the source of truth and the dormant postgres helper is to be aligned/migrated to it (up to and including schema-level changes so even row types match), not the reverse. Confidence: 0.75
|
||||
|
||||
- The dedup directive is general, not limited to DB helpers: for types that are "exactly same but repeated" anywhere in the monorepo, move them into a shared package (@packages/shared), organize them there with proper comments, and export/reuse them from that one source — "Do this for all the types which are same and repeated" (stated when asking to address the duplicate-type spread repo-wide). Confidence: 0.9
|
||||
|
||||
- When organizing a shared type package, prefers to keep similar types together — all types that have identical properties should stay grouped/collocated so exact duplicates live side by side (e.g., clustering same-shaped types together in the shared files rather than scattering them). Confidence: 0.75
|
||||
- When he introduces a processing/batching parameter (e.g., a batch size such as "5 tokens per queue message"), expects it enforced end-to-end on BOTH sides of the pipeline — the write/enqueue producer AND the read/consumer/processing path — and follows up to confirm the read side honors the same limit ("we've also changed the code to read and process 5 tokens at a time right?"); a limit applied on only one side is treated as an incomplete change. Confidence: 0.6
|
||||
- Scrollable content (e.g., a product grid) must never be buried beneath a persistent floating UI element like the floating cart bar: pages that scroll above such a bar need sufficient bottom padding on the content container so the last row of items can scroll fully clear of the bar ("There should be enough padding in bottom. I see the last row of products buried beneath the floating cart bar. They should be scrollable"). Errs toward GENEROUS bottom spacing and iterates for more if it still feels tight — after the initial fix (matching siblings' mobile `pb-24`) he asked to bump it again ("it's improved but add some more padding"), landing at mobile `pb-32` / desktop `md:pb-16`. Expects the fix to follow the sibling pages' bottom-padding pattern but lean roomier rather than matching the minimum. Confidence: 0.85
|
||||
- Prefers an incremental "load more"/"Show More" reveal model over rendering the entire product list at once on listing/dashboard pages: show a fixed batch at a time (the user-ui dashboard's All Products list shows 21 items and adds another 21 per click, hiding the button once all are visible) and expects the same model replicated in the sibling web-ui app ("we aren't showing all the items at once. We have a load more model. Implement the same on the @apps/web-ui too"), matching the reference's batch size and button behavior. Confidence: 0.8
|
||||
- For browser/E2E test automation, prefers Playwright and wants the suite placed in a `tests/` folder at the project ROOT (not inside the app it targets) — "At the project root add a folder tests. I want playwright based tests". Confidence: 0.85
|
||||
- Wants E2E automation organized around long, multi-step, real user workflows executed in a SINGLE browser instance/session (e.g., add a product → update it → create a slot for it → suspend it), rather than split into isolated per-step tests — "I want to automate long tasks in a single instance". Confidence: 0.85
|
||||
- For the E2E harness itself: tests should drive whatever UI URL is targeted (no backend management from the harness), pull staff credentials from environment variables (not committed inline), and NOT auto-start the dev server — the app is assumed to be running. Confidence: 0.6
|
||||
- Wants automation tests (Playwright) to run with a VISIBLE browser, not headless — "I want to see the browser, I don't want it in headless mode" — so local runs should default to headed (CI may stay headless), with a convenience script/flag (e.g. `test:e2e:headed`, `HEADED=0/1`) to switch. Confidence: 0.85
|
||||
- Prefers names/labels displayed in FULL rather than truncated — e.g., product names in the cart drawer should wrap and show completely (text wrap / `break-words` instead of `truncate`) — "show the name full, if needed do text wrap". Confidence: 0.55
|
||||
- Wants the floating cart bar HIDDEN on routes where it is redundant — the cart page (including the flash cart alias) and the entire "me" section — and expects the condition to cover ALL child routes via a path-prefix check (`pathname.startsWith('/me')`) rather than matching only the parent route. Confidence: 0.6
|
||||
- Uses Bun as his package manager / package runner ("I use bun"), so whenever giving or documenting commands he expects `bun run <script>` / `bunx <tool>` rather than `npm run` / `npx` (including inside subfolders, e.g. `bun run --cwd .. <script>`). Confidence: 0.9
|
||||
- Expects generated test automation to be actually EXECUTED and passing, not just written/typechecking — he asks the agent to run the suite and report how many tests succeed ("try to run the script and see how many tests succeed"), treating a suite that was never run against a live app as unverified. Confidence: 0.65
|
||||
- Treats reports/audits (e.g., DEAD_CODE_REPORT.md) as claims to be independently verified against actual code usage, not trusted at face value: he asks the agent to read a report and confirm "if the pointed code is actually dead", expecting per-claim caller searches and explicit flags of false positives. Confidence: 0.75
|
||||
- Before shipping/publishing a branch, wants a pre-flight risk assessment rather than just execution: he names the last published reference branch and asks "Tell me if I'm going to face any problems" — expecting the agent to verify the current branch is a superset of what was published (no feature/migration/commit lost), that removed files and assets have no dangling references or build-config dependencies, and that build/typecheck integrity holds, then present blockers vs. non-blocking caveats (e.g., committed secrets) before he deploys. Confidence: 0.7
|
||||
- After performing removals himself, expects the agent to re-scan the codebase and independently confirm each item is actually gone (and that the removals didn't break anything) — "I've removed them. Check and tell me if all are gone" — treating his own "done" statement as a claim to verify rather than assuming completion. Confidence: 0.6
|
||||
- When asking to compare branches/versions (e.g., "compare the current branch's @apps/user-ui with the product_to_sku_c1 branch"), wants the answer framed "functionality wise" — a semantic/behavioral summary of what actually changed for the app and its users (new logic, changed API calls, changed UI, removed screens), explicitly distinguishing real functional changes from no-op churn (pure type imports, dead-code deletions, config tweaks which he should be told are non-functional), rather than a raw file/line diff dump. Confidence: 0.6
|
||||
- Wants order-item rows to display the pack size / unit notation (e.g., `2 × 500g · ₹250`) alongside quantity and unit price, not quantity alone — composed via the existing shared `composeUnitNotation` helper and surfaced through the shared item type to both user-ui and web-ui. Confidence: 0.6
|
||||
- Prefers that disruptive user-facing dialogs be reserved for genuinely important events: the OTA update prompt should NOT be shown for every update — only for a few crucial releases ("I don't want to show this for all the updates. Only a few updates are so important"), while routine updates apply silently in the background. Confidence: 0.7
|
||||
- Uses "quantity" loosely to refer to the pack size / unit notation on order items: he reported "we aren't showing quantity" about a row that already rendered the numeric count, then clarified "yeah, show the pack size" — so when he says a quantity isn't shown, check whether he actually means the unit/pack-size notation is missing. Confidence: 0.55
|
||||
- For new operational switches/flags, prefers a backend constant in the admin-editable constants store (CONST_KEYS + seed + exposed via essentialConsts, editable in Customize App) over build/publish-time config (e.g. an `expo.extra` flag in app.json) or hardcoding — chosen when offered the trade-off ("let's introduce a new constant"), because it can be changed remotely with no rebuild or republish. Confidence: 0.6
|
||||
e changes. Confidence: 0.95
|
||||
er-ui/app/(drawer)/(tabs)/home/index.tsx, I want similar here") and expects the agent to mirror that pattern exactly, including details like item counts and container styling. Confidence: 0.9
|
||||
als) rather than introducing new brand palettes; approval of a redesign's layout/composition does not imply approval of color or theme changes. Confidence: 0.95
|
||||
roducing new brand palettes; approval of a redesign's layout/composition does not imply approval of color or theme changes. Confidence: 0.95
|
||||
er-ui/app/(drawer)/(tabs)/home/index.tsx, I want similar here") and expects the agent to mirror that pattern exactly, including details like item counts and container styling. Confidence: 0.9
|
||||
tes; approval of a redesign's layout/composition does not imply approval of color or theme changes. Confidence: 0.95
|
||||
er-ui/app/(drawer)/(tabs)/home/index.tsx, I want similar here") and expects the agent to mirror that pattern exactly, including details like item counts and container styling. Confidence: 0.9
|
||||
y, including details like item counts and container styling. Confidence: 0.9
|
||||
tes; approval of a redesign's layout/composition does not imply approval of color or theme changes. Confidence: 0.95
|
||||
er-ui/app/(drawer)/(tabs)/home/index.tsx, I want similar here") and expects the agent to mirror that pattern exactly, including details like item counts and container styling. Confidence: 0.9
|
||||
olor or theme changes. Confidence: 0.95
|
||||
roducing new brand palettes; approval of a redesign's layout/composition does not imply approval of color or theme changes. Confidence: 0.95
|
||||
er-ui/app/(drawer)/(tabs)/home/index.tsx, I want similar here") and expects the agent to mirror that pattern exactly, including details like item counts and container styling. Confidence: 0.9
|
||||
tes; approval of a redesign's layout/composition does not imply approval of color or theme changes. Confidence: 0.95
|
||||
er-ui/app/(drawer)/(tabs)/home/index.tsx, I want similar here") and expects the agent to mirror that pattern exactly, including details like item counts and container styling. Confidence: 0.9
|
||||
y, including details like item counts and container styling. Confidence: 0.9
|
||||
tes; approval of a redesign's layout/composition does not imply approval of color or theme changes. Confidence: 0.95
|
||||
er-ui/app/(drawer)/(tabs)/home/index.tsx, I want similar here") and expects the agent to mirror that pattern exactly, including details like item counts and container styling. Confidence: 0.9
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -132,4 +132,3 @@ dist
|
|||
.pnp.*
|
||||
|
||||
|
||||
type-clusters/*
|
||||
|
|
@ -48,6 +48,3 @@ react-native. They are available in the common-ui as MyText, MyTextInput, MyTouc
|
|||
|
||||
## Important Notes
|
||||
- Don't do anything with git. Don't do git add or git commit. That will be managed entirely by the user
|
||||
|
||||
## Change Log
|
||||
- there should be a change log file. unless explicitly specified, assume change-log.txt as the name of the log file. Ensure the file exists. If it doesn't exist create it. Before changing any file which is not a md file or txt file write the change to that file exactly. Write the diff to it and then start making changes. for every change add the current time stamp.
|
||||
|
|
|
|||
|
|
@ -1,313 +0,0 @@
|
|||
# `any` Usage Audit — what each one should be
|
||||
|
||||
**Branch:** `DEAD_CODE_CLEAN`
|
||||
**Scope:** all `any` keyword usages in `apps/*` and `packages/*` source (`.ts`/`.tsx`), excluding `node_modules`, `dist`, `.wrangler`, `.output`, `.expo`, `.turbo`, `.tanstack`, dumps, `*.sql`, and generated route trees (`routeTree.gen.ts`).
|
||||
**Nature:** documentation only — no code changed.
|
||||
|
||||
## Totals (type-level `any`, excluding prose/comments/generated)
|
||||
|
||||
| Area | Count | Dominant cause |
|
||||
|---|---|---|
|
||||
| `apps/backend` | ~85 | `as any` casts on JWT/Drizzle/error shapes; `: any` where a shared type exists |
|
||||
| `packages/db_helper_sqlite` + `_postgres` | ~105 | `(x: any)` on already-typed Drizzle results; `Promise<any>` helpers |
|
||||
| `apps/admin-ui` | ~161 | `onError/catch (e: any)`; `router.push(... as any)`; entity `useState<any>` |
|
||||
| `apps/user-ui` + `apps/fallback-ui` | ~137 | `catch/onError`; Expo/TanStack `as any`; catalog/cart `item: any` |
|
||||
| `apps/web-ui` | ~138 | `Record<number, any>`/`(p: any)` product+slot callbacks; `onError` |
|
||||
| `apps/admin-web` | ~128 | `onError/catch` (36); domain rows; `navigate({ to: ... as any })` |
|
||||
| `packages/shared` + `web-components` + `ui` + `migrator` | ~28 | untyped component props; RN style/asset props; migrator rows |
|
||||
| **Total** | **~780** | |
|
||||
|
||||
Regex-based raw totals are a bit higher because the word "any" also appears in comments/prose and `tsconfig` files; those are excluded here.
|
||||
|
||||
## The replacement playbook ("what it should have been")
|
||||
|
||||
1. **`catch (e: any)` / `onError: (e: any)` → `unknown`** (and narrow with `e instanceof Error ? e.message : '…'`). This is the single largest mechanical category (~100 sites across the repo).
|
||||
2. **`onError: (error: any)` on tRPC hooks → delete the annotation.** tRPC v11 already infers `TRPCClientError<AppRouter>`; `.message` keeps working.
|
||||
3. **Callback params on typed Drizzle/array results → delete `: any`.** Because `db_index.ts` passes the full `schema` to `drizzle(...)`, `db.query.*` and `.select()` results are fully inferred; `(x: any) => …` is pure noise.
|
||||
4. **`(item: any)` / `(product: any)` in catalog/cart lists → the real entity type:** `MergedProduct` (catalog), `CartItem` (carts), `AdminSku`, `AdminSlotWithProducts`, `AdminOrderDetails`, `UserOrderSummary`, etc.
|
||||
5. **`router.push(… as any)` → typed routes** (`Href` from `expo-router`; `FileRouteTypes['to']`/generated union for TanStack Router). Static routes can simply drop the cast.
|
||||
6. **`Record<string, any>` → `Record<string, unknown>`** for heterogeneous bags.
|
||||
7. **`z.any()` → `z.unknown()`** in Zod schemas (or a precise union).
|
||||
8. **RN/Expo props → their real types:** `StyleProp<ViewStyle>` / `StyleProp<ImageStyle>`, `NativeSyntheticEvent<NativeScrollEvent>`, `TextStyle['fontWeight']`, `ImagePicker.ImagePickerAsset`.
|
||||
9. **Icon `name={x as any}` → `React.ComponentProps<typeof MaterialIcons>['name']`** (the glyph-key union).
|
||||
10. **`Promise<any>` db-helper returns → `InferSelectModel<typeof table>` / explicit `Pick<Row, …>`.**
|
||||
|
||||
## Highest-leverage shared fixes (fix once, removes many)
|
||||
|
||||
These untyped *shared* declarations force `any` at dozens of call sites — tighten them first:
|
||||
|
||||
| Fix | File | Impact |
|
||||
|---|---|---|
|
||||
| `value: any` → a `ConstValueType \| ConstValueType[]` union (union already defined at line 18) | `packages/shared/types/const.types.ts:8` | Removes `value.map((id: any) => …)` in admin-ui **and** admin-web reorder screens |
|
||||
| `ListItemProps.item: any`, `CompactProductCardProps.item: any`, `OffersSectionProps.products: any[]`, `AddedToCartProduct.product: any` → hoist a shared structural product type (or make the props generic) | `packages/shared/types/app-common.types.ts:60,78,87,96` | Caps how far web-ui/user-ui flash/offers/complaints props can be tightened |
|
||||
| `queryParams?: Record<string, any>` → `Record<string, unknown>` | `packages/shared/types/app-common.types.ts:20` | Consumer only does `String(value)` |
|
||||
| `setFile: (file: any) => void` → `ImagePickerAsset \| ImagePickerAsset[] \| null` | `packages/ui/src/components/use-pick-image.tsx:13` | Type flows into admin-ui `setFile: (assets: any)` (3 sites) and `profile-image`/`ImageUploaderNeo` |
|
||||
| Export app-local entity types: `MergedProduct`, `CartItem`, `SlotInfo`/`ProductSlotMap` (web-ui/user-ui); `ProductFormData`, `Variant`, `Attribute`, `CouponFormValues` (admin-ui/admin-web) | respective hook/component files | Removes dozens of `values: any` / `item: any` in mutations and list callbacks |
|
||||
| Backend procedures returning `any[]` (coupon getAll/getReservedCoupons, admin `getUserIncidents`, store) → concrete shared types | `apps/backend/.../admin-apis/apis/{coupon,user,store}.ts` | Removes the downstream `Coupon`/`UserIncident` `any`s in both admin frontends |
|
||||
|
||||
---
|
||||
|
||||
## apps/backend
|
||||
|
||||
### Summary
|
||||
- **85** `any` keywords across **83 lines / 31 files**.
|
||||
- **Top files:** `coupon.ts` & `const-store.ts` (7 each), `admin/user.ts`, `post-order-handler.ts`, `app.ts` (6 each), `admin/product.ts` (5), `auth.ts`, `cache-creator.ts` (4).
|
||||
- **Dominant patterns:** (1) `x as any` around JWT payloads, Drizzle `json` columns, and untyped db-helper returns; (2) `: any` params/locals where a concrete shared type exists (`OrderWithFullData`, `AdminVendorSnippet`, `TagBasicData`, `StaffRole`); (3) generic defaults `<T = any>` and catch clauses.
|
||||
- **10 of the 85 are inside commented-out "Old implementation" blocks** → delete, don't retype.
|
||||
|
||||
| Location | Current code | Should be | Why |
|
||||
|---|---|---|---|
|
||||
| `app.ts:57`, `middleware/auth.middleware.ts:19` | `const decoded = payload as any` | `AppJwtPayload` = `{ userId?: number; staffId?: number; name?: string }` | jose `JWTPayload`; tokens are signed as `SignJWT({ userId })` / `SignJWT({ staffId, name })` |
|
||||
| `app.ts:131,132` | `(err as any).statusCode` | `err instanceof ApiError ? err.statusCode` | `ApiError` is the only error with `statusCode` |
|
||||
| `app.ts:134,135` | `(err as any).status` | `err instanceof HTTPException ? err.status` | Hono `HTTPException` |
|
||||
| `app.ts:141` | `c.json({ message }, status as any)` | `status as StatusCode` | Hono status union |
|
||||
| `types/hono.d.ts:5`, `trpc/trpc-index.ts:7` | `user?: any` | `AppJwtPayload` | consumed as `ctx.user?.userId` |
|
||||
| `trpc/trpc-index.ts:37` | `const err = error as any` | `unknown` + narrow | catch var; reads name/message/code/stack/meta/sql |
|
||||
| `jobs/cache-creator.ts:10,13` | `state: any` | `DurableObjectState` | uses storage/alarm |
|
||||
| `jobs/cache-creator.ts:11,13` | `env: any` | `WorkerEnv` (needs domain input) | passed to `ensureWorkerInit` |
|
||||
| `jobs/payment-status-checker.ts:3,4,5` | `payment:any; order:any; slot:any` | `typeof payments.$inferSelect` / `{id;userId}` / row types | stub interface; only `order.id/.userId` read |
|
||||
| `lib/queue-consumer.ts:4,27,40` | `batch: any` | `MessageBatch<NotifQueueMessage>` / `<{orderIds:number[]}>` / `<CancellationMessage>` | pushed message shapes are known |
|
||||
| `lib/const-store.ts:34,58` | `<T = any>` | `<T = unknown>` | generic default |
|
||||
| `lib/const-store.ts:90,102,107` | `Record<…, any>` | `Record<…, unknown>` | opaque KV values |
|
||||
| `lib/api-error.ts:3,5` | `details?: any` | `unknown` | never consumed typed |
|
||||
| `lib/notif-job.ts:11,22` | `notificationQueue:any`, `notificationWorker:any` | bullmq `Queue`/`Worker` or `Record<string, never>` | stubbed (`{}`) |
|
||||
| `lib/notif-job.ts:91` | `scheduleNotification(userId, payload: any, …)` | `{ title; body; type; orderId? }` | callers pass exactly this |
|
||||
| `lib/worker-init.ts:3,4` | `env: any`, `(globalThis as any).ENV` | `WorkerEnv` / typed global | matches existing typed-global pattern |
|
||||
| `lib/post-order-handler.ts:44,52,95,167,168` | `ordersData: any[]`, `(item: any)`, `createdOrders: any[]`, `Map<…, any[]>` | `OrderWithFullData[]`, `OrderWithFullData['orderItems'][number]`, `PlacedOrder[]` | db-helper return types |
|
||||
| `lib/post-order-handler.ts:86` | `orderData: any` | `OrderWithCancellationData & { refundStatus: string }` | known shape |
|
||||
| `lib/flash-delivery-cron.ts:12` | `env: any` | `WorkerEnv` | |
|
||||
| `lib/env-exporter.ts:62` | `(globalThis as any).ENV \|\| (globalThis as any).process?.env` | typed structural cast | matches rest of codebase |
|
||||
| `lib/notif-job.ts:91`, `trpc/.../staff-user.ts:64,112` | mixture | `AdminVendorSnippet`, `StaffRole` | concrete shared types exist |
|
||||
| `admin-apis/apis/store.ts:17,37` | `stores: any[]`, `store: any` | `Store & { owner: User }` | query includes `owner` |
|
||||
| `admin-apis/apis/banner.ts:59`, `user-apis/apis/auth.ts:274` | `catch (e: any)` | `unknown` + narrow | |
|
||||
| `admin-apis/apis/const.ts:38` | `z.any()` | `z.unknown()` | |
|
||||
| `admin-apis/apis/slots.ts:74` | `deliverySequence: z.any()` | `Record<string, number[]>` union | shared `AdminDeliverySequence` |
|
||||
| `user-apis/apis/payments.ts:129` | `payload as any` | `Record<string, unknown>` | schema is `jsonText<unknown>` |
|
||||
| `admin-apis/apis/product.ts:265,357,754` | `as any` casts / `product: any` | `CreateProductInput` / `AdminProductTagWithProducts` | helpers/return types exist |
|
||||
| `user-apis/apis/user.ts:61`, `auth.ts:110,374` | `new Date(userDetail.dateOfBirth as any)` | drop cast | field is `Date \| null` |
|
||||
| `user-apis/apis/order.ts:293` | `slotId: null as any` | `null` | param is `number \| null` |
|
||||
| `admin-apis/apis/coupon.ts:129,141,153,154,351,366,440` | `coupons: any[]`, `Promise<any>`, `(au:any)`, `(ap:any)`, `coupon: any` | `Coupon & { applicableUsers; applicableProducts }` / `ReservedCoupon` (needs domain input) | relations not modelled in shared |
|
||||
| `common-apis/common.ts:79,84,85` | `(id:any)`, `(tag:any)`, `orderedTags:any[]` | `number`, `TagBasicData`, `TagBasicData[]` | `getConstant<T>` default; `getAllTagsForCache()` |
|
||||
| `admin-apis/apis/user.ts:48,60,98,117,170,258` | `(u:any)` etc | `Pick<User,…>`, `Pick<PlacedOrder,…>`, `UserIncident` (needs domain input) | select shapes |
|
||||
| Dead-code `any`s (delete) | `product.ts:565,700`, `banner.ts:194`, `slots.ts:176,347`, `address.ts:192`, `admin/order.ts:384`, `vendor-snippets.ts:260`, `const-store.ts:91,97` | — | commented-out old impls |
|
||||
|
||||
**Easy wins:** drop redundant `as any` (`product.ts:265/357`, `order.ts:293`, `user.ts:61`, `auth.ts:110/374`, `payments.ts:129`); `: any`→`unknown` (`api-error`, `const-store` generics, `payment-status-checker`, `payments-utils`); `catch`→`unknown` (`banner.ts:59`, `auth.ts:274`); `z.any()`→`z.unknown()`; delete dead `any`s; define `AppJwtPayload` once.
|
||||
|
||||
---
|
||||
|
||||
## packages/db_helper_sqlite + db_helper_postgres
|
||||
|
||||
**Summary:** **105** tokens across **98 lines / 17 files**. Top: `sqlite/admin-apis/product.ts` (27), `sqlite/admin-apis/order.ts` (19), `run-batched.ts` (7), `admin-apis/user.ts` (7 each pkg), `admin-apis/coupon.ts` (6 each). Dominant: `(x: any)` on already-typed Drizzle results (~60) — just delete. `Row<T>` = `InferSelectModel<typeof T>`, `Insert<T>` = `InferInsertModel<typeof T>`.
|
||||
|
||||
| Location | Current code | Should be | Why |
|
||||
|---|---|---|---|
|
||||
| `sqlite/admin-apis/product.ts:182,235,385,483,530` | `(ci\|f: any)` | `CreateComboItemInput` / `SkuFeatureLike` | element types of typed relations |
|
||||
| `product.ts:185,238` | `(f: any)` | `Row<skuFeatures>` | |
|
||||
| `product.ts:596` | `(tag: any)` | inferred (cast at 594) | |
|
||||
| `product.ts:598,669,709` | `(assignment: any)` | `Row<productTags> & { sku: … }` | |
|
||||
| `product.ts:756` | `(review: any)` | inferred select shape | |
|
||||
| `product.ts:817,822` | `(group:any)`, `(membership:any)` | inferred | |
|
||||
| `product.ts:367` | `updateProduct(id, input: any)` | `UpdateProductInput` (domain) | |
|
||||
| `product.ts:39,41,114,340,498,545` | `any[]`/`(ci:any)` | `CreateComboItemInput` / `AdminProductComboItem[]` | |
|
||||
| `product.ts:384,398,401` | `(sku: any)` | `CreateSkuInput & { id?: number }` | |
|
||||
| `product.ts:956` | `const updateData: any` | `Partial<Insert<productMarketStats>>` | |
|
||||
| `sqlite/admin-apis/order.ts:174,239,250,364,369,378,388,412,523,528,538,549,554,577,615,616,622,643` | `(u\|item\|f\|order\|acc: any)` | delete → inferred | typed query results |
|
||||
| `sqlite/admin-apis/slots.ts:122,125,133,199` | `(item\|s\|sku: any)` | delete → inferred | |
|
||||
| `slots.ts:194` | `(item: any)` + reads `item.isDeleted` | inferred; **add `isDeleted: true` to `columns` at :191** | hidden bug surfaced by typing |
|
||||
| `slots.ts:56` | `fetchExistingSkuIds(tx: any, …)` | Drizzle tx type (`Parameters<Parameters<typeof db.transaction>[0]>[0]`) | |
|
||||
| `stores/store-helpers.ts:312,315,326` | `(item\|s\|sku: any)` | delete → inferred | |
|
||||
| `admin-apis/store.ts:7,17` (both pkgs) | `Promise<any[]>` / `Promise<any\|null>` | `Array<Row<storeInfo> & { owner: Row<staffUsers>\|null }>` | |
|
||||
| `admin-apis/staff-user.ts:22,78` (both) | `Promise<any[]>` | inferred relation / `StaffRole[]` | |
|
||||
| `admin-apis/user.ts:5,28,95,122,184,224,245` (both) | `Promise<any…>` | `Row<users>`, `Pick<Row<users>,…>`, `Row<userIncidents>`, etc. | select shapes |
|
||||
| `admin-apis/coupon.ts:10,53,175,211,213,300` (both) | `any[]`/`Promise<any…>`/`input: any` | `Row<coupons> & {…}`, `Row<reservedCoupons>`, `ReservedCouponInput` | InferInsertModel |
|
||||
| `user-apis/auth.ts:102,119` (both) | `const userUpdate: any` | `Partial<Insert<users>>` / `Partial<Insert<userDetails>>` | |
|
||||
| `user-apis/auth.ts:184` (both) | `catch (error: any)` | `unknown` + narrow | `23505` is pg-only |
|
||||
| `lib/run-batched.ts:3,19` | `(cb: (tx: infer Tx) => any, ...args: any[]) => any` | `unknown` | unused callback return/args |
|
||||
| `lib/automated-jobs.ts:25` (both) | `value: any` | `unknown` | |
|
||||
|
||||
**Easy wins:** delete `: any` from ~60 map/filter callbacks; `run-batched` `any`→`unknown`; `automated-jobs` `value: unknown`; object literals → `Partial<Insert<…>>`; `catch`→`unknown`; **free bug fix** at `slots.ts:191/194`; ~35 `Promise<any>` return annotations via `Row`/`Insert`.
|
||||
|
||||
---
|
||||
|
||||
## apps/admin-ui
|
||||
|
||||
**Summary:** **161** code occurrences / 49 files. Top: `delivery-sequences/index.tsx` (10), `prices-overview` (11), `products/add+edit` (14), `_layout.tsx` (13). Dominant: `catch/onError (error: any)` (35) and `router.* as any` (36) — both mechanical.
|
||||
|
||||
| Location | Current code | Should be | Why |
|
||||
|---|---|---|---|
|
||||
| `_layout.tsx:53…137`, `dashboard/index.tsx:32,99,242`, banners/products/slots/stores/prices/manage-orders (≈18 files) | `router.push("…" as any)` | `as Href` (`expo-router`); drop cast for static routes | typed routes |
|
||||
| `dashboard/index.tsx:39,250,275`, `manage-orders/index.tsx:77` | `name={item.icon as any}` | `keyof typeof MaterialIcons.glyphMap` | glyph union |
|
||||
| `TagMenu.tsx:62`, `stores/edit:28`, `send-notifications:48`, `coupons/*`, `prices:286`, `SlotForm:92,107`, `delivery-sequences`, `order-details`, `orders` (≈24) | `onError: (error: any)` | delete annotation | tRPC infers |
|
||||
| VendorSnippet/ProductGroupForm/products/coupons/stores/vendor-snippets/product-tags/send-notifications (≈11) | `catch (error: any)` | `unknown` + narrow | |
|
||||
| `coupons/index.tsx:11,111` | `item: any` | `Coupon & {relations}` / reserved (domain) | backend returns `any[]` |
|
||||
| `stores/index.tsx:15` | `item: any` | `Store` | |
|
||||
| `products/index.tsx:148` | `{ item: any }` | `Omit<AdminProductWithRelations,'skus'> & { skus: SerializedAdminSku[] }` | |
|
||||
| `slots/index.tsx:11`, `rebalance-orders:10` | `item: any` | `AdminSlotWithProducts` | |
|
||||
| `slots/slot-details.tsx:24`, `product-groupings:130`, `products/detail:146` | `useState<any…>` | `AdminSlotProductSummary[]`, `AdminSku[]`, `AdminProductReviewWithSignedUrls` | |
|
||||
| `slots/index:238`, `rebalance:123` | `useState<any[]>` | `string[]` | product names |
|
||||
| `products/add:15,64,29,36,80`, `edit` (same) | `values: any`, `(variant:any)`, `(a:any)`, `(ci:any)` | `ProductFormData`, `Variant`, `Attribute`, `CreateComboItemInput` (export from ProductForm) | |
|
||||
| `BannerForm:66`, `StoreForm:72`, `products/detail:32` | `setFile: async (assets: any)` | `ImagePicker.ImagePickerAsset \| ImagePickerAsset[]` | |
|
||||
| `TagForm.tsx:40` | `forwardRef<any,…>` | `unknown` | no imperative handle |
|
||||
| `TagForm.tsx:57` | `(sku: any)` | `SkuSummary` | |
|
||||
| `toaster.tsx:39` | `data: any` | `{ rideId?; carId? } \| null` (domain) | |
|
||||
| `CouponForm.tsx:60,66,156,191,192,205,237,238` | `function(value:any)`, `(values as any)`, `(errors.couponCodes as any)` | `CouponFormValues`, narrow error shapes | |
|
||||
| `ProductGroupForm:13`, `SnippetOrdersView:21`, `SlotForm:37,48,191` | `products: any[]`, `sequence: any[]`, `(snippet/p: any)` | `AdminSku[]`, `number[]`, `AdminVendorSnippetWithAccess`, `AdminSlotProductSummary` | |
|
||||
| `UserIncidentsView:141` | `(incident: any)` | `UserIncident` (domain) | |
|
||||
| `customize-app/index:9,58,59,60` | `Record<string,any>`, `value:any`, `setFieldValue(any)`, `router:any` | `Record<string,unknown>`, `unknown`, `Router` | |
|
||||
| `popular-items:139`, `all-items-order:111`, `product-tags/order:102` | `value.map((id: any) => parseInt(id))` | `number` (root: `Constant.value`) | |
|
||||
| `prices-overview:25,28,29,30,46,153,204,224,270` | `sku:any`, `Record<string,any>`, `const update:any` | local `PriceSku`, existing `PendingChange`, mutation input | |
|
||||
| `delivery-sequences:53,354,373,389,438,588,622` | `orders:any[]`, `(deliverySequence as any)` | `AdminSlotOrder[]`, drop cast (`AdminDeliverySequence`) | |
|
||||
| `order-details:148` | `const mutationData:any` | `initiateRefund` input | |
|
||||
| `coupons/create:27` | `values: any` | `CouponFormValues` | |
|
||||
|
||||
**Easy wins:** drop `: any` on 24 `onError`; `catch`→`unknown` (11); `as any`→`as Href` (36); icon glyph types (4); drop `deliverySequence as any` (6); remove `products/edit:180 as any`; `triggerStyle` → `StyleProp<ViewStyle>`; export `ProductFormData/Variant/Attribute`.
|
||||
|
||||
---
|
||||
|
||||
## apps/user-ui + apps/fallback-ui
|
||||
|
||||
**Summary:** **137** real type-level / 46 files (4 non-type "any" words ignored). Top: `home/index.tsx` (20), `cart-page` (11), `ProductDetail` (11). Dominant: `catch/onError` (29); router casts (23); catalog/cart `item: any` (30+).
|
||||
|
||||
| Location | Current code | Should be | Why |
|
||||
|---|---|---|---|
|
||||
| login/register/edit-profile/ComplaintForm/OrderMenu/ProductDetail/registration-form + fallback create-coupon/demo | `catch (error: any)` | `unknown` + narrow | |
|
||||
| many `onError: (error: any)` (14+) | `onError: (error: any)` | `unknown` / delete (tRPC infers) | |
|
||||
| `me/index`, `SlotSpecificView`, `cart-page` (≈12) | `router.replace/push(target as any)` | `Href` | |
|
||||
| fallback AuthWrapper/home/login/super-admin/user-home (≈13) | `navigate({ to: '…' as any })` | drop cast | routes registered |
|
||||
| `me/index:137`, `my-orders:111,278` | icon `as any` | `ComponentProps<typeof Ionicons/MaterialIcons>['name']` | |
|
||||
| `my-orders:68,69` | `getStatusColor(): any` | typed `StatusColor` | |
|
||||
| `home/index.tsx` (many) | `slot: any`, `(p: any)`, `dashboardTags:any[]`, `Record<number,any[]>`, `useRef<any>`, `onLayout(e:any)`, `useState<any[]>` | `UserSlotWithProducts`, `UserSlotProduct`, `AllProductsApiType['tags']`, `MergedProduct[]`, `ComponentRef<typeof ScrollView>`, `LayoutChangeEvent` | catalog types exist |
|
||||
| `ProductDetail:48,75,76,79,87,147,710` | `useState<any[]>`, `(slot/p/a/b/item:any)`, `setFile(assets:any)` | review/cart/slot types + `ImagePickerAsset` | shared `UserProductReviewWithSignedUrls` missing `adminResponse`/`signedAdminImageUrls` (domain) |
|
||||
| `cart-page:60` | `coupon: any` | `UserCouponWithRelations` | |
|
||||
| `registration-form:29` | `useState<any>` | `ImagePicker.ImagePickerAsset` | |
|
||||
| `useUploadToObjectStore:26` | `contextString as any` | drop cast | shared `ContextString` assignable |
|
||||
| `notif-context:45,46` | `useRef<any>` | listener return types | |
|
||||
| `toaster:5` | `data: any` | `Record<string, unknown>` | |
|
||||
| `AddToCartDialog` (6) | `(item/slot/p: any)`, `Record<number,any>` | `CartItem`, `UserSlotWithProducts`, `UserSlotProduct` | |
|
||||
| `AuthContext:14` | `Record<string,any>` | tighten shared `AuthRedirectOptions.queryParams` | |
|
||||
| `getCurrentUserId:8` | `jwtDecode(token)` any | `jwtDecode<{id?;userId?}>` | |
|
||||
| fallback `order-details:33,51,223`, `user-details:44,45,141`, `login:23`, `demo:11,76`, `inauguration:74` | `as any` / `any[]` / `any` | `AdminOrderDetails`, inferred, `ReturnType<typeof setInterval>`, local `DemoResponse` | |
|
||||
| `ProductCard:22,29,84,93,94` | `item:any`, `ComponentType<any>`, `(cartItem:any)`, `Record<number,any>`, `(slot:any)` | `MergedProduct`, `ComponentType<PropsWithChildren>`, `CartItem`, `UserSlotWithProducts` | |
|
||||
| `SlotSpecificView:232,252,457` | `item:any`, `(cartItem:any)`, `(p:any)` | `MergedProduct`, `CartItem` | |
|
||||
| `PaymentAndOrderComponent:17,24,25` | `cartItems:any[]`, `constsData:any`, `selectedCoupons:any[]` | `CartItem[]`, `EssentialConstsApiType`, `EligibleCoupon[]` | |
|
||||
| `hooks/useProductSlotIdentifier:18,25`, `prominent-api-hooks:127` | `(slot/a/b/p: any)` | drop → inferred | |
|
||||
|
||||
**Easy wins:** bulk `unknown` (29); delete fallback `as any` casts (≈18 incl. `contextString`); catalog/cart annotations (≈30) → `MergedProduct`/`CartItem`; icon types (4); trivial typed `any`s (jwt, setInterval, notif refs, toaster, styles).
|
||||
|
||||
---
|
||||
|
||||
## apps/web-ui
|
||||
|
||||
**Summary:** **138** annotations / 35 files (2 prose excluded). Top: `slot-view` (20), `home.index` (18), `flash` (10), `home.product.$id` (9). Dominant: untyped list callbacks over cached API data (`MergedProduct`, `UserSlotWithProducts`), then `(error: any)`.
|
||||
|
||||
| Location | Current code | Should be | Why |
|
||||
|---|---|---|---|
|
||||
| `FloatingCartBar:43,44`, `PaymentAndOrderComponent:34,35`, `cart:18,19`, `checkout:38,39`, `home.cart:18,19` | `Record<number,any>` / `(p:any)` | `Record<number, MergedProduct>` / `(p: MergedProduct)` | from `useAllProducts()` |
|
||||
| `AddToCartDialog:45,78`, `ProductCard:38`, `home.product.$id:63`, `flash:205` | `(item:any)` | `CartItem` | `useGetCart()` |
|
||||
| `slot-view:399` | `(cartItem:any).productId` | `(cartItem: CartItem).skuId` | **latent bug** |
|
||||
| `AddToCartDialog:53,57,59,143`, `usePopulateCentralStores:15,30,31,43`, `home.product.$id:51,52,55,94,316,397`, `slot-view:58…153`, `home.index:623` | `(slot:any)`, `slot.products.forEach((p:any)`, slot maps, `(a/b:any)` | `UserSlotWithProducts`, `UserSlotProduct`, `Record<number, UserSlotWithProducts>` | `useSlots()` |
|
||||
| `usePopulateCentralStores:15` (+43–48) | extra fields `deliveryDate/displayDate/displayTime` | **domain**: not on `UserSlotWithProducts` but required by `SlotInfo` | stale/dead-field mismatch |
|
||||
| `usePopulateCentralStores:25` | `Record<number,any>` | `ProductSlotMap[number]` (export) | |
|
||||
| `flash:38,67,77,83,166`, `home.index:154,155,225,326`, `home.search:87,166`, `offers:32,67,68`, `slot-view:139…238`, `stores.$storeId:44,125,163`, `usePopulateCentralStores:28`, `prominent-api-hooks:167` | `(product/p/a/b:any)` | `MergedProduct` | |
|
||||
| `stores.$storeId:28,38` | `new Map<number,any>`, `(product:any)` | `Map<number, MergedProduct>`, `(product: UserStoreProduct)` | |
|
||||
| `home.index:133,135,142,143` | `Record<number,any[]>`, `Map<number,any>`, `const ordered/rest: any[]` | `MergedProduct[]` etc | |
|
||||
| `ProductCard:11` | `item:any` | `MergedProduct` | |
|
||||
| `flash:36,54,124,156`, `home.index:378,530`, `slot-view:228,294,339`, `stores:78,104`, `stores:161` | `(store:any)`, `stores:any[]`, `(product:any)` | `UserStoreSummary`, `UserStoreSampleProduct` | |
|
||||
| `checkout-hooks:16,44,67`, `CheckoutAddressSelector:11,72`, `checkout:31`, `flash.checkout:74`, `home.checkout:75` | `useState<any>`, `(addr/address:any)` | `UserAddress` | |
|
||||
| `AddressForm:48,59,90,92`, `MeOrderMenu:68,95`, `PaymentAndOrderComponent:67`, `me.addresses:121,149`, `me.edit-profile:46,56,103`, `me.orders.$id:42,54` | `onError/catch (e:any)`, `(error:any)` | delete / `unknown`; `AddressForm` narrow `Yup.ValidationError` | |
|
||||
| `me.orders.$id:99` | `order as any` | drop (`UserOrderDetail`) | |
|
||||
| `me.orders.$id:117,301`, `me.orders:189,75,325` | `(item/product:any)`, `useState<any[]>` | `UserOrderItemSummary`, `UserOrderSummary` | |
|
||||
| `BottomNavigation:72`, `Sidebar:81`, `me:167` | `to: … as any` | generated route union | |
|
||||
| `Topbar:27` | `'/home/search' as any` | drop cast | |
|
||||
| `home.index:122,291,472,474,513` | `(t/tag/b/_:any)`, `banners:any[]` | tag type, `UserBanner`, `string` | |
|
||||
| `home.product.$id:379` | `(deal:any)` | `SpecialDealCore` | |
|
||||
| `lib/auth-context:15,24,110` | `userDetails:any`, `(details:any)` | local `User` | |
|
||||
| `flash-cart-store:4,5` | `any \| null` | `MergedProduct \| null` (domain) | |
|
||||
| `useUploadToObjectStorage:26` | `contextString as any` | drop | |
|
||||
| `ProductCard:47,48`, `PaymentAndOrderComponent:13`, `slot-view:101,104`, `me.complaints:168` | `Record<number,any>`, `(slot:any)`, `cartItems:any[]`, `as any` time, `(complaint:any)` | `SlotInfo`, `CartItem[]`, widen helper, `UserComplaint` | |
|
||||
|
||||
**Easy wins:** drop redundant annotations on tRPC `onError` (10) and `cartData.items.find` (5); replace 5-file `productsById` boilerplate (10); `catch`→`unknown` (4); export `CartItem` + `SlotInfo`/`ProductSlotMap`; drop `navigate` casts; **fix `slot-view:399` bug**.
|
||||
|
||||
---
|
||||
|
||||
## apps/admin-web
|
||||
|
||||
**Summary:** **128** annotations (3 prose excluded) / 50 files. Top: `orders.sequence` (11), `prices` (10), `products.edit` (9). Dominant: `onError/catch` (36 = 28%), untyped rows, route casts.
|
||||
|
||||
| Location | Current code | Should be | Why |
|
||||
|---|---|---|---|
|
||||
| 24 sites (`stores.edit:35`, `orders.sequence:471,623,665`, `orders.list:71,171`, `coupons.*`, `slots:51`, `complaints:81`, `SlotForm:91,106`, `notifications:35`, `TagMenu:56`, `UserIncidentsView:31`, `staff-auth:62`, `orders.$id:68,84,95,105`, `CancelOrderDialog:34`, `prices:281`, `stores.new:27`) | `onError: (error:any)` | delete | tRPC infers |
|
||||
| 13 sites (`product-tags.new:50`, `product-tags.edit:65`, `coupons.new:57`, `products.$id:81`, `notifications:72`, `vendor-snippets:54,296`, `OrderOptionsMenu:69`, `products.new:106`, ProductGroupForm:68, VendorSnippetForm:87, products.edit:189, stores:114) | `catch (error:any)` | `unknown` + narrow | |
|
||||
| `orders.sequence:363,382,398,456,603,635` | `(deliverySequence as any)` | drop → `AdminDeliverySequence` | |
|
||||
| `slots.$id.edit:48`, `slots:28`, `rebalance:28`, `slots.new:43`, `SlotForm:47` | `(p:any)` | `AdminSlotProductSummary` | |
|
||||
| `SlotForm:36` | `(snippet:any)` | `AdminVendorSnippetWithAccess` (shape mismatch, domain) | |
|
||||
| `SlotForm:189`, `TagForm:114`, `prices:41` | `(prod/sku/f:any)` | `AdminSku`, `SkuSummary`, `AdminSkuFeature` | |
|
||||
| `product-tags.edit:92` | `(p:any)` | `AdminProductTagAssignment` | |
|
||||
| `ProductGroupForm:12`, `product-groupings:25,108` | `products:any[]`, `useState<any[]>` | `AdminSku[]` | |
|
||||
| `UserIncidentsView:142` | `(incident:any)` | `UserIncident` (domain) | |
|
||||
| `products:149` | `(product:any)` | `AdminProductWithRelations` | |
|
||||
| `rebalance:13`, `slots:14` | `item:any` | `AdminSlotWithProducts` | |
|
||||
| `rebalance:16,128`, `slots:15,242` | `Dispatch<…any[]>`, `useState<any[]>` | `string[]` | |
|
||||
| `slots.detail:31` | `useState<any[]>` | domain (`AdminSlotProductSummary` lacks `shortDescription`) | |
|
||||
| `orders.sequence:69` | `orders:any[]` | `AdminSlotOrder[]` | |
|
||||
| `prices:20,23,24,25,148,199,219,265` | `sku:any`, `Record<string,any>`, `const update:any` | `SkuRow`, existing `PendingChange`, mutation input | |
|
||||
| `vendor-snippets:233` | `useState<any>` | `{ orders; snippetCode }` (align `totalAmount` number/string) | |
|
||||
| `SnippetOrdersView:20` | `sequence:any[]` | `unknown`/`number[]` | shared is `unknown` |
|
||||
| `coupons.new:30`, `products.edit:87`, `products.new:19` | `values: any` | export `CouponFormValues` / `ProductFormData` | |
|
||||
| `CouponForm:78,84,171,206,207,220,252,253` | `function(value:any)`, `(values as any)`, `(errors as any)` | `CouponFormValues`, narrow | |
|
||||
| `coupons.tsx:14,17,114,220,363` | `item/coupon:any` | `Coupon & {relations}` / reserved (domain) | |
|
||||
| `MultiSelect:25`, `SearchableSelect:23`, `coupons:302,430` | `triggerComponent?: any` | `(props:{onClick:()=>void}) => ReactNode` | |
|
||||
| `SnippetMenu:20`, `TagMenu:11` | `triggerStyle?: any` | `string \| CSSProperties` | |
|
||||
| `TagForm:96` | `forwardRef<any,…>` | `unknown` (ref unused) | |
|
||||
| `customize-app:12,61,62,63` | `Record<string,any>`, `value:any`, `setFieldValue(any)`, `router:any` | `Record<string,unknown>`, `unknown`, local router shape | |
|
||||
| `customize-app.ordering:113`, `popular:126`, `product-tags.order:106` | `value.map((id:any)=>parseInt(id))` | root: `Constant.value` | |
|
||||
| `stores.edit:33`, `dashboard.index:82`, `customize-app:173`, `orders.tsx:24`, `dashboard.tsx:108` | `navigate({to: … as any})` | generated route union / drop | |
|
||||
| `products.edit:66,101,108,139,162,166,183`, `products.new:33,40,68,84,88` | `(variant/ci/a/attr:any)`, `as any` | `Variant`, `AdminProductComboItem`, `SkuFeatureLike`, `CreateComboItemInput` | |
|
||||
| `orders.$id:159` | `const mutationData:any` | `initiateRefund` input | |
|
||||
| `toaster:16`, `event-bus:6,11` | `_data:any`, `...args: any[]` | `unknown` | |
|
||||
|
||||
**Easy wins:** drop 23 `onError`; 13 `catch`→`unknown`; type slot rows (9); `prices` `SkuRow`+`PendingChange` (10); export `ProductFormData/Variant/Attribute/CouponFormValues`; fix shared `Constant.value`; typed routes (5).
|
||||
|
||||
---
|
||||
|
||||
## packages/shared + web-components + ui + migrator
|
||||
|
||||
**Summary:** **28** tokens / 17 files. Top: `app-common.types.ts` (5), `migrator/sqliteToPostgres` (4), `web-components/data-table.tsx` (4). Dominant: untyped component props + RN style/asset props. (No `catch (e: any)` in scope.)
|
||||
|
||||
| Location | Current code | Should be | Why |
|
||||
|---|---|---|---|
|
||||
| `packages/shared/types/app-common.types.ts:20` | `queryParams?: Record<string, any>` | `Record<string, unknown>` | only `String(value)` |
|
||||
| `app-common.types.ts:60` | `item: any` | generic `ListItemProps<T>` (domain) | shared list renderer |
|
||||
| `app-common.types.ts:78` | `item: any` | hoist shared product type (domain) | consumers use id/images/name/flashPrice/incrementStep |
|
||||
| `app-common.types.ts:87` | `products: any[]` | shared product type (domain) | offers section |
|
||||
| `app-common.types.ts:96` | `product: any` | shared product type (domain) | AddToCartDialog |
|
||||
| `packages/shared/types/const.types.ts:8` | `value: any` | `ConstValueType \| ConstValueType[]` (defined line 18) | consumers branch/narrow |
|
||||
| `migrator/sqliteToPostgres/index.ts:47` | `.all() as any[]` | named `SqliteColumnRow` | PRAGMA shape fixed |
|
||||
| `migrator/.../index.ts:113` | `parseValue(value:any): any` | `unknown` | returns JSON.parse/raw |
|
||||
| `migrator/.../index.ts:181` | `.all() as any[]` | `Record<string, unknown>[]` | dynamic `SELECT *` |
|
||||
| `packages/ui/src/services/axios.ts:56` | `(err as any).original = error` | `Object.assign` / typed `Error & { original: unknown }` | |
|
||||
| `packages/ui/src/lib/refresh-context.tsx:7` | `queryClient: any` | `QueryClient` | dep exists |
|
||||
| `packages/ui/src/lib/tailwind.ts:6` | `create(tailwindConfig as any)` | `as TwConfig` (`twrnc`) | |
|
||||
| `packages/ui/src/components/image-viewer.tsx:16` | `style?: any` | `StyleProp<ImageStyle>` | |
|
||||
| `packages/ui/src/components/info-dialog.tsx:15`, `dropdown.tsx:17` | `style?: any` | `StyleProp<ViewStyle>` | |
|
||||
| `packages/ui/src/components/search-bar.tsx:21` | `forwardRef<any,…>` | `React.ElementRef<typeof PaperTextInput>` | |
|
||||
| `packages/ui/src/components/text.tsx:25` | `let fontWeight: any` | `TextStyle['fontWeight']` | |
|
||||
| `packages/ui/src/components/profile-image.tsx:11,25` | `file?: any`, `setFile:(file:any)` | `ImagePickerAsset & { name }` | |
|
||||
| `packages/ui/src/components/use-pick-image.tsx:13` | `setFile: (file: any) => void` | `ImagePickerAsset \| ImagePickerAsset[] \| null` | root of many `assets: any` |
|
||||
| `packages/ui/src/components/ImageUploaderNeo.tsx:22` | `(files: any)` | `ImagePickerAsset \| ImagePickerAsset[] \| null` | |
|
||||
| `packages/ui/src/components/ImageCarousel.tsx:27` | `(event: any)` | `NativeSyntheticEvent<NativeScrollEvent>` | |
|
||||
| `packages/web-components/.../my-text-input.tsx:45` | `{...(props as any)}` | `TextareaHTMLAttributes` union | spread onto `<textarea>` |
|
||||
| `packages/web-components/.../data-table.tsx:7,12,13` | `(value:any,row:any)`, `data:any[]`, `(row:any,index)` | generic `Column<T>` / `DataTableProps<T>`, `T extends Record<string, unknown>` | |
|
||||
|
||||
**Easy wins:** `queryParams`/`Constant.value`; migrator `unknown` + typed PRAGMA row; `QueryClient`/`TwConfig`; RN prop types (10); `Object.assign`; make `DataTable` generic. **4 domain items:** product-shaped props in `app-common.types.ts`.
|
||||
|
||||
---
|
||||
|
||||
## Suggested remediation order
|
||||
|
||||
1. **Mechanical, huge payoff (~200 sites, no domain knowledge):** `catch`/`onError` → `unknown`; delete tRPC `onError` annotations; `Record<string, any>` → `unknown`; `z.any()` → `z.unknown()`; delete `: any` from typed Drizzle callbacks.
|
||||
2. **Shared root causes (removes dozens downstream):** `Constant.value`, `app-common.types.ts`, `use-pick-image.tsx`, export `CartItem`/`SlotInfo`/`MergedProduct` and `ProductFormData`/`CouponFormValues`.
|
||||
3. **Backend result typing:** replace `Promise<any…>` db-helper returns and `any[]` procedures with `InferSelectModel`/shared types — unblocks both admin frontends.
|
||||
4. **Typed routes:** Expo `Href` / TanStack generated union (removes ~50 `as any`).
|
||||
5. **Domain-input types to define:** `UserIncident`, reserved-coupon/client-coupon-with-relations, `RefundInput`, shared product type, `NotificationToast` payload.
|
||||
6. **Bugs masked by `any` (fix while typing):** `web-ui/src/routes/slot-view.tsx:399` (`cartItem.productId` → `skuId`), `db_helper_sqlite/src/admin-apis/slots.ts:191/194` (`isDeleted` not selected), `usePopulateCentralStores.ts:15` (`deliveryDate`/`displayDate`/`displayTime` fields not on the slot type).
|
||||
4
APIS_TO_REMOVE.md
Normal file
4
APIS_TO_REMOVE.md
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
- trpc.user.tags.getTagsByStore — apps/backend/src/trpc/apis/user-apis/apis/tags.ts
|
||||
- trpc.common.product.getAllProductsSummary — apps/backend/src/trpc/apis/common-apis/common.ts
|
||||
- remove slots from products cache
|
||||
- remove redundant product details like name, description etc from the slots api
|
||||
|
|
@ -1,162 +0,0 @@
|
|||
# Dead Code Report — branch `DEAD_CODE_CLEAN`
|
||||
|
||||
> **Status: EXECUTED.** The dead code listed below has been removed (see "Removal status" at the bottom). The one exception is the Drizzle relation exports, which were kept after verification.
|
||||
|
||||
Static-analysis sweep for reachable-code / caller-less code across the monorepo.
|
||||
**Method:** ripgrep-based caller search + manual verification.
|
||||
|
||||
- **tRPC endpoints:** every procedure in `admin`/`user`/`common` routers was enumerated, then searched for callers (`trpc.<path>` and `trpcClient.<path>`) in `apps/admin-ui`, `apps/user-ui`, `apps/web-ui`, `apps/admin-web`, `apps/fallback-ui`, `apps/info-site`.
|
||||
- **Methods/functions:** exported + local `function`/`const` definitions in `apps/backend/src` and `packages/db_helper_sqlite/src` were searched repo-wide; classified as *truly uncalled* only when they appear nowhere except their definition (and pure re-export files).
|
||||
- **Files:** inbound-reference scan by module name.
|
||||
- Excludes `node_modules`, `dist`, `.wrangler`, `.output`, `.expo`, `.turbo`, `.tanstack`.
|
||||
|
||||
> Caveats at the bottom — some of these may be intentional (manual tooling, external API consumers, planned features). Verify before deleting.
|
||||
|
||||
---
|
||||
|
||||
## 1. Dead tRPC endpoints (15)
|
||||
|
||||
No caller in any client app (native or web). Each is still registered on the router, so it is reachable over HTTP but unused by this repo.
|
||||
|
||||
| Endpoint | File | Line | Note |
|
||||
|---|---|---|---|
|
||||
| `trpc.admin.coupon.validate` | `apps/backend/src/trpc/apis/admin-apis/apis/coupon.ts` | 265 | Admin coupon validation |
|
||||
| `trpc.admin.vendorSnippets.getById` | `.../admin-apis/apis/vendor-snippets.ts` | 184 | Single snippet fetch |
|
||||
| `trpc.admin.vendorSnippets.getVendorOrders` | `.../admin-apis/apis/vendor-snippets.ts` | 464 | Vendor orders |
|
||||
| `trpc.admin.slots.getSlotsProductIds` | `.../admin-apis/apis/slots.ts` | 138 | Dup of product.* variant |
|
||||
| `trpc.admin.slots.updateSlotProducts` | `.../admin-apis/apis/slots.ts` | 194 | Dup of product.* variant |
|
||||
| `trpc.admin.slots.getSlots` | `.../admin-apis/apis/slots.ts` | 387 | Only `trpc.user.slots.getSlots` is used |
|
||||
| `trpc.admin.slots.deleteSlot` | `.../admin-apis/apis/slots.ts` | 583 | No delete-slot UI |
|
||||
| `trpc.admin.product.updateSlotProducts` | `.../admin-apis/apis/product.ts` | 392 | Duplicate router entry |
|
||||
| `trpc.admin.product.getSlotsProductIds` | `.../admin-apis/apis/product.ts` | 462 | Duplicate router entry |
|
||||
| `trpc.admin.staffUser.getUsers` | `.../admin-apis/apis/staff-user.ts` | 79 | `admin.user.getAllUsers` is the used one |
|
||||
| `trpc.admin.staffUser.getUserDetails` | `.../admin-apis/apis/staff-user.ts` | 104 | `admin.user.getUserDetails` is used |
|
||||
| `trpc.admin.staffUser.updateUserSuspension` | `.../admin-apis/apis/staff-user.ts` | 128 | `admin.user.updateUserSuspension` is used |
|
||||
| `trpc.admin.user.createUserByMobile` | `.../admin-apis/apis/user.ts` | 29 | No caller |
|
||||
| `trpc.common.product.getDashboardTags` | `.../common-apis/common.ts` | 144 | Home reads tags from `products.json`, not this |
|
||||
| `trpc.hello` | `apps/backend/src/trpc/router.ts` | 14 | Scaffold sample endpoint |
|
||||
|
||||
**Notable:** the `slots` ↔ `product` routers have **four duplicated** procedures (`getSlotsProductIds`, `updateSlotProducts`) — both copies are dead. The `staffUser` router duplicates three user-management procedures that are only ever called via the `admin.user` router.
|
||||
|
||||
---
|
||||
|
||||
## 2. Dead backend functions / constants (truly uncalled)
|
||||
|
||||
Defined/exported but never referenced anywhere else in the repo.
|
||||
|
||||
### Env / config
|
||||
| Symbol | File |
|
||||
|---|---|
|
||||
| `getJwtSecret` | `apps/backend/src/lib/env-exporter.ts` |
|
||||
| `getRedisUrl` | `apps/backend/src/lib/env-exporter.ts` |
|
||||
| `getPhonePeBaseUrl` | `apps/backend/src/lib/env-exporter.ts` |
|
||||
| `getPhonePeClientId` | `apps/backend/src/lib/env-exporter.ts` |
|
||||
| `getPhonePeClientVersion` | `apps/backend/src/lib/env-exporter.ts` |
|
||||
| `getPhonePeClientSecret` | `apps/backend/src/lib/env-exporter.ts` |
|
||||
| `getPhonePeMerchantId` | `apps/backend/src/lib/env-exporter.ts` |
|
||||
| `READABLE_ORDER_ID_KEY` | `apps/backend/src/lib/const-strings.ts` |
|
||||
| `WELCOME_MESSAGE` | `apps/backend/src/lib/const-strings.ts` |
|
||||
| `defaultRole` | `apps/backend/src/lib/roles-manager.ts` |
|
||||
|
||||
### Notifications / jobs
|
||||
| Symbol | File | Note |
|
||||
|---|---|---|
|
||||
| `sendPushNotificationsMany` | `apps/backend/src/lib/expo-service.ts` | Whole file has no importers (see §4) |
|
||||
| `sendOrderPlacedNotification` | `apps/backend/src/lib/notif-job.ts` | Only an unused import + a commented call in `user-apis/order.ts` |
|
||||
| `sendOrderCancelledNotification` | `apps/backend/src/lib/notif-job.ts` | Same — imported, call commented |
|
||||
| `sendPaymentFailedNotification` | `apps/backend/src/lib/notif-job.ts` | No reference |
|
||||
| `sendOrderOutForDeliveryNotification` | `apps/backend/src/lib/notif-job.ts` | No reference |
|
||||
| `sendRefundInitiatedNotification` | `apps/backend/src/lib/notif-job.ts` | No reference |
|
||||
|
||||
*(Alive: `sendAdminNotification` via `queue-consumer.ts` ← `worker.ts`; `sendOrderPackagedNotification` and `sendOrderDeliveredNotification` via admin order api; `scheduleNotification` via those.)*
|
||||
|
||||
### Stores / cache / misc
|
||||
| Symbol | File |
|
||||
|---|---|
|
||||
| `getOrderDetailsWrapper` | `apps/backend/src/dbService.ts` |
|
||||
| `clearAllCache` | `apps/backend/src/lib/cloud_cache.ts` |
|
||||
| `getAllBanners` | `apps/backend/src/stores/banner-store.ts` |
|
||||
| `getTagById` | `apps/backend/src/stores/product-tag-store.ts` |
|
||||
| `getAllTags` | `apps/backend/src/stores/product-tag-store.ts` |
|
||||
| `getProductSlots` | `apps/backend/src/stores/slot-store.ts` |
|
||||
| `getAllProductsSlots` | `apps/backend/src/stores/slot-store.ts` |
|
||||
| `getUserNegativity` | `apps/backend/src/stores/user-negativity-store.ts` |
|
||||
| `createTRPCRouter` | `apps/backend/src/trpc/trpc-index.ts` (alias re-export, unused) |
|
||||
|
||||
---
|
||||
|
||||
## 3. Dead exports in `db_helper_sqlite`
|
||||
|
||||
| Symbol | File | Note |
|
||||
|---|---|---|
|
||||
| `mergeDuplicateProducts` | `packages/db_helper_sqlite/src/admin-apis/merge-duplicate-products.ts` | Exported via index + `sqliteImporter`, but **no code calls it** (function had a "how to use manually" doc comment → manual utility). **Removed.** |
|
||||
|
||||
`productSkusRelations`, `productMarketStatsRelations`, `skuFeaturesRelations`, `productCombosRelations` (`src/db/schema.ts`) looked unreferenced but are **NOT dead** — Drizzle registers relation exports via `import * as schema` in `db_index.ts`, and `db.query.*.findMany({ with: … })` depends on them. **Kept.**
|
||||
|
||||
Used only inside their own file (helpers of live code — **not dead**): `parseDuplicateProductsMd` (deleted with the merge utility), `splitQuantityFeature`, `cleanFeatureValue`, `productTypeEnum`.
|
||||
|
||||
---
|
||||
|
||||
## 4. Dead / unreachable files & endpoints
|
||||
|
||||
| Item | File | Note |
|
||||
|---|---|---|
|
||||
| Expo push service | `apps/backend/src/lib/expo-service.ts` | No module imports it; its only export is uncalled |
|
||||
| `av-router` | `apps/backend/src/apis/admin-apis/apis/av-router.ts` | Mounted at `/api/v1/av` but registers **zero routes** (staff-auth middleware only) |
|
||||
| REST product summary | `apps/backend/src/apis/common-apis/apis/common-product.controller.ts` (`getAllProductsSummary`) | Reachable at `/api/v1/cm/products/summary`, but **no client calls it** (apps use tRPC) |
|
||||
|
||||
`/api/test` (`test-controller.ts`) is referenced only by `apps/fallback-ui/src/routes/demo.tsx` (a demo page).
|
||||
|
||||
---
|
||||
|
||||
## 5. Unused imports that point at dead code
|
||||
|
||||
| File | Unused import | Reality |
|
||||
|---|---|---|
|
||||
| `apps/backend/src/trpc/apis/user-apis/apis/order.ts` | `sendOrderPlacedNotification` (line 29) | Call is commented out (line 235) |
|
||||
| `apps/backend/src/trpc/apis/user-apis/apis/order.ts` | `sendOrderCancelledNotification` (line 30) | Call is commented out (line 598) |
|
||||
|
||||
---
|
||||
|
||||
## 6. Commented-out / unreachable code blocks
|
||||
|
||||
Not "callers-less" but unreachable. Many "Old implementation" blocks were kept across the backend; the most notable registered-looking ones:
|
||||
|
||||
- `apps/backend/src/trpc/apis/common-apis/common.ts:160-176` — `getStoresSummary` and `healthCheck` procedures inside a `/* … */` block (NOT registered; the live ones are in `common-trpc-index.ts`).
|
||||
- `apps/backend/src/main-router.ts` — `// router.route('/av', avRouter)` (route disabled).
|
||||
- Numerous `// Old implementation - direct DB queries:` blocks in `admin-apis/*`, `user-apis/*`, and db helpers (historical only).
|
||||
|
||||
---
|
||||
|
||||
## 7. Checked and NOT dead (for reference)
|
||||
|
||||
- `apps/admin-ui` / `user-ui` / `web-ui` exports: **0 truly-unused**. 18 shared exports (`AdminOrderItemCore`, `SkuFlagCore`, `UserStoreSummaryCore`, …) are referenced only within their own file to compose other types — that's normal, not dead.
|
||||
- Backend functions used only inside their own file (e.g. `cloud_cache` internals `createProductsFileInternal`, `constructCacheUrl`, `clearUrlCache`; `slot-store` `transformSlotToStoreSlot`/`extractSlotInfo`/`fetchAllTransformedSlots`; `trpc-index` `createCallerFactory`) — all reachable via their parent functions, **not dead**.
|
||||
- `queue-consumer.ts` — although it imports only-in-backend, it IS used by `worker.ts`, so alive.
|
||||
|
||||
---
|
||||
|
||||
## Caveats
|
||||
|
||||
1. **Static analysis only.** A tRPC procedure can still be called by an external/undisclosed client (mobile build, partner integration). Confirm before removing public procedures.
|
||||
2. **`mergeDuplicateProducts`** and **`expo-service`** look intentionally kept for manual/one-off use — decide whether to delete or document.
|
||||
3. The `admin-web` tree is present on disk but **untracked** on this branch; it was included in the caller search.
|
||||
4. Some "dead" management procedures (`staffUser.*`, duplicated `slots`/`product` entries) may be leftovers from older UI screens; safe to remove once confirmed.
|
||||
|
||||
## Removal status (executed)
|
||||
|
||||
**Removed — 15 tRPC procedures:** `admin.coupon.validate`; `admin.vendorSnippets.getById`, `getVendorOrders`; `admin.slots.getSlotsProductIds`, `updateSlotProducts`, `getSlots`, `deleteSlot`; `admin.product.updateSlotProducts`, `getSlotsProductIds`; `admin.staffUser.getUsers`, `getUserDetails`, `updateUserSuspension`; `admin.user.createUserByMobile`; `common.product.getDashboardTags`; `trpc.hello`.
|
||||
|
||||
**Removed — backend functions/constants:** `getJwtSecret`, `getRedisUrl`, `getExpoAccessToken`, `getPhonePeBaseUrl/ClientId/ClientVersion/ClientSecret/MerchantId`, `READABLE_ORDER_ID_KEY`, `WELCOME_MESSAGE`, `defaultRole`, `getOrderDetailsWrapper`, `clearAllCache`, `getAllBanners`, `getTagById`, `getAllTags`, `getDashboardTags`, `getProductSlots`, `getAllProductsSlots`, `getUserNegativity`, `createTRPCRouter`, `createCallerFactory`, and the notification senders `sendOrderPlacedNotification`, `sendPaymentFailedNotification`, `sendOrderOutForDeliveryNotification`, `sendOrderCancelledNotification`, `sendRefundInitiatedNotification` + their message constants.
|
||||
|
||||
**Removed — DB helper functions orphaned by the procedures above (from BOTH `db_helper_sqlite` and `db_helper_postgres`, plus their barrel exports and `sqliteImporter` re-exports):**
|
||||
`createUserByMobile`, `deleteSlotById`, `getVendorOrders`, `updateSlotProducts`, `getSlotsProductIds` (the five behind the deleted endpoints) **and** `getActiveSlots`, `getAllUsers`, `getUserWithDetails`, `validateCoupon`, `checkUnitExists`, `getProductImagesById`, `replaceProductTags` (same class, missed by the first scan because their names also exist in the parallel package).
|
||||
|
||||
**Removed — files:** `apps/backend/src/lib/expo-service.ts`, `apps/backend/src/middleware/staff-auth.ts` (only used by the empty `av-router`), `apps/backend/src/apis/admin-apis/apis/av-router.ts` (empty router), `packages/db_helper_sqlite/src/admin-apis/merge-duplicate-products.ts`.
|
||||
|
||||
**Removed — wiring:** `avRouter` import/route from `main-router.ts` + `v1-router.ts`; `mergeDuplicateProducts` exports from `db_helper_sqlite/index.ts` and `sqliteImporter.ts`; unused `sendOrder*Notification` import + commented calls in `user-apis/order.ts`; orphaned imports in all touched files.
|
||||
|
||||
**Kept (false positives / intentional):** the four Drizzle relation exports (implicitly registered); `mergeDuplicateProducts` was deleted per §3.
|
||||
|
||||
**Verification:** `tsc` clean for `apps/backend`, `packages/db_helper_sqlite`, `apps/web-ui`, `apps/admin-ui`, `apps/user-ui`, `apps/admin-web`. (`apps/fallback-ui` has one pre-existing `vite.config.ts` error unrelated to this change.)
|
||||
|
||||
83
apps/admin-ui/.detoxrc.js
Normal file
83
apps/admin-ui/.detoxrc.js
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
/** @type {Detox.DetoxConfig} */
|
||||
module.exports = {
|
||||
testRunner: {
|
||||
args: {
|
||||
'$0': 'jest',
|
||||
config: 'e2e/jest.config.js'
|
||||
},
|
||||
jest: {
|
||||
setupTimeout: 120000
|
||||
}
|
||||
},
|
||||
apps: {
|
||||
'ios.debug': {
|
||||
type: 'ios.app',
|
||||
binaryPath: 'ios/build/Build/Products/Debug-iphonesimulator/YOUR_APP.app',
|
||||
build: 'xcodebuild -workspace ios/YOUR_APP.xcworkspace -scheme YOUR_APP -configuration Debug -sdk iphonesimulator -derivedDataPath ios/build'
|
||||
},
|
||||
'ios.release': {
|
||||
type: 'ios.app',
|
||||
binaryPath: 'ios/build/Build/Products/Release-iphonesimulator/YOUR_APP.app',
|
||||
build: 'xcodebuild -workspace ios/YOUR_APP.xcworkspace -scheme YOUR_APP -configuration Release -sdk iphonesimulator -derivedDataPath ios/build'
|
||||
},
|
||||
'android.debug': {
|
||||
type: 'android.apk',
|
||||
binaryPath: 'android/app/build/outputs/apk/debug/app-debug.apk',
|
||||
build: 'cd android && ./gradlew assembleDebug assembleAndroidTest -DtestBuildType=debug',
|
||||
reversePorts: [
|
||||
8081
|
||||
]
|
||||
},
|
||||
'android.release': {
|
||||
type: 'android.apk',
|
||||
binaryPath: 'android/app/build/outputs/apk/release/app-release.apk',
|
||||
build: 'cd android && ./gradlew assembleRelease assembleAndroidTest -DtestBuildType=release'
|
||||
}
|
||||
},
|
||||
devices: {
|
||||
simulator: {
|
||||
type: 'ios.simulator',
|
||||
device: {
|
||||
type: 'iPhone 15'
|
||||
}
|
||||
},
|
||||
attached: {
|
||||
type: 'android.attached',
|
||||
device: {
|
||||
adbName: '.*'
|
||||
}
|
||||
},
|
||||
emulator: {
|
||||
type: 'android.emulator',
|
||||
device: {
|
||||
avdName: 'Pixel_3a_API_30_x86'
|
||||
}
|
||||
}
|
||||
},
|
||||
configurations: {
|
||||
'ios.sim.debug': {
|
||||
device: 'simulator',
|
||||
app: 'ios.debug'
|
||||
},
|
||||
'ios.sim.release': {
|
||||
device: 'simulator',
|
||||
app: 'ios.release'
|
||||
},
|
||||
'android.att.debug': {
|
||||
device: 'attached',
|
||||
app: 'android.debug'
|
||||
},
|
||||
'android.att.release': {
|
||||
device: 'attached',
|
||||
app: 'android.release'
|
||||
},
|
||||
'android.emu.debug': {
|
||||
device: 'emulator',
|
||||
app: 'android.debug'
|
||||
},
|
||||
'android.emu.release': {
|
||||
device: 'emulator',
|
||||
app: 'android.release'
|
||||
}
|
||||
}
|
||||
};
|
||||
6
apps/admin-ui/.expo/types/router.d.ts
vendored
6
apps/admin-ui/.expo/types/router.d.ts
vendored
File diff suppressed because one or more lines are too long
|
|
@ -1,6 +1,7 @@
|
|||
import { Drawer } from "expo-router/drawer";
|
||||
import { DrawerContentScrollView, DrawerItem } from "@react-navigation/drawer";
|
||||
import { useRouter, Redirect } from "expo-router";
|
||||
import { useNavigation, DrawerActions } from "@react-navigation/native";
|
||||
import {
|
||||
TouchableOpacity,
|
||||
DeviceEventEmitter,
|
||||
|
|
@ -12,25 +13,6 @@ import { REFRESH_EVENT } from "common-ui/src/lib/const-strs";
|
|||
import { useStaffAuth } from "@/components/context/staff-auth-context";
|
||||
import { tw, MyText, theme } from "common-ui";
|
||||
|
||||
const SCREEN_TITLES: Record<string, string> = {
|
||||
index: "Dashboard",
|
||||
products: "Products",
|
||||
"prices-overview": "Prices Overview",
|
||||
"product-groupings": "Product Groupings",
|
||||
"dashboard-banners": "Dashboard Banners",
|
||||
slots: "Slots",
|
||||
complaints: "Complaints",
|
||||
"manage-orders": "Manage Orders",
|
||||
coupons: "Coupons",
|
||||
"vendor-snippets": "Vendor Snippets",
|
||||
stores: "Stores",
|
||||
"user-management": "User Management",
|
||||
"send-notifications": "Send Notifications",
|
||||
"product-tags": "Product Tags",
|
||||
"rebalance-orders": "Rebalance Orders",
|
||||
"customize-app": "Customize App",
|
||||
};
|
||||
|
||||
function CustomDrawerContent() {
|
||||
const router = useRouter();
|
||||
const { logout, staff } = useStaffAuth();
|
||||
|
|
@ -50,108 +32,128 @@ function CustomDrawerContent() {
|
|||
)}
|
||||
<DrawerItem
|
||||
label="Dashboard"
|
||||
onPress={() => router.dismissTo("/(drawer)/dashboard" as any)}
|
||||
onPress={() => router.push("/(drawer)/dashboard" as any)}
|
||||
icon={({ color, size }) => (
|
||||
<MaterialIcons name="dashboard" size={size} color={color} />
|
||||
)}
|
||||
/>
|
||||
<DrawerItem
|
||||
label="Products"
|
||||
onPress={() => router.push("/(drawer)/dashboard/products" as any)}
|
||||
{/* <DrawerItem
|
||||
label="Add Product"
|
||||
onPress={() => router.push("/(drawer)/add-product" as any)}
|
||||
icon={({ color, size }) => (
|
||||
<MaterialIcons name="add" size={size} color={color} />
|
||||
)}
|
||||
/> */}
|
||||
<DrawerItem
|
||||
label="Products"
|
||||
onPress={() => router.push("/(drawer)/products" as any)}
|
||||
icon={({ color, size }) => (
|
||||
<MaterialIcons name="inventory" size={size} color={color} />
|
||||
)}
|
||||
/>
|
||||
<DrawerItem
|
||||
label="Prices Overview"
|
||||
onPress={() => router.push("/(drawer)/prices-overview" as any)}
|
||||
icon={({ color, size }) => (
|
||||
<MaterialIcons name="attach-money" size={size} color={color} />
|
||||
)}
|
||||
/>
|
||||
<DrawerItem
|
||||
label="Product Groupings"
|
||||
onPress={() => router.push("/(drawer)/product-groupings" as any)}
|
||||
icon={({ color, size }) => (
|
||||
<MaterialIcons name="group-work" size={size} color={color} />
|
||||
)}
|
||||
/>
|
||||
<DrawerItem
|
||||
label="Dashboard Banners"
|
||||
onPress={() => router.push("/(drawer)/dashboard-banners" as any)}
|
||||
icon={({ color, size }) => (
|
||||
<MaterialIcons name="view-carousel" size={size} color={color} />
|
||||
)}
|
||||
/>
|
||||
<DrawerItem
|
||||
label="Slots"
|
||||
onPress={() => router.push("/(drawer)/slots" as any)}
|
||||
icon={({ color, size }) => (
|
||||
<MaterialIcons name="schedule" size={size} color={color} />
|
||||
)}
|
||||
/>
|
||||
{/* <DrawerItem
|
||||
label="Edit Product"
|
||||
onPress={() => router.push("/(drawer)/edit-product" as any)}
|
||||
icon={({ color, size }) => (
|
||||
<MaterialIcons name="inventory" size={size} color={color} />
|
||||
<MaterialIcons name="edit" size={size} color={color} />
|
||||
)}
|
||||
/>
|
||||
<DrawerItem
|
||||
label="Prices Overview"
|
||||
onPress={() => router.push("/(drawer)/dashboard/prices-overview" as any)}
|
||||
icon={({ color, size }) => (
|
||||
<MaterialIcons name="attach-money" size={size} color={color} />
|
||||
)}
|
||||
/>
|
||||
<DrawerItem
|
||||
label="Product Groupings"
|
||||
onPress={() => router.push("/(drawer)/dashboard/product-groupings" as any)}
|
||||
icon={({ color, size }) => (
|
||||
<MaterialIcons name="group-work" size={size} color={color} />
|
||||
)}
|
||||
/>
|
||||
<DrawerItem
|
||||
label="Dashboard Banners"
|
||||
onPress={() => router.push("/(drawer)/dashboard/dashboard-banners" as any)}
|
||||
icon={({ color, size }) => (
|
||||
<MaterialIcons name="view-carousel" size={size} color={color} />
|
||||
)}
|
||||
/>
|
||||
<DrawerItem
|
||||
label="Slots"
|
||||
onPress={() => router.push("/(drawer)/dashboard/slots" as any)}
|
||||
icon={({ color, size }) => (
|
||||
<MaterialIcons name="schedule" size={size} color={color} />
|
||||
)}
|
||||
/>
|
||||
/> */}
|
||||
<DrawerItem
|
||||
label="Complaints"
|
||||
onPress={() => router.push("/(drawer)/dashboard/complaints" as any)}
|
||||
onPress={() => router.push("/(drawer)/complaints" as any)}
|
||||
icon={({ color, size }) => (
|
||||
<MaterialIcons name="report-problem" size={size} color={color} />
|
||||
)}
|
||||
/>
|
||||
<DrawerItem
|
||||
label="Manage Orders"
|
||||
onPress={() => router.push("/(drawer)/dashboard/manage-orders" as any)}
|
||||
onPress={() => router.push("/(drawer)/manage-orders" as any)}
|
||||
icon={({ color, size }) => (
|
||||
<MaterialIcons name="shopping-bag" size={size} color={color} />
|
||||
)}
|
||||
/>
|
||||
<DrawerItem
|
||||
label="Coupons"
|
||||
onPress={() => router.push("/(drawer)/dashboard/coupons" as any)}
|
||||
icon={({ color, size }) => (
|
||||
<MaterialIcons name="local-offer" size={size} color={color} />
|
||||
)}
|
||||
/>
|
||||
<DrawerItem
|
||||
label="Vendor Snippets"
|
||||
onPress={() => router.push("/(drawer)/dashboard/vendor-snippets" as any)}
|
||||
icon={({ color, size }) => (
|
||||
<MaterialIcons name="code" size={size} color={color} />
|
||||
)}
|
||||
/>
|
||||
<DrawerItem
|
||||
label="Stores"
|
||||
onPress={() => router.push("/(drawer)/dashboard/stores" as any)}
|
||||
icon={({ color, size }) => (
|
||||
<MaterialIcons name="store" size={size} color={color} />
|
||||
)}
|
||||
/>
|
||||
<DrawerItem
|
||||
label="User Management"
|
||||
onPress={() => router.push("/(drawer)/dashboard/user-management" as any)}
|
||||
icon={({ color, size }) => (
|
||||
<MaterialIcons name="people" size={size} color={color} />
|
||||
)}
|
||||
/>
|
||||
<DrawerItem
|
||||
label="Send Notifications"
|
||||
onPress={() => router.push("/(drawer)/dashboard/send-notifications" as any)}
|
||||
icon={({ color, size }) => (
|
||||
<MaterialIcons name="campaign" size={size} color={color} />
|
||||
)}
|
||||
/>
|
||||
<DrawerItem
|
||||
label="Logout"
|
||||
onPress={() => logout()}
|
||||
icon={({ color, size }) => (
|
||||
<MaterialIcons name="logout" size={size} color={color} />
|
||||
)}
|
||||
/>
|
||||
<DrawerItem
|
||||
label="Coupons"
|
||||
onPress={() => router.push("coupons" as any)}
|
||||
icon={({ color, size }) => (
|
||||
<MaterialIcons name="local-offer" size={size} color={color} />
|
||||
)}
|
||||
/>
|
||||
<DrawerItem
|
||||
label="Vendor Snippets"
|
||||
onPress={() => router.push("vendor-snippets" as any)}
|
||||
icon={({ color, size }) => (
|
||||
<MaterialIcons name="code" size={size} color={color} />
|
||||
)}
|
||||
/>
|
||||
<DrawerItem
|
||||
label="Stores"
|
||||
onPress={() => router.push("/(drawer)/stores" as any)}
|
||||
icon={({ color, size }) => (
|
||||
<MaterialIcons name="store" size={size} color={color} />
|
||||
)}
|
||||
/>
|
||||
<DrawerItem
|
||||
label="User Management"
|
||||
onPress={() => router.push("/(drawer)/user-management" as any)}
|
||||
icon={({ color, size }) => (
|
||||
<MaterialIcons name="people" size={size} color={color} />
|
||||
)}
|
||||
/>
|
||||
<DrawerItem
|
||||
label="Send Notifications"
|
||||
onPress={() => router.push("/(drawer)/send-notifications" as any)}
|
||||
icon={({ color, size }) => (
|
||||
<MaterialIcons name="campaign" size={size} color={color} />
|
||||
)}
|
||||
/>
|
||||
<DrawerItem
|
||||
label="Offers"
|
||||
onPress={() => router.push("/(drawer)/offers" as any)}
|
||||
icon={({ color, size }) => (
|
||||
<MaterialIcons name="sell" size={size} color={color} />
|
||||
)}
|
||||
/>
|
||||
<DrawerItem
|
||||
label="Logout"
|
||||
onPress={() => logout()}
|
||||
icon={({ color, size }) => (
|
||||
<MaterialIcons name="logout" size={size} color={color} />
|
||||
)}
|
||||
/>
|
||||
</DrawerContentScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Layout() {
|
||||
const router = useRouter();
|
||||
const { isLoggedIn, isLoading } = useStaffAuth();
|
||||
|
||||
if (isLoading) {
|
||||
|
|
@ -168,72 +170,75 @@ export default function Layout() {
|
|||
|
||||
return (
|
||||
<Drawer
|
||||
backBehavior="history"
|
||||
drawerContent={CustomDrawerContent}
|
||||
screenOptions={({ navigation }) => {
|
||||
const state = navigation.getState();
|
||||
const focusedRoute = state?.routes?.[state?.index ?? 0];
|
||||
const dashboardStack = (focusedRoute as any)?.state;
|
||||
const nestedIndex = dashboardStack?.index ?? 0;
|
||||
const canGoBack = nestedIndex > 0;
|
||||
const nestedRoute = dashboardStack?.routes?.[nestedIndex];
|
||||
const title = SCREEN_TITLES[nestedRoute?.name ?? ""] ?? "Dashboard";
|
||||
|
||||
return {
|
||||
swipeEnabled: false,
|
||||
headerShown: true,
|
||||
headerStyle: {
|
||||
backgroundColor: theme.colors.gray1,
|
||||
shadowOpacity: 0,
|
||||
shadowRadius: 0,
|
||||
shadowOffset: { height: 0, width: 0 },
|
||||
elevation: 0,
|
||||
},
|
||||
headerTitle: title,
|
||||
headerTitleAlign: "center",
|
||||
headerLeft: () => (
|
||||
<TouchableOpacity
|
||||
onPress={() =>
|
||||
canGoBack ? router.back() : (navigation as any).openDrawer()
|
||||
}
|
||||
style={{
|
||||
marginLeft: 15,
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
backgroundColor: "#f2f2f2",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<MaterialIcons
|
||||
name={canGoBack ? "arrow-back" : "menu"}
|
||||
size={24}
|
||||
color="black"
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
),
|
||||
headerRight: () => (
|
||||
<TouchableOpacity
|
||||
style={{
|
||||
marginRight: 15,
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
backgroundColor: "#f2f2f2",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
onPress={() => {
|
||||
DeviceEventEmitter.emit(REFRESH_EVENT);
|
||||
}}
|
||||
>
|
||||
<MaterialIcons name="refresh" size={24} color="black" />
|
||||
</TouchableOpacity>
|
||||
),
|
||||
};
|
||||
}}
|
||||
screenOptions={({ navigation, route }) => ({
|
||||
swipeEnabled: false,
|
||||
headerShown: true,
|
||||
headerStyle: {
|
||||
backgroundColor: theme.colors.gray1,
|
||||
shadowOpacity: 0,
|
||||
shadowRadius: 0,
|
||||
shadowOffset: { height: 0, width: 0 },
|
||||
elevation: 0,
|
||||
},
|
||||
headerTitleAlign: "center",
|
||||
headerLeft: () => (
|
||||
<TouchableOpacity
|
||||
onPress={() => (navigation as any).openDrawer()}
|
||||
style={{
|
||||
marginLeft: 15,
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
backgroundColor: "#f2f2f2",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<MaterialIcons name="menu" size={24} color="black" />
|
||||
</TouchableOpacity>
|
||||
),
|
||||
headerRight: () => (
|
||||
<TouchableOpacity
|
||||
style={{
|
||||
marginRight: 15,
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
backgroundColor: "#f2f2f2",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
onPress={() => {
|
||||
DeviceEventEmitter.emit(REFRESH_EVENT);
|
||||
}}
|
||||
>
|
||||
<MaterialIcons name="refresh" size={24} color="black" />
|
||||
</TouchableOpacity>
|
||||
),
|
||||
})}
|
||||
>
|
||||
<Drawer.Screen name="dashboard" options={{ title: "Dashboard" }} />
|
||||
</Drawer>
|
||||
<Drawer.Screen name="dashboard" options={{ title: "Dashboard" }} />
|
||||
<Drawer.Screen name="products" options={{ title: "Products" }} />
|
||||
<Drawer.Screen name="prices-overview" options={{ title: "Prices Overview" }} />
|
||||
<Drawer.Screen name="product-groupings" options={{ title: "Product Groupings" }} />
|
||||
|
||||
|
||||
<Drawer.Screen
|
||||
name="manage-orders"
|
||||
options={{ title: "Manage Orders" }}
|
||||
/>
|
||||
<Drawer.Screen name="complaints" options={{ title: "Complaints" }} />
|
||||
<Drawer.Screen name="coupons" options={{ title: "Coupons" }} />
|
||||
<Drawer.Screen name="slots" options={{ title: "Slots" }} />
|
||||
<Drawer.Screen name="offers" options={{ title: "Offers" }} />
|
||||
<Drawer.Screen name="vendor-snippets" options={{ title: "Vendor Snippets" }} />
|
||||
<Drawer.Screen name="stores" options={{ title: "Stores" }} />
|
||||
<Drawer.Screen name="product-tags" options={{ title: "Product Tags" }} />
|
||||
<Drawer.Screen name="rebalance-orders" options={{ title: "Rebalance Orders" }} />
|
||||
<Drawer.Screen name="user-management" options={{ title: "User Management" }} />
|
||||
<Drawer.Screen name="send-notifications" options={{ title: "Send Notifications" }} />
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,11 +47,11 @@ export default function Complaints() {
|
|||
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
|
||||
|
||||
const handleUserPress = useCallback((userId: number) => {
|
||||
router.push(`/(drawer)/dashboard/user-management/${userId}`);
|
||||
router.push(`/(drawer)/user-management/${userId}`);
|
||||
}, [router]);
|
||||
|
||||
const handleOrderPress = useCallback((orderId: number) => {
|
||||
router.push(`/(drawer)/dashboard/manage-orders/order-details/${orderId}`);
|
||||
router.push(`/(drawer)/manage-orders/order-details/${orderId}`);
|
||||
}, [router]);
|
||||
|
||||
const handleMarkResolved = (id: number) => {
|
||||
|
|
@ -63,7 +63,7 @@ export default function Complaints() {
|
|||
if (!selectedComplaintId) return;
|
||||
|
||||
resolveComplaint.mutate(
|
||||
{ id: String(selectedComplaintId), response: response ?? '' },
|
||||
{ id: String(selectedComplaintId), response },
|
||||
{
|
||||
onSuccess: () => {
|
||||
Alert.alert("Success", "Complaint marked as resolved");
|
||||
|
|
@ -117,10 +117,6 @@ export default function Complaints() {
|
|||
contentContainerStyle={tw`px-4 py-4`}
|
||||
data={complaints}
|
||||
keyExtractor={(item) => item.id.toString()}
|
||||
initialNumToRender={10}
|
||||
maxToRenderPerBatch={10}
|
||||
windowSize={10}
|
||||
removeClippedSubviews
|
||||
onEndReached={handleLoadMore}
|
||||
onEndReachedThreshold={0.5}
|
||||
renderItem={({ item }) => (
|
||||
|
|
@ -150,20 +146,6 @@ export default function Complaints() {
|
|||
{item.text}
|
||||
</MyText>
|
||||
|
||||
{item.response && (
|
||||
<View style={tw`mb-3 bg-blue-50 border border-blue-100 rounded-xl p-3`}>
|
||||
<View style={tw`flex-row items-center mb-1`}>
|
||||
<MaterialIcons name="reply" size={16} color="#2563EB" />
|
||||
<MyText style={tw`ml-1 text-xs font-bold text-blue-700`}>
|
||||
Admin Reply
|
||||
</MyText>
|
||||
</View>
|
||||
<MyText style={tw`text-sm text-gray-700 leading-5`}>
|
||||
{item.response}
|
||||
</MyText>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{item.images && item.images.length > 0 && (
|
||||
<View style={tw`mb-3`}>
|
||||
<MyText style={tw`text-sm font-semibold text-gray-700 mb-2`}>
|
||||
|
|
@ -24,37 +24,34 @@ export default function CreateCoupon() {
|
|||
}
|
||||
)
|
||||
|
||||
const handleCreateCoupon = async (values: any) => {
|
||||
const { isReservedCoupon, couponCodes, ...rest } = values;
|
||||
const handleCreateCoupon = (values: any) => {
|
||||
console.log('Form values:', values); // Debug log
|
||||
const { isReservedCoupon, ...rest } = values;
|
||||
// Transform targetUsers array to targetUser for backend compatibility
|
||||
const payload = {
|
||||
...rest,
|
||||
targetUser: rest.targetUsers?.[0] || undefined,
|
||||
};
|
||||
delete payload.targetUsers;
|
||||
|
||||
// For flat discounts, send the discount value as maxValue too
|
||||
if (payload.flatDiscount !== undefined) {
|
||||
payload.maxValue = payload.flatDiscount;
|
||||
}
|
||||
|
||||
const codes = (couponCodes || []).map((c: string) => c.trim()).filter(Boolean);
|
||||
if (codes.length === 0) {
|
||||
Alert.alert('Error', 'At least one coupon code is required');
|
||||
return;
|
||||
}
|
||||
console.log('Payload:', payload); // Debug log
|
||||
|
||||
const mutation = isReservedCoupon ? createReservedCoupon : createCoupon;
|
||||
try {
|
||||
await mutation.mutateAsync({ ...payload, couponCodes: codes });
|
||||
await refetchCoupons()
|
||||
await refetchReservedCoupons()
|
||||
Alert.alert('Success', `${codes.length} coupon${codes.length > 1 ? 's' : ''} created successfully`, [
|
||||
{ text: 'OK', onPress: () => router.back() }
|
||||
]);
|
||||
} catch (error: any) {
|
||||
Alert.alert('Error', error.message || 'Failed to create coupons');
|
||||
}
|
||||
const isLoading = isReservedCoupon ? createReservedCoupon.isPending : createCoupon.isPending;
|
||||
|
||||
if (isLoading) return; // Prevent double submission
|
||||
|
||||
mutation.mutate(payload, {
|
||||
onSuccess: async () => {
|
||||
await refetchCoupons()
|
||||
await refetchReservedCoupons()
|
||||
Alert.alert('Success', `${isReservedCoupon ? 'Reserved coupon' : 'Coupon'} created successfully`, [
|
||||
{ text: 'OK', onPress: () => router.back() }
|
||||
]);
|
||||
},
|
||||
onError: (error: any) => {
|
||||
Alert.alert('Error', error.message || 'Failed to create coupon');
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -37,11 +37,6 @@ export default function EditCoupon() {
|
|||
};
|
||||
delete updates.targetUsers;
|
||||
|
||||
// For flat discounts, send the discount value as maxValue too
|
||||
if (updates.flatDiscount !== undefined) {
|
||||
updates.maxValue = updates.flatDiscount;
|
||||
}
|
||||
|
||||
updateCoupon.mutate({ id: couponId, updates }, {
|
||||
onSuccess: async () => {
|
||||
await refetch()
|
||||
|
|
@ -79,7 +74,7 @@ export default function EditCoupon() {
|
|||
|
||||
// Transform coupon data for form (targetUser to targetUsers array)
|
||||
const initialValues: Partial<CreateCouponPayload & { isReservedCoupon?: boolean }> = {
|
||||
couponCodes: coupon.couponCode ? [coupon.couponCode] : [''],
|
||||
couponCode: coupon.couponCode,
|
||||
isUserBased: coupon.isUserBased,
|
||||
isApplyForAll: coupon.isApplyForAll,
|
||||
targetUsers: coupon.targetUser ? [coupon.targetUser.id] : [],
|
||||
|
|
@ -89,8 +84,7 @@ export default function EditCoupon() {
|
|||
maxValue: coupon.maxValue ? parseFloat(coupon.maxValue) : undefined,
|
||||
validTill: coupon.validTill ? dayjs(coupon.validTill).format('YYYY-MM-DD') : undefined,
|
||||
maxLimitForUser: coupon.maxLimitForUser || undefined,
|
||||
isFirstOrderOnly: coupon.isFirstOrderOnly,
|
||||
skuIds: coupon.skuIds,
|
||||
productIds: coupon.productIds,
|
||||
isReservedCoupon: false, // Normal coupons
|
||||
};
|
||||
|
||||
|
|
@ -90,7 +90,7 @@ const CouponItem = ({ item, onDelete }: { item: any; onDelete: (id: number) => v
|
|||
|
||||
<View style={tw`flex-row mt-3 gap-2`}>
|
||||
<TouchableOpacity
|
||||
onPress={() => router.push(`/(drawer)/dashboard/coupons/edit/${item.id}`)}
|
||||
onPress={() => router.push(`/coupons/edit/${item.id}`)}
|
||||
style={tw`bg-blue-500 p-3 rounded-lg shadow-md flex-1 flex-row items-center justify-center`}
|
||||
>
|
||||
<MaterialCommunityIcons name="pencil" size={16} color="white" />
|
||||
|
|
@ -497,7 +497,7 @@ export default function Coupons() {
|
|||
)}
|
||||
/>
|
||||
<MyTouchableOpacity
|
||||
onPress={() => router.push('/(drawer)/dashboard/coupons/create')}
|
||||
onPress={() => router.push('/coupons/create')}
|
||||
activeOpacity={0.95}
|
||||
style={{ position: 'absolute', bottom: 32, right: 24, zIndex: 100 }}
|
||||
>
|
||||
248
apps/admin-ui/app/(drawer)/coupons/reserved-coupons/index.tsx
Normal file
248
apps/admin-ui/app/(drawer)/coupons/reserved-coupons/index.tsx
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
import React, { useState, useCallback, useMemo } from 'react';
|
||||
import { View, TouchableOpacity, Alert, RefreshControl } from 'react-native';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { tw, MyButton, MyText, SearchBar, MyFlatList, useMarkDataFetchers, MyTouchableOpacity, BottomDropdown } from 'common-ui';
|
||||
import useManualRefresh from 'common-ui/hooks/useManualRefresh';
|
||||
import { trpc } from '@/src/trpc-client';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { useInfiniteQuery } from '@tanstack/react-query';
|
||||
|
||||
const ReservedCouponItem = ({ item }: { item: any }) => {
|
||||
const getStatus = () => {
|
||||
if (item.isRedeemed) return 'redeemed';
|
||||
if (item.validTill && new Date(item.validTill) <= new Date()) return 'expired';
|
||||
return 'active';
|
||||
};
|
||||
|
||||
const status = getStatus();
|
||||
|
||||
const getBorderColor = () => {
|
||||
if (status === 'active') return 'border-green-500';
|
||||
if (status === 'expired') return 'border-yellow-500';
|
||||
return 'border-blue-500';
|
||||
};
|
||||
|
||||
const getBgColor = () => {
|
||||
if (status === 'active') return 'bg-green-100';
|
||||
if (status === 'expired') return 'bg-yellow-100';
|
||||
return 'bg-blue-100';
|
||||
};
|
||||
|
||||
const getTextColor = () => {
|
||||
if (status === 'active') return 'text-green-600';
|
||||
if (status === 'expired') return 'text-yellow-600';
|
||||
return 'text-blue-600';
|
||||
};
|
||||
|
||||
const getIconColor = () => {
|
||||
if (status === 'active') return '#10b981';
|
||||
if (status === 'expired') return '#f59e0b';
|
||||
return '#3b82f6';
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={tw`bg-white p-4 mb-4 rounded-2xl shadow-lg border-l-4 ${getBorderColor()}`}>
|
||||
<View style={tw`flex-row items-center mb-3`}>
|
||||
<View style={tw`w-10 h-10 rounded-full ${getBgColor()} items-center justify-center mr-3`}>
|
||||
<MaterialCommunityIcons
|
||||
name={item.discountPercent ? "percent" : "currency-inr"}
|
||||
size={20}
|
||||
color={getIconColor()}
|
||||
/>
|
||||
</View>
|
||||
<View style={tw`flex-1`}>
|
||||
<MyText style={tw`text-lg font-bold text-gray-800`}>{item.secretCode}</MyText>
|
||||
<MyText style={tw`text-sm text-gray-500`}>Coupon: {item.couponCode}</MyText>
|
||||
</View>
|
||||
<View style={tw`px-2 py-1 rounded-full ${getBgColor()}`}>
|
||||
<MyText style={tw`text-xs font-semibold ${getTextColor()}`}>
|
||||
{status === 'active' ? 'Active' : status === 'expired' ? 'Expired' : 'Redeemed'}
|
||||
</MyText>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={tw`bg-gray-50 p-3 rounded-lg mb-3`}>
|
||||
<MyText style={tw`text-base font-semibold mb-1 text-gray-800`}>
|
||||
Discount: {item.discountPercent ? `${item.discountPercent}% off` : item.flatDiscount ? `₹${item.flatDiscount} off` : 'N/A'}
|
||||
</MyText>
|
||||
<View style={tw`flex-row justify-between`}>
|
||||
<MyText style={tw`text-sm text-gray-600`}>Min Order: {item.minOrder ? `₹${item.minOrder}` : 'None'}</MyText>
|
||||
<MyText style={tw`text-sm text-gray-600`}>Max: {item.maxValue ? `₹${item.maxValue}` : 'None'}</MyText>
|
||||
</View>
|
||||
<MyText style={tw`text-sm text-gray-600 mt-1`}>
|
||||
Valid Till: {item.validTill ? new Date(item.validTill).toLocaleDateString() : 'No expiry'}
|
||||
</MyText>
|
||||
</View>
|
||||
|
||||
{item.isRedeemed && item.redeemedUser && (
|
||||
<View style={tw`mb-3`}>
|
||||
<MyText style={tw`text-sm text-gray-700`}>
|
||||
<MaterialCommunityIcons name="account-check" size={14} color="#6b7280" /> Redeemed by: {item.redeemedUser.name || 'Unknown'} ({item.redeemedUser.mobile})
|
||||
</MyText>
|
||||
<MyText style={tw`text-sm text-gray-600`}>
|
||||
Redeemed on: {item.redeemedAt ? new Date(item.redeemedAt).toLocaleDateString() : 'N/A'}
|
||||
</MyText>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={tw`flex-row justify-between items-center mt-3`}>
|
||||
<MyText style={tw`text-sm text-gray-700`}>
|
||||
<MaterialCommunityIcons name="account" size={14} color="#6b7280" /> Created by: {item.creator?.name || 'Unknown'}
|
||||
</MyText>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default function ReservedCoupons() {
|
||||
const router = useRouter();
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [statusFilters, setStatusFilters] = useState<string[]>([]);
|
||||
|
||||
const {
|
||||
data,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isLoading,
|
||||
isFetchingNextPage,
|
||||
refetch,
|
||||
} = trpc.admin.coupon.getReservedCoupons.useInfiniteQuery(
|
||||
{ limit: 50 },
|
||||
{
|
||||
getNextPageParam: (lastPage) => lastPage.nextCursor,
|
||||
}
|
||||
);
|
||||
|
||||
const coupons = data?.pages.flatMap(page => page.coupons) || [];
|
||||
|
||||
const getStatus = (coupon: any) => {
|
||||
if (coupon.isRedeemed) return 'redeemed';
|
||||
if (coupon.validTill && new Date(coupon.validTill) <= new Date()) return 'expired';
|
||||
return 'active';
|
||||
};
|
||||
|
||||
const filteredCoupons = useMemo(() => {
|
||||
let filtered = coupons.filter(coupon =>
|
||||
coupon.secretCode.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
coupon.couponCode.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
if (statusFilters.length > 0) {
|
||||
filtered = filtered.filter(coupon => statusFilters.includes(getStatus(coupon)));
|
||||
}
|
||||
return filtered;
|
||||
}, [coupons, searchQuery, statusFilters]);
|
||||
|
||||
const handleRefresh = useCallback(async () => {
|
||||
setRefreshing(true);
|
||||
await refetch();
|
||||
setRefreshing(false);
|
||||
}, [refetch]);
|
||||
|
||||
useManualRefresh(() => refetch());
|
||||
|
||||
useMarkDataFetchers(() => {
|
||||
refetch();
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<View style={tw`flex-1 justify-center items-center bg-white`}>
|
||||
<View style={tw`w-16 h-16 bg-blue-100 rounded-full items-center justify-center mb-4`}>
|
||||
<MaterialCommunityIcons name="loading" size={32} color="#3b82f6" />
|
||||
</View>
|
||||
<MyText style={tw`text-lg font-semibold text-gray-600`}>Loading Reserved Coupons...</MyText>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={tw`flex-1 bg-white`}>
|
||||
<View style={tw`flex-row items-center px-4 py-2`}>
|
||||
<View style={tw`flex-1 mr-2`}>
|
||||
<SearchBar
|
||||
value={searchQuery}
|
||||
onChangeText={setSearchQuery}
|
||||
placeholder="Search reserved coupons..."
|
||||
/>
|
||||
</View>
|
||||
<BottomDropdown
|
||||
label="Filter by Status"
|
||||
value={statusFilters}
|
||||
options={[
|
||||
{ label: 'Active', value: 'active' },
|
||||
{ label: 'Expired', value: 'expired' },
|
||||
{ label: 'Redeemed', value: 'redeemed' },
|
||||
]}
|
||||
onValueChange={(value) => setStatusFilters(value as string[])}
|
||||
multiple={true}
|
||||
triggerComponent={({ onPress }) => (
|
||||
<TouchableOpacity onPress={onPress} style={tw`p-2`}>
|
||||
<MaterialCommunityIcons name="filter-variant" size={24} color="#6b7280" />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
<MyFlatList
|
||||
data={filteredCoupons}
|
||||
keyExtractor={(item) => item.id.toString()}
|
||||
renderItem={({ item }) => <ReservedCouponItem item={item} />}
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={refreshing} onRefresh={handleRefresh} />
|
||||
}
|
||||
contentContainerStyle={tw`px-4 pb-4`}
|
||||
onEndReached={() => {
|
||||
if (hasNextPage && !isFetchingNextPage) {
|
||||
fetchNextPage();
|
||||
}
|
||||
}}
|
||||
onEndReachedThreshold={0.5}
|
||||
ListEmptyComponent={
|
||||
searchQuery ? (
|
||||
<View style={tw`flex-1 justify-center items-center py-20`}>
|
||||
<View style={tw`w-20 h-20 bg-gray-100 rounded-full items-center justify-center mb-4`}>
|
||||
<MaterialCommunityIcons name="magnify" size={40} color="#9ca3af" />
|
||||
</View>
|
||||
<MyText style={tw`text-xl font-semibold text-gray-600 mb-2`}>No Results</MyText>
|
||||
<MyText style={tw`text-gray-500 text-center mb-4`}>No reserved coupons match “{searchQuery}”</MyText>
|
||||
<MyButton onPress={() => setSearchQuery('')} style={tw`bg-gray-500`}>
|
||||
<MyText style={tw`text-white font-semibold`}>Clear Search</MyText>
|
||||
</MyButton>
|
||||
</View>
|
||||
) : (
|
||||
<View style={tw`flex-1 justify-center items-center py-20`}>
|
||||
<View style={tw`w-20 h-20 bg-gray-100 rounded-full items-center justify-center mb-4`}>
|
||||
<MaterialCommunityIcons name="ticket-percent-outline" size={40} color="#9ca3af" />
|
||||
</View>
|
||||
<MyText style={tw`text-xl font-semibold text-gray-600 mb-2`}>No Reserved Coupons Yet</MyText>
|
||||
<MyText style={tw`text-gray-500 text-center mb-4`}>Create your first reserved coupon to start offering secret discounts</MyText>
|
||||
<MyButton onPress={() => router.push('/coupons/create')} style={tw`bg-blue-500`}>
|
||||
<View style={tw`flex-row items-center`}>
|
||||
<MaterialCommunityIcons name="plus" size={16} color="white" />
|
||||
<MyText style={tw`text-white font-semibold ml-1`}>Create Reserved Coupon</MyText>
|
||||
</View>
|
||||
</MyButton>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
{/* FAB for Add New Reserved Coupon */}
|
||||
<MyTouchableOpacity
|
||||
onPress={() => router.push('/coupons/create')}
|
||||
activeOpacity={0.95}
|
||||
style={{ position: 'absolute', bottom: 32, right: 24, zIndex: 100 }}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={['#F83758', '#E91E63']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={tw`w-16 h-16 rounded-[24px] items-center justify-center shadow-lg shadow-pink300`}
|
||||
>
|
||||
<MaterialCommunityIcons name="plus" size={32} color="white" />
|
||||
</LinearGradient>
|
||||
</MyTouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,5 +1,4 @@
|
|||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import type { StoreProductCard } from '@packages/shared'
|
||||
import {
|
||||
View,
|
||||
Alert,
|
||||
|
|
@ -19,7 +18,7 @@ import {
|
|||
MyTouchableOpacity,
|
||||
} from "common-ui";
|
||||
import { useRouter } from "expo-router";
|
||||
import { trpc } from "../../../../src/trpc-client";
|
||||
import { trpc } from "../../../src/trpc-client";
|
||||
import MaterialIcons from "@expo/vector-icons/MaterialIcons";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
|
|
@ -28,12 +27,20 @@ const { width: screenWidth } = Dimensions.get("window");
|
|||
const itemWidth = screenWidth - 48; // 24px padding each side
|
||||
const itemHeight = 80;
|
||||
|
||||
// Reorder row — anchored to the shared store-card core
|
||||
type Product = Pick<StoreProductCard, 'id' | 'name' | 'images' | 'isOutOfStock'>
|
||||
interface Product {
|
||||
id: number;
|
||||
name: string;
|
||||
images: string[];
|
||||
isOutOfStock: boolean;
|
||||
}
|
||||
|
||||
import type { DragOrderItemProps } from '@/types/drag-order-item';
|
||||
interface ProductItemProps {
|
||||
item: Product;
|
||||
drag: () => void;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
const ProductItem: React.FC<DragOrderItemProps<Product>> = ({
|
||||
const ProductItem: React.FC<ProductItemProps> = ({
|
||||
item,
|
||||
drag,
|
||||
isActive,
|
||||
|
|
@ -94,7 +101,7 @@ export default function AllItemsOrder() {
|
|||
|
||||
// Get current order from constants
|
||||
const { data: constants, isLoading: isLoadingConstants, error: constantsError } = trpc.admin.const.getConstants.useQuery();
|
||||
const { data: allProducts, isLoading: isLoadingProducts, error: productsError } = trpc.common.product.getAllProductsSummary.useQuery();
|
||||
const { data: allProducts, isLoading: isLoadingProducts, error: productsError } = trpc.common.product.getAllProductsSummary.useQuery({});
|
||||
const updateConstants = trpc.admin.const.updateConstants.useMutation();
|
||||
|
||||
// Initialize products from constants
|
||||
|
|
@ -2,7 +2,7 @@ import React from 'react';
|
|||
import { View, ScrollView, Alert } from 'react-native';
|
||||
import { Formik } from 'formik';
|
||||
import { MyText, MyTextInput, MyTouchableOpacity, tw, AppContainer, Checkbox } from 'common-ui';
|
||||
import { trpc } from '../../../../src/trpc-client';
|
||||
import { trpc } from '../../../src/trpc-client';
|
||||
import { useRouter } from 'expo-router';
|
||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||
|
||||
|
|
@ -71,7 +71,7 @@ const ConstantInput: React.FC<ConstantInputProps> = ({ constantKey, value, setFi
|
|||
{CONST_LABELS[constantKey] || constantKey}
|
||||
</MyText>
|
||||
<MyTouchableOpacity
|
||||
onPress={() => router.push('/(drawer)/dashboard/customize-app/popular-items')}
|
||||
onPress={() => router.push('/(drawer)/customize-app/popular-items')}
|
||||
style={tw`bg-blue-50 border-2 border-dashed border-blue-200 p-4 rounded-lg flex-row items-center justify-center`}
|
||||
>
|
||||
<MaterialIcons name="edit" size={20} color="#3b82f6" style={tw`mr-2`} />
|
||||
|
|
@ -93,7 +93,7 @@ const ConstantInput: React.FC<ConstantInputProps> = ({ constantKey, value, setFi
|
|||
{CONST_LABELS[constantKey] || constantKey}
|
||||
</MyText>
|
||||
<MyTouchableOpacity
|
||||
onPress={() => router.push('/(drawer)/dashboard/customize-app/all-items-order')}
|
||||
onPress={() => router.push('/(drawer)/customize-app/all-items-order')}
|
||||
style={tw`bg-green-50 border-2 border-dashed border-green-200 p-4 rounded-lg flex-row items-center justify-center`}
|
||||
>
|
||||
<MaterialIcons name="reorder" size={20} color="#16a34a" style={tw`mr-2`} />
|
||||
|
|
@ -19,27 +19,31 @@ import {
|
|||
BottomDropdown,
|
||||
MyTouchableOpacity,
|
||||
} from "common-ui";
|
||||
import ProductsSelector from "../../../../components/ProductsSelector";
|
||||
import ProductsSelector from "../../../components/ProductsSelector";
|
||||
import { useRouter } from "expo-router";
|
||||
import { trpc } from "../../../../src/trpc-client";
|
||||
import { trpc } from "../../../src/trpc-client";
|
||||
import MaterialIcons from "@expo/vector-icons/MaterialIcons";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import type { StoreProductCard } from '@packages/shared';
|
||||
import type { DragOrderItemProps } from '@/types/drag-order-item';
|
||||
|
||||
// Popular-items row = shared store card core with numeric (form) prices
|
||||
type PopularProduct = Omit<
|
||||
StoreProductCard,
|
||||
'price' | 'marketPrice' | 'unitNotation' | 'images'
|
||||
> & {
|
||||
interface PopularProduct {
|
||||
id: number;
|
||||
name: string;
|
||||
shortDescription: string | null;
|
||||
price: number;
|
||||
marketPrice: number | null;
|
||||
images: string[] | null;
|
||||
unit: string;
|
||||
incrementStep: number;
|
||||
productQuantity: number;
|
||||
storeId: number | null;
|
||||
isOutOfStock: boolean;
|
||||
nextDeliveryDate: string | null;
|
||||
};
|
||||
images: string[];
|
||||
}
|
||||
|
||||
interface ProductItemProps extends DragOrderItemProps<PopularProduct> {
|
||||
interface ProductItemProps {
|
||||
item: PopularProduct;
|
||||
drag: () => void;
|
||||
isActive: boolean;
|
||||
onDelete: (id: number) => void;
|
||||
}
|
||||
|
||||
|
|
@ -121,7 +125,7 @@ export default function CustomizePopularItems() {
|
|||
|
||||
// Get current popular items from constants
|
||||
const { data: constants, isLoading: isLoadingConstants, error: constantsError } = trpc.admin.const.getConstants.useQuery();
|
||||
const { data: allProducts, isLoading: isLoadingProducts, error: productsError } = trpc.common.product.getAllProductsSummary.useQuery();
|
||||
const { data: allProducts, isLoading: isLoadingProducts, error: productsError } = trpc.common.product.getAllProductsSummary.useQuery({});
|
||||
const updateConstants = trpc.admin.const.updateConstants.useMutation();
|
||||
|
||||
// Initialize popular products from constants
|
||||
|
|
@ -412,7 +416,7 @@ export default function CustomizePopularItems() {
|
|||
multiple={true}
|
||||
label="Select Products"
|
||||
placeholder="Choose products..."
|
||||
labelFormat={(product) => `${product.productName} - ₹${product.price}`}
|
||||
labelFormat={(product) => `${product.name} - ₹${product.price}`}
|
||||
/>
|
||||
|
||||
<View style={tw`flex-row gap-3 mt-6`}>
|
||||
|
|
@ -15,7 +15,7 @@ export default function CreateBanner() {
|
|||
name: '',
|
||||
imageUrl: '',
|
||||
description: '',
|
||||
skuIds: [],
|
||||
productIds: [],
|
||||
redirectUrl: '',
|
||||
// serialNum removed - assigned automatically by backend
|
||||
};
|
||||
|
|
@ -38,7 +38,7 @@ export default function CreateBanner() {
|
|||
name: values.name,
|
||||
imageUrl,
|
||||
description: values.description || undefined,
|
||||
skuIds: values.skuIds.length > 0 ? values.skuIds : [],
|
||||
productIds: values.productIds.length > 0 ? values.productIds : [],
|
||||
redirectUrl: values.redirectUrl || undefined,
|
||||
});
|
||||
|
||||
|
|
@ -6,20 +6,19 @@ import { useRouter, useLocalSearchParams } from 'expo-router';
|
|||
import { FormikHelpers } from 'formik';
|
||||
import BannerForm, { BannerFormData } from '@/components/BannerForm';
|
||||
import { trpc } from '@/src/trpc-client';
|
||||
import type { Banner as SharedBanner } from '@packages/shared';
|
||||
|
||||
// Form/hardened view of the shared banner (string dates, non-null editable fields)
|
||||
type Banner = Omit<
|
||||
SharedBanner,
|
||||
'description' | 'skuIds' | 'redirectUrl' | 'serialNum' | 'createdAt' | 'lastUpdated'
|
||||
> & {
|
||||
interface Banner {
|
||||
id: number;
|
||||
name: string;
|
||||
imageUrl: string;
|
||||
description?: string;
|
||||
skuIds?: number[];
|
||||
productIds?: number[];
|
||||
redirectUrl?: string;
|
||||
serialNum: number;
|
||||
isActive: boolean;
|
||||
createdAt: string;
|
||||
lastUpdated: string;
|
||||
};
|
||||
}
|
||||
|
||||
export default function EditBanner() {
|
||||
const router = useRouter();
|
||||
|
|
@ -46,12 +45,12 @@ export default function EditBanner() {
|
|||
|
||||
|
||||
if (bannerData) {
|
||||
// Handle data format compatibility (productId -> skuIds migration)
|
||||
// Handle data format compatibility (productId -> productIds migration)
|
||||
const processedBanner = {
|
||||
...bannerData,
|
||||
skuIds: Array.isArray(bannerData.skuIds)
|
||||
? bannerData.skuIds
|
||||
: (bannerData.skuIds ? [bannerData.skuIds] : [])
|
||||
productIds: Array.isArray(bannerData.productIds)
|
||||
? bannerData.productIds
|
||||
: (bannerData.productIds ? [bannerData.productIds] : [])
|
||||
};
|
||||
|
||||
setBanner(processedBanner);
|
||||
|
|
@ -75,14 +74,14 @@ export default function EditBanner() {
|
|||
name: banner.name,
|
||||
imageUrl: banner.imageUrl,
|
||||
description: banner.description || '',
|
||||
skuIds: banner.skuIds || [],
|
||||
productIds: banner.productIds || [],
|
||||
redirectUrl: banner.redirectUrl || '',
|
||||
// serialNum removed - handled automatically by backend
|
||||
} : {
|
||||
name: '',
|
||||
imageUrl: '',
|
||||
description: '',
|
||||
skuIds: [],
|
||||
productIds: [],
|
||||
redirectUrl: '',
|
||||
// serialNum removed - handled automatically by backend
|
||||
};
|
||||
|
|
@ -100,7 +99,7 @@ export default function EditBanner() {
|
|||
name: values.name,
|
||||
imageUrl: imageUrl || banner.imageUrl,
|
||||
description: values.description || undefined,
|
||||
skuIds: values.skuIds.length > 0 ? values.skuIds : [],
|
||||
productIds: values.productIds.length > 0 ? values.productIds : [],
|
||||
redirectUrl: values.redirectUrl || undefined,
|
||||
});
|
||||
|
||||
|
|
@ -1,16 +1,22 @@
|
|||
import React, { useState } from 'react';
|
||||
import { View, ScrollView, TouchableOpacity, Alert, Image, RefreshControl } from 'react-native';
|
||||
import { AppContainer, MyText, tw, MyTouchableOpacity } from 'common-ui';
|
||||
import { trpc } from '../../../../src/trpc-client';
|
||||
import { trpc } from '../../../src/trpc-client';
|
||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||
import { useRouter } from 'expo-router';
|
||||
import type { Banner as SharedBanner } from '@packages/shared';
|
||||
|
||||
// Serialized banner row (API JSON carries string dates) anchored to the shared shape
|
||||
type Banner = Omit<SharedBanner, 'createdAt' | 'lastUpdated'> & {
|
||||
interface Banner {
|
||||
id: number;
|
||||
name: string;
|
||||
imageUrl: string;
|
||||
description: string | null;
|
||||
productIds: number[] | null;
|
||||
redirectUrl: string | null;
|
||||
serialNum: number | null;
|
||||
isActive: boolean;
|
||||
createdAt: string;
|
||||
lastUpdated: string;
|
||||
};
|
||||
}
|
||||
|
||||
export default function DashboardBanners() {
|
||||
const router = useRouter();
|
||||
|
|
@ -174,7 +180,7 @@ export default function DashboardBanners() {
|
|||
};
|
||||
|
||||
const handleEdit = (banner: Banner) => {
|
||||
router.push(`/(drawer)/dashboard/dashboard-banners/edit/${banner.id}` as any);
|
||||
router.push(`/dashboard-banners/edit/${banner.id}` as any);
|
||||
};
|
||||
|
||||
const handleDelete = (id: number) => {
|
||||
|
|
@ -201,7 +207,7 @@ export default function DashboardBanners() {
|
|||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
router.push('/(drawer)/dashboard/dashboard-banners/create' as any);
|
||||
router.push('/dashboard-banners/create' as any);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
|
|
@ -2,41 +2,14 @@ import { Stack } from "expo-router";
|
|||
|
||||
export default function Layout() {
|
||||
return (
|
||||
<Stack screenOptions={{ headerShown: false }}>
|
||||
<Stack.Screen name="index" options={{ title: "Dashboard" }} />
|
||||
<Stack.Screen name="complaints" options={{ title: "Complaints" }} />
|
||||
<Stack.Screen name="coupons" options={{ title: "Coupons" }} />
|
||||
<Stack.Screen name="customize-app" options={{ title: "Customize App" }} />
|
||||
<Stack>
|
||||
<Stack.Screen
|
||||
name="dashboard-banners"
|
||||
options={{ title: "Dashboard Banners" }}
|
||||
name="index"
|
||||
options={{
|
||||
title: "Dashboard",
|
||||
headerShown: false,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen name="manage-orders" options={{ title: "Manage Orders" }} />
|
||||
<Stack.Screen
|
||||
name="prices-overview"
|
||||
options={{ title: "Prices Overview" }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="product-groupings"
|
||||
options={{ title: "Product Groupings" }}
|
||||
/>
|
||||
<Stack.Screen name="product-tags" options={{ title: "Product Tags" }} />
|
||||
<Stack.Screen name="products" options={{ title: "Products" }} />
|
||||
<Stack.Screen
|
||||
name="rebalance-orders"
|
||||
options={{ title: "Rebalance Orders" }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="send-notifications"
|
||||
options={{ title: "Send Notifications" }}
|
||||
/>
|
||||
<Stack.Screen name="slots" options={{ title: "Slots" }} />
|
||||
<Stack.Screen name="stores" options={{ title: "Stores" }} />
|
||||
<Stack.Screen
|
||||
name="user-management"
|
||||
options={{ title: "User Management" }}
|
||||
/>
|
||||
<Stack.Screen name="vendor-snippets" options={{ title: "Vendor Snippets" }} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ import { View, ScrollView, Pressable } from 'react-native';
|
|||
import { useRouter } from 'expo-router';
|
||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||
import { MyText, tw } from 'common-ui';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { theme } from 'common-ui/src/theme';
|
||||
import { trpc } from '@/src/trpc-client';
|
||||
import { useNavigationTarget } from 'common-ui/hooks/useNavigationTarget';
|
||||
|
|
@ -62,21 +63,21 @@ export default function Dashboard() {
|
|||
|
||||
|
||||
const handleManageOrdersPress = () => {
|
||||
setNavigationTarget('/(drawer)/dashboard/manage-orders/orders');
|
||||
router.push('/(drawer)/dashboard/manage-orders');
|
||||
setNavigationTarget('/manage-orders/orders');
|
||||
router.push('/(drawer)/manage-orders');
|
||||
};
|
||||
|
||||
const handleDeliverySequencesPress = () => {
|
||||
setNavigationTarget('/(drawer)/dashboard/manage-orders/delivery-sequences');
|
||||
router.push('/(drawer)/dashboard/manage-orders');
|
||||
setNavigationTarget('/manage-orders/delivery-sequences');
|
||||
router.push('/(drawer)/manage-orders');
|
||||
};
|
||||
|
||||
const menuItems: MenuItem[] = [
|
||||
{
|
||||
title: 'Manage Orders',
|
||||
title: 'Manage Orderss',
|
||||
icon: 'shopping-bag',
|
||||
description: 'View and manage customer orders',
|
||||
route: '/(drawer)/dashboard/manage-orders',
|
||||
route: '/(drawer)/manage-orders',
|
||||
category: 'orders',
|
||||
iconColor: '#10B981',
|
||||
iconBg: '#D1FAE5',
|
||||
|
|
@ -86,7 +87,7 @@ export default function Dashboard() {
|
|||
title: 'Delivery Sequences',
|
||||
icon: 'alt-route',
|
||||
description: 'Plan and optimize delivery routes',
|
||||
route: '/(drawer)/dashboard/manage-orders/delivery-sequences',
|
||||
route: '/manage-orders/delivery-sequences',
|
||||
category: 'orders',
|
||||
iconColor: '#8B5CF6',
|
||||
iconBg: '#EDE9FE',
|
||||
|
|
@ -96,7 +97,7 @@ export default function Dashboard() {
|
|||
title: 'Delivery Slots',
|
||||
icon: 'schedule',
|
||||
description: 'Configure delivery times',
|
||||
route: '/(drawer)/dashboard/slots' as any,
|
||||
route: '/(drawer)/slots' as any,
|
||||
category: 'quick',
|
||||
iconColor: '#06B6D4',
|
||||
iconBg: '#CFFAFE',
|
||||
|
|
@ -106,7 +107,7 @@ export default function Dashboard() {
|
|||
title: 'Add Product',
|
||||
icon: 'add-circle',
|
||||
description: 'Create a new product listing',
|
||||
route: '/(drawer)/dashboard/products/add',
|
||||
route: '/products/add',
|
||||
category: 'quick',
|
||||
iconColor: theme.colors.brand500,
|
||||
iconBg: theme.colors.brand50,
|
||||
|
|
@ -115,7 +116,7 @@ export default function Dashboard() {
|
|||
title: 'Complaints',
|
||||
icon: 'report-problem',
|
||||
description: 'Handle customer complaints',
|
||||
route: '/(drawer)/dashboard/complaints',
|
||||
route: '/(drawer)/complaints',
|
||||
category: 'quick',
|
||||
iconColor: '#F59E0B',
|
||||
iconBg: '#FEF3C7',
|
||||
|
|
@ -125,7 +126,7 @@ export default function Dashboard() {
|
|||
title: 'Products',
|
||||
icon: 'inventory-2',
|
||||
description: 'Manage product catalog',
|
||||
route: '/(drawer)/dashboard/products',
|
||||
route: '/(drawer)/products',
|
||||
category: 'products',
|
||||
iconColor: '#6366F1',
|
||||
iconBg: '#E0E7FF',
|
||||
|
|
@ -134,7 +135,7 @@ export default function Dashboard() {
|
|||
title: 'Prices Overview',
|
||||
icon: 'attach-money',
|
||||
description: 'Update product pricing',
|
||||
route: '/(drawer)/dashboard/prices-overview',
|
||||
route: '/(drawer)/prices-overview',
|
||||
category: 'products',
|
||||
iconColor: '#059669',
|
||||
iconBg: '#ECFDF5',
|
||||
|
|
@ -143,7 +144,7 @@ export default function Dashboard() {
|
|||
title: 'Product Tags',
|
||||
icon: 'label',
|
||||
description: 'Organize with tags',
|
||||
route: '/(drawer)/dashboard/product-tags',
|
||||
route: '/(drawer)/product-tags',
|
||||
category: 'products',
|
||||
iconColor: '#EC4899',
|
||||
iconBg: '#FCE7F3',
|
||||
|
|
@ -152,7 +153,7 @@ export default function Dashboard() {
|
|||
title: 'Product Groupings',
|
||||
icon: 'category',
|
||||
description: 'Group products together',
|
||||
route: '/(drawer)/dashboard/product-groupings',
|
||||
route: '/(drawer)/product-groupings',
|
||||
category: 'products',
|
||||
iconColor: '#8B5CF6',
|
||||
iconBg: '#F3E8FF',
|
||||
|
|
@ -161,7 +162,7 @@ export default function Dashboard() {
|
|||
title: 'Stores',
|
||||
icon: 'store',
|
||||
description: 'Manage store locations',
|
||||
route: '/(drawer)/dashboard/stores',
|
||||
route: '/(drawer)/stores',
|
||||
category: 'settings',
|
||||
iconColor: '#14B8A6',
|
||||
iconBg: '#CCFBF1',
|
||||
|
|
@ -170,16 +171,26 @@ export default function Dashboard() {
|
|||
title: 'Coupons',
|
||||
icon: 'local-offer',
|
||||
description: 'Create discount coupons',
|
||||
route: '/(drawer)/dashboard/coupons',
|
||||
route: '/(drawer)/coupons',
|
||||
category: 'marketing',
|
||||
iconColor: '#F97316',
|
||||
iconBg: '#FFEDD5',
|
||||
},
|
||||
{
|
||||
title: 'Offers',
|
||||
icon: 'sell',
|
||||
description: 'Create and manage product offers',
|
||||
route: '/(drawer)/offers' as any,
|
||||
category: 'marketing',
|
||||
iconColor: '#DC2626',
|
||||
iconBg: '#FEE2E2',
|
||||
testID: 'offers-menu-item',
|
||||
},
|
||||
{
|
||||
title: 'App Constants',
|
||||
icon: 'settings-applications',
|
||||
description: 'Customize app settings',
|
||||
route: '/(drawer)/dashboard/customize-app',
|
||||
route: '/(drawer)/customize-app',
|
||||
category: 'settings',
|
||||
iconColor: '#7C3AED',
|
||||
iconBg: '#F3E8FF',
|
||||
|
|
@ -188,7 +199,7 @@ export default function Dashboard() {
|
|||
title: 'User Management',
|
||||
icon: 'people',
|
||||
description: 'View and manage all users',
|
||||
route: '/(drawer)/dashboard/user-management',
|
||||
route: '/(drawer)/user-management',
|
||||
category: 'users',
|
||||
iconColor: '#0EA5E9',
|
||||
iconBg: '#E0F2FE',
|
||||
|
|
@ -197,7 +208,7 @@ export default function Dashboard() {
|
|||
title: 'Send Notifications',
|
||||
icon: 'campaign',
|
||||
description: 'Send push notifications to users',
|
||||
route: '/(drawer)/dashboard/send-notifications',
|
||||
route: '/(drawer)/send-notifications',
|
||||
category: 'users',
|
||||
iconColor: '#8B5CF6',
|
||||
iconBg: '#F3E8FF',
|
||||
|
|
|
|||
|
|
@ -1,364 +0,0 @@
|
|||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
View,
|
||||
Alert,
|
||||
ActivityIndicator,
|
||||
Dimensions,
|
||||
StyleSheet,
|
||||
} from 'react-native';
|
||||
import { TouchableOpacity } from 'react-native-gesture-handler';
|
||||
import { Image } from 'expo-image';
|
||||
import DraggableFlatList, {
|
||||
ScaleDecorator,
|
||||
} from 'react-native-draggable-flatlist';
|
||||
import {
|
||||
AppContainer,
|
||||
MyText,
|
||||
tw,
|
||||
MyTouchableOpacity,
|
||||
} from 'common-ui';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { trpc } from '../../../../src/trpc-client';
|
||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import type { DragOrderItemProps } from '@/types/drag-order-item';
|
||||
|
||||
const { width: screenWidth } = Dimensions.get('window');
|
||||
const itemWidth = screenWidth - 48;
|
||||
const itemHeight = 80;
|
||||
|
||||
interface Tag {
|
||||
id: number;
|
||||
tagName: string;
|
||||
imageUrl: string | null;
|
||||
}
|
||||
|
||||
const TagItem: React.FC<DragOrderItemProps<Tag>> = ({ item, drag, isActive }) => {
|
||||
return (
|
||||
<ScaleDecorator>
|
||||
<TouchableOpacity
|
||||
onLongPress={drag}
|
||||
activeOpacity={1}
|
||||
style={[
|
||||
styles.item,
|
||||
isActive && styles.activeItem,
|
||||
]}
|
||||
>
|
||||
{/* Drag Handle */}
|
||||
<View style={styles.dragHandle}>
|
||||
<MaterialIcons
|
||||
name="drag-indicator"
|
||||
size={24}
|
||||
color={isActive ? '#3b82f6' : '#9ca3af'}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Tag Image */}
|
||||
{item.imageUrl ? (
|
||||
<Image
|
||||
source={{ uri: item.imageUrl }}
|
||||
style={styles.image}
|
||||
contentFit="cover"
|
||||
/>
|
||||
) : (
|
||||
<View style={styles.placeholderImage}>
|
||||
<MaterialIcons name="label" size={24} color="#9ca3af" />
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Tag Info */}
|
||||
<View style={styles.info}>
|
||||
<MyText style={styles.name} numberOfLines={1}>
|
||||
{item.tagName}
|
||||
</MyText>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</ScaleDecorator>
|
||||
);
|
||||
};
|
||||
|
||||
export default function TagOrders() {
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const [tags, setTags] = useState<Tag[]>([]);
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
|
||||
// Get current order from constants
|
||||
const { data: constants, isLoading: isLoadingConstants, error: constantsError } = trpc.admin.const.getConstants.useQuery();
|
||||
const { data: tagsData, isLoading: isLoadingTags, error: tagsError } = trpc.admin.product.getProductTags.useQuery();
|
||||
const updateConstants = trpc.admin.const.updateConstants.useMutation();
|
||||
|
||||
// Initialize tags from the tagsOrder constant
|
||||
useEffect(() => {
|
||||
if (tagsData?.tags) {
|
||||
const tagsOrderConstant = constants?.find(c => c.key === 'tagsOrder');
|
||||
|
||||
let orderedIds: number[] = [];
|
||||
|
||||
if (tagsOrderConstant) {
|
||||
const value = tagsOrderConstant.value;
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
orderedIds = value.map((id: any) => parseInt(id));
|
||||
} else if (typeof value === 'string') {
|
||||
orderedIds = value.split(',').map((id: string) => parseInt(id.trim())).filter(id => !isNaN(id));
|
||||
}
|
||||
}
|
||||
|
||||
// Create tag map for quick lookup
|
||||
const tagMap = new Map(tagsData.tags.map(t => [t.id, t]));
|
||||
|
||||
// Sort tags based on order, tags not in order go to end
|
||||
const sortedTags: Tag[] = [];
|
||||
|
||||
// First add tags in the specified order
|
||||
for (const id of orderedIds) {
|
||||
const tag = tagMap.get(id);
|
||||
if (tag) {
|
||||
sortedTags.push({
|
||||
id: tag.id,
|
||||
tagName: tag.tagName,
|
||||
imageUrl: tag.imageUrl || null,
|
||||
});
|
||||
tagMap.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
// Then add remaining tags (not in order yet)
|
||||
for (const tag of tagMap.values()) {
|
||||
sortedTags.push({
|
||||
id: tag.id,
|
||||
tagName: tag.tagName,
|
||||
imageUrl: tag.imageUrl || null,
|
||||
});
|
||||
}
|
||||
|
||||
setTags(sortedTags);
|
||||
}
|
||||
}, [constants, tagsData]);
|
||||
|
||||
const handleDragEnd = useCallback(({ data }: { data: Tag[] }) => {
|
||||
setTags(data);
|
||||
setHasChanges(true);
|
||||
}, []);
|
||||
|
||||
const renderItem = useCallback(({ item, drag, isActive }: { item: Tag; drag: () => void; isActive: boolean }) => {
|
||||
return (
|
||||
<TagItem
|
||||
item={item}
|
||||
drag={drag}
|
||||
isActive={isActive}
|
||||
/>
|
||||
);
|
||||
}, []);
|
||||
|
||||
const handleSave = () => {
|
||||
const tagIds = tags.map(t => t.id);
|
||||
|
||||
updateConstants.mutate(
|
||||
{
|
||||
constants: [{
|
||||
key: 'tagsOrder',
|
||||
value: tagIds
|
||||
}]
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setHasChanges(false);
|
||||
Alert.alert('Success', 'Tag order updated successfully!');
|
||||
queryClient.invalidateQueries({ queryKey: ['const.getConstants'] });
|
||||
},
|
||||
onError: (error) => {
|
||||
Alert.alert('Error', 'Failed to update tag order. Please try again.');
|
||||
console.error('Update tag order error:', error);
|
||||
}
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
// Show loading state while data is being fetched
|
||||
if (isLoadingConstants || isLoadingTags) {
|
||||
return (
|
||||
<AppContainer>
|
||||
<View style={tw`flex-1 bg-gray-50`}>
|
||||
<View style={tw`bg-white px-4 py-4 border-b border-gray-200 flex-row items-center justify-between`}>
|
||||
<TouchableOpacity
|
||||
onPress={() => router.back()}
|
||||
style={tw`p-2 -ml-4`}
|
||||
>
|
||||
<MaterialIcons name="chevron-left" size={24} color="#374151" />
|
||||
</TouchableOpacity>
|
||||
<MyText style={tw`text-xl font-bold text-gray-900`}>Tag Orders</MyText>
|
||||
<View style={tw`w-16`} />
|
||||
</View>
|
||||
<View style={tw`flex-1 justify-center items-center p-8`}>
|
||||
<ActivityIndicator size="large" color="#3b82f6" />
|
||||
<MyText style={tw`text-gray-500 mt-4 text-center`}>
|
||||
{isLoadingConstants ? 'Loading order...' : 'Loading tags...'}
|
||||
</MyText>
|
||||
</View>
|
||||
</View>
|
||||
</AppContainer>
|
||||
);
|
||||
}
|
||||
|
||||
// Show error state if queries failed
|
||||
if (constantsError || tagsError) {
|
||||
return (
|
||||
<AppContainer>
|
||||
<View style={tw`flex-1 bg-gray-50`}>
|
||||
<View style={tw`bg-white px-4 py-4 border-b border-gray-200 flex-row items-center justify-between`}>
|
||||
<TouchableOpacity
|
||||
onPress={() => router.back()}
|
||||
style={tw`p-2 -ml-4`}
|
||||
>
|
||||
<MaterialIcons name="chevron-left" size={24} color="#374151" />
|
||||
</TouchableOpacity>
|
||||
<MyText style={tw`text-xl font-bold text-gray-900`}>Tag Orders</MyText>
|
||||
<View style={tw`w-16`} />
|
||||
</View>
|
||||
<View style={tw`flex-1 justify-center items-center p-8`}>
|
||||
<MaterialIcons name="error-outline" size={64} color="#ef4444" />
|
||||
<MyText style={tw`text-gray-900 text-lg font-bold mt-4`}>Error</MyText>
|
||||
<MyText style={tw`text-gray-500 mt-2 text-center`}>
|
||||
{constantsError ? 'Failed to load order' : 'Failed to load tags'}
|
||||
</MyText>
|
||||
<TouchableOpacity
|
||||
onPress={() => router.back()}
|
||||
style={tw`mt-6 bg-blue-600 px-6 py-3 rounded-full`}
|
||||
>
|
||||
<MyText style={tw`text-white font-semibold`}>Go Back</MyText>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</AppContainer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={tw`flex-1 bg-gray-50`}>
|
||||
{/* Header */}
|
||||
<View style={tw`bg-white px-4 py-4 border-b border-gray-200 flex-row items-center justify-between`}>
|
||||
<MyTouchableOpacity
|
||||
onPress={() => router.back()}
|
||||
style={tw`p-2 -ml-4`}
|
||||
>
|
||||
<MaterialIcons name="chevron-left" size={24} color="#374151" />
|
||||
</MyTouchableOpacity>
|
||||
|
||||
<MyText style={tw`text-xl font-bold text-gray-900`}>Tag Orders</MyText>
|
||||
|
||||
<MyTouchableOpacity
|
||||
onPress={handleSave}
|
||||
disabled={!hasChanges || updateConstants.isPending}
|
||||
style={tw`px-4 py-2 rounded-lg ${
|
||||
hasChanges && !updateConstants.isPending
|
||||
? 'bg-blue-600'
|
||||
: 'bg-gray-300'
|
||||
}`}
|
||||
>
|
||||
<MyText style={tw`${
|
||||
hasChanges && !updateConstants.isPending
|
||||
? 'text-white'
|
||||
: 'text-gray-500'
|
||||
} font-semibold`}>
|
||||
{updateConstants.isPending ? 'Saving...' : 'Save'}
|
||||
</MyText>
|
||||
</MyTouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Content */}
|
||||
{tags.length === 0 ? (
|
||||
<View style={tw`flex-1 justify-center items-center p-8`}>
|
||||
<MaterialIcons name="label-off" size={64} color="#e5e7eb" />
|
||||
<MyText style={tw`text-gray-500 mt-4 text-center text-lg`}>
|
||||
No tags available
|
||||
</MyText>
|
||||
</View>
|
||||
) : (
|
||||
<View style={tw`flex-1`}>
|
||||
<View style={tw`bg-blue-50 px-4 py-2 mb-2 mt-2 mx-4 rounded-lg`}>
|
||||
<MyText style={tw`text-blue-700 text-xs text-center`}>
|
||||
Long press and drag to reorder • {tags.length} items
|
||||
</MyText>
|
||||
</View>
|
||||
|
||||
<View style={tw`flex-1 px-3`}>
|
||||
<DraggableFlatList
|
||||
data={tags}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={(item) => item.id.toString()}
|
||||
onDragEnd={handleDragEnd}
|
||||
showsVerticalScrollIndicator={true}
|
||||
contentContainerStyle={{ paddingBottom: 20 }}
|
||||
containerStyle={tw`flex-1`}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
activationDistance={10}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
item: {
|
||||
width: itemWidth,
|
||||
height: 60,
|
||||
backgroundColor: 'white',
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
borderColor: '#e5e7eb',
|
||||
padding: 10,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 2,
|
||||
elevation: 2,
|
||||
marginVertical: 4,
|
||||
},
|
||||
activeItem: {
|
||||
shadowColor: '#3b82f6',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 8,
|
||||
elevation: 8,
|
||||
borderColor: '#3b82f6',
|
||||
transform: [{ scale: 1.02 }],
|
||||
},
|
||||
dragHandle: {
|
||||
marginRight: 8,
|
||||
padding: 2,
|
||||
},
|
||||
image: {
|
||||
width: 30,
|
||||
height: 30,
|
||||
borderRadius: 6,
|
||||
marginRight: 10,
|
||||
},
|
||||
placeholderImage: {
|
||||
width: 30,
|
||||
height: 30,
|
||||
borderRadius: 6,
|
||||
backgroundColor: '#f3f4f6',
|
||||
marginRight: 10,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
info: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
name: {
|
||||
fontSize: 13,
|
||||
color: '#111827',
|
||||
fontWeight: '500',
|
||||
flex: 1,
|
||||
marginRight: 4,
|
||||
},
|
||||
});
|
||||
|
|
@ -1,141 +0,0 @@
|
|||
import React from 'react'
|
||||
import { Alert } from 'react-native'
|
||||
import { AppContainer, ImageUploaderNeoPayload } from 'common-ui'
|
||||
import ProductForm from '@/src/components/ProductForm'
|
||||
import { trpc } from '@/src/trpc-client'
|
||||
import { useUploadToObjectStorage } from '@/hooks/useUploadToObjectStore'
|
||||
|
||||
export default function AddProduct() {
|
||||
const createProduct = trpc.admin.product.createProduct.useMutation()
|
||||
const { refetch: refetchProducts } = trpc.admin.product.getProducts.useQuery(undefined, {
|
||||
enabled: false,
|
||||
})
|
||||
const { upload, isUploading } = useUploadToObjectStorage()
|
||||
|
||||
const handleSubmit = async (values: any, variantImages: ImageUploaderNeoPayload[][], _deletedImageKeys?: string[]) => {
|
||||
try {
|
||||
for (const variant of values.variants) {
|
||||
const price = variant.price
|
||||
if (price == null || price <= 0) {
|
||||
Alert.alert('Error', 'Please enter a valid price for every variant')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const seenSignatures = new Set<string>()
|
||||
for (const variant of values.variants) {
|
||||
const features = variant.features || []
|
||||
const hasQuantity = features.some(
|
||||
(a: any) => (a.featureName ?? '').trim().toLowerCase() === 'quantity'
|
||||
)
|
||||
if (!hasQuantity) {
|
||||
Alert.alert('Error', 'Every SKU must have a quantity feature')
|
||||
return
|
||||
}
|
||||
const signature = features
|
||||
.map((a: any) => `${(a.featureName ?? '').trim().toLowerCase()}::${a.featureValue.trim().toLowerCase()}`)
|
||||
.sort()
|
||||
.join('|')
|
||||
if (seenSignatures.has(signature)) {
|
||||
Alert.alert('Error', 'Two variants have the same attributes')
|
||||
return
|
||||
}
|
||||
seenSignatures.add(signature)
|
||||
}
|
||||
|
||||
const allBlobs: { blob: Blob; mimeType: string }[] = []
|
||||
const imageCounts: number[] = variantImages.map((imgs) => imgs.length)
|
||||
|
||||
for (const imgs of variantImages) {
|
||||
for (const img of imgs) {
|
||||
const response = await fetch(img.url)
|
||||
const blob = await response.blob()
|
||||
allBlobs.push({ blob, mimeType: img.mimeType || 'image/jpeg' })
|
||||
}
|
||||
}
|
||||
|
||||
let allUploadUrls: string[] = []
|
||||
if (allBlobs.length > 0) {
|
||||
const result = await upload({ images: allBlobs, contextString: 'product_info' })
|
||||
allUploadUrls = result.presignedUrls
|
||||
}
|
||||
|
||||
let urlCursor = 0
|
||||
const skus = values.variants.map((variant: any, vIndex: number) => {
|
||||
const count = imageCounts[vIndex]
|
||||
const variantUrls = allUploadUrls.slice(urlCursor, urlCursor + count)
|
||||
urlCursor += count
|
||||
|
||||
return {
|
||||
name: variant.name || null,
|
||||
price: variant.price as number,
|
||||
marketPrice: variant.marketPrice ?? undefined,
|
||||
images: variantUrls,
|
||||
isFlashAvailable: variant.isFlashAvailable || false,
|
||||
flashPrice: variant.flashPrice ?? undefined,
|
||||
isOffer: variant.isOffer || false,
|
||||
isComboOnly: variant.isComboOnly || false,
|
||||
isDeleted: variant.isDeleted || false,
|
||||
isSuspended: variant.isSuspended || false,
|
||||
features: variant.features.map((attr: any) => ({
|
||||
featureName: attr.featureName,
|
||||
featureValue: attr.featureValue,
|
||||
})),
|
||||
comboItems: (variant.comboItems || []).map((ci: any) => ({
|
||||
skuId: ci.skuId,
|
||||
})),
|
||||
}
|
||||
})
|
||||
|
||||
await createProduct.mutateAsync({
|
||||
name: values.name,
|
||||
shortDescription: values.shortDescription || undefined,
|
||||
longDescription: values.longDescription || undefined,
|
||||
storeId: values.storeId,
|
||||
productType: values.productType,
|
||||
incrementStep: 1,
|
||||
skus,
|
||||
})
|
||||
|
||||
await refetchProducts()
|
||||
Alert.alert('Success', 'Product created successfully!')
|
||||
} catch (error: any) {
|
||||
Alert.alert('Error', error.message || 'Failed to create product')
|
||||
}
|
||||
}
|
||||
|
||||
const initialValues = {
|
||||
name: '',
|
||||
shortDescription: '',
|
||||
longDescription: '',
|
||||
storeId: 1,
|
||||
productType: 'item' as const,
|
||||
variants: [
|
||||
{
|
||||
id: undefined as number | undefined,
|
||||
name: '',
|
||||
price: undefined,
|
||||
marketPrice: null,
|
||||
isFlashAvailable: false,
|
||||
flashPrice: null,
|
||||
isOffer: false,
|
||||
isComboOnly: false,
|
||||
isDeleted: false,
|
||||
isSuspended: false,
|
||||
features: [{ featureName: 'quantity', featureValue: '' }],
|
||||
comboItems: [],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
return (
|
||||
<AppContainer>
|
||||
<ProductForm
|
||||
mode="create"
|
||||
initialValues={initialValues}
|
||||
onSubmit={handleSubmit}
|
||||
isLoading={createProduct.isPending || isUploading}
|
||||
/>
|
||||
</AppContainer>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,224 +0,0 @@
|
|||
import React, { useRef, useMemo } from 'react';
|
||||
import { View, Alert } from 'react-native';
|
||||
import { useLocalSearchParams } from 'expo-router';
|
||||
import { AppContainer, useManualRefresh, MyText, tw, ImageUploaderNeoItem, ImageUploaderNeoPayload } from 'common-ui';
|
||||
import ProductForm, { ProductFormRef } from '@/src/components/ProductForm';
|
||||
import { trpc } from '@/src/trpc-client';
|
||||
import { useUploadToObjectStorage } from '@/hooks/useUploadToObjectStore';
|
||||
|
||||
export default function EditProduct() {
|
||||
const { id } = useLocalSearchParams();
|
||||
const productId = Number(id);
|
||||
const productFormRef = useRef<ProductFormRef>(null);
|
||||
|
||||
const { data: productResponse, isLoading: isFetching, refetch } = trpc.admin.product.getProductById.useQuery(
|
||||
{ id: productId },
|
||||
{ enabled: !!productId }
|
||||
);
|
||||
const { refetch: refetchProducts } = trpc.admin.product.getProducts.useQuery(undefined, {
|
||||
enabled: false,
|
||||
});
|
||||
|
||||
const updateProduct = trpc.admin.product.updateProduct.useMutation();
|
||||
const { upload, isUploading } = useUploadToObjectStorage();
|
||||
|
||||
useManualRefresh(() => refetch());
|
||||
|
||||
const productData = productResponse?.product;
|
||||
|
||||
const initialValues = useMemo(() => {
|
||||
if (!productData) {
|
||||
return {
|
||||
name: '',
|
||||
shortDescription: '',
|
||||
longDescription: '',
|
||||
storeId: 0,
|
||||
productType: 'item' as const,
|
||||
variants: [],
|
||||
}
|
||||
}
|
||||
return {
|
||||
name: productData.name,
|
||||
shortDescription: productData.shortDescription || '',
|
||||
longDescription: productData.longDescription || '',
|
||||
storeId: productData.storeId || 1,
|
||||
productType: (productData.productType as 'item' | 'combo') || 'item',
|
||||
variants: (productData.skus || []).map((sku) => ({
|
||||
id: sku.id,
|
||||
name: sku.name || '',
|
||||
price: sku.price ? parseFloat(sku.price) : undefined,
|
||||
marketPrice: sku.marketPrice ? parseFloat(sku.marketPrice) : null,
|
||||
isFlashAvailable: sku.isFlashAvailable || false,
|
||||
flashPrice: sku.flashPrice ? parseFloat(sku.flashPrice) : null,
|
||||
isOffer: sku.isOffer || false,
|
||||
isComboOnly: sku.isComboOnly || false,
|
||||
isDeleted: sku.isDeleted || false,
|
||||
isSuspended: sku.isSuspended || false,
|
||||
features: (sku.features || []).map((f) => ({
|
||||
featureName: f.featureName,
|
||||
featureValue: f.featureValue,
|
||||
})),
|
||||
comboItems: (sku.comboItems || []).map((ci: any) => ({
|
||||
skuId: ci.skuId,
|
||||
})),
|
||||
})),
|
||||
}
|
||||
}, [productData])
|
||||
|
||||
const existingVariantImages = useMemo(() => {
|
||||
if (!productData) return []
|
||||
return (productData.skus || []).map((sku) =>
|
||||
(sku.images || []).map((url) => ({ imgUrl: url, mimeType: null } as ImageUploaderNeoItem))
|
||||
)
|
||||
}, [productData])
|
||||
|
||||
const existingVariantImageKeys = useMemo(() => {
|
||||
if (!productData) return []
|
||||
return (productData.skus || []).map((sku) =>
|
||||
(sku.imageKeys || []).map(String)
|
||||
)
|
||||
}, [productData])
|
||||
|
||||
const handleSubmit = async (values: any, variantImages: ImageUploaderNeoPayload[][], deletedImageKeys: string[]) => {
|
||||
try {
|
||||
for (const variant of values.variants) {
|
||||
const price = variant.price
|
||||
if (price == null || price <= 0) {
|
||||
Alert.alert('Error', 'Please enter a valid price for every variant')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const seenSignatures = new Set<string>()
|
||||
for (const variant of values.variants) {
|
||||
const features = variant.features || []
|
||||
const hasQuantity = features.some(
|
||||
(a: any) => (a.featureName ?? '').trim().toLowerCase() === 'quantity'
|
||||
)
|
||||
if (!hasQuantity) {
|
||||
Alert.alert('Error', 'Every SKU must have a quantity feature')
|
||||
return
|
||||
}
|
||||
const signature = features
|
||||
.map((a: any) => `${(a.featureName ?? '').trim().toLowerCase()}::${a.featureValue.trim().toLowerCase()}`)
|
||||
.sort()
|
||||
.join('|')
|
||||
if (seenSignatures.has(signature)) {
|
||||
Alert.alert('Error', 'Two variants have the same attributes')
|
||||
return
|
||||
}
|
||||
seenSignatures.add(signature)
|
||||
}
|
||||
|
||||
const allBlobs: { blob: Blob; mimeType: string }[] = []
|
||||
const imageCounts: number[] = variantImages.map((imgs) =>
|
||||
imgs.filter((img) => img.mimeType !== null).length
|
||||
)
|
||||
|
||||
for (const imgs of variantImages) {
|
||||
for (const img of imgs) {
|
||||
if (img.mimeType === null) continue // existing image, skip
|
||||
const response = await fetch(img.url)
|
||||
const blob = await response.blob()
|
||||
allBlobs.push({ blob, mimeType: img.mimeType || 'image/jpeg' })
|
||||
}
|
||||
}
|
||||
|
||||
let allUploadUrls: string[] = []
|
||||
if (allBlobs.length > 0) {
|
||||
const result = await upload({ images: allBlobs, contextString: 'product_info' })
|
||||
allUploadUrls = result.presignedUrls
|
||||
}
|
||||
|
||||
// Build SKU inputs with images (existing + new)
|
||||
let urlCursor = 0
|
||||
const skus = values.variants.map((variant: any, vIndex: number) => {
|
||||
const count = imageCounts[vIndex]
|
||||
const newUrls = allUploadUrls.slice(urlCursor, urlCursor + count)
|
||||
urlCursor += count
|
||||
|
||||
// Existing images (mimeType === null) stay as-is
|
||||
const existingUrls = variantImages[vIndex]
|
||||
?.filter((img) => img.mimeType === null)
|
||||
.map((img) => img.url) || []
|
||||
|
||||
const allUrls = [...existingUrls, ...newUrls]
|
||||
|
||||
return {
|
||||
id: variant.id,
|
||||
name: variant.name || null,
|
||||
price: variant.price as number,
|
||||
marketPrice: variant.marketPrice ?? undefined,
|
||||
images: allUrls,
|
||||
isFlashAvailable: variant.isFlashAvailable || false,
|
||||
flashPrice: variant.flashPrice ?? undefined,
|
||||
isOffer: variant.isOffer || false,
|
||||
isComboOnly: variant.isComboOnly || false,
|
||||
isDeleted: variant.isDeleted || false,
|
||||
isSuspended: variant.isSuspended || false,
|
||||
features: variant.features.map((attr: any) => ({
|
||||
featureName: attr.featureName,
|
||||
featureValue: attr.featureValue,
|
||||
})),
|
||||
comboItems: (variant.comboItems || []).map((ci: any) => ({
|
||||
skuId: ci.skuId,
|
||||
})),
|
||||
}
|
||||
})
|
||||
|
||||
await updateProduct.mutateAsync({
|
||||
id: productId,
|
||||
name: values.name,
|
||||
shortDescription: values.shortDescription || undefined,
|
||||
longDescription: values.longDescription || undefined,
|
||||
storeId: values.storeId,
|
||||
productType: values.productType,
|
||||
incrementStep: 1,
|
||||
skus,
|
||||
deletedImageKeys,
|
||||
newImageUrls: allUploadUrls,
|
||||
} as any)
|
||||
|
||||
await refetch()
|
||||
await refetchProducts()
|
||||
Alert.alert('Success', 'Product updated successfully!')
|
||||
productFormRef.current?.clearImages()
|
||||
} catch (error: any) {
|
||||
Alert.alert('Error', error.message || 'Failed to update product')
|
||||
}
|
||||
};
|
||||
|
||||
if (isFetching) {
|
||||
return (
|
||||
<AppContainer>
|
||||
<View style={tw`flex-1 justify-center items-center`}>
|
||||
<MyText style={tw`text-gray-600`}>Loading product...</MyText>
|
||||
</View>
|
||||
</AppContainer>
|
||||
);
|
||||
}
|
||||
|
||||
if (!productData) {
|
||||
return (
|
||||
<AppContainer>
|
||||
<View style={tw`flex-1 justify-center items-center`}>
|
||||
<MyText style={tw`text-red-600`}>Product not found</MyText>
|
||||
</View>
|
||||
</AppContainer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AppContainer>
|
||||
<ProductForm
|
||||
ref={productFormRef}
|
||||
mode="edit"
|
||||
initialValues={initialValues}
|
||||
onSubmit={handleSubmit}
|
||||
isLoading={updateProduct.isPending || isUploading}
|
||||
existingVariantImages={existingVariantImages}
|
||||
existingVariantImageKeys={existingVariantImageKeys}
|
||||
/>
|
||||
</AppContainer>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,267 +0,0 @@
|
|||
import React, { useState, useMemo } from 'react';
|
||||
import { View, TouchableOpacity, RefreshControl } from 'react-native';
|
||||
import { Image } from 'expo-image';
|
||||
import { useRouter } from 'expo-router';
|
||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||
import { AppContainer, MyText, tw, MyButton, useManualRefresh, SearchBar, useMarkDataFetchers, MyFlatList } from 'common-ui';
|
||||
|
||||
import { trpc } from '@/src/trpc-client';
|
||||
import { SuccessToast, ErrorToast } from '@/services/toaster';
|
||||
import type { AdminSku } from '@packages/shared';
|
||||
|
||||
type FilterType = 'all' | 'in-stock' | 'out-of-stock';
|
||||
|
||||
type SerializedAdminSku = Omit<AdminSku, 'createdAt'> & { createdAt: string | Date }
|
||||
|
||||
function getDefaultSku(product: { skus: SerializedAdminSku[] }): SerializedAdminSku | null {
|
||||
return product.skus?.[0] ?? null
|
||||
}
|
||||
|
||||
export default function Products() {
|
||||
const router = useRouter();
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [activeFilter, setActiveFilter] = useState<FilterType>('all');
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
const { data: productsData, isLoading, error, refetch } = trpc.admin.product.getProducts.useQuery();
|
||||
|
||||
const toggleOutOfStock = trpc.admin.product.toggleOutOfStock.useMutation({
|
||||
onSuccess: async (res) => {
|
||||
await refetch();
|
||||
SuccessToast(res.message);
|
||||
},
|
||||
onError: (err) => {
|
||||
ErrorToast(err.message || 'Failed to update stock status');
|
||||
},
|
||||
});
|
||||
|
||||
useManualRefresh(refetch);
|
||||
|
||||
useMarkDataFetchers(() => {
|
||||
refetch();
|
||||
});
|
||||
|
||||
const products = productsData?.products || [];
|
||||
|
||||
const handleRefresh = async () => {
|
||||
setRefreshing(true);
|
||||
await refetch();
|
||||
setRefreshing(false);
|
||||
};
|
||||
|
||||
const filteredProducts = useMemo(() => {
|
||||
return products.filter(product => {
|
||||
const defaultSku = getDefaultSku(product)
|
||||
|
||||
const matchesSearch = product.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
(product.shortDescription?.toLowerCase().includes(searchTerm.toLowerCase()));
|
||||
|
||||
const matchesFilter = activeFilter === 'all' ||
|
||||
(activeFilter === 'in-stock' && !defaultSku?.isOutOfStock) ||
|
||||
(activeFilter === 'out-of-stock' && defaultSku?.isOutOfStock);
|
||||
|
||||
return matchesSearch && matchesFilter;
|
||||
});
|
||||
}, [products, searchTerm, activeFilter]);
|
||||
|
||||
const handleEdit = (productId: number) => {
|
||||
router.push(`/(drawer)/dashboard/products/edit?id=${productId}` as any);
|
||||
};
|
||||
|
||||
const handleViewDetails = (productId: number) => {
|
||||
router.push(`/(drawer)/dashboard/products/detail/${productId}` as any);
|
||||
};
|
||||
|
||||
const handleToggleStock = (product: { id: number }) => {
|
||||
toggleOutOfStock.mutate({ id: product.id });
|
||||
};
|
||||
|
||||
const FilterButton = ({ filter, label, count }: { filter: FilterType; label: string; count: number }) => (
|
||||
<TouchableOpacity
|
||||
onPress={() => setActiveFilter(filter)}
|
||||
style={tw`px-4 py-2 rounded-lg ${activeFilter === filter ? 'bg-blue-500' : 'bg-gray-200'}`}
|
||||
>
|
||||
<MyText style={tw`${activeFilter === filter ? 'text-white' : 'text-gray-700'} font-semibold`}>
|
||||
{label} ({count})
|
||||
</MyText>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<AppContainer>
|
||||
<View style={tw`flex-1 justify-center items-center`}>
|
||||
<MyText style={tw`text-gray-600`}>Loading products...</MyText>
|
||||
</View>
|
||||
</AppContainer>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<AppContainer>
|
||||
<View style={tw`flex-1 justify-center items-center`}>
|
||||
<MyText style={tw`text-red-600`}>Error loading products</MyText>
|
||||
<TouchableOpacity
|
||||
onPress={() => refetch()}
|
||||
style={tw`mt-4 bg-blue-500 px-4 py-2 rounded-lg`}
|
||||
>
|
||||
<MyText style={tw`text-white font-semibold`}>Retry</MyText>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</AppContainer>
|
||||
);
|
||||
}
|
||||
|
||||
const inStockCount = products.filter(p => getDefaultSku(p) && !getDefaultSku(p)!.isOutOfStock).length;
|
||||
const outOfStockCount = products.filter(p => getDefaultSku(p)?.isOutOfStock).length;
|
||||
|
||||
const listHeader = (
|
||||
<>
|
||||
{/* Header */}
|
||||
<View style={tw`flex-row justify-between items-center mb-6`}>
|
||||
<MyText style={tw`text-2xl font-bold text-gray-800`}>Products</MyText>
|
||||
<MyButton onPress={() => router.push('/(drawer)/dashboard/products/add' as any)}>
|
||||
Add Product
|
||||
</MyButton>
|
||||
</View>
|
||||
|
||||
{/* Search Bar */}
|
||||
<View style={tw`mb-4`}>
|
||||
<SearchBar
|
||||
value={searchTerm}
|
||||
onChangeText={setSearchTerm}
|
||||
placeholder="Search products..."
|
||||
containerStyle={tw`mb-0`}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Filter Tabs */}
|
||||
<View style={tw`flex-row gap-2 mb-6`}>
|
||||
<FilterButton filter="all" label="All" count={products.length} />
|
||||
<FilterButton filter="in-stock" label="In Stock" count={inStockCount} />
|
||||
<FilterButton filter="out-of-stock" label="Out of Stock" count={outOfStockCount} />
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
|
||||
const renderItem = ({ item: product }: { item: any }) => {
|
||||
const defaultSku = getDefaultSku(product)
|
||||
const skuImages = defaultSku?.images ?? null
|
||||
const isOut = defaultSku?.isOutOfStock ?? false
|
||||
|
||||
return (
|
||||
<View key={product.id} style={tw`bg-white rounded-2xl shadow-lg mb-4 overflow-hidden`}>
|
||||
{/* Product Image */}
|
||||
{skuImages && skuImages.length > 0 ? (
|
||||
<Image
|
||||
source={{ uri: skuImages[0] }}
|
||||
style={tw`w-full h-48`}
|
||||
resizeMode="cover"
|
||||
/>
|
||||
) : (
|
||||
<View style={tw`w-full h-48 bg-gray-200 justify-center items-center`}>
|
||||
<MaterialIcons name="image" size={48} color="#9CA3AF" />
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Product Info */}
|
||||
<View style={tw`p-4`}>
|
||||
<View style={tw`flex-row justify-between items-start mb-2`}>
|
||||
<MyText style={tw`text-lg font-bold text-gray-800 flex-1 mr-2`} numberOfLines={2}>
|
||||
{product.name}
|
||||
</MyText>
|
||||
<View style={tw`flex-row items-center`}>
|
||||
<View style={tw`w-3 h-3 rounded-full mr-1 ${isOut ? 'bg-red-500' : 'bg-green-500'}`} />
|
||||
<MyText style={tw`text-sm ${isOut ? 'text-red-500' : 'text-green-500'} font-semibold`}>
|
||||
{isOut ? 'Out' : 'In'}
|
||||
</MyText>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{product.shortDescription && (
|
||||
<MyText style={tw`text-gray-600 mb-2`} numberOfLines={2}>
|
||||
{product.shortDescription}
|
||||
</MyText>
|
||||
)}
|
||||
|
||||
<View style={tw`flex-row justify-between items-center mb-3`}>
|
||||
<MyText style={tw`text-sm text-gray-500 font-medium`}>
|
||||
{product.skus?.length ?? 0} Variants
|
||||
</MyText>
|
||||
</View>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<View style={tw`flex-row gap-2`}>
|
||||
<TouchableOpacity
|
||||
onPress={() => handleViewDetails(product.id)}
|
||||
style={tw`flex-1 bg-gray-500 p-3 rounded-lg flex-row items-center justify-center`}
|
||||
>
|
||||
<MaterialIcons name="visibility" size={16} color="white" />
|
||||
<MyText style={tw`text-white font-semibold ml-1`}>View</MyText>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={() => handleEdit(product.id)}
|
||||
style={tw`flex-1 bg-blue-500 p-3 rounded-lg flex-row items-center justify-center`}
|
||||
>
|
||||
<MaterialIcons name="edit" size={16} color="white" />
|
||||
<MyText style={tw`text-white font-semibold ml-1`}>Edit</MyText>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={() => handleToggleStock(product)}
|
||||
disabled={toggleOutOfStock.isPending && toggleOutOfStock.variables?.id === product.id}
|
||||
style={tw`flex-1 ${isOut ? 'bg-green-500' : 'bg-orange-500'} p-3 rounded-lg flex-row items-center justify-center ${toggleOutOfStock.isPending && toggleOutOfStock.variables?.id === product.id ? 'opacity-50' : ''}`}
|
||||
>
|
||||
<MaterialIcons name={isOut ? 'check-circle' : 'block'} size={16} color="white" />
|
||||
<MyText style={tw`text-white font-semibold ml-1`}>
|
||||
{isOut ? 'Stock' : 'Out'}
|
||||
</MyText>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
};
|
||||
|
||||
return (
|
||||
<AppContainer>
|
||||
<View style={tw`flex-1`}>
|
||||
<MyFlatList
|
||||
data={filteredProducts}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={(item) => item.id.toString()}
|
||||
ListHeaderComponent={listHeader}
|
||||
ListEmptyComponent={
|
||||
<View style={tw`flex-1 justify-center items-center py-10`}>
|
||||
<MaterialIcons name="inventory" size={64} color="#D1D5DB" />
|
||||
<MyText style={tw`text-gray-500 text-center mt-4 text-lg`}>
|
||||
{searchTerm || activeFilter !== 'all' ? 'No products match your filters' : 'No products found'}
|
||||
</MyText>
|
||||
<MyText style={tw`text-gray-400 text-center mt-2`}>
|
||||
{searchTerm || activeFilter !== 'all' ? 'Try adjusting your search or filters' : 'Start by adding your first product'}
|
||||
</MyText>
|
||||
{(searchTerm || activeFilter !== 'all') && (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
setSearchTerm('');
|
||||
setActiveFilter('all');
|
||||
}}
|
||||
style={tw`mt-4 bg-blue-500 px-4 py-2 rounded-lg`}
|
||||
>
|
||||
<MyText style={tw`text-white font-semibold`}>Clear Filters</MyText>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
}
|
||||
contentContainerStyle={tw`pb-4`}
|
||||
showsVerticalScrollIndicator={false}
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={refreshing} onRefresh={handleRefresh} />
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
</AppContainer>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
import { Stack } from 'expo-router'
|
||||
|
||||
export default function Layout() {
|
||||
return (
|
||||
<Stack screenOptions={{ headerShown: false }}>
|
||||
<Stack.Screen name="index" options={{ title: 'Send Notifications' }} />
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
import { Stack } from 'expo-router'
|
||||
|
||||
export default function Layout() {
|
||||
return (
|
||||
<Stack screenOptions={{ headerShown: false }}>
|
||||
<Stack.Screen name="index" options={{ title: 'User Management' }} />
|
||||
<Stack.Screen name="[id]" options={{ title: 'User Details' }} />
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
|
|
@ -302,7 +302,7 @@ export default function DeliverySequences() {
|
|||
// Auto-select first slot if no slotId provided
|
||||
useEffect(() => {
|
||||
if (!slotId && slotsData?.slots && slotsData.slots.length > 0) {
|
||||
router.replace(`/(drawer)/dashboard/manage-orders/delivery-sequences?slotId=${slotsData.slots[0].id}`);
|
||||
router.replace(`/manage-orders/delivery-sequences?slotId=${slotsData.slots[0].id}`);
|
||||
}
|
||||
}, [slotId, slotsData, router]);
|
||||
|
||||
|
|
@ -505,7 +505,7 @@ export default function DeliverySequences() {
|
|||
value={selectedSlotId || ""}
|
||||
onValueChange={(val) => {
|
||||
if (val) {
|
||||
router.replace(`/(drawer)/dashboard/manage-orders/delivery-sequences?slotId=${val}`);
|
||||
router.replace(`/manage-orders/delivery-sequences?slotId=${val}`);
|
||||
}
|
||||
}}
|
||||
placeholder="Select slot"
|
||||
|
|
@ -537,6 +537,11 @@ export default function DeliverySequences() {
|
|||
</View>
|
||||
) : (
|
||||
<View style={tw`flex-1`}>
|
||||
{/* <View style={tw`bg-blue-50 px-4 py-2 mb-2`}>
|
||||
<MyText style={tw`text-blue-700 text-xs text-center`}>
|
||||
Long press an item to drag and reorder
|
||||
</MyText>
|
||||
</View> */}
|
||||
<DraggableFlatList
|
||||
data={localOrderedOrders}
|
||||
renderItem={({ item, drag, isActive }) => (
|
||||
|
|
@ -721,7 +726,7 @@ export default function DeliverySequences() {
|
|||
}}
|
||||
onViewDetails={() => {
|
||||
if (selectedOrder) {
|
||||
router.push(`/(drawer)/dashboard/manage-orders/order-details/${selectedOrder.id}`);
|
||||
router.push(`/manage-orders/order-details/${selectedOrder.id}`);
|
||||
}
|
||||
setShowOrderMenu(false);
|
||||
}}
|
||||
|
|
@ -46,7 +46,7 @@ export default function ManageOrders() {
|
|||
Alert.alert('Flash Deliveries', 'Flash deliveries do not have delivery sequences. Use the Orders menu to manage flash deliveries.');
|
||||
return;
|
||||
}
|
||||
router.push(`/(drawer)/dashboard/manage-orders/delivery-sequences?slotId=${selectedSlotId}`);
|
||||
router.push(`/manage-orders/delivery-sequences?slotId=${selectedSlotId}`);
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
@ -55,9 +55,9 @@ export default function ManageOrders() {
|
|||
color: 'bg-cyan-500',
|
||||
onPress: () => {
|
||||
if (selectedSlotId === 'flash') {
|
||||
router.push('/(drawer)/dashboard/manage-orders/orders?filter=flash');
|
||||
router.push('/manage-orders/orders?filter=flash');
|
||||
} else {
|
||||
router.push('/(drawer)/dashboard/manage-orders/orders');
|
||||
router.push('/manage-orders/orders');
|
||||
}
|
||||
},
|
||||
},
|
||||
|
|
@ -378,7 +378,7 @@ export default function OrderDetails() {
|
|||
|
||||
{/* Customer Details */}
|
||||
<TouchableOpacity
|
||||
onPress={() => order.userId && router.push(`/(drawer)/dashboard/user-management/${order.userId}`)}
|
||||
onPress={() => order.userId && router.push(`/(drawer)/user-management/${order.userId}`)}
|
||||
activeOpacity={0.7}
|
||||
style={tw`bg-white p-5 rounded-2xl shadow-sm mb-4 border border-gray-100`}
|
||||
>
|
||||
|
|
@ -463,8 +463,8 @@ export default function OrderDetails() {
|
|||
{item.name}
|
||||
</MyText>
|
||||
<MyText style={tw`text-xs text-gray-500`}>
|
||||
{Number(item.quantity)} x {item.unit} × ₹{item.price}
|
||||
</MyText>
|
||||
{Number(item.quantity) * item.productSize} {item.unit} × ₹{item.price}
|
||||
</MyText>
|
||||
<View style={tw`flex-row items-center mt-2 gap-3`}>
|
||||
<TouchableOpacity
|
||||
style={tw`flex-row items-center`}
|
||||
|
|
@ -784,7 +784,7 @@ export default function OrderDetails() {
|
|||
</TouchableOpacity>
|
||||
)}
|
||||
<TouchableOpacity
|
||||
onPress={() => router.push("/(drawer)/dashboard/manage-orders")}
|
||||
onPress={() => router.push("/(drawer)/manage-orders")}
|
||||
style={tw`bg-gray-900 rounded-xl py-4 items-center shadow-lg`}
|
||||
>
|
||||
<MyText style={tw`text-white font-bold text-base`}>
|
||||
|
|
@ -9,7 +9,6 @@ import { Entypo } from '@expo/vector-icons';
|
|||
import CancelOrderDialog from '@/components/CancelOrderDialog';
|
||||
import { OrderOptionsMenu } from '@/components/OrderOptionsMenu';
|
||||
import * as Location from 'expo-location';
|
||||
import type { AdminOrderListItem, AdminOrderListItemProduct } from '@packages/shared';
|
||||
|
||||
const AdminNotesForm = ({ orderId, existingNotes, onClose, refetch }: { orderId: string; existingNotes?: string | null; onClose: () => void; refetch: () => void }) => {
|
||||
const [notesText, setNotesText] = useState(existingNotes || '');
|
||||
|
|
@ -52,32 +51,43 @@ const AdminNotesForm = ({ orderId, existingNotes, onClose, refetch }: { orderId:
|
|||
};
|
||||
|
||||
|
||||
// Order list row — derived from shared AdminOrderListItem (serialized dates,
|
||||
// coupon display fields, optional flags)
|
||||
type OrderItemRow = Omit<
|
||||
AdminOrderListItemProduct,
|
||||
'id' | 'skuName' | 'features' | 'isPackaged' | 'isPackageVerified'
|
||||
> & {
|
||||
id?: number;
|
||||
isPackaged?: boolean;
|
||||
isPackageVerified?: boolean;
|
||||
};
|
||||
|
||||
type OrderType = Omit<
|
||||
AdminOrderListItem,
|
||||
'customerName' | 'customerMobile' | 'items' | 'createdAt' | 'adminNotes' | 'userNotes' | 'userNegativityScore'
|
||||
> & {
|
||||
interface OrderType {
|
||||
id: number;
|
||||
orderId: string;
|
||||
readableId: number;
|
||||
customerName: string | null;
|
||||
customerMobile?: string | null;
|
||||
items: OrderItemRow[];
|
||||
address: string;
|
||||
addressId: number;
|
||||
latitude: number | null;
|
||||
longitude: number | null;
|
||||
totalAmount: number;
|
||||
deliveryCharge: number;
|
||||
items: {
|
||||
id?: number;
|
||||
name: string;
|
||||
quantity: number;
|
||||
price: number;
|
||||
amount: number;
|
||||
unit: string;
|
||||
isPackaged?: boolean;
|
||||
isPackageVerified?: boolean;
|
||||
productSize: number;
|
||||
}[];
|
||||
createdAt: string;
|
||||
adminNotes?: string | null;
|
||||
userNotes?: string | null;
|
||||
userNegativityScore?: number;
|
||||
deliveryTime: string | null;
|
||||
status: 'pending' | 'delivered' | 'cancelled';
|
||||
isPackaged: boolean;
|
||||
isDelivered: boolean;
|
||||
isCod: boolean;
|
||||
isFlashDelivery: boolean;
|
||||
couponCode?: string;
|
||||
couponDescription?: string;
|
||||
discountAmount?: number;
|
||||
};
|
||||
adminNotes?: string | null;
|
||||
userNotes?: string | null;
|
||||
userNegativityScore?: number;
|
||||
}
|
||||
|
||||
const OrderItem = ({ order, refetch }: { order: OrderType; refetch: () => void }) => {
|
||||
const id = order.orderId;
|
||||
|
|
@ -95,12 +105,12 @@ const OrderItem = ({ order, refetch }: { order: OrderType; refetch: () => void }
|
|||
const updateItemPackagingMutation = trpc.admin.order.updateOrderItemPackaging.useMutation();
|
||||
|
||||
const handleOrderPress = () => {
|
||||
router.push(`/(drawer)/dashboard/manage-orders/order-details/${order.orderId}` as any);
|
||||
router.push(`/manage-orders/order-details/${order.orderId}` as any);
|
||||
};
|
||||
|
||||
const handleMenuOption = () => {
|
||||
setMenuOpen(false);
|
||||
router.push(`/(drawer)/dashboard/manage-orders/order-details/${order.orderId}` as any);
|
||||
router.push(`/manage-orders/order-details/${order.orderId}` as any);
|
||||
};
|
||||
|
||||
const handleMarkPackaged = (isPackaged: boolean) => {
|
||||
|
|
@ -274,17 +284,17 @@ const OrderItem = ({ order, refetch }: { order: OrderType; refetch: () => void }
|
|||
>
|
||||
<MaterialIcons name="shopping-cart" size={16} color="#3B82F6" />
|
||||
<MyText style={tw`text-sm font-medium text-blue-700 ml-2`}>
|
||||
{`${order.items.length} ${order.items.length === 1 ? 'item' : 'items'}`}
|
||||
{order.items.length} {order.items.length === 1 ? 'item' : 'items'}
|
||||
</MyText>
|
||||
{order.isFlashDelivery && (
|
||||
<View style={tw`ml-2 bg-amber-100 px-1.5 py-0.5 rounded-full`}>
|
||||
<MaterialIcons name="bolt" size={12} color="#D97706" />
|
||||
<MyText style={tw`text-xs font-bold text-amber-700`}>⚡</MyText>
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
<View style={tw`flex-row items-center`}>
|
||||
<MyText style={tw`text-base font-semibold text-gray-900 mr-2`}>Total:</MyText>
|
||||
<MyText style={tw`text-lg font-bold ${order.isFlashDelivery ? 'text-amber-700' : 'text-gray-900'}`}>{`₹${order.totalAmount}`}</MyText>
|
||||
<MyText style={tw`text-lg font-bold ${order.isFlashDelivery ? 'text-amber-700' : 'text-gray-900'}`}>₹{order.totalAmount}</MyText>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
|
@ -305,8 +315,8 @@ const OrderItem = ({ order, refetch }: { order: OrderType; refetch: () => void }
|
|||
)}
|
||||
{order.discountAmount && (
|
||||
<MyText style={tw`text-sm font-bold text-pink-800`}>
|
||||
{`Discount: ₹${order.discountAmount}`}
|
||||
</MyText>
|
||||
Discount: ₹{order.discountAmount}
|
||||
</MyText>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
|
@ -343,7 +353,7 @@ const OrderItem = ({ order, refetch }: { order: OrderType; refetch: () => void }
|
|||
<View style={tw`pt-3 border-t border-gray-100`}>
|
||||
<View style={tw`flex-row justify-between items-center`}>
|
||||
<MyText style={tw`text-sm text-gray-500`}>Delivery Charge</MyText>
|
||||
<MyText style={tw`text-sm text-gray-900`}>{`₹${order.deliveryCharge}`}</MyText>
|
||||
<MyText style={tw`text-sm text-gray-900`}>₹{order.deliveryCharge}</MyText>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
|
@ -397,13 +407,13 @@ const OrderItem = ({ order, refetch }: { order: OrderType; refetch: () => void }
|
|||
)}
|
||||
</View>
|
||||
<MyText style={tw`text-sm text-gray-600 mb-6`}>
|
||||
{`Total: ₹${order.totalAmount}`}
|
||||
Total: ₹{order.totalAmount}
|
||||
</MyText>
|
||||
{order.items.map((item, idx) => (
|
||||
<View key={idx} style={tw`py-2 border-b border-gray-50 last:border-0`}>
|
||||
<View style={tw`flex-row items-center`}>
|
||||
<View style={tw`bg-gray-100 px-2 py-1 rounded items-center justify-center mr-2`}>
|
||||
<MyText style={tw`text-xs font-bold text-gray-600`}>{`${item.quantity} x ${item.unit}`}</MyText>
|
||||
<MyText style={tw`text-xs font-bold text-gray-600`}>{item.quantity * item.productSize } {item.unit}</MyText>
|
||||
</View>
|
||||
<MyText style={tw`text-sm text-gray-800 flex-1`} numberOfLines={1} ellipsizeMode="tail">
|
||||
{item.name.length > 30 ? `${item.name.substring(0, 30)}...` : item.name}
|
||||
11
apps/admin-ui/app/(drawer)/offers/_layout.tsx
Normal file
11
apps/admin-ui/app/(drawer)/offers/_layout.tsx
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { Stack } from 'expo-router'
|
||||
|
||||
export default function Layout() {
|
||||
return (
|
||||
<Stack screenOptions={{ headerShown: false }}>
|
||||
<Stack.Screen name="index" options={{ title: 'Offers' }} />
|
||||
<Stack.Screen name="add" options={{ title: 'Add Offer' }} />
|
||||
<Stack.Screen name="edit/[id]" options={{ title: 'Edit Offer' }} />
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
21
apps/admin-ui/app/(drawer)/offers/add.tsx
Normal file
21
apps/admin-ui/app/(drawer)/offers/add.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import React from 'react'
|
||||
import { AppContainer } from 'common-ui'
|
||||
import OfferForm from '@/components/OfferForm'
|
||||
import { useRouter } from 'expo-router'
|
||||
import { trpc } from '@/src/trpc-client'
|
||||
|
||||
export default function AddOffer() {
|
||||
const router = useRouter()
|
||||
const { refetch } = trpc.admin.offers.getAll.useQuery()
|
||||
|
||||
const handleOfferAdded = () => {
|
||||
refetch()
|
||||
router.back()
|
||||
}
|
||||
|
||||
return (
|
||||
<AppContainer>
|
||||
<OfferForm onOfferAdded={handleOfferAdded} />
|
||||
</AppContainer>
|
||||
)
|
||||
}
|
||||
67
apps/admin-ui/app/(drawer)/offers/edit/[id].tsx
Normal file
67
apps/admin-ui/app/(drawer)/offers/edit/[id].tsx
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import React from 'react'
|
||||
import { View, Text, ActivityIndicator } from 'react-native'
|
||||
import { AppContainer, tw } from 'common-ui'
|
||||
import OfferForm from '@/components/OfferForm'
|
||||
import { useRouter, useLocalSearchParams } from 'expo-router'
|
||||
import { trpc } from '@/src/trpc-client'
|
||||
|
||||
export default function EditOffer() {
|
||||
const router = useRouter()
|
||||
const { id } = useLocalSearchParams()
|
||||
const offerId = parseInt(id as string)
|
||||
|
||||
const { data: offerData, isLoading } = trpc.admin.offers.getById.useQuery(
|
||||
{ id: offerId },
|
||||
{ enabled: !!offerId }
|
||||
)
|
||||
|
||||
const handleOfferUpdated = () => {
|
||||
router.back()
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<AppContainer>
|
||||
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
|
||||
<ActivityIndicator size="large" color="#3B82F6" />
|
||||
<Text style={tw`mt-4 text-gray-500`}>Loading offer...</Text>
|
||||
</View>
|
||||
</AppContainer>
|
||||
)
|
||||
}
|
||||
|
||||
if (!offerData?.offer) {
|
||||
return (
|
||||
<AppContainer>
|
||||
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
|
||||
<Text style={tw`text-gray-500`}>Offer not found</Text>
|
||||
</View>
|
||||
</AppContainer>
|
||||
)
|
||||
}
|
||||
|
||||
const offer = offerData.offer
|
||||
|
||||
return (
|
||||
<AppContainer>
|
||||
<OfferForm
|
||||
offerId={offer.id}
|
||||
initialName={offer.name}
|
||||
initialShortDescription={offer.shortDescription}
|
||||
initialLongDescription={offer.longDescription}
|
||||
initialPrice={offer.price}
|
||||
initialMarketPrice={offer.marketPrice}
|
||||
initialImages={offer.images || []}
|
||||
initialImageKeys={offer.imageKeys || []}
|
||||
initialIsSuspended={offer.isSuspended}
|
||||
initialIsFlashEnabled={offer.isFlashEnabled}
|
||||
initialItems={offer.items.map((item: any) => ({
|
||||
productId: item.productId,
|
||||
quantity: item.quantity,
|
||||
product: item.product,
|
||||
}))}
|
||||
onOfferAdded={handleOfferUpdated}
|
||||
/>
|
||||
</AppContainer>
|
||||
)
|
||||
}
|
||||
146
apps/admin-ui/app/(drawer)/offers/index.tsx
Normal file
146
apps/admin-ui/app/(drawer)/offers/index.tsx
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
import React from 'react'
|
||||
import { View, TouchableOpacity, Alert, Image, FlatList } from 'react-native'
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons'
|
||||
import { MyText, tw, MyTouchableOpacity, useManualRefresh, useMarkDataFetchers } from 'common-ui'
|
||||
import { trpc } from '@/src/trpc-client'
|
||||
import { useRouter } from 'expo-router'
|
||||
import dayjs from 'dayjs'
|
||||
import { LinearGradient } from 'expo-linear-gradient'
|
||||
|
||||
export default function OffersList() {
|
||||
const router = useRouter()
|
||||
const { data, isLoading, refetch } = trpc.admin.offers.getAll.useQuery()
|
||||
const toggleSuspend = trpc.admin.offers.toggleSuspend.useMutation({
|
||||
onSuccess: () => refetch(),
|
||||
})
|
||||
const deleteOffer = trpc.admin.offers.delete.useMutation({
|
||||
onSuccess: () => refetch(),
|
||||
})
|
||||
|
||||
const offers = data?.offers || []
|
||||
|
||||
useManualRefresh(() => refetch())
|
||||
useMarkDataFetchers(() => refetch())
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<View style={tw`flex-1 justify-center items-center bg-white`}>
|
||||
<MyText>Loading offers...</MyText>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const handleToggleSuspend = (id: number) => {
|
||||
toggleSuspend.mutate({ id })
|
||||
}
|
||||
|
||||
const handleDelete = (id: number) => {
|
||||
Alert.alert('Delete Offer', 'Are you sure you want to delete this offer?', [
|
||||
{ text: 'Cancel', style: 'cancel' },
|
||||
{ text: 'Delete', style: 'destructive', onPress: () => deleteOffer.mutate({ id }) },
|
||||
])
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={tw`flex-1 bg-white relative`}>
|
||||
<FlatList
|
||||
data={offers}
|
||||
keyExtractor={(item: any) => item.id.toString()}
|
||||
contentContainerStyle={tw`p-4`}
|
||||
ListEmptyComponent={
|
||||
<View style={tw`flex-1 justify-center items-center py-20`}>
|
||||
<MaterialCommunityIcons name="sale" size={64} color="#E5E7EB" />
|
||||
<MyText style={tw`text-gray-500 mt-4 text-lg`}>No offers yet</MyText>
|
||||
<MyText style={tw`text-gray-400 text-sm mt-1`}>Create your first offer</MyText>
|
||||
</View>
|
||||
}
|
||||
renderItem={({ item: offer }: { item: any }) => (
|
||||
<TouchableOpacity
|
||||
onPress={() => router.push(`/offers/edit/${offer.id}` as any)}
|
||||
style={tw`bg-white border border-gray-200 rounded-2xl mb-4 overflow-hidden shadow-sm`}
|
||||
>
|
||||
<View style={tw`h-40 bg-gray-100`}>
|
||||
{offer.images?.[0] ? (
|
||||
<Image source={{ uri: offer.images[0] }} style={tw`w-full h-full`} resizeMode="cover" />
|
||||
) : (
|
||||
<View style={tw`flex-1 justify-center items-center`}>
|
||||
<MaterialCommunityIcons name="image-off" size={48} color="#D1D5DB" />
|
||||
</View>
|
||||
)}
|
||||
{offer.isSuspended && (
|
||||
<View style={tw`absolute top-3 left-3 bg-red-500 px-3 py-1 rounded-full`}>
|
||||
<MyText style={tw`text-white text-xs font-bold`}>Suspended</MyText>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={tw`p-4`}>
|
||||
<MyText style={tw`text-lg font-bold text-gray-900`} numberOfLines={1}>
|
||||
{offer.name}
|
||||
</MyText>
|
||||
|
||||
{offer.shortDescription && (
|
||||
<MyText style={tw`text-gray-500 text-sm mt-1`} numberOfLines={2}>
|
||||
{offer.shortDescription}
|
||||
</MyText>
|
||||
)}
|
||||
|
||||
<View style={tw`flex-row items-center mt-3`}>
|
||||
<MyText style={tw`text-xl font-bold text-gray-900`}>₹{offer.price}</MyText>
|
||||
{offer.marketPrice && Number(offer.marketPrice) > Number(offer.price) && (
|
||||
<MyText style={tw`text-sm text-gray-400 line-through ml-2`}>
|
||||
₹{offer.marketPrice}
|
||||
</MyText>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={tw`flex-row items-center mt-2`}>
|
||||
<MaterialCommunityIcons name="package-variant" size={16} color="#9CA3AF" />
|
||||
<MyText style={tw`text-gray-500 text-sm ml-1`}>
|
||||
{offer.items?.length || 0} product{(offer.items?.length || 0) !== 1 ? 's' : ''}
|
||||
</MyText>
|
||||
</View>
|
||||
|
||||
<MyText style={tw`text-gray-400 text-xs mt-2`}>
|
||||
Added {dayjs(offer.addedOn).format('DD MMM, YYYY')}
|
||||
</MyText>
|
||||
</View>
|
||||
|
||||
<View style={tw`flex-row border-t border-gray-100`}>
|
||||
<TouchableOpacity
|
||||
onPress={() => handleToggleSuspend(offer.id)}
|
||||
style={tw`flex-1 py-3 items-center ${offer.isSuspended ? 'bg-green-50' : 'bg-red-50'}`}
|
||||
>
|
||||
<MyText style={tw`text-sm font-bold ${offer.isSuspended ? 'text-green-600' : 'text-red-600'}`}>
|
||||
{offer.isSuspended ? 'Activate' : 'Suspend'}
|
||||
</MyText>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
onPress={() => handleDelete(offer.id)}
|
||||
style={tw`flex-1 py-3 items-center bg-red-50 border-l border-gray-100`}
|
||||
>
|
||||
<MyText style={tw`text-sm font-bold text-red-600`}>Delete</MyText>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
/>
|
||||
|
||||
<MyTouchableOpacity
|
||||
testID="add-offer-fab"
|
||||
accessibilityLabel="add-offer-fab"
|
||||
onPress={() => router.push('/offers/add' as any)}
|
||||
style={{ position: 'absolute', bottom: 32, right: 24, zIndex: 100 }}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={['#F83758', '#E91E63']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={tw`w-16 h-16 rounded-[24px] items-center justify-center shadow-lg`}
|
||||
>
|
||||
<MaterialCommunityIcons name="plus" size={32} color="white" />
|
||||
</LinearGradient>
|
||||
</MyTouchableOpacity>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
|
@ -21,32 +21,31 @@ import { trpc } from "@/src/trpc-client";
|
|||
import MaterialIcons from "@expo/vector-icons/MaterialIcons";
|
||||
import { Entypo } from "@expo/vector-icons";
|
||||
|
||||
interface SkuItemProps {
|
||||
sku: any;
|
||||
productName: string;
|
||||
hasChanges: (skuId: number) => boolean;
|
||||
interface ProductItemProps {
|
||||
item: any;
|
||||
hasChanges: (productId: number) => boolean;
|
||||
pendingChanges: Record<string, any>;
|
||||
setPendingChanges: React.Dispatch<React.SetStateAction<Record<string, any>>>;
|
||||
openEditDialog: (sku: any, productName: string) => void;
|
||||
openEditDialog: (product: any) => void;
|
||||
}
|
||||
|
||||
const SkuItemComponent: React.FC<SkuItemProps> = ({
|
||||
sku,
|
||||
productName,
|
||||
const ProductItemComponent: React.FC<ProductItemProps> = ({
|
||||
item: product,
|
||||
hasChanges,
|
||||
pendingChanges,
|
||||
setPendingChanges,
|
||||
openEditDialog,
|
||||
}) => {
|
||||
const changed = hasChanges(sku.id);
|
||||
const change = pendingChanges[sku.id] || {};
|
||||
const displayPrice = change.price !== undefined ? change.price : sku.price;
|
||||
const displayMarketPrice = change.marketPrice !== undefined ? change.marketPrice : sku.marketPrice;
|
||||
const displayFlashPrice = change.flashPrice !== undefined ? change.flashPrice : sku.flashPrice;
|
||||
const displayUnit = (sku.features || []).map((f: any) => f.featureValue).join(' ') || '—';
|
||||
const changed = hasChanges(product.id);
|
||||
const change = pendingChanges[product.id] || {};
|
||||
const displayPrice = change.price !== undefined ? change.price : product.price;
|
||||
const displayMarketPrice = change.marketPrice !== undefined ? change.marketPrice : product.marketPrice;
|
||||
const displayFlashPrice = change.flashPrice !== undefined ? change.flashPrice : product.flashPrice;
|
||||
const displayProductQuantity = change.productQuantity !== undefined ? change.productQuantity : product.productQuantity;
|
||||
|
||||
return (
|
||||
<View style={tw`bg-white p-4 mb-3 rounded-xl border border-gray-200 shadow-sm`}>
|
||||
{/* Change indicator */}
|
||||
<View style={tw`absolute top-2 right-2`}>
|
||||
<View
|
||||
style={[
|
||||
|
|
@ -58,33 +57,31 @@ const SkuItemComponent: React.FC<SkuItemProps> = ({
|
|||
</View>
|
||||
</View>
|
||||
|
||||
{/* First row: Image and Name */}
|
||||
<View style={tw`flex-row items-center mb-2`}>
|
||||
{/* Product image */}
|
||||
<Image
|
||||
source={{
|
||||
uri: sku.images?.[0] || "https://via.placeholder.com/32x32?text=No+Image"
|
||||
uri: product.images?.[0] || "https://via.placeholder.com/32x32?text=No+Image"
|
||||
}}
|
||||
style={tw`w-10 h-10 rounded-lg mr-3`}
|
||||
resizeMode="cover"
|
||||
/>
|
||||
|
||||
{/* Product name and Flash Checkbox */}
|
||||
<View style={tw`flex-1 flex-row items-center`}>
|
||||
<View style={tw`flex-1`}>
|
||||
<MyText style={tw`text-base font-medium text-gray-800`} numberOfLines={1}>
|
||||
{productName.length > 20 ? productName.substring(0, 20) + '...' : productName}
|
||||
</MyText>
|
||||
<MyText style={tw`text-xs text-gray-500`} numberOfLines={1}>
|
||||
{sku.displayName || sku.name || ''}
|
||||
</MyText>
|
||||
</View>
|
||||
<MyText style={tw`text-base font-medium text-gray-800`} numberOfLines={1}>
|
||||
{product.name.length > 25 ? product.name.substring(0, 25) + '...' : product.name}
|
||||
</MyText>
|
||||
<View style={tw`flex-row items-center ml-2`}>
|
||||
<Checkbox
|
||||
checked={change.isFlashAvailable ?? sku.isFlashAvailable ?? false}
|
||||
checked={change.isFlashAvailable ?? product.isFlashAvailable ?? false}
|
||||
onPress={() => {
|
||||
const currentValue = change.isFlashAvailable ?? sku.isFlashAvailable ?? false;
|
||||
const currentValue = change.isFlashAvailable ?? product.isFlashAvailable ?? false;
|
||||
setPendingChanges(prev => ({
|
||||
...prev,
|
||||
[sku.id]: {
|
||||
...prev[sku.id],
|
||||
[product.id]: {
|
||||
...change,
|
||||
isFlashAvailable: !currentValue,
|
||||
},
|
||||
}));
|
||||
|
|
@ -96,42 +93,47 @@ const SkuItemComponent: React.FC<SkuItemProps> = ({
|
|||
</View>
|
||||
</View>
|
||||
|
||||
{/* Prices and Product Size Row */}
|
||||
<View style={tw`flex-row items-center justify-between`}>
|
||||
{/* Our Price */}
|
||||
<View style={tw`items-center`}>
|
||||
<MyText style={tw`text-xs text-gray-500 mb-1`}>Our Price</MyText>
|
||||
<View style={tw`flex-row items-center`}>
|
||||
<MyText style={tw`text-sm font-bold text-green-600`}>₹{displayPrice}</MyText>
|
||||
<TouchableOpacity onPress={() => openEditDialog(sku, productName)} style={tw`ml-1`}>
|
||||
<TouchableOpacity onPress={() => openEditDialog(product)} style={tw`ml-1`}>
|
||||
<MaterialIcons name="edit" size={14} color="#6b7280" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Market Price */}
|
||||
<View style={tw`items-center`}>
|
||||
<MyText style={tw`text-xs text-gray-500 mb-1`}>Market Price</MyText>
|
||||
<View style={tw`flex-row items-center`}>
|
||||
<MyText style={tw`text-sm text-gray-600`}>{displayMarketPrice ? `₹${displayMarketPrice}` : "N/A"}</MyText>
|
||||
<TouchableOpacity onPress={() => openEditDialog(sku, productName)} style={tw`ml-1`}>
|
||||
<TouchableOpacity onPress={() => openEditDialog(product)} style={tw`ml-1`}>
|
||||
<MaterialIcons name="edit" size={14} color="#6b7280" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Flash Price */}
|
||||
<View style={tw`items-center`}>
|
||||
<MyText style={tw`text-xs text-gray-500 mb-1`}>Flash Price</MyText>
|
||||
<View style={tw`flex-row items-center`}>
|
||||
<MyText style={tw`text-sm text-orange-600`}>{displayFlashPrice ? `₹${displayFlashPrice}` : "N/A"}</MyText>
|
||||
<TouchableOpacity onPress={() => openEditDialog(sku, productName)} style={tw`ml-1`}>
|
||||
<TouchableOpacity onPress={() => openEditDialog(product)} style={tw`ml-1`}>
|
||||
<MaterialIcons name="edit" size={14} color="#6b7280" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Product Size */}
|
||||
<View style={tw`items-center`}>
|
||||
<MyText style={tw`text-xs text-gray-500 mb-1`}>Size</MyText>
|
||||
<View style={tw`flex-row items-center`}>
|
||||
<MyText style={tw`text-sm text-blue-600`}>{displayUnit}</MyText>
|
||||
<TouchableOpacity onPress={() => openEditDialog(sku, productName)} style={tw`ml-1`}>
|
||||
<MyText style={tw`text-sm text-blue-600`}>{displayProductQuantity ? `${displayProductQuantity}${product.unit.shortNotation || ''}` : "N/A"}</MyText>
|
||||
<TouchableOpacity onPress={() => openEditDialog(product)} style={tw`ml-1`}>
|
||||
<MaterialIcons name="edit" size={14} color="#6b7280" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
|
@ -145,16 +147,17 @@ interface PendingChange {
|
|||
price?: number;
|
||||
marketPrice?: number | null;
|
||||
flashPrice?: number | null;
|
||||
productQuantity?: number | null;
|
||||
isFlashAvailable?: boolean;
|
||||
}
|
||||
|
||||
interface EditDialogState {
|
||||
open: boolean;
|
||||
sku: any;
|
||||
productName: string;
|
||||
product: any;
|
||||
tempPrice: string;
|
||||
tempMarketPrice: string;
|
||||
tempFlashPrice: string;
|
||||
tempProductQuantity: string;
|
||||
}
|
||||
|
||||
export default function PricesOverview() {
|
||||
|
|
@ -163,11 +166,11 @@ export default function PricesOverview() {
|
|||
const [pendingChanges, setPendingChanges] = useState<Record<number, PendingChange>>({});
|
||||
const [editDialog, setEditDialog] = useState<EditDialogState>({
|
||||
open: false,
|
||||
sku: null,
|
||||
productName: "",
|
||||
product: null,
|
||||
tempPrice: "",
|
||||
tempMarketPrice: "",
|
||||
tempFlashPrice: "",
|
||||
tempProductQuantity: "",
|
||||
});
|
||||
const [showMenu, setShowMenu] = useState(false);
|
||||
|
||||
|
|
@ -181,11 +184,13 @@ export default function PricesOverview() {
|
|||
const stores = storesData?.stores || [];
|
||||
const allProducts = productsData?.products || [];
|
||||
|
||||
// Sort stores alphabetically
|
||||
const sortedStores = useMemo(() =>
|
||||
[...stores].sort((a, b) => a.name.localeCompare(b.name)),
|
||||
[stores]
|
||||
);
|
||||
|
||||
// Store options for dropdown
|
||||
const storeOptions = useMemo(() =>
|
||||
sortedStores.map(store => ({
|
||||
label: store.name,
|
||||
|
|
@ -194,49 +199,43 @@ export default function PricesOverview() {
|
|||
[sortedStores]
|
||||
);
|
||||
|
||||
// Initialize selectedStores to all if not set
|
||||
useEffect(() => {
|
||||
if (stores.length > 0 && selectedStores.length === 0) {
|
||||
setSelectedStores(stores.map(s => s.id.toString()));
|
||||
}
|
||||
}, [stores, selectedStores]);
|
||||
|
||||
const allSkus = useMemo(() => {
|
||||
const skus: any[] = [];
|
||||
for (const product of allProducts) {
|
||||
if (product.skus && product.skus.length > 0) {
|
||||
for (const sku of product.skus) {
|
||||
skus.push({ ...sku, _productName: product.name, _storeId: product.storeId });
|
||||
}
|
||||
}
|
||||
}
|
||||
return skus;
|
||||
}, [allProducts]);
|
||||
|
||||
const filteredSkus = useMemo(() => {
|
||||
if (selectedStores.length === 0) return allSkus;
|
||||
return allSkus.filter(sku =>
|
||||
sku._storeId && selectedStores.includes(sku._storeId.toString())
|
||||
// Filter products by selected stores
|
||||
const filteredProducts = useMemo(() => {
|
||||
if (selectedStores.length === 0) return allProducts;
|
||||
return allProducts.filter(product =>
|
||||
product.storeId && selectedStores.includes(product.storeId.toString())
|
||||
);
|
||||
}, [allSkus, selectedStores]);
|
||||
}, [allProducts, selectedStores]);
|
||||
|
||||
const hasChanges = (skuId: number) => !!pendingChanges[skuId];
|
||||
// Check if a product has changes
|
||||
const hasChanges = (productId: number) => !!pendingChanges[productId];
|
||||
|
||||
const openEditDialog = (sku: any, productName: string) => {
|
||||
const change = pendingChanges[sku.id] || {};
|
||||
// Open edit dialog
|
||||
const openEditDialog = (product: any) => {
|
||||
const change = pendingChanges[product.id] || {};
|
||||
setEditDialog({
|
||||
open: true,
|
||||
sku,
|
||||
productName,
|
||||
tempPrice: (change.price ?? sku.price)?.toString() || "",
|
||||
tempMarketPrice: (change.marketPrice ?? sku.marketPrice)?.toString() || "",
|
||||
tempFlashPrice: (change.flashPrice ?? sku.flashPrice)?.toString() || "",
|
||||
product,
|
||||
tempPrice: (change.price ?? product.price)?.toString() || "",
|
||||
tempMarketPrice: (change.marketPrice ?? product.marketPrice)?.toString() || "",
|
||||
tempFlashPrice: (change.flashPrice ?? product.flashPrice)?.toString() || "",
|
||||
tempProductQuantity: (change.productQuantity ?? product.productQuantity)?.toString() || "",
|
||||
});
|
||||
};
|
||||
|
||||
// Save edit dialog
|
||||
const saveEditDialog = () => {
|
||||
const price = parseFloat(editDialog.tempPrice);
|
||||
const marketPrice = editDialog.tempMarketPrice ? parseFloat(editDialog.tempMarketPrice) : null;
|
||||
const flashPrice = editDialog.tempFlashPrice ? parseFloat(editDialog.tempFlashPrice) : null;
|
||||
const productQuantity = editDialog.tempProductQuantity ? parseFloat(editDialog.tempProductQuantity) : null;
|
||||
|
||||
if (isNaN(price) || price <= 0) {
|
||||
Alert.alert("Error", "Please enter a valid price");
|
||||
|
|
@ -253,24 +252,32 @@ export default function PricesOverview() {
|
|||
return;
|
||||
}
|
||||
|
||||
if (editDialog.tempProductQuantity && (isNaN(productQuantity!) || productQuantity! <= 0)) {
|
||||
Alert.alert("Error", "Please enter a valid product size");
|
||||
return;
|
||||
}
|
||||
|
||||
setPendingChanges(prev => ({
|
||||
...prev,
|
||||
[editDialog.sku.id]: {
|
||||
price: price !== parseFloat(editDialog.sku.price) ? price : undefined,
|
||||
marketPrice: marketPrice !== parseFloat(editDialog.sku.marketPrice || '0') ? marketPrice : undefined,
|
||||
flashPrice: flashPrice !== parseFloat(editDialog.sku.flashPrice || '0') ? flashPrice : undefined,
|
||||
[editDialog.product.id]: {
|
||||
price: price !== editDialog.product.price ? price : undefined,
|
||||
marketPrice: marketPrice !== editDialog.product.marketPrice ? marketPrice : undefined,
|
||||
flashPrice: flashPrice !== editDialog.product.flashPrice ? flashPrice : undefined,
|
||||
productQuantity: productQuantity !== editDialog.product.productQuantity ? productQuantity : undefined,
|
||||
},
|
||||
}));
|
||||
|
||||
setEditDialog({ open: false, product: null, tempPrice: "", tempMarketPrice: "", tempFlashPrice: "", tempProductQuantity: "" });
|
||||
};
|
||||
|
||||
// Handle save all changes
|
||||
const handleSave = () => {
|
||||
const updates = Object.entries(pendingChanges).map(([skuId, change]) => {
|
||||
const sku = allSkus.find(s => s.id === parseInt(skuId));
|
||||
const update: any = { productId: sku?.id };
|
||||
const updates = Object.entries(pendingChanges).map(([productId, change]) => {
|
||||
const update: any = { productId: parseInt(productId) };
|
||||
if (change.price !== undefined) update.price = change.price;
|
||||
if (change.marketPrice !== undefined) update.marketPrice = change.marketPrice;
|
||||
if (change.flashPrice !== undefined) update.flashPrice = change.flashPrice;
|
||||
if (change.productQuantity !== undefined) update.productQuantity = change.productQuantity;
|
||||
if (change.isFlashAvailable !== undefined) update.isFlashAvailable = change.isFlashAvailable;
|
||||
return update;
|
||||
});
|
||||
|
|
@ -290,10 +297,13 @@ export default function PricesOverview() {
|
|||
);
|
||||
};
|
||||
|
||||
|
||||
|
||||
const changeCount = Object.keys(pendingChanges).length;
|
||||
|
||||
return (
|
||||
<View style={tw`flex-1 bg-gray-50`}>
|
||||
{/* Stores filter, save button, and menu */}
|
||||
<View style={tw`bg-white px-4 py-4 border-b border-gray-200 flex-row items-center`}>
|
||||
<View style={tw`flex-1 mr-4`}>
|
||||
<BottomDropdown
|
||||
|
|
@ -340,6 +350,7 @@ export default function PricesOverview() {
|
|||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Content */}
|
||||
{productsLoading || storesLoading ? (
|
||||
<View style={tw`flex-1 justify-center items-center`}>
|
||||
<ActivityIndicator size="large" color="#3b82f6" />
|
||||
|
|
@ -347,11 +358,10 @@ export default function PricesOverview() {
|
|||
</View>
|
||||
) : (
|
||||
<FlatList
|
||||
data={filteredSkus}
|
||||
data={filteredProducts}
|
||||
renderItem={({ item }) => (
|
||||
<SkuItemComponent
|
||||
sku={item}
|
||||
productName={item._productName}
|
||||
<ProductItemComponent
|
||||
item={item}
|
||||
hasChanges={hasChanges}
|
||||
pendingChanges={pendingChanges}
|
||||
setPendingChanges={setPendingChanges}
|
||||
|
|
@ -364,10 +374,10 @@ export default function PricesOverview() {
|
|||
/>
|
||||
)}
|
||||
|
||||
<BottomDialog open={editDialog.open} onClose={() => setEditDialog({ ...editDialog, open: false, tempFlashPrice: "", tempMarketPrice: "", tempPrice: "" })}>
|
||||
{/* Edit Dialog */}
|
||||
<BottomDialog open={editDialog.open} onClose={() => setEditDialog({ ...editDialog, open: false, tempFlashPrice: "", tempMarketPrice: "", tempPrice: "", tempProductQuantity: "" })}>
|
||||
<View style={tw`p-4`}>
|
||||
<MyText style={tw`text-lg font-bold mb-1`}>{editDialog.productName}</MyText>
|
||||
<MyText style={tw`text-sm text-gray-500 mb-4`}>{editDialog.sku?.displayName || editDialog.sku?.name || ''}</MyText>
|
||||
<MyText style={tw`text-lg font-bold mb-4`}>{editDialog.product?.name}</MyText>
|
||||
|
||||
<View style={tw`mb-4`}>
|
||||
<MyText style={tw`text-sm font-medium mb-1`}>Our Price</MyText>
|
||||
|
|
@ -402,6 +412,17 @@ export default function PricesOverview() {
|
|||
/>
|
||||
</View>
|
||||
|
||||
<View style={tw`mb-4`}>
|
||||
<MyText style={tw`text-sm font-medium mb-1`}>Product Size</MyText>
|
||||
<TextInput
|
||||
style={tw`border border-gray-300 rounded-md px-3 py-2`}
|
||||
value={editDialog.tempProductQuantity}
|
||||
onChangeText={(text) => setEditDialog({ ...editDialog, tempProductQuantity: text })}
|
||||
keyboardType="decimal-pad"
|
||||
placeholder="Enter product size"
|
||||
/>
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
style={tw`bg-blue-600 py-3 rounded-md items-center`}
|
||||
onPress={saveEditDialog}
|
||||
|
|
@ -411,6 +432,7 @@ export default function PricesOverview() {
|
|||
</View>
|
||||
</BottomDialog>
|
||||
|
||||
{/* Menu Dialog */}
|
||||
<BottomDialog open={showMenu} onClose={() => setShowMenu(false)}>
|
||||
<View style={tw`p-6`}>
|
||||
<MyText style={tw`text-lg font-bold text-gray-800 mb-6`}>
|
||||
|
|
@ -419,7 +441,7 @@ export default function PricesOverview() {
|
|||
<TouchableOpacity
|
||||
style={tw`flex-row items-center p-4 bg-gray-50 rounded-lg`}
|
||||
onPress={() => {
|
||||
router.push('/(drawer)/dashboard/rebalance-orders' as any);
|
||||
router.push('/rebalance-orders' as any);
|
||||
setShowMenu(false);
|
||||
}}
|
||||
>
|
||||
|
|
@ -430,6 +452,8 @@ export default function PricesOverview() {
|
|||
</TouchableOpacity>
|
||||
</View>
|
||||
</BottomDialog>
|
||||
|
||||
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import React from 'react';
|
||||
import { View } from 'react-native';
|
||||
import { AppContainer } from 'common-ui';
|
||||
import ProductGroupForm from '../../../../components/ProductGroupForm';
|
||||
import ProductGroupForm from '../../../components/ProductGroupForm';
|
||||
import { useRouter } from 'expo-router';
|
||||
|
||||
export default function CreateProductGroup() {
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import React from 'react';
|
||||
import { View } from 'react-native';
|
||||
import { AppContainer, MyText } from 'common-ui';
|
||||
import ProductGroupForm from '../../../../../components/ProductGroupForm';
|
||||
import ProductGroupForm from '../../../../components/ProductGroupForm';
|
||||
import { useRouter, useLocalSearchParams } from 'expo-router';
|
||||
import { trpc } from '@/src/trpc-client';
|
||||
|
||||
|
|
@ -10,12 +10,20 @@ import {
|
|||
MyTouchableOpacity,
|
||||
BottomDialog,
|
||||
} from "common-ui";
|
||||
import { trpc } from "../../../../src/trpc-client";
|
||||
import { trpc } from "../../../src/trpc-client";
|
||||
import MaterialIcons from "@expo/vector-icons/MaterialIcons";
|
||||
import { LinearGradient } from "expo-linear-gradient";
|
||||
import dayjs from "dayjs";
|
||||
import { useRouter } from "expo-router";
|
||||
import type { ProductGroup } from "@/components/ProductGroupForm";
|
||||
|
||||
interface ProductGroup {
|
||||
id: number;
|
||||
groupName: string;
|
||||
description: string | null;
|
||||
createdAt: string;
|
||||
products: any[];
|
||||
productCount: number;
|
||||
}
|
||||
|
||||
const GroupItem = ({
|
||||
group,
|
||||
|
|
@ -135,11 +143,11 @@ export default function ProductGroupings() {
|
|||
});
|
||||
|
||||
const handleCreate = () => {
|
||||
router.push("/(drawer)/dashboard/product-groupings/create");
|
||||
router.push("/product-groupings/create");
|
||||
};
|
||||
|
||||
const handleEdit = (group: ProductGroup) => {
|
||||
router.push(`/(drawer)/dashboard/product-groupings/edit/${group.id}`);
|
||||
router.push(`/product-groupings/edit/${group.id}`);
|
||||
};
|
||||
|
||||
const handleDelete = (id: number) => {
|
||||
|
|
@ -6,7 +6,6 @@ export default function Layout() {
|
|||
<Stack.Screen name="index" options={{ title: "Product Tags" }} />
|
||||
<Stack.Screen name="add" options={{ title: "Add Tag" }} />
|
||||
<Stack.Screen name="edit/index" options={{ title: "Edit Tag" }} />
|
||||
<Stack.Screen name="order" options={{ title: "Tag Orders" }} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
|
@ -5,7 +5,13 @@ import { AppContainer, MyText, tw, type ImageUploaderNeoItem } from 'common-ui';
|
|||
import TagForm from '@/src/components/TagForm';
|
||||
import { trpc } from '@/src/trpc-client';
|
||||
import { useUploadToObjectStorage } from '@/hooks/useUploadToObjectStore';
|
||||
import type { TagFormData } from '@/src/components/TagForm';
|
||||
|
||||
interface TagFormData {
|
||||
tagName: string;
|
||||
tagDescription: string;
|
||||
isDashboardTag: boolean;
|
||||
relatedStores: number[];
|
||||
}
|
||||
|
||||
export default function AddTag() {
|
||||
const router = useRouter();
|
||||
|
|
@ -39,7 +45,6 @@ export default function AddTag() {
|
|||
imageUrl,
|
||||
isDashboardTag: values.isDashboardTag,
|
||||
relatedStores: values.relatedStores,
|
||||
productIds: values.productIds,
|
||||
uploadUrls,
|
||||
})
|
||||
|
||||
|
|
@ -61,7 +66,6 @@ export default function AddTag() {
|
|||
tagDescription: '',
|
||||
isDashboardTag: false,
|
||||
relatedStores: [],
|
||||
productIds: [],
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -3,12 +3,16 @@ import { View, Alert } from 'react-native';
|
|||
import { useRouter, useLocalSearchParams } from 'expo-router';
|
||||
import { AppContainer, MyText, tw, type ImageUploaderNeoItem } from 'common-ui';
|
||||
import TagForm from '@/src/components/TagForm';
|
||||
import type { TagFormData as BaseTagFormData } from '@/src/components/TagForm';
|
||||
import { trpc } from '@/src/trpc-client';
|
||||
import { useUploadToObjectStorage } from '@/hooks/useUploadToObjectStore';
|
||||
|
||||
// Edit screen adds the current image to the canonical TagForm data
|
||||
type TagFormData = BaseTagFormData & { existingImageUrl?: string };
|
||||
interface TagFormData {
|
||||
tagName: string;
|
||||
tagDescription: string;
|
||||
isDashboardTag: boolean;
|
||||
relatedStores: number[];
|
||||
existingImageUrl?: string;
|
||||
}
|
||||
|
||||
export default function EditTag() {
|
||||
const router = useRouter();
|
||||
|
|
@ -54,7 +58,6 @@ export default function EditTag() {
|
|||
imageUrl,
|
||||
isDashboardTag: values.isDashboardTag,
|
||||
relatedStores: values.relatedStores,
|
||||
productIds: values.productIds,
|
||||
uploadUrls,
|
||||
})
|
||||
|
||||
|
|
@ -92,16 +95,11 @@ export default function EditTag() {
|
|||
}
|
||||
|
||||
const tag = tagData.tag;
|
||||
const tagProductIds = tag.productIds || (tag.products || []).map((p: any) => p.productId);
|
||||
// Order by the saved sortOrder (fall back to the join order if empty).
|
||||
const orderedProductIds = (tag.sortOrder || []).filter((id: number) => tagProductIds.includes(id));
|
||||
const remainingProductIds = tagProductIds.filter((id: number) => !orderedProductIds.includes(id));
|
||||
const initialValues: TagFormData = {
|
||||
tagName: tag.tagName,
|
||||
tagDescription: tag.tagDescription || '',
|
||||
isDashboardTag: tag.isDashboardTag,
|
||||
relatedStores: Array.isArray(tag.relatedStores) ? tag.relatedStores : [],
|
||||
productIds: [...orderedProductIds, ...remainingProductIds],
|
||||
existingImageUrl: tag.imageUrl || undefined,
|
||||
};
|
||||
|
||||
|
|
@ -1,5 +1,4 @@
|
|||
import React, { useState } from 'react';
|
||||
import type { ProductTagCore } from '@packages/shared'
|
||||
import { View, TouchableOpacity, Alert, RefreshControl } from 'react-native';
|
||||
import { Image } from 'expo-image';
|
||||
import { useRouter } from 'expo-router';
|
||||
|
|
@ -8,12 +7,13 @@ import { tw, MyText, useManualRefresh, useMarkDataFetchers, MyFlatList } from 'c
|
|||
import { TagMenu } from '@/src/components/TagMenu';
|
||||
import { trpc } from '@/src/trpc-client';
|
||||
|
||||
// Tag list row — anchored to the shared tag core (relatedStores now typed)
|
||||
interface TagItemData extends ProductTagCore {
|
||||
interface TagItemData {
|
||||
id: number;
|
||||
tagName: string;
|
||||
tagDescription: string | null;
|
||||
imageUrl: string | null;
|
||||
isDashboardTag: boolean;
|
||||
relatedStores?: unknown;
|
||||
createdAt: string | Date;
|
||||
}
|
||||
|
||||
|
|
@ -53,18 +53,11 @@ const TagItem: React.FC<TagItemProps> = ({ item, onDeleteSuccess }) => (
|
|||
|
||||
interface TagHeaderProps {
|
||||
onAddNewTag: () => void;
|
||||
onOrderTags: () => void;
|
||||
}
|
||||
|
||||
const TagHeader: React.FC<TagHeaderProps> = ({ onAddNewTag, onOrderTags }) => (
|
||||
const TagHeader: React.FC<TagHeaderProps> = ({ onAddNewTag }) => (
|
||||
<View style={tw`flex-row justify-between items-center p-4 bg-white border-b border-gray-200`}>
|
||||
<TouchableOpacity
|
||||
onPress={onOrderTags}
|
||||
style={tw`bg-gray-100 px-3 py-2 rounded-lg flex-row items-center`}
|
||||
>
|
||||
<MaterialIcons name="sort" size={18} color="#374151" />
|
||||
<MyText style={tw`text-gray-800 font-medium ml-1.5`}>Tag Orders</MyText>
|
||||
</TouchableOpacity>
|
||||
<MyText style={tw`text-xl font-bold text-gray-800`}>Product Tags</MyText>
|
||||
<TouchableOpacity
|
||||
onPress={onAddNewTag}
|
||||
style={tw`bg-blue-500 px-4 py-2 rounded-lg flex-row items-center`}
|
||||
|
|
@ -95,11 +88,7 @@ export default function ProductTags() {
|
|||
};
|
||||
|
||||
const handleAddNewTag = () => {
|
||||
router.push('/(drawer)/dashboard/product-tags/add');
|
||||
};
|
||||
|
||||
const handleOrderTags = () => {
|
||||
router.push('/(drawer)/dashboard/product-tags/order');
|
||||
router.push('/product-tags/add');
|
||||
};
|
||||
|
||||
|
||||
|
|
@ -138,7 +127,7 @@ export default function ProductTags() {
|
|||
refreshControl={
|
||||
<RefreshControl refreshing={refreshing} onRefresh={handleRefresh} />
|
||||
}
|
||||
ListHeaderComponent={<TagHeader onAddNewTag={handleAddNewTag} onOrderTags={handleOrderTags} />}
|
||||
ListHeaderComponent={<TagHeader onAddNewTag={handleAddNewTag} />}
|
||||
contentContainerStyle={tw`pb-4`}
|
||||
ListEmptyComponent={
|
||||
<View style={tw`flex-1 justify-center items-center py-12`}>
|
||||
82
apps/admin-ui/app/(drawer)/products/add.tsx
Normal file
82
apps/admin-ui/app/(drawer)/products/add.tsx
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import React from 'react';
|
||||
import { Alert } from 'react-native';
|
||||
import { AppContainer, ImageUploaderNeoPayload } from 'common-ui';
|
||||
import ProductForm from '@/src/components/ProductForm';
|
||||
import { trpc } from '@/src/trpc-client';
|
||||
import { useUploadToObjectStorage } from '@/hooks/useUploadToObjectStore';
|
||||
|
||||
export default function AddProduct() {
|
||||
const createProduct = trpc.admin.product.createProduct.useMutation();
|
||||
const { refetch: refetchProducts } = trpc.admin.product.getProducts.useQuery(undefined, {
|
||||
enabled: false,
|
||||
});
|
||||
const { upload, isUploading } = useUploadToObjectStorage();
|
||||
|
||||
const handleSubmit = async (values: any, images: ImageUploaderNeoPayload[]) => {
|
||||
try {
|
||||
let uploadUrls: string[] = [];
|
||||
|
||||
if (images.length > 0) {
|
||||
const blobs = await Promise.all(
|
||||
images.map(async (img) => {
|
||||
const response = await fetch(img.url);
|
||||
const blob = await response.blob();
|
||||
return { blob, mimeType: img.mimeType || 'image/jpeg' };
|
||||
})
|
||||
);
|
||||
|
||||
const result = await upload({ images: blobs, contextString: 'product_info' });
|
||||
uploadUrls = result.presignedUrls;
|
||||
}
|
||||
|
||||
await createProduct.mutateAsync({
|
||||
name: values.name,
|
||||
shortDescription: values.shortDescription,
|
||||
longDescription: values.longDescription,
|
||||
unitId: parseInt(values.unitId),
|
||||
storeId: parseInt(values.storeId),
|
||||
price: parseFloat(values.price),
|
||||
marketPrice: values.marketPrice ? parseFloat(values.marketPrice) : undefined,
|
||||
incrementStep: 1,
|
||||
productQuantity: values.productQuantity || 1,
|
||||
isSuspended: values.isSuspended || false,
|
||||
isFlashAvailable: values.isFlashAvailable || false,
|
||||
flashPrice: values.flashPrice ? parseFloat(values.flashPrice) : undefined,
|
||||
uploadUrls,
|
||||
tagIds: values.tagIds || [],
|
||||
});
|
||||
|
||||
await refetchProducts();
|
||||
Alert.alert('Success', 'Product created successfully!');
|
||||
} catch (error: any) {
|
||||
Alert.alert('Error', error.message || 'Failed to create product');
|
||||
}
|
||||
};
|
||||
|
||||
const initialValues = {
|
||||
name: '',
|
||||
shortDescription: '',
|
||||
longDescription: '',
|
||||
unitId: 0,
|
||||
price: '',
|
||||
storeId: 1,
|
||||
marketPrice: '',
|
||||
deals: [{ quantity: '', price: '', validTill: new Date() }],
|
||||
tagIds: [],
|
||||
isSuspended: false,
|
||||
isFlashAvailable: false,
|
||||
flashPrice: '',
|
||||
productQuantity: 1,
|
||||
};
|
||||
|
||||
return (
|
||||
<AppContainer>
|
||||
<ProductForm
|
||||
mode="create"
|
||||
initialValues={initialValues}
|
||||
onSubmit={handleSubmit}
|
||||
isLoading={createProduct.isPending || isUploading}
|
||||
/>
|
||||
</AppContainer>
|
||||
);
|
||||
}
|
||||
|
|
@ -149,11 +149,12 @@ export default function ProductDetail() {
|
|||
refetch();
|
||||
});
|
||||
|
||||
const toggleOutOfStock = trpc.admin.product.toggleOutOfStock.useMutation();
|
||||
|
||||
const product = productData?.product;
|
||||
const defaultSku = product?.skus?.[0]
|
||||
|
||||
const handleEdit = () => {
|
||||
router.push(`/(drawer)/dashboard/products/edit?id=${productId}` as any);
|
||||
router.push(`/products/edit?id=${productId}` as any);
|
||||
};
|
||||
|
||||
|
||||
|
|
@ -187,9 +188,9 @@ export default function ProductDetail() {
|
|||
|
||||
{/* Hero Section */}
|
||||
<View style={tw`relative`}>
|
||||
{defaultSku?.images && defaultSku.images.length > 0 ? (
|
||||
{product.images && product.images.length > 0 ? (
|
||||
<ImageCarousel
|
||||
urls={defaultSku.images}
|
||||
urls={product.images}
|
||||
imageWidth={screenWidth}
|
||||
imageHeight={carouselHeight}
|
||||
showPaginationDots={true}
|
||||
|
|
@ -228,15 +229,46 @@ export default function ProductDetail() {
|
|||
<Animated.View entering={FadeInUp.delay(100).duration(500)} style={tw`bg-white px-6 pt-8 pb-6 rounded-b-[32px] shadow-sm mb-4`}>
|
||||
<View style={tw`flex-row justify-between items-start mb-2`}>
|
||||
<MyText style={tw`text-3xl font-extrabold text-gray-900 flex-1 mr-4 leading-tight`}>{product.name}</MyText>
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
toggleOutOfStock.mutate({ id: productId }, {
|
||||
onSuccess: () => Alert.alert('Success', 'Stock status updated'),
|
||||
onError: (err) => Alert.alert('Error', err.message)
|
||||
});
|
||||
}}
|
||||
activeOpacity={0.9}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={product.isOutOfStock ? ['#EF4444', '#DC2626'] : ['#10B981', '#059669']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 0 }}
|
||||
style={tw`px-4 py-1.5 rounded-full shadow-sm`}
|
||||
>
|
||||
<Text style={tw`text-white text-xs font-bold uppercase tracking-wide`}>
|
||||
{product.isOutOfStock ? 'Out of Stock' : 'In Stock'}
|
||||
</Text>
|
||||
</LinearGradient>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View style={tw`flex-row items-end mt-2`}>
|
||||
<View style={tw`bg-blue-50 px-3 py-1.5 rounded-full border border-blue-100`}>
|
||||
<Text style={tw`text-blue-700 text-sm font-bold`}>
|
||||
{product.skus?.length ?? 0} Variant{(product.skus?.length ?? 0) !== 1 ? 's' : ''}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Text style={tw`text-4xl font-black text-gray-900`}>₹{product.price}</Text>
|
||||
<Text style={tw`text-gray-500 text-xl font-medium mb-1.5 ml-2`}>/ {product.unit?.shortNotation}</Text>
|
||||
{product.marketPrice && (
|
||||
<View style={tw`ml-4 mb-2 px-2 py-0.5 bg-red-50 rounded`}>
|
||||
<Text style={tw`text-red-400 text-base line-through font-medium`}>₹{product.marketPrice}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Increment Step Info */}
|
||||
<View style={tw`mt-3 flex-row items-center`}>
|
||||
<View style={tw`bg-blue-50 px-3 py-1.5 rounded-full border border-blue-100`}>
|
||||
<Text style={tw`text-blue-700 text-sm font-bold`}>
|
||||
Increment: {product.incrementStep || 1}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Quick Stats Row */}
|
||||
<View style={tw`flex-row mt-6 pt-6 border-t border-gray-100`}>
|
||||
|
|
@ -255,10 +287,29 @@ export default function ProductDetail() {
|
|||
<Text style={tw`text-lg font-bold text-gray-900`}>{reviewsData?.reviews.length || 0}</Text>
|
||||
<Text style={tw`text-xs text-gray-400 font-medium mt-1 uppercase`}>Reviews</Text>
|
||||
</View>
|
||||
<View style={tw`flex-1 items-center`}>
|
||||
<Text style={tw`text-lg font-bold text-gray-900`}>{product.incrementStep || 1}</Text>
|
||||
<Text style={tw`text-xs text-gray-400 font-medium mt-1 uppercase`}>Step</Text>
|
||||
</View>
|
||||
{/* <View style={tw`flex-1 items-center`}>
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
toggleOutOfStock.mutate({ id: productId }, {
|
||||
onSuccess: () => Alert.alert('Success', 'Stock status updated'),
|
||||
onError: (err) => Alert.alert('Error', err.message)
|
||||
});
|
||||
}}
|
||||
activeOpacity={0.9}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={product.isOutOfStock ? ['#EF4444', '#DC2626'] : ['#10B981', '#059669']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 0 }}
|
||||
style={tw`px-3 py-1 rounded-full shadow-sm`}
|
||||
>
|
||||
<Text style={tw`text-white text-xs font-bold uppercase tracking-wide`}>
|
||||
{product.isOutOfStock ? 'Out of Stock' : 'In Stock'}
|
||||
</Text>
|
||||
</LinearGradient>
|
||||
</TouchableOpacity>
|
||||
<Text style={tw`text-xs text-gray-400 font-medium mt-1 uppercase`}>Stock</Text>
|
||||
</View> */}
|
||||
</View>
|
||||
</Animated.View>
|
||||
|
||||
|
|
@ -280,69 +331,45 @@ export default function ProductDetail() {
|
|||
{product.longDescription || "No detailed description available for this product."}
|
||||
</Text>
|
||||
</View>
|
||||
</Animated.View>
|
||||
</Animated.View>
|
||||
|
||||
{/* Variants Section */}
|
||||
<Animated.View entering={FadeInDown.delay(300).duration(500)} style={tw`px-4 mb-4`}>
|
||||
<View style={tw`bg-white p-6 rounded-3xl shadow-sm`}>
|
||||
<View style={tw`flex-row items-center mb-4`}>
|
||||
<View style={tw`w-10 h-10 bg-green-50 rounded-full items-center justify-center mr-3`}>
|
||||
<MaterialIcons name="category" size={22} color="#059669" />
|
||||
</View>
|
||||
<Text style={tw`text-lg font-bold text-gray-900`}>Variants</Text>
|
||||
<View style={tw`bg-gray-100 px-3 py-1 rounded-full ml-3`}>
|
||||
<Text style={tw`text-xs font-bold text-gray-600`}>{product.skus?.length ?? 0}</Text>
|
||||
</View>
|
||||
</View>
|
||||
{/* Availability */}
|
||||
<Animated.View entering={FadeInDown.delay(250).duration(500)} style={tw`px-4 mb-4`}>
|
||||
<View style={tw`bg-white p-6 rounded-3xl shadow-sm`}>
|
||||
<View style={tw`flex-row items-center mb-4`}>
|
||||
<View style={tw`w-10 h-10 bg-blue-50 rounded-full items-center justify-center mr-3`}>
|
||||
<MaterialIcons name="inventory" size={22} color="#2563EB" />
|
||||
</View>
|
||||
<Text style={tw`text-lg font-bold text-gray-900`}>Availability</Text>
|
||||
</View>
|
||||
|
||||
{product.skus?.map((sku) => (
|
||||
<View key={sku.id} style={tw`border border-gray-200 rounded-2xl p-4 mb-3`}>
|
||||
{/* Attributes */}
|
||||
{sku.features?.map((f) => (
|
||||
<View key={f.id} style={tw`flex-row mb-1`}>
|
||||
<Text style={tw`text-gray-500 text-sm w-24 font-medium`}>{f.featureName}</Text>
|
||||
<Text style={tw`text-gray-900 text-sm font-semibold`}>{f.featureValue}</Text>
|
||||
</View>
|
||||
))}
|
||||
<Text style={tw`text-gray-600 mb-4`}>
|
||||
This product is currently {product.isOutOfStock ? 'out of stock' : 'in stock'}.
|
||||
</Text>
|
||||
|
||||
{/* Pricing row */}
|
||||
<View style={tw`flex-row items-end justify-between mt-3 pt-3 border-t border-gray-100`}>
|
||||
<View>
|
||||
<Text style={tw`text-2xl font-black text-gray-900`}>₹{sku.price}</Text>
|
||||
{sku.marketPrice && (
|
||||
<Text style={tw`text-sm text-gray-400 line-through`}>₹{sku.marketPrice}</Text>
|
||||
)}
|
||||
</View>
|
||||
<View style={tw`flex-row items-center gap-2`}>
|
||||
{sku.isFlashAvailable && (
|
||||
<View style={tw`bg-red-50 px-2 py-1 rounded-full border border-red-200`}>
|
||||
<Text style={tw`text-red-600 text-xs font-bold`}>Flash ₹{sku.flashPrice}</Text>
|
||||
</View>
|
||||
)}
|
||||
<View style={tw`px-3 py-1 rounded-full ${sku.isOutOfStock ? 'bg-red-100' : 'bg-green-100'}`}>
|
||||
<Text style={tw`text-xs font-bold ${sku.isOutOfStock ? 'text-red-700' : 'text-green-700'}`}>
|
||||
{sku.isOutOfStock ? 'Out of Stock' : 'In Stock'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
toggleOutOfStock.mutate({ id: productId }, {
|
||||
onSuccess: () => {
|
||||
Alert.alert('Success', 'Stock status updated');
|
||||
refetch();
|
||||
},
|
||||
onError: (err) => Alert.alert('Error', err.message)
|
||||
});
|
||||
}}
|
||||
activeOpacity={0.8}
|
||||
style={tw`bg-gray-100 px-4 py-2 rounded-full border border-gray-200 self-start`}
|
||||
>
|
||||
<Text style={tw`text-gray-700 font-bold text-sm`}>
|
||||
Mark as {product.isOutOfStock ? 'In Stock' : 'Out of Stock'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</Animated.View>
|
||||
|
||||
{/* SKU Images */}
|
||||
{sku.images && sku.images.length > 0 && (
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false} style={tw`mt-3`}>
|
||||
{sku.images.map((url, idx) => (
|
||||
<Image key={idx} source={{ uri: url }} style={tw`w-16 h-16 rounded-lg mr-2 bg-gray-100`} />
|
||||
))}
|
||||
</ScrollView>
|
||||
)}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</Animated.View>
|
||||
|
||||
{/* Special Deals */}
|
||||
{/* Special Deals */}
|
||||
{product.deals && product.deals.length > 0 && (
|
||||
<Animated.View entering={FadeInDown.delay(350).duration(500)} style={tw`px-4 mb-4`}>
|
||||
<Animated.View entering={FadeInDown.delay(300).duration(500)} style={tw`px-4 mb-4`}>
|
||||
<LinearGradient
|
||||
colors={['#FFFBEB', '#FEF3C7']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
135
apps/admin-ui/app/(drawer)/products/edit.tsx
Normal file
135
apps/admin-ui/app/(drawer)/products/edit.tsx
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
import React, { useRef } from 'react';
|
||||
import { View, Alert } from 'react-native';
|
||||
import { useLocalSearchParams } from 'expo-router';
|
||||
import { AppContainer, useManualRefresh, MyText, tw, ImageUploaderNeoItem, ImageUploaderNeoPayload } from 'common-ui';
|
||||
import ProductForm, { ProductFormRef } from '@/src/components/ProductForm';
|
||||
import { trpc } from '@/src/trpc-client';
|
||||
import { useUploadToObjectStorage } from '@/hooks/useUploadToObjectStore';
|
||||
|
||||
export default function EditProduct() {
|
||||
const { id } = useLocalSearchParams();
|
||||
const productId = Number(id);
|
||||
const productFormRef = useRef<ProductFormRef>(null);
|
||||
|
||||
const { data: product, isLoading: isFetching, refetch } = trpc.admin.product.getProductById.useQuery(
|
||||
{ id: productId },
|
||||
{ enabled: !!productId }
|
||||
);
|
||||
const { refetch: refetchProducts } = trpc.admin.product.getProducts.useQuery(undefined, {
|
||||
enabled: false,
|
||||
});
|
||||
|
||||
const updateProduct = trpc.admin.product.updateProduct.useMutation();
|
||||
const { upload, isUploading } = useUploadToObjectStorage();
|
||||
|
||||
useManualRefresh(() => refetch());
|
||||
|
||||
const handleSubmit = async (values: any, images: ImageUploaderNeoPayload[], imagesToDelete: string[]) => {
|
||||
try {
|
||||
// New images have mimeType !== null, existing images have mimeType === null
|
||||
const newImages = images.filter(img => img.mimeType !== null);
|
||||
let uploadUrls: string[] = [];
|
||||
|
||||
if (newImages.length > 0) {
|
||||
const blobs = await Promise.all(
|
||||
newImages.map(async (img) => {
|
||||
const response = await fetch(img.url);
|
||||
const blob = await response.blob();
|
||||
return { blob, mimeType: img.mimeType || 'image/jpeg' };
|
||||
})
|
||||
);
|
||||
|
||||
const result = await upload({ images: blobs, contextString: 'product_info' });
|
||||
uploadUrls = result.presignedUrls;
|
||||
}
|
||||
|
||||
await updateProduct.mutateAsync({
|
||||
id: productId,
|
||||
name: values.name,
|
||||
shortDescription: values.shortDescription,
|
||||
longDescription: values.longDescription,
|
||||
unitId: parseInt(values.unitId),
|
||||
storeId: parseInt(values.storeId),
|
||||
price: parseFloat(values.price),
|
||||
marketPrice: values.marketPrice ? parseFloat(values.marketPrice) : undefined,
|
||||
incrementStep: 1,
|
||||
productQuantity: values.productQuantity || 1,
|
||||
isSuspended: values.isSuspended || false,
|
||||
isFlashAvailable: values.isFlashAvailable || false,
|
||||
flashPrice: values.flashPrice ? parseFloat(values.flashPrice) : null,
|
||||
uploadUrls,
|
||||
imagesToDelete,
|
||||
tagIds: values.tagIds || [],
|
||||
});
|
||||
|
||||
await refetch();
|
||||
await refetchProducts();
|
||||
Alert.alert('Success', 'Product updated successfully!');
|
||||
productFormRef.current?.clearImages();
|
||||
} catch (error: any) {
|
||||
Alert.alert('Error', error.message || 'Failed to update product');
|
||||
}
|
||||
};
|
||||
|
||||
if (isFetching) {
|
||||
return (
|
||||
<AppContainer>
|
||||
<View style={tw`flex-1 justify-center items-center`}>
|
||||
<MyText style={tw`text-gray-600`}>Loading product...</MyText>
|
||||
</View>
|
||||
</AppContainer>
|
||||
);
|
||||
}
|
||||
|
||||
if (!product) {
|
||||
return (
|
||||
<AppContainer>
|
||||
<View style={tw`flex-1 justify-center items-center`}>
|
||||
<MyText style={tw`text-red-600`}>Product not found</MyText>
|
||||
</View>
|
||||
</AppContainer>
|
||||
);
|
||||
}
|
||||
|
||||
const productData = product.product;
|
||||
|
||||
const existingImages: ImageUploaderNeoItem[] = (productData.images || []).map((url) => ({
|
||||
imgUrl: url,
|
||||
mimeType: null,
|
||||
}));
|
||||
const existingImageKeys = productData.imageKeys || [];
|
||||
|
||||
const initialValues = {
|
||||
name: productData.name,
|
||||
shortDescription: productData.shortDescription || '',
|
||||
longDescription: productData.longDescription || '',
|
||||
unitId: productData.unitId,
|
||||
storeId: productData.storeId || 1,
|
||||
price: productData.price.toString(),
|
||||
marketPrice: productData.marketPrice?.toString() || '',
|
||||
deals: productData.deals?.map(deal => ({
|
||||
quantity: deal.quantity,
|
||||
price: deal.price,
|
||||
validTill: deal.validTill ? new Date(deal.validTill) : null,
|
||||
})) || [{ quantity: '', price: '', validTill: null }],
|
||||
tagIds: productData.tags?.map((tag: any) => tag.id) || [],
|
||||
isSuspended: productData.isSuspended || false,
|
||||
isFlashAvailable: productData.isFlashAvailable || false,
|
||||
flashPrice: productData.flashPrice?.toString() || '',
|
||||
productQuantity: productData.productQuantity || 1,
|
||||
};
|
||||
|
||||
return (
|
||||
<AppContainer>
|
||||
<ProductForm
|
||||
ref={productFormRef}
|
||||
mode="edit"
|
||||
initialValues={initialValues}
|
||||
onSubmit={handleSubmit}
|
||||
isLoading={updateProduct.isPending || isUploading}
|
||||
existingImages={existingImages}
|
||||
existingImageKeys={existingImageKeys}
|
||||
/>
|
||||
</AppContainer>
|
||||
);
|
||||
}
|
||||
271
apps/admin-ui/app/(drawer)/products/index.tsx
Normal file
271
apps/admin-ui/app/(drawer)/products/index.tsx
Normal file
|
|
@ -0,0 +1,271 @@
|
|||
import React, { useState, useMemo } from 'react';
|
||||
import { View, ScrollView, TouchableOpacity, Alert, RefreshControl } from 'react-native';
|
||||
import { Image } from 'expo-image';
|
||||
import { useRouter } from 'expo-router';
|
||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||
import { AppContainer, MyText, tw, MyButton, useManualRefresh, MyTextInput, SearchBar, useMarkDataFetchers } from 'common-ui';
|
||||
|
||||
import { trpc } from '@/src/trpc-client';
|
||||
import type { AdminProduct } from '@packages/shared';
|
||||
|
||||
type FilterType = 'all' | 'in-stock' | 'out-of-stock';
|
||||
|
||||
export default function Products() {
|
||||
const router = useRouter();
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [activeFilter, setActiveFilter] = useState<FilterType>('all');
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
const { data: productsData, isLoading, error, refetch } = trpc.admin.product.getProducts.useQuery();
|
||||
|
||||
const toggleOutOfStockMutation = trpc.admin.product.toggleOutOfStock.useMutation();
|
||||
|
||||
useManualRefresh(refetch);
|
||||
|
||||
useMarkDataFetchers(() => {
|
||||
refetch();
|
||||
});
|
||||
|
||||
const products = productsData?.products || [];
|
||||
|
||||
const handleRefresh = async () => {
|
||||
setRefreshing(true);
|
||||
await refetch();
|
||||
setRefreshing(false);
|
||||
};
|
||||
|
||||
const filteredProducts = useMemo(() => {
|
||||
return products.filter(product => {
|
||||
const matchesSearch = product.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
(product.shortDescription?.toLowerCase().includes(searchTerm.toLowerCase()));
|
||||
|
||||
const matchesFilter = activeFilter === 'all' ||
|
||||
(activeFilter === 'in-stock' && !product.isOutOfStock) ||
|
||||
(activeFilter === 'out-of-stock' && product.isOutOfStock);
|
||||
|
||||
return matchesSearch && matchesFilter;
|
||||
});
|
||||
}, [products, searchTerm, activeFilter]);
|
||||
|
||||
const handleEdit = (productId: number) => {
|
||||
router.push(`/products/edit?id=${productId}` as any);
|
||||
};
|
||||
|
||||
|
||||
|
||||
// const handleToggleStock = (product: any) => {
|
||||
const handleToggleStock = (product: Pick<AdminProduct, 'id' | 'name' | 'isOutOfStock'>) => {
|
||||
const action = product.isOutOfStock ? 'mark as in stock' : 'mark as out of stock';
|
||||
Alert.alert(
|
||||
'Update Stock Status',
|
||||
`Are you sure you want to ${action} "${product.name}"?`,
|
||||
[
|
||||
{ text: 'Cancel', style: 'cancel' },
|
||||
{
|
||||
text: 'Confirm',
|
||||
onPress: () => {
|
||||
toggleOutOfStockMutation.mutate({ id: product.id }, {
|
||||
onSuccess: (data) => {
|
||||
Alert.alert('Success', data.message);
|
||||
refetch(); // Refresh the list
|
||||
},
|
||||
onError: (error: any) => {
|
||||
Alert.alert('Error', error.message || 'Failed to update stock status');
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
const handleViewDetails = (productId: number) => {
|
||||
router.push(`/products/detail/${productId}` as any);
|
||||
};
|
||||
|
||||
const FilterButton = ({ filter, label, count }: { filter: FilterType; label: string; count: number }) => (
|
||||
<TouchableOpacity
|
||||
onPress={() => setActiveFilter(filter)}
|
||||
style={tw`px-4 py-2 rounded-lg ${activeFilter === filter ? 'bg-blue-500' : 'bg-gray-200'}`}
|
||||
>
|
||||
<MyText style={tw`${activeFilter === filter ? 'text-white' : 'text-gray-700'} font-semibold`}>
|
||||
{label} ({count})
|
||||
</MyText>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<AppContainer>
|
||||
<View style={tw`flex-1 justify-center items-center`}>
|
||||
<MyText style={tw`text-gray-600`}>Loading products...</MyText>
|
||||
</View>
|
||||
</AppContainer>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<AppContainer>
|
||||
<View style={tw`flex-1 justify-center items-center`}>
|
||||
<MyText style={tw`text-red-600`}>Error loading products</MyText>
|
||||
<TouchableOpacity
|
||||
onPress={() => refetch()}
|
||||
style={tw`mt-4 bg-blue-500 px-4 py-2 rounded-lg`}
|
||||
>
|
||||
<MyText style={tw`text-white font-semibold`}>Retry</MyText>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</AppContainer>
|
||||
);
|
||||
}
|
||||
|
||||
const inStockCount = products.filter(p => !p.isOutOfStock).length;
|
||||
const outOfStockCount = products.filter(p => p.isOutOfStock).length;
|
||||
|
||||
return (
|
||||
<AppContainer>
|
||||
<View style={tw`flex-1`}>
|
||||
{/* Header */}
|
||||
<View style={tw`flex-row justify-between items-center mb-6`}>
|
||||
<MyText style={tw`text-2xl font-bold text-gray-800`}>Products</MyText>
|
||||
<MyButton onPress={() => router.push('/products/add' as any)}>
|
||||
Add Product
|
||||
</MyButton>
|
||||
</View>
|
||||
|
||||
{/* Search Bar */}
|
||||
<View style={tw`mb-4`}>
|
||||
<SearchBar
|
||||
value={searchTerm}
|
||||
onChangeText={setSearchTerm}
|
||||
onSearch={() => {}}
|
||||
placeholder="Search products..."
|
||||
containerStyle={tw`mb-0`}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Filter Tabs */}
|
||||
<View style={tw`flex-row gap-2 mb-6`}>
|
||||
<FilterButton filter="all" label="All" count={products.length} />
|
||||
<FilterButton filter="in-stock" label="In Stock" count={inStockCount} />
|
||||
<FilterButton filter="out-of-stock" label="Out of Stock" count={outOfStockCount} />
|
||||
</View>
|
||||
|
||||
{/* Products List */}
|
||||
<ScrollView
|
||||
style={tw`flex-1`}
|
||||
showsVerticalScrollIndicator={false}
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={refreshing} onRefresh={handleRefresh} />
|
||||
}
|
||||
>
|
||||
{filteredProducts.length === 0 ? (
|
||||
<View style={tw`flex-1 justify-center items-center py-10`}>
|
||||
<MaterialIcons name="inventory" size={64} color="#D1D5DB" />
|
||||
<MyText style={tw`text-gray-500 text-center mt-4 text-lg`}>
|
||||
{searchTerm || activeFilter !== 'all' ? 'No products match your filters' : 'No products found'}
|
||||
</MyText>
|
||||
<MyText style={tw`text-gray-400 text-center mt-2`}>
|
||||
{searchTerm || activeFilter !== 'all' ? 'Try adjusting your search or filters' : 'Start by adding your first product'}
|
||||
</MyText>
|
||||
{(searchTerm || activeFilter !== 'all') && (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
setSearchTerm('');
|
||||
setActiveFilter('all');
|
||||
}}
|
||||
style={tw`mt-4 bg-blue-500 px-4 py-2 rounded-lg`}
|
||||
>
|
||||
<MyText style={tw`text-white font-semibold`}>Clear Filters</MyText>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
) : (
|
||||
<View style={tw`pb-4`}>
|
||||
{filteredProducts.map(product => (
|
||||
<View key={product.id} style={tw`bg-white rounded-2xl shadow-lg mb-4 overflow-hidden`}>
|
||||
{/* Product Image */}
|
||||
{product.images && product.images.length > 0 ? (
|
||||
<Image
|
||||
source={{ uri: product.images[0] }}
|
||||
style={tw`w-full h-48`}
|
||||
resizeMode="cover"
|
||||
/>
|
||||
) : (
|
||||
<View style={tw`w-full h-48 bg-gray-200 justify-center items-center`}>
|
||||
<MaterialIcons name="image" size={48} color="#9CA3AF" />
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Product Info */}
|
||||
<View style={tw`p-4`}>
|
||||
<View style={tw`flex-row justify-between items-start mb-2`}>
|
||||
<MyText style={tw`text-lg font-bold text-gray-800 flex-1 mr-2`} numberOfLines={2}>
|
||||
{product.name}
|
||||
</MyText>
|
||||
<View style={tw`flex-row items-center`}>
|
||||
<View style={tw`w-3 h-3 rounded-full mr-1 ${product.isOutOfStock ? 'bg-red-500' : 'bg-green-500'}`} />
|
||||
<MyText style={tw`text-sm ${product.isOutOfStock ? 'text-red-500' : 'text-green-500'} font-semibold`}>
|
||||
{product.isOutOfStock ? 'Out' : 'In'}
|
||||
</MyText>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{product.shortDescription && (
|
||||
<MyText style={tw`text-gray-600 mb-2`} numberOfLines={2}>
|
||||
{product.shortDescription}
|
||||
</MyText>
|
||||
)}
|
||||
|
||||
<View style={tw`flex-row justify-between items-center mb-3`}>
|
||||
<View>
|
||||
<MyText style={tw`text-xl font-bold text-green-600`}>
|
||||
₹{product.price}
|
||||
</MyText>
|
||||
{product.marketPrice && (
|
||||
<MyText style={tw`text-sm text-gray-500 line-through`}>
|
||||
₹{product.marketPrice}
|
||||
</MyText>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<View style={tw`flex-row gap-2`}>
|
||||
<TouchableOpacity
|
||||
onPress={() => handleViewDetails(product.id)}
|
||||
style={tw`flex-1 bg-gray-500 p-3 rounded-lg flex-row items-center justify-center`}
|
||||
>
|
||||
<MaterialIcons name="visibility" size={16} color="white" />
|
||||
<MyText style={tw`text-white font-semibold ml-1`}>View</MyText>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={() => handleEdit(product.id)}
|
||||
style={tw`flex-1 bg-blue-500 p-3 rounded-lg flex-row items-center justify-center`}
|
||||
>
|
||||
<MaterialIcons name="edit" size={16} color="white" />
|
||||
<MyText style={tw`text-white font-semibold ml-1`}>Edit</MyText>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={() => handleToggleStock(product)}
|
||||
style={tw`flex-1 ${product.isOutOfStock ? 'bg-green-500' : 'bg-orange-500'} p-3 rounded-lg flex-row items-center justify-center`}
|
||||
>
|
||||
<MaterialIcons name={product.isOutOfStock ? "check-circle" : "block"} size={16} color="white" />
|
||||
<MyText style={tw`text-white font-semibold ml-1`}>
|
||||
{product.isOutOfStock ? 'Stock' : 'Out'}
|
||||
</MyText>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
</AppContainer>
|
||||
);
|
||||
}
|
||||
|
|
@ -2,7 +2,7 @@ import React, { useState } from 'react';
|
|||
import { View, TouchableOpacity, Alert, FlatList } from 'react-native';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { MyText, tw, MyTouchableOpacity, MyFlatList, BottomDialog } from 'common-ui';
|
||||
import { trpc } from '../../../../src/trpc-client';
|
||||
import { trpc } from '../../../src/trpc-client';
|
||||
import dayjs from 'dayjs';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
|
||||
|
|
@ -14,13 +14,16 @@ import {
|
|||
tw,
|
||||
MyTextInput,
|
||||
BottomDropdown,
|
||||
ImageUploader,
|
||||
} from 'common-ui';
|
||||
import { trpc } from '@/src/trpc-client';
|
||||
import type { UserMiniInfo } from '@packages/shared';
|
||||
import usePickImage from 'common-ui/src/components/use-pick-image';
|
||||
import { useUploadToObjectStorage } from '@/hooks/useUploadToObjectStore';
|
||||
|
||||
// Notification recipient = shared mini user + eligibility flag
|
||||
type User = Omit<UserMiniInfo, 'name'> & {
|
||||
interface User {
|
||||
id: number;
|
||||
name: string | null;
|
||||
mobile: string | null;
|
||||
isEligibleForNotif: boolean;
|
||||
}
|
||||
|
||||
|
|
@ -29,6 +32,8 @@ export default function SendNotifications() {
|
|||
const [selectedUserIds, setSelectedUserIds] = useState<number[]>([]);
|
||||
const [title, setTitle] = useState('');
|
||||
const [message, setMessage] = useState('');
|
||||
const [selectedImage, setSelectedImage] = useState<{ blob: Blob; mimeType: string } | null>(null);
|
||||
const [displayImage, setDisplayImage] = useState<{ uri?: string } | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
// Query users eligible for notifications
|
||||
|
|
@ -36,6 +41,8 @@ export default function SendNotifications() {
|
|||
search: searchQuery,
|
||||
});
|
||||
|
||||
const { uploadSingle } = useUploadToObjectStorage();
|
||||
|
||||
// Send notification mutation
|
||||
const sendNotification = trpc.admin.user.sendNotification.useMutation({
|
||||
onSuccess: () => {
|
||||
|
|
@ -44,6 +51,8 @@ export default function SendNotifications() {
|
|||
setSelectedUserIds([]);
|
||||
setTitle('');
|
||||
setMessage('');
|
||||
setSelectedImage(null);
|
||||
setDisplayImage(null);
|
||||
},
|
||||
onError: (error: any) => {
|
||||
Alert.alert('Error', error.message || 'Failed to send notification');
|
||||
|
|
@ -57,6 +66,29 @@ export default function SendNotifications() {
|
|||
value: user.id,
|
||||
}));
|
||||
|
||||
const handleImagePick = usePickImage({
|
||||
setFile: async (assets: any) => {
|
||||
if (!assets || (Array.isArray(assets) && assets.length === 0)) {
|
||||
setSelectedImage(null);
|
||||
setDisplayImage(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const file = Array.isArray(assets) ? assets[0] : assets;
|
||||
const response = await fetch(file.uri);
|
||||
const blob = await response.blob();
|
||||
|
||||
setSelectedImage({ blob, mimeType: file.mimeType || 'image/jpeg' });
|
||||
setDisplayImage({ uri: file.uri });
|
||||
},
|
||||
multiple: false,
|
||||
});
|
||||
|
||||
const handleRemoveImage = () => {
|
||||
setSelectedImage(null);
|
||||
setDisplayImage(null);
|
||||
};
|
||||
|
||||
const handleSend = async () => {
|
||||
if (title.trim().length === 0) {
|
||||
Alert.alert('Error', 'Please enter a title');
|
||||
|
|
@ -85,11 +117,20 @@ export default function SendNotifications() {
|
|||
}
|
||||
|
||||
try {
|
||||
let imageUrl: string | undefined;
|
||||
|
||||
// Upload image if selected
|
||||
if (selectedImage) {
|
||||
const { key } = await uploadSingle(selectedImage.blob, selectedImage.mimeType, 'notification');
|
||||
imageUrl = key;
|
||||
}
|
||||
|
||||
// Send notification
|
||||
await sendNotification.mutateAsync({
|
||||
userIds: selectedUserIds,
|
||||
title: title.trim(),
|
||||
text: message.trim(),
|
||||
imageUrl,
|
||||
});
|
||||
} catch (error: any) {
|
||||
Alert.alert('Error', error.message || 'Failed to send notification');
|
||||
|
|
@ -155,6 +196,17 @@ export default function SendNotifications() {
|
|||
/>
|
||||
</View>
|
||||
|
||||
{/* Image Upload - Hidden for now */}
|
||||
{/* <View style={tw`bg-white rounded-xl border border-gray-100 p-4 mb-4 shadow-sm`}>
|
||||
<MyText style={tw`text-base font-bold text-gray-900 mb-3`}>Image (Optional)</MyText>
|
||||
<ImageUploader
|
||||
images={displayImage ? [displayImage] : []}
|
||||
existingImageUrls={[]}
|
||||
onAddImage={handleImagePick}
|
||||
onRemoveImage={handleRemoveImage}
|
||||
/>
|
||||
</View> */}
|
||||
|
||||
{/* User Selection */}
|
||||
<View style={tw`bg-white rounded-xl border border-gray-100 p-4 mb-4 shadow-sm`}>
|
||||
<MyText style={tw`text-base font-bold text-gray-900 mb-3`}>Select Users (Optional)</MyText>
|
||||
|
|
@ -55,7 +55,7 @@ const SlotItemComponent: React.FC<SlotItemProps> = ({
|
|||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={() => router.push(`/(drawer)/dashboard/slots/slot-details?slotId=${slot.id}`)}
|
||||
onPress={() => router.push(`/(drawer)/slots/slot-details?slotId=${slot.id}`)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View style={tw`bg-white p-5 mb-4 rounded-3xl shadow-sm border border-gray-100`}>
|
||||
|
|
@ -72,7 +72,7 @@ const SlotItemComponent: React.FC<SlotItemProps> = ({
|
|||
</View>
|
||||
<View style={tw`flex-row items-center`}>
|
||||
<TouchableOpacity
|
||||
onPress={() => router.push(`/(drawer)/dashboard/slots/edit/${slot.id}` as any)}
|
||||
onPress={() => router.push(`/slots/edit/${slot.id}` as any)}
|
||||
style={tw`px-3 py-1 rounded-full bg-pink2 mr-2`}
|
||||
>
|
||||
<View style={tw`flex-row items-center`}>
|
||||
|
|
@ -147,7 +147,7 @@ const SlotItemComponent: React.FC<SlotItemProps> = ({
|
|||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
setMenuOpen(false);
|
||||
router.push(`/(drawer)/dashboard/slots/add?baseslot=${slot.id}` as any);
|
||||
router.push(`/slots/add?baseslot=${slot.id}` as any);
|
||||
}}
|
||||
style={tw`py-4 border-b border-gray-200`}
|
||||
>
|
||||
|
|
@ -277,7 +277,7 @@ export default function Slots() {
|
|||
<MyTouchableOpacity
|
||||
testID="add-slot-fab"
|
||||
accessibilityLabel="add-slot-fab"
|
||||
onPress={() => router.push('/(drawer)/dashboard/slots/add' as any)}
|
||||
onPress={() => router.push('/slots/add' as any)}
|
||||
activeOpacity={0.95}
|
||||
style={{ position: 'absolute', bottom: 32, right: 24, zIndex: 100 }}
|
||||
>
|
||||
|
|
@ -113,7 +113,7 @@ export default function SlotDetails() {
|
|||
<View style={tw`flex-row items-center`}>
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
const snippetProducts = products.filter(p => snippet.skuIds.includes(p.id));
|
||||
const snippetProducts = products.filter(p => snippet.productIds.includes(p.id));
|
||||
setDialogProducts(snippetProducts);
|
||||
setDialogOpen(true);
|
||||
}}
|
||||
|
|
@ -121,7 +121,7 @@ export default function SlotDetails() {
|
|||
>
|
||||
<MaterialIcons name="shopping-bag" size={14} color="#94A3B8" />
|
||||
<MyText style={tw`text-xs font-medium text-blue-600 ml-1 underline`}>
|
||||
{snippet.skuIds.length} products
|
||||
{snippet.productIds.length} products
|
||||
</MyText>
|
||||
</TouchableOpacity>
|
||||
|
||||
|
|
@ -198,7 +198,7 @@ export default function SlotDetails() {
|
|||
|
||||
{/* FAB for Edit Slot */}
|
||||
<MyTouchableOpacity
|
||||
onPress={() => router.push(`/(drawer)/dashboard/slots/edit/${slot.id}` as any)}
|
||||
onPress={() => router.push(`/slots/edit/${slot.id}` as any)}
|
||||
activeOpacity={0.95}
|
||||
style={{ position: 'absolute', bottom: 32, right: 24, zIndex: 100 }}
|
||||
>
|
||||
|
|
@ -18,7 +18,7 @@ export default function AddStore() {
|
|||
onSuccess: async (data) => {
|
||||
await refetchStores();
|
||||
Alert.alert('Success', data.message);
|
||||
router.push('/(drawer)/dashboard/stores' as any); // Navigate back to stores list
|
||||
router.push('/stores' as any); // Navigate back to stores list
|
||||
},
|
||||
onError: (error: any) => {
|
||||
Alert.alert('Error', error.message || 'Failed to create store');
|
||||
|
|
@ -23,7 +23,7 @@ export default function EditStore() {
|
|||
onSuccess: (data) => {
|
||||
refetch();
|
||||
Alert.alert('Success', data.message);
|
||||
router.push('/(drawer)/dashboard/stores' as any);
|
||||
router.push('/stores' as any);
|
||||
},
|
||||
onError: (error: any) => {
|
||||
Alert.alert('Error', error.message || 'Failed to update store');
|
||||
|
|
@ -106,7 +106,7 @@ export default function Stores() {
|
|||
const stores = storesData?.stores || [];
|
||||
|
||||
const handleEdit = (storeId: number) => {
|
||||
router.push({ pathname: '/(drawer)/dashboard/stores/edit', params: { id: storeId } });
|
||||
router.push({ pathname: '/stores/edit', params: { id: storeId } });
|
||||
};
|
||||
|
||||
const handleDelete = (storeId: number) => {
|
||||
|
|
@ -207,7 +207,7 @@ export default function Stores() {
|
|||
<Animated.View entering={FadeInUp.delay(500)} style={tw`absolute bottom-8 right-6 shadow-xl`}>
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.8}
|
||||
onPress={() => router.push('/(drawer)/dashboard/stores/add' as any)}
|
||||
onPress={() => router.push('/stores/add' as any)}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={['#2563EB', '#1D4ED8']}
|
||||
|
|
@ -1,5 +1,4 @@
|
|||
import React, { useCallback } from 'react';
|
||||
import type { OrderCardActionProps } from '@packages/shared'
|
||||
import {
|
||||
View,
|
||||
TouchableOpacity,
|
||||
|
|
@ -29,7 +28,9 @@ interface Order {
|
|||
itemCount: number;
|
||||
}
|
||||
|
||||
interface OrderItemProps extends OrderCardActionProps<Order> {
|
||||
interface OrderItemProps {
|
||||
order: Order;
|
||||
onPress: () => void;
|
||||
}
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
|
|
@ -111,7 +112,7 @@ export default function UserDetails() {
|
|||
});
|
||||
|
||||
const handleOrderPress = useCallback((orderId: number) => {
|
||||
router.push(`/(drawer)/dashboard/manage-orders/order-details/${orderId}`);
|
||||
router.push(`/(drawer)/manage-orders/order-details/${orderId}`);
|
||||
}, [router]);
|
||||
|
||||
const handleSuspensionToggle = useCallback((isSuspended: boolean) => {
|
||||
|
|
@ -189,7 +189,7 @@ export default function UserManagement() {
|
|||
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
|
||||
|
||||
const handleUserPress = useCallback((userId: number) => {
|
||||
router.push(`/(drawer)/dashboard/user-management/${userId}`);
|
||||
router.push(`/(drawer)/user-management/${userId}`);
|
||||
}, [router]);
|
||||
|
||||
const renderUserItem = useCallback(({ item, index }: { item: User; index: number }) => {
|
||||
93
apps/admin-ui/app/(drawer)/users/index.tsx
Normal file
93
apps/admin-ui/app/(drawer)/users/index.tsx
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
import React, { useState } from 'react';
|
||||
import { View, TouchableOpacity } from 'react-native';
|
||||
import { Image } from 'expo-image';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { AppContainer, MyText, tw, MyFlatList, MyTextInput } from 'common-ui';
|
||||
import { trpc } from '@/src/trpc-client';
|
||||
import Ionicons from '@expo/vector-icons/Ionicons';
|
||||
|
||||
interface UserItemProps {
|
||||
item: any;
|
||||
onPress: (userId: string) => void;
|
||||
}
|
||||
|
||||
const UserItem: React.FC<UserItemProps> = ({ item, onPress }) => (
|
||||
<TouchableOpacity
|
||||
style={tw`flex-row items-center p-4 bg-white border-b border-gray-100`}
|
||||
onPress={() => onPress(item.id)}
|
||||
>
|
||||
{item.image ? (
|
||||
<Image
|
||||
source={{ uri: item.image }}
|
||||
style={tw`w-12 h-12 rounded-full mr-4`}
|
||||
/>
|
||||
) : (
|
||||
<View style={tw`w-12 h-12 rounded-full bg-gray-200 items-center justify-center mr-4`}>
|
||||
<Ionicons name="person" size={24} color="#6b7280" />
|
||||
</View>
|
||||
)}
|
||||
<View style={tw`flex-1`}>
|
||||
<MyText style={tw`font-bold text-gray-800 text-lg`}>{item.name}</MyText>
|
||||
<MyText style={tw`text-gray-600`}>{item.email}</MyText>
|
||||
<MyText style={tw`text-gray-500 text-sm`}>{item.mobile}</MyText>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
|
||||
export default function Users() {
|
||||
const router = useRouter();
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const {
|
||||
data,
|
||||
isLoading,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
refetch,
|
||||
} = trpc.admin.staffUser.getUsers.useInfiniteQuery(
|
||||
{ search },
|
||||
{
|
||||
getNextPageParam: (lastPage) => lastPage.nextCursor,
|
||||
}
|
||||
);
|
||||
|
||||
const users = data?.pages.flatMap(page => page.users) || [];
|
||||
|
||||
const handleUserPress = (userId: string) => {
|
||||
router.push(`/(drawer)/user-management/${userId}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<AppContainer>
|
||||
<View style={tw`flex-1`}>
|
||||
<View style={tw`bg-white px-4 py-2 border-b border-gray-100`}>
|
||||
<MyTextInput
|
||||
topLabel="Search Users"
|
||||
placeholder="Search by name, email, or mobile"
|
||||
value={search}
|
||||
onChangeText={setSearch}
|
||||
style={{ marginBottom: 0 }}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<MyFlatList
|
||||
data={users}
|
||||
renderItem={({ item }) => <UserItem item={item} onPress={handleUserPress} />}
|
||||
keyExtractor={(item) => item.id.toString()}
|
||||
onRefresh={refetch}
|
||||
onEndReached={() => {
|
||||
if (hasNextPage) {
|
||||
fetchNextPage();
|
||||
}
|
||||
}}
|
||||
onEndReachedThreshold={0.5}
|
||||
ListEmptyComponent={
|
||||
<View style={tw`flex-1 justify-center items-center py-8`}>
|
||||
<MyText style={tw`text-gray-500`}>No users found</MyText>
|
||||
</View>
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
</AppContainer>
|
||||
);
|
||||
}
|
||||
|
|
@ -16,22 +16,22 @@ import {
|
|||
useMarkDataFetchers,
|
||||
MyTouchableOpacity,
|
||||
} from "common-ui";
|
||||
import { trpc, trpcClient } from "../../../../src/trpc-client";
|
||||
import { trpc, trpcClient } from "../../../src/trpc-client";
|
||||
import MaterialIcons from "@expo/vector-icons/MaterialIcons";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import dayjs from "dayjs";
|
||||
import { LinearGradient } from "expo-linear-gradient";
|
||||
import { useRouter } from "expo-router";
|
||||
|
||||
import VendorSnippetForm from "../../../../components/VendorSnippetForm";
|
||||
import SnippetOrdersView from "../../../../components/SnippetOrdersView";
|
||||
import { SnippetMenu } from "../../../../components/SnippetMenu";
|
||||
import { ProductListDialog } from "../../../../components/ProductListDialog";
|
||||
import VendorSnippetForm from "../../../components/VendorSnippetForm";
|
||||
import SnippetOrdersView from "../../../components/SnippetOrdersView";
|
||||
import { SnippetMenu } from "../../../components/SnippetMenu";
|
||||
import { ProductListDialog } from "../../../components/ProductListDialog";
|
||||
import {
|
||||
VendorSnippet,
|
||||
VendorSnippetProduct,
|
||||
VendorSnippetForm as VendorSnippetFormType,
|
||||
} from "../../../../types/vendor-snippets";
|
||||
} from "../../../types/vendor-snippets";
|
||||
|
||||
const SnippetItem = ({
|
||||
snippet,
|
||||
|
|
@ -192,7 +192,7 @@ const SnippetItem = ({
|
|||
>
|
||||
<MaterialIcons name="shopping-bag" size={14} color="#94A3B8" />
|
||||
<MyText style={tw`text-xs font-bold text-slate-500 ml-1.5`}>
|
||||
{snippet.skuIds.length} Items
|
||||
{snippet.productIds.length} Items
|
||||
</MyText>
|
||||
<MaterialIcons name="chevron-right" size={14} color="#94A3B8" />
|
||||
</MyTouchableOpacity>
|
||||
|
|
@ -212,7 +212,7 @@ const SnippetItem = ({
|
|||
</View>
|
||||
|
||||
<MyTouchableOpacity
|
||||
// onPress={() => router.push(`/(drawer)/dashboard/slots/slot-details?slotId=${snippet.slotId}`)}
|
||||
// onPress={() => router.push(`/(drawer)/slots/slot-details?slotId=${snippet.slotId}`)}
|
||||
onPress={() => {}}
|
||||
activeOpacity={0.7}
|
||||
style={tw`flex-row items-center`}
|
||||
|
|
@ -279,7 +279,7 @@ const handleViewProducts = (products: VendorSnippetProduct[]) => {
|
|||
snippetCode: snippet.snippetCode,
|
||||
slotId: snippet.slotId || 0, // Convert null to number for form
|
||||
isPermanent: snippet.isPermanent,
|
||||
skuIds: snippet.skuIds,
|
||||
productIds: snippet.productIds,
|
||||
validTill: snippet.validTill,
|
||||
createdAt: snippet.createdAt,
|
||||
};
|
||||
64
apps/admin-ui/components/AddressPlaceForm.tsx
Normal file
64
apps/admin-ui/components/AddressPlaceForm.tsx
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import React from 'react'
|
||||
import { Formik } from 'formik'
|
||||
import * as Yup from 'yup'
|
||||
import { View, Text, TouchableOpacity } from 'react-native'
|
||||
import { MyTextInput, BottomDropdown, tw } from 'common-ui'
|
||||
import { trpc } from '@/src/trpc-client'
|
||||
|
||||
interface AddressPlaceFormProps {
|
||||
onSubmit: (values: { placeName: string; zoneId: number | null }) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const AddressPlaceForm: React.FC<AddressPlaceFormProps> = ({ onSubmit, onClose }) => {
|
||||
const { data: zones } = trpc.admin.address.getZones.useQuery()
|
||||
|
||||
const validationSchema = Yup.object({
|
||||
placeName: Yup.string().required('Place name is required'),
|
||||
zoneId: Yup.number().optional(),
|
||||
})
|
||||
|
||||
const zoneOptions = zones?.map(z => ({ label: z.zoneName, value: z.id })) || []
|
||||
|
||||
return (
|
||||
<View style={tw`p-4`}>
|
||||
<Text style={tw`text-lg font-semibold mb-4`}>Add Place</Text>
|
||||
<Formik
|
||||
initialValues={{ placeName: '', zoneId: null as number | null }}
|
||||
validationSchema={validationSchema}
|
||||
onSubmit={(values) => {
|
||||
onSubmit(values)
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
{({ handleChange, setFieldValue, handleSubmit, values, errors, touched }) => (
|
||||
<View>
|
||||
<MyTextInput
|
||||
label="Place Name"
|
||||
value={values.placeName}
|
||||
onChangeText={handleChange('placeName')}
|
||||
error={!!(touched.placeName && errors.placeName)}
|
||||
/>
|
||||
<BottomDropdown
|
||||
label="Zone (Optional)"
|
||||
value={values.zoneId as any}
|
||||
options={zoneOptions}
|
||||
onValueChange={(value) => setFieldValue('zoneId', value as number | undefined)}
|
||||
placeholder="Select Zone"
|
||||
/>
|
||||
<View style={tw`flex-row justify-between mt-4`}>
|
||||
<TouchableOpacity style={tw`bg-gray2 px-4 py-2 rounded`} onPress={onClose}>
|
||||
<Text style={tw`text-gray-900`}>Cancel</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={tw`bg-blue1 px-4 py-2 rounded`} onPress={() => handleSubmit()}>
|
||||
<Text style={tw`text-white`}>Create</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</Formik>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export default AddressPlaceForm
|
||||
51
apps/admin-ui/components/AddressZoneForm.tsx
Normal file
51
apps/admin-ui/components/AddressZoneForm.tsx
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import React from 'react'
|
||||
import { Formik } from 'formik'
|
||||
import * as Yup from 'yup'
|
||||
import { View, Text, TouchableOpacity } from 'react-native'
|
||||
import { MyTextInput, tw } from 'common-ui'
|
||||
|
||||
interface AddressZoneFormProps {
|
||||
onSubmit: (values: { zoneName: string }) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const AddressZoneForm: React.FC<AddressZoneFormProps> = ({ onSubmit, onClose }) => {
|
||||
const validationSchema = Yup.object({
|
||||
zoneName: Yup.string().required('Zone name is required'),
|
||||
})
|
||||
|
||||
return (
|
||||
<View style={tw`p-4`}>
|
||||
<Text style={tw`text-lg font-semibold mb-4`}>Add Zone</Text>
|
||||
<Formik
|
||||
initialValues={{ zoneName: '' }}
|
||||
validationSchema={validationSchema}
|
||||
onSubmit={(values) => {
|
||||
onSubmit(values)
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
{({ handleChange, handleSubmit, values, errors, touched }) => (
|
||||
<View>
|
||||
<MyTextInput
|
||||
label="Zone Name"
|
||||
value={values.zoneName}
|
||||
onChangeText={handleChange('zoneName')}
|
||||
error={!!(touched.zoneName && errors.zoneName)}
|
||||
/>
|
||||
<View style={tw`flex-row justify-between mt-4`}>
|
||||
<TouchableOpacity style={tw`bg-gray2 px-4 py-2 rounded`} onPress={onClose}>
|
||||
<Text style={tw`text-gray-900`}>Cancel</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={tw`bg-blue1 px-4 py-2 rounded`} onPress={() => handleSubmit()}>
|
||||
<Text style={tw`text-white`}>Create</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</Formik>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export default AddressZoneForm
|
||||
|
|
@ -14,7 +14,7 @@ export interface BannerFormData {
|
|||
name: string;
|
||||
imageUrl: string;
|
||||
description: string;
|
||||
skuIds: number[];
|
||||
productIds: number[];
|
||||
redirectUrl: string;
|
||||
// serialNum removed - will be assigned automatically by backend
|
||||
}
|
||||
|
|
@ -32,7 +32,7 @@ interface BannerFormProps {
|
|||
const validationSchema = Yup.object().shape({
|
||||
name: Yup.string().trim().required('Banner name is required').max(255),
|
||||
description: Yup.string().max(500),
|
||||
skuIds: Yup.array()
|
||||
productIds: Yup.array()
|
||||
.of(Yup.number())
|
||||
.optional(),
|
||||
redirectUrl: Yup.string()
|
||||
|
|
@ -177,15 +177,15 @@ export default function BannerForm({
|
|||
|
||||
<View style={{ marginBottom: 16 }}>
|
||||
<ProductsSelector
|
||||
value={values.skuIds}
|
||||
value={values.productIds}
|
||||
onChange={(value) => {
|
||||
const selectedValues = Array.isArray(value) ? value : [value];
|
||||
setFieldValue('skuIds', selectedValues.map(v => Number(v)));
|
||||
setFieldValue('productIds', selectedValues.map(v => Number(v)));
|
||||
}}
|
||||
multiple={true}
|
||||
label="Select Products"
|
||||
placeholder="Select products for banner (optional)"
|
||||
labelFormat={(product) => `${product.productName} (₹${product.price})`}
|
||||
labelFormat={(product) => `${product.name} (${product.price})`}
|
||||
/>
|
||||
</View>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,12 +1,14 @@
|
|||
import React, { useState } from 'react';
|
||||
import type { OrderDialogBaseProps } from '@packages/shared'
|
||||
import { View, TouchableOpacity } from 'react-native';
|
||||
import { MyText, tw, BottomDialog, MyTextInput } from 'common-ui';
|
||||
import { trpc } from '@/src/trpc-client';
|
||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||
import { Alert } from 'react-native';
|
||||
|
||||
interface CancelOrderDialogProps extends OrderDialogBaseProps {
|
||||
interface CancelOrderDialogProps {
|
||||
orderId: number;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
|
|
|
|||
45
apps/admin-ui/components/HorizontalImageScroller.tsx
Executable file
45
apps/admin-ui/components/HorizontalImageScroller.tsx
Executable file
|
|
@ -0,0 +1,45 @@
|
|||
import React from "react";
|
||||
import { ScrollView, View, StyleSheet } from "react-native";
|
||||
import { ImageViewerURI } from "common-ui";
|
||||
|
||||
interface HorizontalImageScrollerProps {
|
||||
urls: string[];
|
||||
imageHeight?: number;
|
||||
imageWidth?: number;
|
||||
}
|
||||
|
||||
const HorizontalImageScroller: React.FC<HorizontalImageScrollerProps> = ({
|
||||
urls,
|
||||
imageHeight = 128,
|
||||
imageWidth = 128,
|
||||
}) => {
|
||||
if (!urls || urls.length === 0) return null;
|
||||
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||
{urls.map((url, idx) => (
|
||||
<View key={idx} style={{ marginRight: 12 }}>
|
||||
<ImageViewerURI
|
||||
uri={url}
|
||||
style={{
|
||||
height: imageHeight,
|
||||
width: imageWidth,
|
||||
borderRadius: 12,
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
marginVertical: 8,
|
||||
},
|
||||
});
|
||||
|
||||
export default HorizontalImageScroller;
|
||||
312
apps/admin-ui/components/OfferForm.tsx
Normal file
312
apps/admin-ui/components/OfferForm.tsx
Normal file
|
|
@ -0,0 +1,312 @@
|
|||
import React, { useState } from 'react'
|
||||
import { View, Text, TouchableOpacity, Alert, ScrollView } from 'react-native'
|
||||
import { Formik, FieldArray } from 'formik'
|
||||
import { MyTextInput, tw, ImageUploaderNeo, Checkbox, type ImageUploaderNeoItem, type ImageUploaderNeoPayload } from 'common-ui'
|
||||
import { trpc } from '../src/trpc-client'
|
||||
import ProductsSelector from '../components/ProductsSelector'
|
||||
import { useUploadToObjectStorage } from '../hooks/useUploadToObjectStore'
|
||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons'
|
||||
|
||||
interface OfferItem {
|
||||
productId: number
|
||||
quantity: string
|
||||
product?: { id: number; name: string; images: string[]; unitShortNotation: string }
|
||||
}
|
||||
|
||||
interface OfferFormProps {
|
||||
onOfferAdded?: () => void
|
||||
offerId?: number
|
||||
initialName?: string
|
||||
initialShortDescription?: string
|
||||
initialLongDescription?: string
|
||||
initialPrice?: string
|
||||
initialMarketPrice?: string
|
||||
initialImages?: string[]
|
||||
initialImageKeys?: string[]
|
||||
initialIsSuspended?: boolean
|
||||
initialIsFlashEnabled?: boolean
|
||||
initialItems?: OfferItem[]
|
||||
}
|
||||
|
||||
export default function OfferForm({
|
||||
onOfferAdded,
|
||||
offerId,
|
||||
initialName = '',
|
||||
initialShortDescription = '',
|
||||
initialLongDescription = '',
|
||||
initialPrice = '',
|
||||
initialMarketPrice = '',
|
||||
initialImages = [],
|
||||
initialImageKeys = [],
|
||||
initialIsSuspended = false,
|
||||
initialIsFlashEnabled = false,
|
||||
initialItems = [],
|
||||
}: OfferFormProps) {
|
||||
const isEditMode = !!offerId
|
||||
|
||||
const { mutate: createOffer, isPending: isCreating } = trpc.admin.offers.create.useMutation()
|
||||
const { mutate: updateOffer, isPending: isUpdating } = trpc.admin.offers.update.useMutation()
|
||||
const { upload, isUploading } = useUploadToObjectStorage()
|
||||
const isPending = isCreating || isUpdating || isUploading
|
||||
|
||||
const [images, setImages] = useState<ImageUploaderNeoItem[]>(
|
||||
initialImages.map((url) => ({ imgUrl: url, mimeType: 'image/jpeg' }))
|
||||
)
|
||||
|
||||
const initialValues = {
|
||||
name: initialName,
|
||||
shortDescription: initialShortDescription,
|
||||
longDescription: initialLongDescription,
|
||||
price: initialPrice,
|
||||
marketPrice: initialMarketPrice,
|
||||
isFlashEnabled: initialIsFlashEnabled,
|
||||
items: initialItems.length > 0
|
||||
? initialItems.map(item => ({ productId: item.productId, quantity: item.quantity }))
|
||||
: [{ productId: 0, quantity: '1' }],
|
||||
}
|
||||
|
||||
const handleSubmit = async (values: typeof initialValues) => {
|
||||
if (!values.name.trim()) {
|
||||
Alert.alert('Error', 'Offer name is required')
|
||||
return
|
||||
}
|
||||
|
||||
if (!values.price || Number(values.price) <= 0) {
|
||||
Alert.alert('Error', 'Valid price is required')
|
||||
return
|
||||
}
|
||||
|
||||
const validItems = values.items.filter(item => item.productId > 0)
|
||||
if (validItems.length === 0) {
|
||||
Alert.alert('Error', 'At least one product is required')
|
||||
return
|
||||
}
|
||||
|
||||
let imageUrls: string[] = []
|
||||
|
||||
// Build map of signed URL → raw S3 key for existing images
|
||||
const signedUrlToKey: Record<string, string> = {}
|
||||
initialImages.forEach((url, i) => {
|
||||
if (initialImageKeys[i]) {
|
||||
signedUrlToKey[url] = initialImageKeys[i]
|
||||
}
|
||||
})
|
||||
|
||||
// Existing images that are still present → use their raw S3 keys
|
||||
const keptImageKeys = images
|
||||
.filter(img => img.imgUrl.startsWith('http'))
|
||||
.map(img => signedUrlToKey[img.imgUrl])
|
||||
.filter(Boolean)
|
||||
|
||||
// New images (local file:// URIs) → upload to S3
|
||||
const newImages = images.filter(img => !img.imgUrl.startsWith('http'))
|
||||
|
||||
if (newImages.length > 0) {
|
||||
try {
|
||||
const blobs = await Promise.all(
|
||||
newImages.map(async (img) => {
|
||||
const response = await fetch(img.imgUrl)
|
||||
const blob = await response.blob()
|
||||
return { blob, mimeType: img.mimeType || 'image/jpeg' }
|
||||
})
|
||||
)
|
||||
const result = await upload({ images: blobs, contextString: 'product_info' as any })
|
||||
imageUrls = [...keptImageKeys, ...result.keys]
|
||||
} catch (error: any) {
|
||||
Alert.alert('Error', error.message || 'Failed to upload images')
|
||||
return
|
||||
}
|
||||
} else {
|
||||
imageUrls = keptImageKeys
|
||||
}
|
||||
|
||||
const offerData = {
|
||||
name: values.name.trim(),
|
||||
shortDescription: values.shortDescription.trim() || undefined,
|
||||
longDescription: values.longDescription.trim() || undefined,
|
||||
price: values.price,
|
||||
marketPrice: values.marketPrice || undefined,
|
||||
images: imageUrls,
|
||||
isFlashEnabled: values.isFlashEnabled,
|
||||
items: validItems.map(item => ({
|
||||
productId: item.productId,
|
||||
quantity: item.quantity,
|
||||
})),
|
||||
}
|
||||
|
||||
if (isEditMode && offerId) {
|
||||
updateOffer(
|
||||
{ id: offerId, ...offerData } as any,
|
||||
{
|
||||
onSuccess: () => {
|
||||
Alert.alert('Success', 'Offer updated successfully!')
|
||||
onOfferAdded?.()
|
||||
},
|
||||
onError: (error: any) => {
|
||||
Alert.alert('Error', error.message || 'Failed to update offer')
|
||||
},
|
||||
}
|
||||
)
|
||||
} else {
|
||||
createOffer(
|
||||
offerData as any,
|
||||
{
|
||||
onSuccess: () => {
|
||||
Alert.alert('Success', 'Offer created successfully!')
|
||||
onOfferAdded?.()
|
||||
},
|
||||
onError: (error: any) => {
|
||||
Alert.alert('Error', error.message || 'Failed to create offer')
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollView style={tw`flex-1 bg-white`} contentContainerStyle={tw`p-4 pb-12`}>
|
||||
<Text style={tw`text-xl font-bold mb-6 text-center`}>
|
||||
{isEditMode ? 'Edit Offer' : 'Create New Offer'}
|
||||
</Text>
|
||||
|
||||
<Formik initialValues={initialValues} onSubmit={handleSubmit} enableReinitialize>
|
||||
{({ handleSubmit, values, setFieldValue }) => (
|
||||
<View>
|
||||
<View style={tw`mb-4`}>
|
||||
<MyTextInput
|
||||
testID="offer-name-input"
|
||||
topLabel="Offer Name *"
|
||||
placeholder="e.g. Weekend Combo Pack"
|
||||
value={values.name}
|
||||
onChangeText={(text) => setFieldValue('name', text)}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={tw`mb-4`}>
|
||||
<MyTextInput
|
||||
testID="offer-short-desc-input"
|
||||
topLabel="Short Description"
|
||||
placeholder="Brief description of the offer"
|
||||
value={values.shortDescription}
|
||||
onChangeText={(text) => setFieldValue('shortDescription', text)}
|
||||
multiline
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={tw`mb-4`}>
|
||||
<MyTextInput
|
||||
testID="offer-long-desc-input"
|
||||
topLabel="Long Description"
|
||||
placeholder="Detailed description"
|
||||
value={values.longDescription}
|
||||
onChangeText={(text) => setFieldValue('longDescription', text)}
|
||||
multiline
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={tw`mb-4`}>
|
||||
<Text style={tw`text-sm font-medium text-gray-700 mb-2`}>Offer Images</Text>
|
||||
<ImageUploaderNeo
|
||||
images={images}
|
||||
onImageAdd={(newImages) => setImages(prev => [...prev, ...newImages.map((img: ImageUploaderNeoPayload) => ({
|
||||
imgUrl: img.url,
|
||||
mimeType: img.mimeType,
|
||||
}))])}
|
||||
onImageRemove={(img) => setImages(prev => prev.filter(i => i.imgUrl !== img.url))}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={tw`mb-4 flex-row gap-2`}>
|
||||
<View style={tw`flex-1`}>
|
||||
<MyTextInput
|
||||
testID="offer-price-input"
|
||||
topLabel="Price *"
|
||||
placeholder="₹"
|
||||
value={values.price}
|
||||
onChangeText={(text) => setFieldValue('price', text)}
|
||||
keyboardType="numeric"
|
||||
/>
|
||||
</View>
|
||||
<View style={tw`flex-1`}>
|
||||
<MyTextInput
|
||||
testID="offer-market-price-input"
|
||||
topLabel="Market Price"
|
||||
placeholder="₹ (optional)"
|
||||
value={values.marketPrice}
|
||||
onChangeText={(text) => setFieldValue('marketPrice', text)}
|
||||
keyboardType="numeric"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={tw`flex-row items-center mb-4`}>
|
||||
<Checkbox
|
||||
checked={values.isFlashEnabled}
|
||||
onPress={() => setFieldValue('isFlashEnabled', !values.isFlashEnabled)}
|
||||
/>
|
||||
<Text style={tw`text-sm font-medium text-gray-700 ml-2`}>Flash Enabled</Text>
|
||||
</View>
|
||||
|
||||
<View style={tw`mb-4`}>
|
||||
<Text style={tw`text-lg font-semibold mb-3`}>Products in Offer</Text>
|
||||
<FieldArray name="items">
|
||||
{({ push, remove }) => (
|
||||
<View>
|
||||
{values.items.map((item, index) => (
|
||||
<View key={index} style={tw`bg-gray-50 p-4 rounded-xl mb-3 border border-gray-100`}>
|
||||
<View style={tw`flex-row items-center justify-between mb-3`}>
|
||||
<Text style={tw`text-sm font-bold text-gray-700`}>Product {index + 1}</Text>
|
||||
{values.items.length > 1 && (
|
||||
<TouchableOpacity onPress={() => remove(index)}>
|
||||
<MaterialIcons name="close" size={20} color="#EF4444" />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={tw`mb-3`}>
|
||||
<ProductsSelector
|
||||
value={item.productId || []}
|
||||
onChange={(value) => setFieldValue(`items.${index}.productId`, Array.isArray(value) ? value[0] : value)}
|
||||
multiple={false}
|
||||
placeholder="Select a product"
|
||||
/>
|
||||
</View>
|
||||
|
||||
<MyTextInput
|
||||
topLabel="Quantity"
|
||||
placeholder="e.g. 1"
|
||||
value={item.quantity}
|
||||
onChangeText={(text) => setFieldValue(`items.${index}.quantity`, text)}
|
||||
keyboardType="numeric"
|
||||
/>
|
||||
</View>
|
||||
))}
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={() => push({ productId: 0, quantity: '1' })}
|
||||
style={tw`bg-blue-500 px-4 py-3 rounded-xl items-center mb-4`}
|
||||
>
|
||||
<Text style={tw`text-white font-medium`}>Add Product</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
</FieldArray>
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
testID="offer-create-button"
|
||||
accessibilityLabel="offer-create-button"
|
||||
onPress={() => handleSubmit()}
|
||||
disabled={isPending}
|
||||
style={tw`${isPending ? 'bg-pink-300' : 'bg-pink-500'} p-4 rounded-xl items-center mt-4`}
|
||||
>
|
||||
<Text style={tw`text-white text-base font-bold`}>
|
||||
{isPending ? (isEditMode ? 'Updating...' : 'Creating...') : (isEditMode ? 'Update Offer' : 'Create Offer')}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
</Formik>
|
||||
</ScrollView>
|
||||
)
|
||||
}
|
||||
|
|
@ -5,13 +5,15 @@ import { MyText, tw, MyTextInput, MyTouchableOpacity, theme, BottomDropdown } fr
|
|||
import ProductsSelector from './ProductsSelector';
|
||||
import { trpc } from '../src/trpc-client';
|
||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||
import type { AdminProductGroup as SharedAdminProductGroup } from '@packages/shared';
|
||||
|
||||
// Serialized product group (string date, loosely typed products) anchored to shared
|
||||
export type ProductGroup = Omit<SharedAdminProductGroup, 'createdAt' | 'products'> & {
|
||||
interface ProductGroup {
|
||||
id: number;
|
||||
groupName: string;
|
||||
description: string | null;
|
||||
createdAt: string;
|
||||
products: any[];
|
||||
};
|
||||
productCount: number;
|
||||
}
|
||||
|
||||
interface ProductGroupFormProps {
|
||||
group?: ProductGroup | null;
|
||||
|
|
@ -25,7 +27,7 @@ const ProductGroupForm: React.FC<ProductGroupFormProps> = ({
|
|||
onSuccess,
|
||||
}) => {
|
||||
// Fetch products
|
||||
const { data: productsData } = trpc.common.product.getAllProductsSummary.useQuery();
|
||||
const { data: productsData } = trpc.common.product.getAllProductsSummary.useQuery({});
|
||||
|
||||
const createGroup = trpc.admin.product.createGroup.useMutation();
|
||||
const updateGroup = trpc.admin.product.updateGroup.useMutation();
|
||||
|
|
@ -117,7 +119,7 @@ const ProductGroupForm: React.FC<ProductGroupFormProps> = ({
|
|||
multiple={true}
|
||||
label="Products"
|
||||
placeholder="Select products"
|
||||
labelFormat={(product) => product.label}
|
||||
labelFormat={(product) => `${product.name}${product.shortDescription ? ` - ${product.shortDescription}` : ''}`}
|
||||
/>
|
||||
|
||||
{/* Actions */}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
import React from 'react';
|
||||
import { View, ScrollView } from 'react-native';
|
||||
import { BottomDialog, MyText, tw } from 'common-ui';
|
||||
import type { IdName } from '@packages/shared';
|
||||
|
||||
type VendorSnippetProduct = IdName;
|
||||
interface VendorSnippetProduct {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface ProductListDialogProps {
|
||||
open: boolean;
|
||||
|
|
|
|||
|
|
@ -3,15 +3,23 @@ import { View } from 'react-native';
|
|||
import BottomDropdown, { DropdownOption } from 'common-ui/src/components/bottom-dropdown';
|
||||
import { trpc } from '../src/trpc-client';
|
||||
import { tw } from 'common-ui';
|
||||
import type { SkuSummary as SharedSkuSummary } from '@packages/shared';
|
||||
|
||||
// Selector works with skus that may not have images resolved yet
|
||||
type SkuSummary = Omit<SharedSkuSummary, 'images'>
|
||||
interface Product {
|
||||
id: number;
|
||||
name: string;
|
||||
price: number;
|
||||
unit?: string;
|
||||
shortDescription?: string | null;
|
||||
isOutOfStock?: boolean;
|
||||
isSuspended?: boolean;
|
||||
storeId?: number | null;
|
||||
unitNotation?: string;
|
||||
}
|
||||
|
||||
interface Group {
|
||||
id: number;
|
||||
groupName: string;
|
||||
products: { id: number }[];
|
||||
products: Product[];
|
||||
}
|
||||
|
||||
interface ProductsSelectorProps {
|
||||
|
|
@ -22,8 +30,8 @@ interface ProductsSelectorProps {
|
|||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
error?: boolean;
|
||||
isDisabled?: (product: SkuSummary) => boolean;
|
||||
labelFormat?: (product: SkuSummary) => string;
|
||||
isDisabled?: (product: Product) => boolean;
|
||||
labelFormat?: (product: Product) => string;
|
||||
groups?: Group[];
|
||||
selectedGroupIds?: number[];
|
||||
onGroupChange?: (groupIds: number[]) => void;
|
||||
|
|
@ -45,78 +53,85 @@ export default function ProductsSelector({
|
|||
selectedGroupIds = [],
|
||||
onGroupChange,
|
||||
}: ProductsSelectorProps) {
|
||||
const { data: skusData } = trpc.common.product.getAllSkusSummary.useQuery();
|
||||
const allSkus: SkuSummary[] = skusData?.skus || [];
|
||||
const { data: productsData } = trpc.common.product.getAllProductsSummary.useQuery({});
|
||||
const products = productsData?.products || [];
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
// Format product label: name (unit) (₹price)
|
||||
const formatProductLabel = (product: Product): string => {
|
||||
if (labelFormat) {
|
||||
return labelFormat(product);
|
||||
}
|
||||
const unit = product.unit ? ` (${product.unit})` : '';
|
||||
const price = ` (₹${product.price})`;
|
||||
return `${product.name}${unit}${price}`;
|
||||
};
|
||||
|
||||
// Handle group selection changes
|
||||
const handleGroupChange = (newGroupIds: number[]) => {
|
||||
if (!onGroupChange) return;
|
||||
|
||||
const previousGroupIds = selectedGroupIds;
|
||||
|
||||
// Find which groups were added and which were removed
|
||||
const addedGroups = newGroupIds.filter(id => !previousGroupIds.includes(id));
|
||||
const removedGroups = previousGroupIds.filter(id => !newGroupIds.includes(id));
|
||||
|
||||
let currentSkus = Array.isArray(value) ? [...value] : value ? [value] : [];
|
||||
// Get current selected products
|
||||
let currentProducts = Array.isArray(value) ? [...value] : value ? [value] : [];
|
||||
|
||||
const addedSkus = addedGroups.flatMap(groupId => {
|
||||
// Add products from newly selected groups
|
||||
const addedProducts = addedGroups.flatMap(groupId => {
|
||||
const group = groups.find(g => g.id === groupId);
|
||||
if (!group) return [];
|
||||
const productIds = new Set(group.products.map(p => p.id));
|
||||
return allSkus.filter(s => productIds.has(s.productId)).map(s => s.id);
|
||||
return group?.products.map(p => p.id) || [];
|
||||
});
|
||||
|
||||
const removedSkus = removedGroups.flatMap(groupId => {
|
||||
// Remove products from deselected groups
|
||||
const removedProducts = removedGroups.flatMap(groupId => {
|
||||
const group = groups.find(g => g.id === groupId);
|
||||
if (!group) return [];
|
||||
const productIds = new Set(group.products.map(p => p.id));
|
||||
return allSkus.filter(s => productIds.has(s.productId)).map(s => s.id);
|
||||
return group?.products.map(p => p.id) || [];
|
||||
});
|
||||
|
||||
currentSkus = [...new Set([...currentSkus, ...addedSkus])];
|
||||
currentSkus = currentSkus.filter(id => !removedSkus.includes(id));
|
||||
// Update product list: add new ones, remove deselected group ones
|
||||
currentProducts = [...new Set([...currentProducts, ...addedProducts])];
|
||||
currentProducts = currentProducts.filter(id => !removedProducts.includes(id));
|
||||
|
||||
onGroupChange(newGroupIds);
|
||||
if (multiple) {
|
||||
onChange(currentSkus.length > 0 ? currentSkus : []);
|
||||
onChange(currentProducts.length > 0 ? currentProducts : []);
|
||||
} else {
|
||||
onChange(currentSkus.length > 0 ? currentSkus[0] : 0);
|
||||
onChange(currentProducts.length > 0 ? currentProducts[0] : 0);
|
||||
}
|
||||
};
|
||||
|
||||
// Filter products based on search query
|
||||
const filteredSkus = useMemo(() => {
|
||||
if (!searchQuery.trim()) return allSkus;
|
||||
const filteredProducts = useMemo(() => {
|
||||
if (!searchQuery.trim()) return products;
|
||||
|
||||
const query = searchQuery.toLowerCase();
|
||||
return allSkus.filter(sku =>
|
||||
sku.label.toLowerCase().includes(query) ||
|
||||
sku.productName.toLowerCase().includes(query)
|
||||
return products.filter(product =>
|
||||
product.name.toLowerCase().includes(query) ||
|
||||
(product.shortDescription && product.shortDescription.toLowerCase().includes(query)) ||
|
||||
(product.unit && product.unit.toLowerCase().includes(query))
|
||||
);
|
||||
}, [allSkus, searchQuery]);
|
||||
}, [products, searchQuery]);
|
||||
|
||||
// Build dropdown options
|
||||
const productOptions: DropdownOption[] = useMemo(() => {
|
||||
return filteredSkus.map((sku) => {
|
||||
const isFromGroup = selectedGroupIds.length > 0 && groups.some(group => {
|
||||
if (!selectedGroupIds.includes(group.id)) return false;
|
||||
return group.products.some(p => p.id === sku.productId);
|
||||
});
|
||||
return filteredProducts.map((product) => {
|
||||
const isFromGroup = selectedGroupIds.length > 0 && groups.some(group =>
|
||||
selectedGroupIds.includes(group.id) && group.products.some(p => p.id === product.id)
|
||||
);
|
||||
|
||||
const isProductDisabled = isDisabled ? isDisabled(sku) : false;
|
||||
|
||||
const displayLabel = labelFormat
|
||||
? labelFormat(sku)
|
||||
: sku.label;
|
||||
const isProductDisabled = isDisabled ? isDisabled(product as Product) : false;
|
||||
|
||||
return {
|
||||
label: `${displayLabel}${isFromGroup ? ' (from group)' : ''}`,
|
||||
value: sku.id.toString(),
|
||||
label: `${formatProductLabel(product as Product)}${isFromGroup ? ' (from group)' : ''}`,
|
||||
value: product.id.toString(),
|
||||
disabled: isProductDisabled,
|
||||
};
|
||||
});
|
||||
}, [filteredSkus, selectedGroupIds, groups, isDisabled, labelFormat]);
|
||||
}, [filteredProducts, selectedGroupIds, groups, isDisabled, labelFormat]);
|
||||
|
||||
// Build group options if groups are provided
|
||||
const groupOptions: DropdownOption[] = useMemo(() => {
|
||||
|
|
@ -128,6 +143,7 @@ export default function ProductsSelector({
|
|||
|
||||
return (
|
||||
<View style={tw`w-full`}>
|
||||
{/* Groups selector (if groups are provided and showGroups is true) */}
|
||||
{showGroups && groups.length > 0 && (
|
||||
<View style={tw`mb-4`}>
|
||||
<BottomDropdown
|
||||
|
|
@ -135,8 +151,8 @@ export default function ProductsSelector({
|
|||
label="Select Product Groups"
|
||||
options={groupOptions}
|
||||
value={selectedGroupIds.map(id => id.toString())}
|
||||
onValueChange={(selectedValue) => {
|
||||
const selectedValues = Array.isArray(selectedValue) ? selectedValue : typeof selectedValue === 'string' ? [selectedValue] : [];
|
||||
onValueChange={(value) => {
|
||||
const selectedValues = Array.isArray(value) ? value : typeof value === 'string' ? [value] : [];
|
||||
const newGroupIds = selectedValues.map(v => parseInt(v as string));
|
||||
handleGroupChange(newGroupIds);
|
||||
}}
|
||||
|
|
@ -146,6 +162,7 @@ export default function ProductsSelector({
|
|||
</View>
|
||||
)}
|
||||
|
||||
{/* Products selector */}
|
||||
<BottomDropdown
|
||||
label={label}
|
||||
options={productOptions}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,17 @@
|
|||
import React from 'react';
|
||||
import type { SlotSnippetInput } from '@packages/shared'
|
||||
import { View, Text, TouchableOpacity, Alert } from 'react-native';
|
||||
import { Formik, FieldArray } from 'formik';
|
||||
import DateTimePickerMod from 'common-ui/src/components/date-time-picker';
|
||||
import { tw, MyTextInput } from 'common-ui';
|
||||
import { tw, MyTextInput, BottomDropdown } from 'common-ui';
|
||||
import { trpc } from '../src/trpc-client';
|
||||
import ProductsSelector from '../components/ProductsSelector';
|
||||
|
||||
// Snippet form row — single source in @packages/shared (complete payload)
|
||||
type VendorSnippet = SlotSnippetInput
|
||||
interface VendorSnippet {
|
||||
name: string;
|
||||
groupIds: number[];
|
||||
productIds: number[];
|
||||
validTill?: string;
|
||||
}
|
||||
|
||||
interface SlotFormProps {
|
||||
onSlotAdded?: () => void;
|
||||
|
|
@ -37,15 +40,16 @@ export default function SlotForm({
|
|||
const vendorSnippetsFromSlot = (slotData?.slot?.vendorSnippets || []).map((snippet: any) => ({
|
||||
name: snippet.name || '',
|
||||
groupIds: snippet.groupIds || [],
|
||||
skuIds: snippet.skuIds || [],
|
||||
validTill: snippet.validTill || null,
|
||||
productIds: snippet.productIds || [],
|
||||
validTill: snippet.validTill || undefined,
|
||||
})) as VendorSnippet[];
|
||||
|
||||
const initialValues = {
|
||||
deliveryTime: initialDeliveryTime || (slotData?.slot?.deliveryTime ? new Date(slotData.slot.deliveryTime) : null),
|
||||
freezeTime: initialFreezeTime || (slotData?.slot?.freezeTime ? new Date(slotData.slot.freezeTime) : null),
|
||||
selectedGroupIds: initialGroupIds.length > 0 ? initialGroupIds : (slotData?.slot?.groupIds || []),
|
||||
selectedSkuIds: initialProductIds.length > 0 ? initialProductIds : (slotData?.slot?.products?.map((p: any) => p.id) || []),
|
||||
selectedProductIds: initialProductIds.length > 0 ? initialProductIds : (slotData?.slot?.products?.map((p: any) => p.id) || []),
|
||||
selectedOfferIds: slotData?.slot?.offerIds || [],
|
||||
vendorSnippetList: vendorSnippetsFromSlot,
|
||||
};
|
||||
|
||||
|
|
@ -55,8 +59,18 @@ export default function SlotForm({
|
|||
const isEditMode = !!slotId;
|
||||
const isPending = isCreating || isUpdating;
|
||||
|
||||
// Fetch groups
|
||||
const { data: groupsData } = trpc.admin.product.getGroups.useQuery();
|
||||
|
||||
// Fetch offers
|
||||
const { data: offersData } = trpc.admin.offers.getAll.useQuery();
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
const handleFormSubmit = (values: typeof initialValues) => {
|
||||
if (!values.deliveryTime || !values.freezeTime) {
|
||||
Alert.alert('Error', 'Please fill all fields');
|
||||
|
|
@ -68,22 +82,24 @@ export default function SlotForm({
|
|||
return;
|
||||
}
|
||||
|
||||
const slotInputData = {
|
||||
const slotData = {
|
||||
deliveryTime: values.deliveryTime.toISOString(),
|
||||
freezeTime: values.freezeTime.toISOString(),
|
||||
isActive: initialIsActive,
|
||||
groupIds: values.selectedGroupIds,
|
||||
skuIds: values.selectedSkuIds,
|
||||
productIds: values.selectedProductIds,
|
||||
offerIds: values.selectedOfferIds,
|
||||
vendorSnippets: values.vendorSnippetList.map((snippet: VendorSnippet) => ({
|
||||
name: snippet.name,
|
||||
skuIds: snippet.skuIds,
|
||||
validTill: snippet.validTill ?? undefined,
|
||||
productIds: snippet.productIds,
|
||||
validTill: snippet.validTill,
|
||||
})),
|
||||
};
|
||||
|
||||
|
||||
if (isEditMode && slotId) {
|
||||
updateSlot(
|
||||
{ id: slotId, ...slotInputData },
|
||||
{ id: slotId, ...slotData },
|
||||
{
|
||||
onSuccess: () => {
|
||||
Alert.alert('Success', 'Slot updated successfully!');
|
||||
|
|
@ -98,10 +114,12 @@ export default function SlotForm({
|
|||
);
|
||||
} else {
|
||||
createSlot(
|
||||
slotInputData,
|
||||
slotData,
|
||||
{
|
||||
onSuccess: () => {
|
||||
Alert.alert('Success', 'Slot created successfully!');
|
||||
// Reset form
|
||||
// Formik will handle reset
|
||||
onSlotAdded?.();
|
||||
},
|
||||
onError: (error: any) => {
|
||||
|
|
@ -118,11 +136,12 @@ export default function SlotForm({
|
|||
onSubmit={handleFormSubmit}
|
||||
>
|
||||
{({ handleSubmit, values, setFieldValue }) => {
|
||||
// Map groups data to match ProductsSelector types (convert price from string to number)
|
||||
const mappedGroups = (groupsData?.groups || []).map(group => ({
|
||||
...group,
|
||||
products: group.products.map(product => ({
|
||||
...product,
|
||||
id: product.id,
|
||||
price: parseFloat(product.price as unknown as string) || 0,
|
||||
})),
|
||||
}));
|
||||
|
||||
|
|
@ -154,8 +173,8 @@ export default function SlotForm({
|
|||
|
||||
<View style={tw`mb-4`}>
|
||||
<ProductsSelector
|
||||
value={values.selectedSkuIds}
|
||||
onChange={(newSkuIds) => setFieldValue('selectedSkuIds', newSkuIds)}
|
||||
value={values.selectedProductIds}
|
||||
onChange={(newProductIds) => setFieldValue('selectedProductIds', newProductIds)}
|
||||
groups={mappedGroups}
|
||||
selectedGroupIds={values.selectedGroupIds}
|
||||
onGroupChange={(newGroupIds) => setFieldValue('selectedGroupIds', newGroupIds)}
|
||||
|
|
@ -164,6 +183,24 @@ export default function SlotForm({
|
|||
/>
|
||||
</View>
|
||||
|
||||
<View style={tw`mb-4`}>
|
||||
<BottomDropdown
|
||||
testID="slot-offers-dropdown"
|
||||
label="Select Offers"
|
||||
options={(offersData?.offers || []).map((offer: any) => ({
|
||||
label: `${offer.name} (₹${offer.price})`,
|
||||
value: String(offer.id),
|
||||
}))}
|
||||
value={values.selectedOfferIds.map((id: number) => String(id))}
|
||||
onValueChange={(value) => {
|
||||
const selectedValues = Array.isArray(value) ? value : typeof value === 'string' ? [value] : []
|
||||
setFieldValue('selectedOfferIds', selectedValues.map((v: string) => Number(v)))
|
||||
}}
|
||||
placeholder="Select offers for this slot"
|
||||
multiple={true}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Vendor Snippets */}
|
||||
<FieldArray name="vendorSnippetList">
|
||||
{({ push, remove }) => (
|
||||
|
|
@ -182,19 +219,19 @@ export default function SlotForm({
|
|||
|
||||
<View style={tw`mb-4`}>
|
||||
<ProductsSelector
|
||||
value={snippet.skuIds || []}
|
||||
onChange={(newSkuIds) => setFieldValue(`vendorSnippetList.${index}.skuIds`, newSkuIds)}
|
||||
value={snippet.productIds || []}
|
||||
onChange={(newProductIds) => setFieldValue(`vendorSnippetList.${index}.productIds`, newProductIds)}
|
||||
groups={mappedGroups.filter(group =>
|
||||
values.selectedGroupIds.includes(group.id)
|
||||
).map(group => ({
|
||||
...group,
|
||||
products: group.products.filter((p: any) => values.selectedSkuIds.includes(p.id))
|
||||
products: group.products.filter(p => values.selectedProductIds.includes(p.id))
|
||||
}))}
|
||||
selectedGroupIds={snippet.groupIds || []}
|
||||
onGroupChange={(newGroupIds) => setFieldValue(`vendorSnippetList.${index}.groupIds`, newGroupIds)}
|
||||
label="Select Products"
|
||||
placeholder="Select products for snippet"
|
||||
isDisabled={(sku) => !values.selectedSkuIds.includes(sku.id)}
|
||||
isDisabled={(product) => !values.selectedProductIds.includes(product.id)}
|
||||
/>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
|
|
@ -206,7 +243,7 @@ export default function SlotForm({
|
|||
</View>
|
||||
))}
|
||||
<TouchableOpacity
|
||||
onPress={() => push({ name: '', groupIds: [], skuIds: [], validTill: '' })}
|
||||
onPress={() => push({ name: '', groupIds: [], productIds: [], validTill: '' })}
|
||||
style={tw`bg-blue-500 px-4 py-3 rounded-lg items-center`}
|
||||
>
|
||||
<Text style={tw`text-white font-medium`}>Add Vendor Snippet</Text>
|
||||
|
|
|
|||
|
|
@ -2,25 +2,28 @@ import React from 'react';
|
|||
import { View, ScrollView, TouchableOpacity } from 'react-native';
|
||||
import { MyText, tw, AppContainer } from 'common-ui';
|
||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||
import type {
|
||||
AdminVendorSnippetOrderProduct,
|
||||
AdminVendorSnippetOrderSummary as SharedSnippetOrderSummary,
|
||||
} from '@packages/shared';
|
||||
|
||||
// Line item + order summary anchored to the shared vendor-order contract
|
||||
// (string totalAmount + any[] sequence at this view layer)
|
||||
type OrderProduct = Pick<
|
||||
AdminVendorSnippetOrderProduct,
|
||||
'productId' | 'productName' | 'quantity' | 'price' | 'unit' | 'subtotal'
|
||||
>
|
||||
interface OrderProduct {
|
||||
productId: number;
|
||||
productName: string;
|
||||
quantity: number;
|
||||
price: number;
|
||||
unit: string;
|
||||
subtotal: number;
|
||||
}
|
||||
|
||||
type SnippetOrder = Omit<SharedSnippetOrderSummary, 'totalAmount' | 'slotInfo' | 'products'> & {
|
||||
totalAmount: string
|
||||
interface SnippetOrder {
|
||||
orderId: string;
|
||||
orderDate: string;
|
||||
customerName: string;
|
||||
totalAmount: string;
|
||||
slotInfo: {
|
||||
time: string;
|
||||
sequence: any[];
|
||||
} | null;
|
||||
products: OrderProduct[];
|
||||
matchedProducts: number[];
|
||||
snippetCode: string;
|
||||
}
|
||||
|
||||
interface SnippetOrdersViewProps {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import React, { forwardRef, useState, useEffect, useMemo } from 'react';
|
||||
import { View, TouchableOpacity, Alert } from 'react-native';
|
||||
import { Formik } from 'formik';
|
||||
import * as Yup from 'yup';
|
||||
|
|
@ -7,13 +7,18 @@ import ProductsSelector from './ProductsSelector';
|
|||
import { trpc } from '../src/trpc-client';
|
||||
import usePickImage from 'common-ui/src/components/use-pick-image';
|
||||
import { useUploadToObjectStorage } from '../hooks/useUploadToObjectStore';
|
||||
import type { CreateStoreInput } from '@packages/shared';
|
||||
|
||||
// Store form values — required description + product selection on top of CreateStoreInput
|
||||
export type StoreFormData = Omit<CreateStoreInput, 'description'> & {
|
||||
export interface StoreFormData {
|
||||
name: string;
|
||||
description: string;
|
||||
imageUrl?: string;
|
||||
owner: number;
|
||||
products: number[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface StoreFormRef {
|
||||
// Add methods if needed
|
||||
}
|
||||
|
||||
interface StoreFormProps {
|
||||
mode: 'create' | 'edit';
|
||||
|
|
@ -31,7 +36,8 @@ const validationSchema = Yup.object().shape({
|
|||
products: Yup.array().of(Yup.number()),
|
||||
});
|
||||
|
||||
function StoreForm({ mode, initialValues, onSubmit, isLoading, storeId }: StoreFormProps) {
|
||||
const StoreForm = forwardRef<StoreFormRef, StoreFormProps>((props, ref) => {
|
||||
const { mode, initialValues, onSubmit, isLoading, storeId } = props;
|
||||
const { data: staffData } = trpc.admin.staffUser.getStaff.useQuery();
|
||||
const { data: productsData } = trpc.admin.product.getProducts.useQuery();
|
||||
|
||||
|
|
@ -39,12 +45,12 @@ function StoreForm({ mode, initialValues, onSubmit, isLoading, storeId }: StoreF
|
|||
const [selectedImages, setSelectedImages] = useState<{ blob: Blob; mimeType: string }[]>([]);
|
||||
const [displayImages, setDisplayImages] = useState<{ uri?: string }[]>([]);
|
||||
|
||||
// For edit mode, pre-select SKUs belonging to this store's products
|
||||
// For edit mode, pre-select products belonging to this store
|
||||
const initialSelectedProducts = useMemo(() => {
|
||||
if (mode !== 'edit' || !productsData?.products) return [];
|
||||
return productsData.products
|
||||
.filter(p => p.storeId === storeId)
|
||||
.flatMap(p => (p.skus || []).map(sku => sku.id));
|
||||
.map(p => p.id);
|
||||
}, [mode, productsData?.products, storeId]);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -160,8 +166,8 @@ function StoreForm({ mode, initialValues, onSubmit, isLoading, storeId }: StoreF
|
|||
multiple={true}
|
||||
label="Products"
|
||||
placeholder="Select products"
|
||||
isDisabled={(sku) => sku.storeId !== null && sku.storeId !== storeId}
|
||||
labelFormat={(sku) => `${sku.productName} - ₹${sku.price}`}
|
||||
isDisabled={(product) => product.storeId !== null && product.storeId !== storeId}
|
||||
labelFormat={(product) => `${product.name} - ₹${product.price}`}
|
||||
/>
|
||||
<View style={tw`mb-6`}>
|
||||
<MyText style={tw`text-sm font-bold text-gray-700 mb-3 uppercase tracking-wider`}>Store Image</MyText>
|
||||
|
|
@ -193,6 +199,8 @@ function StoreForm({ mode, initialValues, onSubmit, isLoading, storeId }: StoreF
|
|||
}}
|
||||
</Formik>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
StoreForm.displayName = 'StoreForm';
|
||||
|
||||
export default StoreForm;
|
||||
|
|
|
|||
37
apps/admin-ui/components/TabNavigation.tsx
Executable file
37
apps/admin-ui/components/TabNavigation.tsx
Executable file
|
|
@ -0,0 +1,37 @@
|
|||
import React from 'react';
|
||||
import { View, TouchableOpacity } from 'react-native';
|
||||
import { MyText , tw } from "common-ui";
|
||||
|
||||
interface TabNavigationProps {
|
||||
tabs: { key: string; title: string }[];
|
||||
activeTab: string;
|
||||
onTabChange: (tabKey: string) => void;
|
||||
}
|
||||
|
||||
const TabNavigation: React.FC<TabNavigationProps> = ({ tabs, activeTab, onTabChange }) => {
|
||||
return (
|
||||
<View style={tw`flex-row bg-gray-100 rounded-xl p-1 mb-4`}>
|
||||
{tabs.map((tab) => (
|
||||
<TouchableOpacity
|
||||
key={tab.key}
|
||||
style={[
|
||||
tw`flex-1 py-3 px-4 rounded-xl`,
|
||||
activeTab === tab.key ? tw`bg-white shadow` : tw``
|
||||
]}
|
||||
onPress={() => onTabChange(tab.key)}
|
||||
>
|
||||
<MyText
|
||||
style={[
|
||||
tw`text-center font-medium`,
|
||||
activeTab === tab.key ? tw`text-blue-600` : tw`text-gray-500`
|
||||
]}
|
||||
>
|
||||
{tab.title}
|
||||
</MyText>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default TabNavigation;
|
||||
109
apps/admin-ui/components/UserIncidentDialog.tsx
Normal file
109
apps/admin-ui/components/UserIncidentDialog.tsx
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
import React, { useState } from 'react';
|
||||
import { View, TouchableOpacity } from 'react-native';
|
||||
import { MyText, tw, BottomDialog, MyTextInput } from 'common-ui';
|
||||
import { trpc } from '@/src/trpc-client';
|
||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||
import { Alert } from 'react-native';
|
||||
|
||||
interface UserIncidentDialogProps {
|
||||
orderId: number;
|
||||
userId: number;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
export default function UserIncidentDialog({ orderId, userId, open, onClose, onSuccess }: UserIncidentDialogProps) {
|
||||
const [adminComment, setAdminComment] = useState('');
|
||||
const [negativityScore, setNegativityScore] = useState('');
|
||||
|
||||
const addIncidentMutation = trpc.admin.user.addUserIncident.useMutation({
|
||||
onSuccess: () => {
|
||||
Alert.alert('Success', 'Incident added successfully');
|
||||
setAdminComment('');
|
||||
setNegativityScore('');
|
||||
onClose();
|
||||
onSuccess?.();
|
||||
},
|
||||
onError: (error: any) => {
|
||||
Alert.alert('Error', error.message || 'Failed to add incident');
|
||||
},
|
||||
});
|
||||
|
||||
const handleAddIncident = () => {
|
||||
const score = negativityScore ? parseInt(negativityScore) : undefined;
|
||||
|
||||
if (!adminComment.trim() && !negativityScore) {
|
||||
Alert.alert('Error', 'Please enter a comment or negativity score');
|
||||
return;
|
||||
}
|
||||
|
||||
addIncidentMutation.mutate({
|
||||
userId,
|
||||
orderId,
|
||||
adminComment: adminComment || undefined,
|
||||
negativityScore: score,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<BottomDialog open={open} onClose={onClose}>
|
||||
<View style={tw`p-6`}>
|
||||
<View style={tw`items-center mb-6`}>
|
||||
<View style={tw`w-12 h-12 bg-amber-100 rounded-full items-center justify-center mb-3`}>
|
||||
<MaterialIcons name="warning" size={24} color="#D97706" />
|
||||
</View>
|
||||
<MyText style={tw`text-xl font-bold text-gray-900 text-center`}>
|
||||
Add User Incident
|
||||
</MyText>
|
||||
<MyText style={tw`text-gray-500 text-center mt-2 text-sm leading-5`}>
|
||||
Record an incident for this user. This will be visible in their profile.
|
||||
</MyText>
|
||||
</View>
|
||||
|
||||
<MyTextInput
|
||||
topLabel="Admin Comment"
|
||||
value={adminComment}
|
||||
onChangeText={setAdminComment}
|
||||
placeholder="Enter details about the incident..."
|
||||
multiline
|
||||
style={tw`h-24`}
|
||||
/>
|
||||
|
||||
<MyTextInput
|
||||
topLabel="Negativity Score (Optional)"
|
||||
value={negativityScore}
|
||||
onChangeText={setNegativityScore}
|
||||
placeholder="0"
|
||||
keyboardType="numeric"
|
||||
style={tw`mt-4`}
|
||||
/>
|
||||
|
||||
<View style={tw`bg-amber-50 p-4 rounded-xl border border-amber-100 mb-6 mt-4 flex-row items-start`}>
|
||||
<MaterialIcons name="info-outline" size={20} color="#D97706" style={tw`mt-0.5`} />
|
||||
<MyText style={tw`text-sm text-amber-800 ml-2 flex-1 leading-5`}>
|
||||
Higher negativity scores indicate more serious incidents (e.g., repeated cancellations, abusive behavior).
|
||||
</MyText>
|
||||
</View>
|
||||
|
||||
<View style={tw`flex-row gap-3`}>
|
||||
<TouchableOpacity
|
||||
style={tw`flex-1 bg-gray-100 py-3.5 rounded-xl items-center`}
|
||||
onPress={onClose}
|
||||
>
|
||||
<MyText style={tw`text-gray-700 font-bold`}>Cancel</MyText>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={tw`flex-1 bg-amber-500 py-3.5 rounded-xl items-center shadow-sm ${addIncidentMutation.isPending ? 'opacity-50' : ''}`}
|
||||
onPress={handleAddIncident}
|
||||
disabled={addIncidentMutation.isPending}
|
||||
>
|
||||
<MyText style={tw`text-white font-bold`}>
|
||||
{addIncidentMutation.isPending ? 'Adding...' : 'Add Incident'}
|
||||
</MyText>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</BottomDialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -34,7 +34,7 @@ const VendorSnippetForm: React.FC<VendorSnippetFormProps> = ({
|
|||
snippetCode: snippet?.snippetCode || '',
|
||||
slotId: snippet?.slotId?.toString() || '',
|
||||
isPermanent: snippet?.isPermanent || false,
|
||||
skuIds: snippet?.skuIds?.map(id => id.toString()) || [],
|
||||
productIds: snippet?.productIds?.map(id => id.toString()) || [],
|
||||
validTill: snippet?.validTill ? new Date(snippet.validTill) : null,
|
||||
},
|
||||
validate: (values) => {
|
||||
|
|
@ -55,8 +55,8 @@ const VendorSnippetForm: React.FC<VendorSnippetFormProps> = ({
|
|||
errors.slotId = 'Slot selection is required';
|
||||
}
|
||||
|
||||
if (values.skuIds.length === 0) {
|
||||
errors.skuIds = 'At least one product must be selected';
|
||||
if (values.productIds.length === 0) {
|
||||
errors.productIds = 'At least one product must be selected';
|
||||
}
|
||||
|
||||
return errors;
|
||||
|
|
@ -68,7 +68,7 @@ const VendorSnippetForm: React.FC<VendorSnippetFormProps> = ({
|
|||
snippetCode: values.snippetCode,
|
||||
slotId: values.isPermanent ? undefined : parseInt(values.slotId || '0'),
|
||||
isPermanent: values.isPermanent,
|
||||
skuIds: values.skuIds.map(id => parseInt(id)),
|
||||
productIds: values.productIds.map(id => parseInt(id)),
|
||||
validTill: values.validTill ? values.validTill.toISOString() : undefined,
|
||||
};
|
||||
|
||||
|
|
@ -180,15 +180,15 @@ const VendorSnippetForm: React.FC<VendorSnippetFormProps> = ({
|
|||
{/* Product Selection */}
|
||||
<View>
|
||||
<ProductsSelector
|
||||
value={formik.values.skuIds.map(id => parseInt(id))}
|
||||
onChange={(selectedProductIds) => formik.setFieldValue('skuIds', (selectedProductIds as number[]).map(id => id.toString()))}
|
||||
value={formik.values.productIds.map(id => parseInt(id))}
|
||||
onChange={(selectedProductIds) => formik.setFieldValue('productIds', (selectedProductIds as number[]).map(id => id.toString()))}
|
||||
multiple={true}
|
||||
label="Select Products"
|
||||
placeholder="Select products"
|
||||
labelFormat={(product) => product.label}
|
||||
labelFormat={(product) => `${product.name} (${product.unit})`}
|
||||
/>
|
||||
{formik.errors.skuIds && formik.touched.skuIds && (
|
||||
<MyText style={tw`text-red-500 text-sm mt-1`}>{formik.errors.skuIds}</MyText>
|
||||
{formik.errors.productIds && formik.touched.productIds && (
|
||||
<MyText style={tw`text-red-500 text-sm mt-1`}>{formik.errors.productIds}</MyText>
|
||||
)}
|
||||
</View>
|
||||
|
||||
|
|
|
|||
3
apps/admin-ui/components/app-container.tsx
Executable file
3
apps/admin-ui/components/app-container.tsx
Executable file
|
|
@ -0,0 +1,3 @@
|
|||
import { AppContainer } from "common-ui";
|
||||
|
||||
export default AppContainer;
|
||||
270
apps/admin-ui/components/context/auth-context.tsx
Executable file
270
apps/admin-ui/components/context/auth-context.tsx
Executable file
|
|
@ -0,0 +1,270 @@
|
|||
// import React, {
|
||||
// createContext,
|
||||
// useContext,
|
||||
// useEffect,
|
||||
// useState,
|
||||
// ReactNode,
|
||||
// } from "react";
|
||||
// import {
|
||||
// getJWT,
|
||||
// deleteJWT,
|
||||
// getRoles,
|
||||
// saveJWT,
|
||||
// saveRoles,
|
||||
// saveUserId,
|
||||
// getUserId,
|
||||
// } from "../../hooks/useJWT";
|
||||
// import { useFocusEffect, usePathname, useRouter } from "expo-router";
|
||||
// import queryClient from "@/utils/queryClient";
|
||||
// import { DeviceEventEmitter } from "react-native";
|
||||
// import { FORCE_LOGOUT_EVENT, SESSION_EXPIRED_MSG } from "common-ui/src/lib/const-strs";
|
||||
// import { useLogin, useLogout } from "@/api-hooks/auth.api";
|
||||
// import {
|
||||
// useUserResponsibilities,
|
||||
// UserResponsibilities,
|
||||
// } from "@/api-hooks/user.api";
|
||||
// import { InfoToast, SuccessToast } from "@/services/toaster";
|
||||
// import { useNotification } from "@/services/notif-service/notif-context";
|
||||
|
||||
// interface LoginFormInputs {
|
||||
// login: string;
|
||||
// password: string;
|
||||
// useUsername?: boolean;
|
||||
// expoPushToken?: string | null;
|
||||
// }
|
||||
|
||||
// interface AuthContextType {
|
||||
// isLoggedIn: boolean;
|
||||
// setIsLoggedIn: (value: boolean) => void;
|
||||
// logout: ({
|
||||
// isSessionExpired,
|
||||
// }: {
|
||||
// isSessionExpired?: boolean;
|
||||
// }) => Promise<void>;
|
||||
// responsibilities: UserResponsibilities | null;
|
||||
// responsibilitiesLoading: boolean;
|
||||
// responsibilitiesError: Error | null;
|
||||
// roles: string[] | null;
|
||||
// setRoles: (roles: string[] | null) => void;
|
||||
// refreshRoles: () => Promise<void>;
|
||||
// loginFunc: (payload: LoginFormInputs) => Promise<void>;
|
||||
// userId: number | null;
|
||||
// isLoggingIn: boolean;
|
||||
// loginError?: string;
|
||||
// }
|
||||
|
||||
// const defaultResponsibilities: UserResponsibilities = {
|
||||
// hospitalAdminFor: null,
|
||||
// secretaryFor: [],
|
||||
// };
|
||||
|
||||
// export const AuthContext = createContext<AuthContextType | undefined>(
|
||||
// undefined
|
||||
// );
|
||||
|
||||
// export const AuthProvider = ({ children }: { children: ReactNode }) => {
|
||||
// const { mutate: loginApi, isPending: isLoggingIn, error: loginError } = useLogin();
|
||||
// const [isLoggedIn, setIsLoggedIn] = useState(false);
|
||||
// const [roles, setRoles] = useState<string[] | null>(null);
|
||||
// const [userId, setUserId] = useState<number | null>(null);
|
||||
|
||||
// const refreshRoles = async () => {
|
||||
// const r = await getRoles();
|
||||
// setRoles(r);
|
||||
// };
|
||||
|
||||
|
||||
|
||||
// useEffect(() => {
|
||||
// refreshRoles();
|
||||
// }, []);
|
||||
// const { mutate: logoutApi } = useLogout();
|
||||
// const router = useRouter();
|
||||
// const [responsibilitiesError, setResponsibilitiesError] =
|
||||
// useState<Error | null>(null);
|
||||
|
||||
// const {
|
||||
// data: responsibilities,
|
||||
// isLoading: responsibilitiesLoading,
|
||||
// isFetching: responsibilitiesFetching,
|
||||
// refetch: refetchResponsibilities,
|
||||
// error: queryError,
|
||||
// } = useUserResponsibilities(userId);
|
||||
|
||||
|
||||
// React.useEffect(() => {
|
||||
// (async () => {
|
||||
// const token = await getJWT();
|
||||
|
||||
// setIsLoggedIn(!!token);
|
||||
// if (!token) {
|
||||
// if (!pathname.includes("login")) {
|
||||
// router.replace("/login" as any);
|
||||
// }
|
||||
// } else {
|
||||
// refetchResponsibilities();
|
||||
// router.replace("/(drawer)/dashboard");
|
||||
// const userId = await getUserId();
|
||||
// setUserId(userId ? parseInt(userId) : null);
|
||||
// }
|
||||
// })();
|
||||
// }, []);
|
||||
|
||||
// const pathname = usePathname();
|
||||
|
||||
|
||||
// const logout = async ({
|
||||
// isSessionExpired,
|
||||
// }: {
|
||||
// isSessionExpired?: boolean;
|
||||
// }) => {
|
||||
|
||||
// const pageConditon =
|
||||
// pathname.includes("/login") ||
|
||||
// pathname.includes("/signup") ||
|
||||
// pathname === "/";
|
||||
|
||||
// if (!isSessionExpired) {
|
||||
// logoutApi({} as any, {
|
||||
// onSuccess: () => {},
|
||||
// onSettled: () => {
|
||||
|
||||
// if (!pageConditon) {
|
||||
// router.replace({
|
||||
// pathname: "/login" as any,
|
||||
// params: isSessionExpired ? { message: SESSION_EXPIRED_MSG } : {},
|
||||
// });
|
||||
// }
|
||||
// deleteJWT();
|
||||
// },
|
||||
// });
|
||||
// setIsLoggedIn(false);
|
||||
// } else {
|
||||
// deleteJWT();
|
||||
// setIsLoggedIn(false);
|
||||
// InfoToast("Session expired. Please log in again.");
|
||||
// if (!pageConditon) {
|
||||
// router.replace({
|
||||
// pathname: "/login" as any,
|
||||
// params: { message: SESSION_EXPIRED_MSG },
|
||||
// });
|
||||
// }
|
||||
// }
|
||||
// queryClient.clear();
|
||||
// };
|
||||
|
||||
// const loginFunc = async (data: LoginFormInputs) => {
|
||||
// loginApi(data, {
|
||||
// onSuccess: async (result) => {
|
||||
|
||||
// // refetchUserId();
|
||||
// // await refetchUserData();
|
||||
|
||||
// await saveUserId(result.user.id.toString());
|
||||
// setUserId(result.user.id);
|
||||
|
||||
// await saveJWT(result.token);
|
||||
|
||||
// // Update login state in auth context
|
||||
// setIsLoggedIn(true);
|
||||
|
||||
// // Handle roles if available
|
||||
// if (result.user.roles) {
|
||||
// await saveRoles(result.user.roles);
|
||||
// await refreshRoles();
|
||||
// }
|
||||
// await refetchResponsibilities();
|
||||
|
||||
// // Clear the 'message' search param from the URL after login
|
||||
// router.replace({
|
||||
// pathname: "/(drawer)/dashboard",
|
||||
// params: {},
|
||||
// });
|
||||
// },
|
||||
// onError: (e: any) => {
|
||||
// // setError("login", {
|
||||
// // type: "manual",
|
||||
// // message: e.message || "Login failed",
|
||||
// // });
|
||||
// },
|
||||
// });
|
||||
// };
|
||||
|
||||
// React.useEffect(() => {
|
||||
// const subscription = DeviceEventEmitter.addListener(
|
||||
// FORCE_LOGOUT_EVENT,
|
||||
// () => {
|
||||
// logout({ isSessionExpired: true });
|
||||
// }
|
||||
// );
|
||||
|
||||
// return () => {
|
||||
// subscription.remove();
|
||||
// };
|
||||
// }, []);
|
||||
|
||||
|
||||
// return (
|
||||
// <AuthContext.Provider
|
||||
// value={{
|
||||
// isLoggedIn,
|
||||
// setIsLoggedIn,
|
||||
// logout,
|
||||
// responsibilities: responsibilities || defaultResponsibilities,
|
||||
// responsibilitiesLoading,
|
||||
// responsibilitiesError,
|
||||
// roles,
|
||||
// setRoles,
|
||||
// refreshRoles,
|
||||
// loginFunc,
|
||||
// userId,
|
||||
// isLoggingIn,
|
||||
// loginError: loginError?.message
|
||||
// }}
|
||||
// >
|
||||
// {children}
|
||||
// </AuthContext.Provider>
|
||||
// );
|
||||
// };
|
||||
|
||||
// export const useAuth = () => {
|
||||
// const context = useContext(AuthContext);
|
||||
// if (!context) {
|
||||
// throw new Error("useAuth must be used within an AuthProvider");
|
||||
// }
|
||||
// return context;
|
||||
// };
|
||||
|
||||
// /**
|
||||
// * Hook to check if the current user is a hospital admin
|
||||
// * @param hospitalId Hospital ID to check against
|
||||
// * @returns Boolean indicating if user is admin for that hospital
|
||||
// */
|
||||
// export const useIsHospitalAdmin = (hospitalId?: number | string): boolean => {
|
||||
// const { responsibilities } = useAuth();
|
||||
|
||||
// // If no hospitalId provided, return false
|
||||
// if (hospitalId === undefined) {
|
||||
// return false;
|
||||
// }
|
||||
|
||||
// // Check if user is admin for the specified hospital
|
||||
// return responsibilities?.hospitalAdminFor === hospitalId;
|
||||
// };
|
||||
|
||||
// /**
|
||||
// * Hook to check if the current user is a secretary for a specific doctor
|
||||
// * @param doctorId Doctor ID to check against
|
||||
// * @returns Boolean indicating if user is a secretary for that doctor
|
||||
// */
|
||||
// export const useIsDoctorSecretary = (doctorId?: number): boolean => {
|
||||
// const { responsibilities } = useAuth();
|
||||
|
||||
// // If no doctorId provided, return false
|
||||
// if (doctorId === undefined) {
|
||||
// return false;
|
||||
// }
|
||||
|
||||
// // Check if user is a secretary for the specified doctor
|
||||
// return responsibilities?.secretaryFor?.includes(doctorId) || false;
|
||||
// };
|
||||
43
apps/admin-ui/components/context/roles-context.tsx
Executable file
43
apps/admin-ui/components/context/roles-context.tsx
Executable file
|
|
@ -0,0 +1,43 @@
|
|||
import React, { createContext, useContext, useEffect, useState, ReactNode } from "react";
|
||||
import { AuthContext } from "./auth-context";
|
||||
import { ROLE_NAMES } from "common-ui";
|
||||
// import { getRoles, saveRoles, deleteRoles } from "../../hooks/useJWT";
|
||||
// import { ROLE_NAMES } from "../../lib/constants";
|
||||
|
||||
// interface RolesContextType {
|
||||
// roles: string[] | null;
|
||||
// setRoles: (roles: string[] | null) => void;
|
||||
// refreshRoles: () => Promise<void>;
|
||||
// }
|
||||
|
||||
// const RolesContext = createContext<RolesContextType | undefined>(undefined);
|
||||
|
||||
// export const RolesProvider = ({ children }: { children: ReactNode }) => {
|
||||
// const [roles, setRoles] = useState<string[] | null>(null);
|
||||
|
||||
// const refreshRoles = async () => {
|
||||
// const r = await getRoles();
|
||||
// setRoles(r);
|
||||
// };
|
||||
|
||||
// useEffect(() => {
|
||||
// refreshRoles();
|
||||
// }, []);
|
||||
|
||||
// return (
|
||||
// <RolesContext.Provider value={{ roles, setRoles, refreshRoles }}>
|
||||
// {children}
|
||||
// </RolesContext.Provider>
|
||||
// );
|
||||
// };
|
||||
|
||||
export const useRoles = () => {
|
||||
const ctx = useContext(AuthContext);
|
||||
if (!ctx) throw new Error("useRoles must be used within a RolesProvider");
|
||||
return ctx.roles;
|
||||
};
|
||||
|
||||
export const useIsAdmin = () => {
|
||||
const roles = useRoles();
|
||||
return roles?.includes(ROLE_NAMES.ADMIN);
|
||||
}
|
||||
|
|
@ -5,9 +5,11 @@ import { DeviceEventEmitter } from "react-native";
|
|||
import { FORCE_LOGOUT_EVENT } from "common-ui/src/lib/const-strs";
|
||||
import { trpc } from "@/src/trpc-client";
|
||||
import { saveJWT, getJWT, deleteJWT } from "@/hooks/useJWT";
|
||||
import type { IdName } from '@packages/shared';
|
||||
|
||||
type Staff = IdName;
|
||||
interface Staff {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface StaffAuthContextType {
|
||||
isLoggedIn: boolean;
|
||||
|
|
|
|||
115
apps/admin-ui/components/dashboard-header.tsx
Executable file
115
apps/admin-ui/components/dashboard-header.tsx
Executable file
|
|
@ -0,0 +1,115 @@
|
|||
import React from 'react';
|
||||
import { View, TouchableOpacity, Animated, Easing } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { MyText , tw , colors } from "common-ui";
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useThemeColor } from '@/hooks/useThemeColor';
|
||||
import { IconButton } from 'react-native-paper';
|
||||
|
||||
interface DashboardHeaderProps {
|
||||
onMenuPress: () => void;
|
||||
onNotificationsPress: () => void;
|
||||
onProfilePress: () => void;
|
||||
onRefreshPress?: () => void;
|
||||
refreshing?: boolean;
|
||||
}
|
||||
|
||||
const DashboardHeader: React.FC<DashboardHeaderProps> = ({
|
||||
onMenuPress,
|
||||
onNotificationsPress,
|
||||
onProfilePress,
|
||||
onRefreshPress,
|
||||
refreshing = false
|
||||
}) => {
|
||||
const accentColor = useThemeColor({ light: '#4f46e5', dark: '#818cf8' }, 'tint');
|
||||
|
||||
// For the refresh animation
|
||||
const spinAnim = React.useRef(new Animated.Value(0)).current;
|
||||
|
||||
// Update animation when refreshing prop changes
|
||||
React.useEffect(() => {
|
||||
let animation: Animated.CompositeAnimation | null = null;
|
||||
|
||||
if (refreshing) {
|
||||
animation = Animated.loop(
|
||||
Animated.timing(spinAnim, {
|
||||
toValue: 1,
|
||||
duration: 1000,
|
||||
easing: Easing.linear,
|
||||
useNativeDriver: true,
|
||||
})
|
||||
);
|
||||
animation.start();
|
||||
} else {
|
||||
spinAnim.stopAnimation();
|
||||
spinAnim.setValue(0);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (animation) {
|
||||
animation.stop();
|
||||
}
|
||||
};
|
||||
}, [refreshing]);
|
||||
|
||||
const spin = spinAnim.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: ['0deg', '360deg'],
|
||||
});
|
||||
|
||||
return (
|
||||
<SafeAreaView edges={['top']} style={tw`mb-2 bg-white dark:bg-gray-900`}>
|
||||
<View style={tw`flex-row justify-between items-center px-4 py-2`}>
|
||||
{/* Menu Button */}
|
||||
<TouchableOpacity
|
||||
style={tw`w-10 h-10 rounded-full bg-white dark:bg-gray-800 items-center justify-center shadow-sm`}
|
||||
onPress={onMenuPress}
|
||||
>
|
||||
<Ionicons name="menu" size={24} color={accentColor} />
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Logo/App Name */}
|
||||
<MyText style={tw`text-xl font-bold text-gray-800 dark:text-white`}>
|
||||
HealthPetal
|
||||
</MyText>
|
||||
|
||||
{/* Right Actions */}
|
||||
<View style={tw`flex-row`}>
|
||||
{/* Refresh Button */}
|
||||
{onRefreshPress && (
|
||||
<Animated.View style={{ transform: [{ rotate: spin }], marginRight: 8 }}>
|
||||
<IconButton
|
||||
icon="refresh"
|
||||
size={20}
|
||||
onPress={onRefreshPress}
|
||||
accessibilityLabel="Refresh"
|
||||
disabled={refreshing}
|
||||
iconColor={colors.blue1}
|
||||
/>
|
||||
</Animated.View>
|
||||
)}
|
||||
|
||||
{/* Notifications */}
|
||||
<TouchableOpacity
|
||||
style={tw`w-10 h-10 rounded-full bg-white dark:bg-gray-800 items-center justify-center shadow-sm mr-2 relative`}
|
||||
onPress={onNotificationsPress}
|
||||
>
|
||||
<Ionicons name="notifications" size={20} color="#6b7280" />
|
||||
{/* Notification Badge */}
|
||||
<View style={tw`absolute top-1 right-1 w-3 h-3 bg-red-500 rounded-full`}></View>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Profile */}
|
||||
<TouchableOpacity
|
||||
style={tw`w-10 h-10 rounded-full bg-white dark:bg-gray-800 items-center justify-center shadow-sm`}
|
||||
onPress={onProfilePress}
|
||||
>
|
||||
<Ionicons name="person" size={20} color="#6b7280" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardHeader;
|
||||
278
apps/admin-ui/components/date-time-picker.tsx
Executable file
278
apps/admin-ui/components/date-time-picker.tsx
Executable file
|
|
@ -0,0 +1,278 @@
|
|||
// import { useTheme } from "@/hooks/theme-context";
|
||||
import { MaterialCommunityIcons } from "@expo/vector-icons";
|
||||
import DateTimePicker, {
|
||||
AndroidNativeProps,
|
||||
DateTimePickerAndroid,
|
||||
DateTimePickerEvent,
|
||||
} from "@react-native-community/datetimepicker";
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
Modal,
|
||||
Platform,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
|
||||
import { useTheme, MyText } from "common-ui";
|
||||
|
||||
interface Props {
|
||||
value: Date | null;
|
||||
setValue: (date: Date | null) => void;
|
||||
showLabels?: boolean; // Optional prop to control label visibility
|
||||
timeOnly?: boolean; // Optional prop to show only time picker
|
||||
}
|
||||
|
||||
type Mode = "date" | "time" | "datetime";
|
||||
|
||||
function DateTimePickerMod(props: Props) {
|
||||
const { value, setValue, showLabels = true, timeOnly = false } = props;
|
||||
const [show, setShow] = useState<boolean>(false);
|
||||
const [mode, setMode] = useState<Mode>("date");
|
||||
|
||||
const onChange = (event: DateTimePickerEvent, selectedDate?: Date) => {
|
||||
const currentDate = selectedDate || value;
|
||||
if (Platform.OS === "ios") setShow(false);
|
||||
setValue(currentDate);
|
||||
};
|
||||
|
||||
const showMode = (currentMode: Mode) => {
|
||||
if (Platform.OS === "android") {
|
||||
DateTimePickerAndroid.open({
|
||||
value: value || new Date(),
|
||||
onChange: onChange,
|
||||
mode: currentMode,
|
||||
is24Hour: true,
|
||||
display: "default",
|
||||
} as AndroidNativeProps);
|
||||
} else {
|
||||
setShow(true);
|
||||
setMode(currentMode);
|
||||
}
|
||||
};
|
||||
const showDatepicker = () => {
|
||||
showMode("date");
|
||||
};
|
||||
|
||||
const showTimepicker = () => {
|
||||
showMode("time");
|
||||
};
|
||||
const { theme } = useTheme();
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{timeOnly ? (
|
||||
<TouchableOpacity onPress={showTimepicker}>
|
||||
{showLabels && <MyText>Select Time</MyText>}
|
||||
<View
|
||||
style={{
|
||||
borderColor: theme.colors.gray1,
|
||||
borderWidth: 0.5,
|
||||
borderRadius: 4,
|
||||
paddingVertical: 4,
|
||||
paddingHorizontal: 4,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
<Text style={[styles.timeText, { opacity: value ? 1 : 0.5 }]}>
|
||||
{value?.toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}) || "Select Time"}
|
||||
</Text>
|
||||
<MaterialCommunityIcons
|
||||
name="clock"
|
||||
size={24}
|
||||
color={theme.colors.gray1}
|
||||
/>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
<View
|
||||
style={{ width: "100%", flexDirection: "row", alignItems: "stretch" }}
|
||||
>
|
||||
<TouchableOpacity
|
||||
onPress={showDatepicker}
|
||||
style={{
|
||||
width: "50%",
|
||||
}}
|
||||
>
|
||||
{showLabels && <MyText>Select Date</MyText>}
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
borderColor: theme.colors.gray1,
|
||||
borderWidth: 0.5,
|
||||
borderRadius: 4,
|
||||
paddingVertical: 4,
|
||||
paddingHorizontal: 4,
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<Text style={[styles.dateText, { opacity: value ? 1 : 0.5 }]}>{value?.toLocaleDateString() || "Select Date"}</Text>
|
||||
<MaterialCommunityIcons
|
||||
name="calendar"
|
||||
size={24}
|
||||
color={theme.colors.gray1}
|
||||
/>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
<View style={styles.spacerHorizontalSmall} />
|
||||
<TouchableOpacity
|
||||
onPress={showTimepicker}
|
||||
style={{
|
||||
width: "50%",
|
||||
}}
|
||||
>
|
||||
{showLabels && <MyText>Select Time</MyText>}
|
||||
<View
|
||||
style={{
|
||||
borderColor: theme.colors.gray1,
|
||||
borderWidth: 0.5,
|
||||
borderRadius: 4,
|
||||
paddingVertical: 4,
|
||||
paddingHorizontal: 4,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
<Text style={[styles.timeText, { opacity: value ? 1 : 0.5 }]}>
|
||||
{value?.toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}) || "Select Time"}
|
||||
</Text>
|
||||
<MaterialCommunityIcons
|
||||
name="clock"
|
||||
size={24}
|
||||
color={theme.colors.gray1}
|
||||
/>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
{/* Conditional rendering for iOS, as it uses the declarative API */}
|
||||
{show && Platform.OS === "ios" && (
|
||||
<Modal
|
||||
transparent
|
||||
animationType="fade"
|
||||
visible={show}
|
||||
onRequestClose={() => setShow(false)}
|
||||
>
|
||||
<View style={styles.modalOverlay}>
|
||||
<View style={styles.pickerContainer}>
|
||||
<DateTimePicker
|
||||
testID="dateTimePicker"
|
||||
value={value || new Date()}
|
||||
mode={mode}
|
||||
is24Hour={true}
|
||||
display="default"
|
||||
onChange={onChange}
|
||||
/>
|
||||
<TouchableOpacity
|
||||
onPress={() => setShow(false)}
|
||||
style={styles.doneButton}
|
||||
>
|
||||
<Text style={styles.doneButtonText}>Done</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export default DateTimePickerMod;
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
// flex: 1, // Remove flex to avoid taking up extra space
|
||||
justifyContent: "center",
|
||||
alignItems: "flex-start",
|
||||
padding: 0, // Reduce padding for compactness
|
||||
marginBottom: 16, // Add margin for spacing in forms
|
||||
},
|
||||
iconRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
marginBottom: 20,
|
||||
},
|
||||
iconRowSingleLine: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
marginBottom: 0,
|
||||
},
|
||||
iconNoBg: {
|
||||
backgroundColor: "transparent",
|
||||
borderRadius: 0,
|
||||
padding: 0,
|
||||
elevation: 0,
|
||||
},
|
||||
spacerHorizontal: {
|
||||
width: 30,
|
||||
},
|
||||
spacerHorizontalSmall: {
|
||||
width: 8,
|
||||
},
|
||||
spacer: {
|
||||
height: 20, // Add some space between buttons
|
||||
},
|
||||
selectedText: {
|
||||
marginTop: 30,
|
||||
fontSize: 18,
|
||||
fontWeight: "bold",
|
||||
textAlign: "center",
|
||||
},
|
||||
timeTextContainer: {
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
timeText: {
|
||||
fontSize: 15,
|
||||
fontWeight: "500",
|
||||
color: "#333",
|
||||
marginLeft: 2,
|
||||
},
|
||||
dateText: {
|
||||
fontSize: 15,
|
||||
fontWeight: "500",
|
||||
color: "#333",
|
||||
marginLeft: 2,
|
||||
marginRight: 2,
|
||||
},
|
||||
|
||||
modalOverlay: {
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||
},
|
||||
pickerContainer: {
|
||||
width: "80%",
|
||||
backgroundColor: "white",
|
||||
borderRadius: 10,
|
||||
padding: 20,
|
||||
elevation: 5,
|
||||
},
|
||||
doneButton: {
|
||||
marginTop: 10,
|
||||
backgroundColor: "#007bff",
|
||||
borderRadius: 5,
|
||||
padding: 10,
|
||||
alignItems: "center",
|
||||
},
|
||||
doneButtonText: {
|
||||
color: "white",
|
||||
fontWeight: "bold",
|
||||
},
|
||||
});
|
||||
77
apps/admin-ui/components/day-account-view.tsx
Executable file
77
apps/admin-ui/components/day-account-view.tsx
Executable file
|
|
@ -0,0 +1,77 @@
|
|||
import React from 'react';
|
||||
import { View } from 'react-native';
|
||||
import { MyText , tw } from "common-ui";
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
|
||||
// Define the types
|
||||
export interface DoctorWiseCount {
|
||||
doctorName: string;
|
||||
doctorId: number;
|
||||
fee: number;
|
||||
issuedTokens: number;
|
||||
totalAmount: number;
|
||||
}
|
||||
|
||||
export interface DayAccountData {
|
||||
doctorWiseCount: DoctorWiseCount[];
|
||||
date: string;
|
||||
totalAmount: number;
|
||||
settled: boolean;
|
||||
}
|
||||
|
||||
interface DayAccountViewProps {
|
||||
dayData: DayAccountData;
|
||||
}
|
||||
|
||||
const DayAccountView: React.FC<DayAccountViewProps> = ({ dayData }) => {
|
||||
return (
|
||||
<View
|
||||
style={tw`mb-4 p-4 bg-white dark:bg-gray-800 rounded-xl shadow border border-gray-200 dark:border-gray-700`}
|
||||
>
|
||||
<View style={tw`flex-row justify-between items-center mb-3`}>
|
||||
<MyText style={tw`text-lg font-bold text-gray-800 dark:text-white`}>
|
||||
{dayData.date}
|
||||
</MyText>
|
||||
<View style={tw`flex-row items-center`}>
|
||||
<MyText style={tw`text-lg font-bold text-gray-800 dark:text-white mr-2`}>
|
||||
₹{dayData.totalAmount}
|
||||
</MyText>
|
||||
{dayData.settled ? (
|
||||
<View style={tw`flex-row items-center bg-green-100 dark:bg-green-900/30 px-2 py-1 rounded-full`}>
|
||||
<Ionicons name="checkmark-circle" size={16} color="#16a34a" />
|
||||
<MyText style={tw`text-green-700 dark:text-green-400 ml-1 text-xs`}>Settled</MyText>
|
||||
</View>
|
||||
) : (
|
||||
<View style={tw`flex-row items-center bg-yellow-100 dark:bg-yellow-900/30 px-2 py-1 rounded-full`}>
|
||||
<Ionicons name="alert-circle" size={16} color="#f59e0b" />
|
||||
<MyText style={tw`text-yellow-700 dark:text-yellow-400 ml-1 text-xs`}>Pending</MyText>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={tw`mt-3`}>
|
||||
{dayData.doctorWiseCount.map((doctor, idx) => (
|
||||
<View
|
||||
key={idx}
|
||||
style={tw`flex-row justify-between items-center p-3 bg-gray-50 dark:bg-gray-700/30 rounded-lg mb-2`}
|
||||
>
|
||||
<View>
|
||||
<MyText style={tw`font-medium text-gray-800 dark:text-white`}>
|
||||
{doctor.doctorName}
|
||||
</MyText>
|
||||
<MyText style={tw`text-xs text-gray-600 dark:text-gray-400`}>
|
||||
₹{doctor.fee} × {doctor.issuedTokens} tokens
|
||||
</MyText>
|
||||
</View>
|
||||
<MyText style={tw`font-bold text-gray-800 dark:text-white`}>
|
||||
₹{doctor.totalAmount}
|
||||
</MyText>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default DayAccountView;
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue