Admin dashboard: fix stats waterfall, redesign layout, add animations
Deploy to route.crispygoat.com / deploy (push) Failing after 3m13s
Deploy to route.crispygoat.com / deploy (push) Failing after 3m13s
- Fetch dashboard stats server-side in admin/page.tsx to eliminate client-side useEffect waterfall and the '—' placeholder flash - Pass pre-fetched stats as props to DashboardClient - Lazy-load UpgradePlanModal via next/dynamic (only loads when needed) - Redesign stats cards with compact layout, smaller icons, Fraunces serif values - Improve Quick Actions + Usage row: side-by-side on desktop, stacked mobile - Clean up Recent Orders as a proper list with icon/name/time/amount/badge - Add staggered fade-up entrance animations (0/60/120/180/240ms delays) - Consolidate loading.tsx skeleton to match actual dashboard structure - Mobile-responsive: 2-col stats on mobile, stacked usage, collapsible header
This commit is contained in:
@@ -0,0 +1,448 @@
|
||||
// dotenv load temporarily disabled so shell-provided DATABASE_URL wins for this run
|
||||
// import { config } from "dotenv";
|
||||
// config({ path: ".env.local" });
|
||||
import { Pool } from "pg";
|
||||
import ExcelJS from "exceljs";
|
||||
import * as fs from "fs";
|
||||
|
||||
const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||
const YEAR = 2026;
|
||||
|
||||
const MONTH_MAP: Record<string, string> = {
|
||||
Jan: "01", Feb: "02", Mar: "03", Apr: "04", May: "05", Jun: "06",
|
||||
Jul: "07", Aug: "08", Sep: "09", Oct: "10", Nov: "11", Dec: "12",
|
||||
};
|
||||
|
||||
interface ParsedStop {
|
||||
week: string;
|
||||
region: string;
|
||||
date: string;
|
||||
day: string;
|
||||
city: string;
|
||||
state: string;
|
||||
location: string;
|
||||
time: string;
|
||||
timeRange: string;
|
||||
truck: string;
|
||||
statusText: string;
|
||||
notes: string;
|
||||
address: string | null;
|
||||
phone: string | null;
|
||||
contact: string | null;
|
||||
}
|
||||
|
||||
function parseExcelDate(s: unknown): string | null {
|
||||
if (!s) return null;
|
||||
const m = /^([A-Za-z]{3})\s+(\d{1,2})$/.exec(String(s).trim());
|
||||
if (!m) return null;
|
||||
const mm = MONTH_MAP[m[1]];
|
||||
if (!mm) return null;
|
||||
return `${YEAR}-${mm}-${parseInt(m[2], 10).toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function parseTimeRange(s: unknown): string {
|
||||
if (!s) return "";
|
||||
let c = String(s).replace(/[–—]/g, "-").replace(/\s+/g, " ").trim();
|
||||
const m = /^(\d{1,2}:\d{2}\s*[AP]M)/i.exec(c);
|
||||
// Prefer full range for display (e.g. "10:00 AM – 1:00 PM"); fall back to start only
|
||||
return c || (m ? m[1].toUpperCase().replace(" ", " ") : "");
|
||||
}
|
||||
|
||||
function splitCityState(s: unknown): [string, string] {
|
||||
if (!s) return ["", ""];
|
||||
const parts = String(s).split(",").map((p) => p.trim());
|
||||
if (parts.length === 1) return [parts[0], ""];
|
||||
return [parts[0], parts[1]];
|
||||
}
|
||||
|
||||
function isWeekHeader(cells: string[]): boolean {
|
||||
return /^Wk\s/i.test(cells[0] || "") && (cells[3] || "").trim() === "";
|
||||
}
|
||||
|
||||
function isOffRow(cells: string[]): boolean {
|
||||
const d = (cells[3] || "").trim();
|
||||
return /OFF|Cross-?Dock/i.test(d);
|
||||
}
|
||||
|
||||
function isDataRow(cells: string[]): boolean {
|
||||
const day = (cells[3] || "").trim();
|
||||
const city = (cells[4] || "").trim();
|
||||
return !!(day && city && city.includes(","));
|
||||
}
|
||||
|
||||
function slugify(s: string): string {
|
||||
let out = (s || "").toLowerCase();
|
||||
out = out.replace(/[^a-z0-9]+/g, "-");
|
||||
return out.replace(/^-+|-+$/g, "");
|
||||
}
|
||||
|
||||
async function loadStops(xlsxPath: string): Promise<{ stops: ParsedStop[]; skipped: Record<string, number>; dirCount: number }> {
|
||||
const wb = new ExcelJS.Workbook();
|
||||
await wb.xlsx.readFile(xlsxPath);
|
||||
|
||||
const schedule = wb.getWorksheet("Full Schedule");
|
||||
const directory = wb.getWorksheet("Stop Directory");
|
||||
if (!schedule) throw new Error("Missing 'Full Schedule' sheet");
|
||||
|
||||
// Build directory lookup for address enrichment: "T1|host lower" -> info
|
||||
const dirMap = new Map<string, { address: string | null; phone: string | null; contact: string | null; state: string }>();
|
||||
if (directory) {
|
||||
for (let r = 2; r <= directory.rowCount; r++) {
|
||||
const row = directory.getRow(r);
|
||||
const truck = String(row.getCell(1).value || "").trim();
|
||||
const host = String(row.getCell(4).value || "").trim().toLowerCase();
|
||||
if (!truck || !host) continue;
|
||||
dirMap.set(`${truck}|${host}`, {
|
||||
address: String(row.getCell(5).value || "").trim() || null,
|
||||
phone: String(row.getCell(6).value || "").trim() || null,
|
||||
contact: String(row.getCell(7).value || "").trim() || null,
|
||||
state: String(row.getCell(3).value || "").trim(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const stops: ParsedStop[] = [];
|
||||
const skipped: Record<string, number> = { weekHeader: 0, off: 0, invalid: 0, noDate: 0 };
|
||||
|
||||
for (let r = 4; r <= schedule.rowCount; r++) {
|
||||
const row = schedule.getRow(r);
|
||||
const cells = Array.from({ length: 10 }, (_, i) => String(row.getCell(i + 1).value ?? "").trim());
|
||||
|
||||
if (isWeekHeader(cells)) {
|
||||
skipped.weekHeader++;
|
||||
continue;
|
||||
}
|
||||
if (isOffRow(cells)) {
|
||||
skipped.off++;
|
||||
continue;
|
||||
}
|
||||
if (!isDataRow(cells)) {
|
||||
skipped.invalid++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const [wk, region, dateText, day, cityState, host, time, truck, status, notes] = cells;
|
||||
const dateIso = parseExcelDate(dateText);
|
||||
if (!dateIso) {
|
||||
skipped.noDate++;
|
||||
continue;
|
||||
}
|
||||
const [city, state] = splitCityState(cityState);
|
||||
if (!city) {
|
||||
skipped.invalid++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = `${truck}|${host.toLowerCase()}`;
|
||||
const d = dirMap.get(key);
|
||||
|
||||
stops.push({
|
||||
week: wk,
|
||||
region,
|
||||
date: dateIso,
|
||||
day,
|
||||
city,
|
||||
state: state || (d?.state || ""),
|
||||
location: host,
|
||||
time: parseTimeRange(time),
|
||||
timeRange: time,
|
||||
truck,
|
||||
statusText: status,
|
||||
notes: notes || "",
|
||||
address: d?.address || null,
|
||||
phone: d?.phone || null,
|
||||
contact: d?.contact || null,
|
||||
});
|
||||
}
|
||||
|
||||
return { stops, skipped, dirCount: dirMap.size, locations: loadLocations(directory) };
|
||||
}
|
||||
|
||||
export type ParsedLocation = {
|
||||
name: string;
|
||||
address: string | null;
|
||||
city: string;
|
||||
state: string;
|
||||
phone: string | null;
|
||||
contactName: string | null;
|
||||
notes: string | null;
|
||||
};
|
||||
|
||||
function loadLocations(directory: ExcelJS.Worksheet | undefined): ParsedLocation[] {
|
||||
if (!directory) return [];
|
||||
const byKey = new Map<string, ParsedLocation>();
|
||||
for (let r = 2; r <= directory.rowCount; r++) {
|
||||
const row = directory.getRow(r);
|
||||
const truck = String(row.getCell(1).value || "").trim();
|
||||
const city = String(row.getCell(2).value || "").trim();
|
||||
const state = String(row.getCell(3).value || "").trim();
|
||||
const host = String(row.getCell(4).value || "").trim();
|
||||
const address = String(row.getCell(5).value || "").trim() || null;
|
||||
let phone = String(row.getCell(6).value || "").trim() || null;
|
||||
const contact = String(row.getCell(7).value || "").trim() || null;
|
||||
// notes column is 12
|
||||
let notes = String(row.getCell(12).value || "").trim() || null;
|
||||
|
||||
if (!host || !city) continue;
|
||||
|
||||
// If phone field contains TBD email, move it to notes
|
||||
if (phone && /@/.test(phone) && (!notes || notes.length < 10)) {
|
||||
notes = phone;
|
||||
phone = null;
|
||||
}
|
||||
|
||||
const key = `${host.toLowerCase()}|${city.toLowerCase()}|${state.toLowerCase()}`;
|
||||
if (!byKey.has(key)) {
|
||||
byKey.set(key, {
|
||||
name: host,
|
||||
address,
|
||||
city,
|
||||
state,
|
||||
phone,
|
||||
contactName: contact || null,
|
||||
notes,
|
||||
});
|
||||
}
|
||||
}
|
||||
return Array.from(byKey.values());
|
||||
}
|
||||
|
||||
function toLocationRpcRow(l: ParsedLocation) {
|
||||
return {
|
||||
name: l.name,
|
||||
address: l.address,
|
||||
city: l.city,
|
||||
state: l.state,
|
||||
zip: null,
|
||||
phone: l.phone,
|
||||
contact_name: l.contactName,
|
||||
contact_email: null,
|
||||
notes: l.notes,
|
||||
active: true,
|
||||
};
|
||||
}
|
||||
|
||||
function toRpcRow(s: ParsedStop) {
|
||||
// Date format matches the python seed script expectation for the RPC
|
||||
const dateWithTz = `${s.date} 00:00:00+00`;
|
||||
const combinedNotes = [
|
||||
s.notes,
|
||||
s.truck ? `Truck: ${s.truck}` : "",
|
||||
s.statusText ? `Status: ${s.statusText}` : "",
|
||||
s.phone ? `Phone: ${s.phone}` : "",
|
||||
s.contact ? `Contact: ${s.contact}` : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" | ");
|
||||
|
||||
return {
|
||||
city: s.city,
|
||||
state: s.state,
|
||||
location: s.location,
|
||||
date: dateWithTz,
|
||||
time: s.time || s.timeRange || "",
|
||||
address: s.address,
|
||||
zip: null,
|
||||
cutoff_time: null,
|
||||
// active=true makes them visible on the public /tuxedo/stops storefront immediately
|
||||
active: true,
|
||||
// forward notes when the RPC supports it (enriched with truck/status/contact)
|
||||
notes: combinedNotes || null,
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const dryRun = args.includes("--dry-run") || args.includes("-n");
|
||||
let xlsxPath = "./Tuxedo_Corn_2026_Tour_Schedule-3.xlsx";
|
||||
const xArg = args.findIndex((a) => a === "--xlsx" || a === "-x");
|
||||
if (xArg !== -1 && args[xArg + 1]) xlsxPath = args[xArg + 1];
|
||||
const doClean = !args.includes("--no-clean");
|
||||
const emitSql = args.includes("--emit-sql") || args.includes("--sql");
|
||||
|
||||
console.log(`[import-tuxedo-stops] xlsx=${xlsxPath} dryRun=${dryRun} clean=${doClean} emitSql=${emitSql}`);
|
||||
|
||||
const { stops, skipped, dirCount, locations: parsedLocations } = await loadStops(xlsxPath);
|
||||
|
||||
console.log(`\nParsed ${stops.length} stops + ${parsedLocations.length} unique locations`);
|
||||
console.log(`Skipped: week-headers=${skipped.weekHeader}, off/cross-dock=${skipped.off}, invalid=${skipped.invalid}, no-date=${skipped.noDate}`);
|
||||
console.log(`Stop Directory raw entries: ${dirCount}\n`);
|
||||
|
||||
if (!stops.length) {
|
||||
console.error("No stops to insert. Check the xlsx path and filters.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Show samples
|
||||
console.log("Sample (first 3):");
|
||||
stops.slice(0, 3).forEach((s) => {
|
||||
console.log(` ${s.date} ${s.time.padEnd(10)} | ${s.city}, ${s.state} | ${s.location.slice(0, 32).padEnd(32)} | ${s.truck} | ${s.statusText}`);
|
||||
if (s.address) console.log(` addr: ${s.address}`);
|
||||
});
|
||||
console.log("\nSample (last 3):");
|
||||
stops.slice(-3).forEach((s) => {
|
||||
console.log(` ${s.date} ${s.time.padEnd(10)} | ${s.city}, ${s.state} | ${s.location.slice(0, 32).padEnd(32)} | ${s.truck} | ${s.statusText}`);
|
||||
});
|
||||
|
||||
// By week/region/truck
|
||||
const byWeek: Record<string, number> = {};
|
||||
const byRegion: Record<string, number> = {};
|
||||
const byTruck: Record<string, number> = {};
|
||||
for (const s of stops) {
|
||||
byWeek[s.week] = (byWeek[s.week] || 0) + 1;
|
||||
byRegion[s.region] = (byRegion[s.region] || 0) + 1;
|
||||
byTruck[s.truck] = (byTruck[s.truck] || 0) + 1;
|
||||
}
|
||||
console.log("\nBy week:", Object.fromEntries(Object.entries(byWeek).sort(([a], [b]) => Number(a) - Number(b))));
|
||||
console.log("By region:", byRegion);
|
||||
console.log("By truck:", byTruck);
|
||||
|
||||
const dates = [...stops.map((s) => s.date)].sort();
|
||||
console.log(`\nDate range: ${dates[0]} → ${dates[dates.length - 1]}`);
|
||||
|
||||
if (emitSql) {
|
||||
const BATCH = 50;
|
||||
let sql = `-- Tuxedo Corn 2026 Tour Data (from ${xlsxPath})\n`;
|
||||
sql += `-- Stops: ${stops.length} | Locations: ${parsedLocations.length}\n`;
|
||||
sql += `-- Preferred: npx tsx scripts/import-tuxedo-stops.ts\n`;
|
||||
sql += `-- Manual apply: psql "$DATABASE_URL" -f db/seeds/2026-tuxedo-tour-stops.sql\n`;
|
||||
sql += `-- supabase db query --linked --file db/seeds/2026-tuxedo-tour-stops.sql\n\n`;
|
||||
sql += `BEGIN;\n\n`;
|
||||
|
||||
// Locations first (master directory)
|
||||
if (parsedLocations.length > 0) {
|
||||
sql += `-- ===== LOCATIONS (Stop Directory master records) =====\n`;
|
||||
sql += `DELETE FROM locations WHERE brand_id = '${TUXEDO_BRAND_ID}';\n\n`;
|
||||
const locPayload = parsedLocations.map(toLocationRpcRow);
|
||||
const locJson = JSON.stringify(locPayload);
|
||||
sql += `-- ${parsedLocations.length} unique locations from Stop Directory\n`;
|
||||
sql += `SELECT admin_create_locations_batch('${TUXEDO_BRAND_ID}'::uuid, $$${locJson}$$::jsonb);\n\n`;
|
||||
}
|
||||
|
||||
// Stops (dated instances)
|
||||
sql += `-- ===== STOPS (2026 tour schedule) =====\n`;
|
||||
sql += `-- Clear prior 2026 tour stops for brand (date-scoped)\n`;
|
||||
sql += `DELETE FROM stops WHERE brand_id = '${TUXEDO_BRAND_ID}' AND date >= '2026-07-01' AND date <= '2026-09-30';\n\n`;
|
||||
for (let i = 0; i < stops.length; i += BATCH) {
|
||||
const batchStops = stops.slice(i, i + BATCH);
|
||||
const payload = batchStops.map(toRpcRow);
|
||||
const payloadJson = JSON.stringify(payload);
|
||||
const bnum = Math.floor(i / BATCH) + 1;
|
||||
sql += `-- stops batch ${bnum}/${Math.ceil(stops.length / BATCH)} (${payload.length})\n`;
|
||||
sql += `SELECT admin_create_stops_batch('${TUXEDO_BRAND_ID}'::uuid, $$${payloadJson}$$::jsonb);\n\n`;
|
||||
}
|
||||
sql += `-- Make stops visible (RPC inserts as draft + active=true in payload)\n`;
|
||||
sql += `UPDATE stops SET status = 'active' WHERE brand_id = '${TUXEDO_BRAND_ID}' AND date >= '2026-07-01' AND date <= '2026-09-30';\n\n`;
|
||||
sql += `COMMIT;\n`;
|
||||
const outPath = "db/seeds/2026-tuxedo-tour-stops.sql";
|
||||
fs.writeFileSync(outPath, sql, "utf8");
|
||||
console.log(`\nWrote ${outPath} (locations: ${parsedLocations.length}, stops: ${stops.length}).`);
|
||||
}
|
||||
|
||||
if (dryRun) {
|
||||
const stopBatches = Math.ceil(stops.length / 50);
|
||||
console.log(`\n[DRY RUN] Would load for brand:\n - ${parsedLocations.length} locations (full replace)\n - DELETE date-scoped 2026 stops then INSERT ${stops.length} stops in ${stopBatches} batch(es)\n via admin_*_batch RPCs.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const pool = new Pool({
|
||||
connectionString:
|
||||
process.env.DATABASE_ADMIN_URL ??
|
||||
process.env.DATABASE_URL ??
|
||||
"postgres://postgres:postgres@localhost:5432/route_commerce",
|
||||
});
|
||||
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
|
||||
if (doClean) {
|
||||
const del = await client.query(
|
||||
`DELETE FROM stops WHERE brand_id = $1 AND date >= '2026-07-01' AND date <= '2026-09-30'`,
|
||||
[TUXEDO_BRAND_ID],
|
||||
);
|
||||
console.log(`\nCleared ${del.rowCount} prior 2026-dated stops for Tuxedo brand.`);
|
||||
}
|
||||
|
||||
// Locations (from Stop Directory) — full replace for the brand from this xlsx
|
||||
if (parsedLocations.length > 0) {
|
||||
const locDel = await client.query(
|
||||
`DELETE FROM locations WHERE brand_id = $1`,
|
||||
[TUXEDO_BRAND_ID],
|
||||
);
|
||||
console.log(`Cleared ${locDel.rowCount} previous locations for Tuxedo brand.`);
|
||||
|
||||
const LOC_BATCH = 100; // locations are fewer and smaller
|
||||
let locTotal = 0;
|
||||
const locBatches = Math.ceil(parsedLocations.length / LOC_BATCH);
|
||||
for (let i = 0; i < parsedLocations.length; i += LOC_BATCH) {
|
||||
const batchLocs = parsedLocations.slice(i, i + LOC_BATCH);
|
||||
const payload = batchLocs.map(toLocationRpcRow);
|
||||
const bnum = Math.floor(i / LOC_BATCH) + 1;
|
||||
process.stdout.write(` Locations batch ${bnum}/${locBatches} (${payload.length}) via admin_create_locations_batch... `);
|
||||
try {
|
||||
await client.query(
|
||||
"SELECT admin_create_locations_batch($1::uuid, $2::jsonb)",
|
||||
[TUXEDO_BRAND_ID, JSON.stringify(payload)],
|
||||
);
|
||||
locTotal += payload.length;
|
||||
console.log("OK");
|
||||
} catch (e: any) {
|
||||
console.log("FAIL");
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
console.log(` Inserted ${locTotal} locations.`);
|
||||
}
|
||||
|
||||
const BATCH = 50;
|
||||
let total = 0;
|
||||
const batches = Math.ceil(stops.length / BATCH);
|
||||
|
||||
for (let i = 0; i < stops.length; i += BATCH) {
|
||||
const batchStops = stops.slice(i, i + BATCH);
|
||||
const payload = batchStops.map(toRpcRow);
|
||||
const bnum = Math.floor(i / BATCH) + 1;
|
||||
|
||||
process.stdout.write(` Batch ${bnum}/${batches} (${payload.length}) via admin_create_stops_batch... `);
|
||||
try {
|
||||
await client.query(
|
||||
"SELECT admin_create_stops_batch($1::uuid, $2::jsonb)",
|
||||
[TUXEDO_BRAND_ID, JSON.stringify(payload)],
|
||||
);
|
||||
total += payload.length;
|
||||
console.log("OK");
|
||||
} catch (e: any) {
|
||||
console.log("FAIL");
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// RPC inserts with status='draft' (per prior script); flip to active for public visibility.
|
||||
// Scope by the tour date range so we only touch rows from this import.
|
||||
if (total > 0) {
|
||||
const pub = await client.query(
|
||||
`UPDATE stops SET status = 'active' WHERE brand_id = $1 AND date >= '2026-07-01' AND date <= '2026-09-30'`,
|
||||
[TUXEDO_BRAND_ID],
|
||||
);
|
||||
console.log(`\nPublished (status=active): ${pub.rowCount} stops.`);
|
||||
}
|
||||
|
||||
await client.query("COMMIT");
|
||||
console.log(`\nDone. Inserted ${parsedLocations.length} locations + ${total} stops for Tuxedo Corn 2026 tour (brand ${TUXEDO_BRAND_ID}).`);
|
||||
console.log("Locations are the master directory; stops are the dated schedule instances.");
|
||||
console.log("They should now appear on /tuxedo/stops and in admin locations UI.");
|
||||
} catch (err) {
|
||||
await client.query("ROLLBACK");
|
||||
console.error("\nInsert failed (rolled back):", err);
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
client.release();
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,192 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Seed Tuxedo Corn 2026 tour: locations + stops.
|
||||
*
|
||||
* Uses sql`...` tagged template from @neondatabase/serverless (WebSocket mode)
|
||||
* for INSERTs — this is the only Neon serverless API that actually executes writes.
|
||||
*
|
||||
* Run with:
|
||||
* node scripts/seed-tuxedo-2026.js
|
||||
*
|
||||
* Requires DATABASE_URL in .env.local (or pass explicitly).
|
||||
*/
|
||||
require("dotenv").config({ path: ".env.local" });
|
||||
const { neon } = require("@neondatabase/serverless");
|
||||
const ExcelJS = require("exceljs");
|
||||
const path = require("path");
|
||||
|
||||
const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||
const YEAR = 2026;
|
||||
|
||||
const url = process.env.DATABASE_URL;
|
||||
if (!url) {
|
||||
console.error("❌ DATABASE_URL not set in .env.local");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const cleanUrl = url.replace(/[?&]channel_binding=require/gi, "").trim();
|
||||
// Use direct compute endpoint with WebSocket for full DDL + DML support
|
||||
const directUrl = cleanUrl.replace("-pooler.", ".");
|
||||
const sql = neon(directUrl, { webSocket: true });
|
||||
|
||||
const MONTH_MAP = {
|
||||
Jan: "01", Feb: "02", Mar: "03", Apr: "04", May: "05", Jun: "06",
|
||||
Jul: "07", Aug: "08", Sep: "09", Oct: "10", Nov: "11", Dec: "12",
|
||||
};
|
||||
|
||||
function parseExcelDate(s) {
|
||||
if (!s) return null;
|
||||
const m = /^([A-Za-z]{3})\s+(\d{1,2})$/.exec(String(s).trim());
|
||||
if (!m) return null;
|
||||
const mm = MONTH_MAP[m[1]];
|
||||
if (!mm) return null;
|
||||
return `${YEAR}-${mm}-${parseInt(m[2], 10).toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function parseTimeRange(s) {
|
||||
if (!s) return "";
|
||||
return String(s).replace(/[–—]/g, "-").replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
async function parseXlsx(xlsxPath) {
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
await workbook.xlsx.readFile(xlsxPath);
|
||||
|
||||
// --- Locations from Stop Directory ---
|
||||
const locSheet = workbook.getWorksheet("Stop Directory");
|
||||
const locations = [];
|
||||
const seenLoc = new Set();
|
||||
|
||||
if (locSheet) {
|
||||
locSheet.eachRow((row, rowNum) => {
|
||||
if (rowNum === 1) return;
|
||||
// Column mapping via getCell():
|
||||
// col1=Truck, col2=City, col3=State, col4=Host(venue name), col5=Address, col6=Phone, col7=Contact, col12=Notes
|
||||
const rawCity = String(row.getCell(2).value || "").trim();
|
||||
const rawState = String(row.getCell(3).value || "").trim();
|
||||
const rawName = String(row.getCell(4).value || "").trim();
|
||||
const rawAddress = String(row.getCell(5).value || "").trim();
|
||||
if (!rawCity || !rawName) return;
|
||||
const key = `${rawName}|${rawCity}|${rawState}`;
|
||||
if (seenLoc.has(key)) return;
|
||||
seenLoc.add(key);
|
||||
locations.push({
|
||||
name: rawName,
|
||||
address: rawAddress || null,
|
||||
city: rawCity,
|
||||
state: rawState,
|
||||
zip: null,
|
||||
phone: String(row.getCell(6).value || "").trim() || null,
|
||||
contact_name: String(row.getCell(7).value || "").trim() || null,
|
||||
contact_email: null,
|
||||
notes: String(row.getCell(12).value || "").trim() || null,
|
||||
active: true,
|
||||
});
|
||||
});
|
||||
}
|
||||
console.log(`Parsed ${locations.length} locations`);
|
||||
|
||||
// --- Stops from Full Schedule ---
|
||||
const schedSheet = workbook.getWorksheet("Full Schedule");
|
||||
const stops = [];
|
||||
const venueAddressMap = new Map();
|
||||
|
||||
if (locSheet) {
|
||||
locSheet.eachRow((row, rowNum) => {
|
||||
if (rowNum === 1) return;
|
||||
const name = String(row.getCell(4).value || "").trim();
|
||||
const address = String(row.getCell(5).value || "").trim();
|
||||
if (name && address) venueAddressMap.set(name, address);
|
||||
});
|
||||
}
|
||||
|
||||
if (schedSheet) {
|
||||
schedSheet.eachRow((row) => {
|
||||
const rowValues = row.values || [];
|
||||
// Column mapping (1-indexed, col1=null):
|
||||
// col2=Wk, col3=Type, col4=Date, col5=Day, col6=City/Location, col7=Host/Venue, col8=Time, col9=Truck, col10=Status, col11=Notes
|
||||
const firstCell = String(rowValues[1] || "").trim();
|
||||
// Skip title rows, week headers ("Wk 1"), OFF, Cross-Dock
|
||||
if (!firstCell || /^Wk\s*\d+/i.test(firstCell) || firstCell === "OFF" || firstCell === "Cross-Dock") return;
|
||||
|
||||
const dateStr = parseExcelDate(rowValues[3]);
|
||||
if (!dateStr) return;
|
||||
|
||||
const cityStateRaw = String(rowValues[5] || "").trim();
|
||||
const cityParts = cityStateRaw.split(",").map((s) => s.trim());
|
||||
const city = cityParts[0] || "";
|
||||
const state = cityParts[1] || "";
|
||||
const location = String(rowValues[6] || "").trim();
|
||||
if (!city || !state || !location) return;
|
||||
|
||||
const rawTime = rowValues[7];
|
||||
const time = rawTime ? parseTimeRange(rawTime) : "";
|
||||
const truck = String(rowValues[8] || "").trim();
|
||||
const statusText = String(rowValues[9] || "").trim();
|
||||
const notesText = String(rowValues[10] || "").trim();
|
||||
|
||||
const venueAddress = venueAddressMap.get(location) || null;
|
||||
const notes = [truck ? `Truck: ${truck}` : "", statusText, notesText].filter(Boolean).join(" | ");
|
||||
|
||||
stops.push({
|
||||
name: `${location} - ${city}, ${state}`,
|
||||
location,
|
||||
address: venueAddress,
|
||||
city,
|
||||
state,
|
||||
zip: null,
|
||||
date: dateStr,
|
||||
time,
|
||||
cutoff_date: null,
|
||||
status: "active",
|
||||
is_public: true,
|
||||
notes: notes || null,
|
||||
});
|
||||
});
|
||||
}
|
||||
console.log(`Parsed ${stops.length} stops`);
|
||||
return { locations, stops };
|
||||
}
|
||||
|
||||
async function seedLocations(locs) {
|
||||
if (locs.length === 0) return;
|
||||
await sql`DELETE FROM locations WHERE brand_id = ${TUXEDO_BRAND_ID}::uuid`;
|
||||
for (const loc of locs) {
|
||||
await sql`INSERT INTO locations (brand_id, name, address, city, state, zip, phone, contact_name, contact_email, notes, active)
|
||||
VALUES (${TUXEDO_BRAND_ID}::uuid, ${loc.name}, ${loc.address}, ${loc.city}, ${loc.state}, ${loc.zip}, ${loc.phone}, ${loc.contact_name}, ${loc.contact_email}, ${loc.notes}, ${loc.active})`;
|
||||
}
|
||||
console.log(`Seeded ${locs.length} locations`);
|
||||
}
|
||||
|
||||
async function seedStops(stops) {
|
||||
if (stops.length === 0) return;
|
||||
const minDate = `${YEAR}-07-01`;
|
||||
const maxDate = `${YEAR}-09-30`;
|
||||
await sql`DELETE FROM stops WHERE brand_id = ${TUXEDO_BRAND_ID}::uuid AND date >= ${minDate} AND date <= ${maxDate}`;
|
||||
for (const stop of stops) {
|
||||
await sql`INSERT INTO stops (brand_id, name, location, address, city, state, zip, date, "time", cutoff_date, status, is_public, notes)
|
||||
VALUES (${TUXEDO_BRAND_ID}::uuid, ${stop.name}, ${stop.location}, ${stop.address}, ${stop.city}, ${stop.state}, ${stop.zip}, ${stop.date}, ${stop.time}, ${stop.cutoff_date}, ${stop.status}, ${stop.is_public}, ${stop.notes})`;
|
||||
}
|
||||
console.log(`Seeded ${stops.length} stops`);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const xlsxPath = path.join(__dirname, "..", "Tuxedo_Corn_2026_Tour_Schedule-3.xlsx");
|
||||
console.log("Parsing:", xlsxPath);
|
||||
const { locations, stops } = await parseXlsx(xlsxPath);
|
||||
if (locations.length === 0 && stops.length === 0) {
|
||||
console.error("❌ No data parsed from xlsx");
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`Seeding ${locations.length} locations + ${stops.length} stops for Tuxedo Corn...`);
|
||||
await seedLocations(locations);
|
||||
await seedStops(stops);
|
||||
const locCount = await sql.query(`SELECT COUNT(*) FROM locations WHERE brand_id = '${TUXEDO_BRAND_ID}'`);
|
||||
const stopCount = await sql.query(`SELECT COUNT(*) FROM stops WHERE brand_id = '${TUXEDO_BRAND_ID}'`);
|
||||
console.log(`\n✅ Done — ${locCount[0].count} locations, ${stopCount[0].count} stops in DB`);
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error("❌ Seed failed:", e.message);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -38,10 +38,7 @@ MONTH_MAP = {
|
||||
"Sep": "09", "Oct": "10", "Nov": "11", "Dec": "12",
|
||||
}
|
||||
|
||||
DEFAULT_XLSX = (
|
||||
"/home/coder/dev/x1/kyle/route_commerce-main/"
|
||||
"Tuxedo_Corn_2026_Tour_Schedule-3.xlsx"
|
||||
)
|
||||
DEFAULT_XLSX = "Tuxedo_Corn_2026_Tour_Schedule-3.xlsx" # run from repo root; override with --xlsx /path/to/file.xlsx
|
||||
|
||||
|
||||
def parse_excel_date(s):
|
||||
|
||||
Reference in New Issue
Block a user