fix: react-doctor dialog/a11y/labels → 68/100 with 973 warnings
This commit is contained in:
@@ -13,6 +13,8 @@ const ENTITY_LABELS: Record<string, string> = {
|
||||
unknown: "Unknown",
|
||||
};
|
||||
|
||||
const analysisLabels = ["Reading your file...", "AI is mapping columns...", "Cleaning and normalizing data...", "Finalizing preview..."];
|
||||
|
||||
const ALL_FIELDS = ["ignore", "product_name", "price", "description", "product_type", "active", "image_url",
|
||||
"customer_name", "customer_email", "customer_phone", "stop_id", "quantity", "fulfillment", "product_id",
|
||||
"first_name", "last_name", "full_name", "tags", "email_opt_in", "sms_opt_in", "external_id",
|
||||
@@ -134,7 +136,6 @@ export default function ImportCenterClient({ brandId: initialBrandId, brandName:
|
||||
}
|
||||
|
||||
const stepIndex = ["upload", "analysis", "preview", "import"].indexOf(step);
|
||||
const analysisLabels = ["Reading your file...", "AI is mapping columns...", "Cleaning and normalizing data...", "Finalizing preview..."];
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-2xl border border-[var(--admin-border)] overflow-hidden">
|
||||
|
||||
+10
-9
@@ -9,6 +9,16 @@ import { pool } from "@/lib/db";
|
||||
|
||||
const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||
|
||||
const featureKeys = [
|
||||
"harvest_reach",
|
||||
"wholesale_portal",
|
||||
"water_log",
|
||||
"ai_tools",
|
||||
"sms_campaigns",
|
||||
"square_sync",
|
||||
"route_trace",
|
||||
] as const;
|
||||
|
||||
export default async function AdminPage() {
|
||||
const adminUser = await getAdminUser();
|
||||
const isStoreEmployee = adminUser?.role === "store_employee";
|
||||
@@ -62,15 +72,6 @@ export default async function AdminPage() {
|
||||
enabledAddons = o.enabledAddons;
|
||||
} else {
|
||||
// Fallback to per-feature flag check (matches prior behavior)
|
||||
const featureKeys = [
|
||||
"harvest_reach",
|
||||
"wholesale_portal",
|
||||
"water_log",
|
||||
"ai_tools",
|
||||
"sms_campaigns",
|
||||
"square_sync",
|
||||
"route_trace",
|
||||
] as const;
|
||||
const featureFlags = await Promise.all(
|
||||
featureKeys.map((key) => isFeatureEnabled(dashboardBrandId, key)),
|
||||
);
|
||||
|
||||
@@ -22,6 +22,37 @@ type ProductRow = {
|
||||
available_until: string | null;
|
||||
};
|
||||
|
||||
const baseQuery = (db: Parameters<Parameters<typeof withBrand>[1]>[0]) =>
|
||||
db
|
||||
.select({
|
||||
id: products.id,
|
||||
name: products.name,
|
||||
description: products.description,
|
||||
priceCents: products.priceCents,
|
||||
active: products.active,
|
||||
brandId: products.brandId,
|
||||
brandName: brands.name,
|
||||
firstImageKey: productImages.storageKey,
|
||||
firstImagePosition: productImages.position,
|
||||
})
|
||||
.from(products)
|
||||
.leftJoin(brands, eq(brands.id, products.brandId))
|
||||
// Pull only the lowest-position image per product. The
|
||||
// `position` index on product_images (product_id, position) makes
|
||||
// this cheap; a real-world scale would replace it with a
|
||||
// DISTINCT ON subquery, but for the current catalog size this
|
||||
// join is fine and avoids a second round-trip.
|
||||
.leftJoin(
|
||||
productImages,
|
||||
sql`${productImages.id} = (
|
||||
SELECT id FROM product_images
|
||||
WHERE product_id = ${products.id}
|
||||
ORDER BY position ASC, created_at ASC
|
||||
LIMIT 1
|
||||
)`,
|
||||
)
|
||||
.orderBy(asc(products.name));
|
||||
|
||||
export default async function AdminProductsPage() {
|
||||
const adminUser = await getAdminUser();
|
||||
|
||||
@@ -61,37 +92,6 @@ export default async function AdminProductsPage() {
|
||||
let productsList: ProductRow[] = [];
|
||||
let queryError: string | null = null;
|
||||
try {
|
||||
const baseQuery = (db: Parameters<Parameters<typeof withBrand>[1]>[0]) =>
|
||||
db
|
||||
.select({
|
||||
id: products.id,
|
||||
name: products.name,
|
||||
description: products.description,
|
||||
priceCents: products.priceCents,
|
||||
active: products.active,
|
||||
brandId: products.brandId,
|
||||
brandName: brands.name,
|
||||
firstImageKey: productImages.storageKey,
|
||||
firstImagePosition: productImages.position,
|
||||
})
|
||||
.from(products)
|
||||
.leftJoin(brands, eq(brands.id, products.brandId))
|
||||
// Pull only the lowest-position image per product. The
|
||||
// `position` index on product_images (product_id, position) makes
|
||||
// this cheap; a real-world scale would replace it with a
|
||||
// DISTINCT ON subquery, but for the current catalog size this
|
||||
// join is fine and avoids a second round-trip.
|
||||
.leftJoin(
|
||||
productImages,
|
||||
sql`${productImages.id} = (
|
||||
SELECT id FROM product_images
|
||||
WHERE product_id = ${products.id}
|
||||
ORDER BY position ASC, created_at ASC
|
||||
LIMIT 1
|
||||
)`,
|
||||
)
|
||||
.orderBy(asc(products.name));
|
||||
|
||||
const rows = isPlatformAdmin
|
||||
? await withPlatformAdmin((db) => baseQuery(db))
|
||||
: brandId
|
||||
|
||||
@@ -5,6 +5,29 @@ import Link from "next/link";
|
||||
import { AdminCard } from "@/components/admin/design-system";
|
||||
import { setAIProviderSettings } from "@/actions/integrations/ai-providers";
|
||||
|
||||
// ── Pure helpers (module scope) ──────────────────────────────────────────────
|
||||
|
||||
function copyToClipboard(text: string) {
|
||||
navigator.clipboard.writeText(text);
|
||||
}
|
||||
|
||||
const reportTypes = [
|
||||
{ value: "orders-by-stop", label: "Orders by Stop" },
|
||||
{ value: "sales-by-product", label: "Sales by Product" },
|
||||
{ value: "fulfillment", label: "Fulfillment" },
|
||||
{ value: "pickup-status", label: "Pickup Status" },
|
||||
{ value: "contact-growth", label: "Contact Growth" },
|
||||
{ value: "campaigns", label: "Campaign Activity" },
|
||||
];
|
||||
|
||||
const exampleQueries = [
|
||||
"Which customers haven't ordered in 45 days?",
|
||||
"What products are trending this month?",
|
||||
"Who are my top customers by revenue?",
|
||||
"Show recent orders from the last 7 days",
|
||||
"Which customers are at risk of churning?",
|
||||
];
|
||||
|
||||
// ── Header Icon Component ─────────────────────────────────────────────────────
|
||||
|
||||
function AIHeaderIcon() {
|
||||
@@ -662,15 +685,6 @@ function ReportExplainerTool({ brandId }: { brandId: string }) {
|
||||
const [result, setResult] = useState<{ summary: string; keyInsights: string[]; suggestedActions: string[] } | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const reportTypes = [
|
||||
{ value: "orders-by-stop", label: "Orders by Stop" },
|
||||
{ value: "sales-by-product", label: "Sales by Product" },
|
||||
{ value: "fulfillment", label: "Fulfillment" },
|
||||
{ value: "pickup-status", label: "Pickup Status" },
|
||||
{ value: "contact-growth", label: "Contact Growth" },
|
||||
{ value: "campaigns", label: "Campaign Activity" },
|
||||
];
|
||||
|
||||
async function handleExplain() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
@@ -692,10 +706,6 @@ function ReportExplainerTool({ brandId }: { brandId: string }) {
|
||||
}
|
||||
}
|
||||
|
||||
function copyToClipboard(text: string) {
|
||||
navigator.clipboard.writeText(text);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm rounded-lg p-3" style={{ backgroundColor: 'rgba(0, 0, 0, 0.02)', color: 'var(--admin-text-muted)' }}>
|
||||
@@ -850,10 +860,6 @@ function PricingAdvisorTool({ brandId }: { brandId: string }) {
|
||||
}
|
||||
}
|
||||
|
||||
function copyToClipboard(text: string) {
|
||||
navigator.clipboard.writeText(text);
|
||||
}
|
||||
|
||||
function addTier() { setPriceTiers([...priceTiers, { tier: "", price: "" }]); }
|
||||
function removeTier(i: number) { setPriceTiers(priceTiers.filter((_, idx) => idx !== i)); }
|
||||
function updateTier(i: number, field: keyof PriceTier, val: string) {
|
||||
@@ -1099,14 +1105,11 @@ function StopBlastAdvisorTool({ brandId }: { brandId: string }) {
|
||||
}
|
||||
}
|
||||
|
||||
function copyToClipboard(text: string) {
|
||||
navigator.clipboard.writeText(text);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm rounded-lg p-3" style={{ backgroundColor: 'rgba(0, 0, 0, 0.02)', color: 'var(--admin-text-muted)' }}>
|
||||
Enter details about the stop you want to send a blast for. The AI will suggest optimal timing, subject lines, content angles, and audience targeting.
|
||||
Enter details about the stop you want to send a blast for.
|
||||
The AI will suggest optimal timing, subject lines, content angles, and audience targeting.
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
@@ -1203,14 +1206,6 @@ function CustomerInsightsTool({ brandId }: { brandId: string }) {
|
||||
} | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const exampleQueries = [
|
||||
"Which customers haven't ordered in 45 days?",
|
||||
"What products are trending this month?",
|
||||
"Who are my top customers by revenue?",
|
||||
"Show recent orders from the last 7 days",
|
||||
"Which customers are at risk of churning?",
|
||||
];
|
||||
|
||||
async function handleAnalyze(nlQuery?: string) {
|
||||
const q = nlQuery ?? query;
|
||||
if (!q.trim()) return;
|
||||
@@ -1389,10 +1384,6 @@ function RouteOptimizerTool({ brandId }: { brandId: string }) {
|
||||
}
|
||||
}
|
||||
|
||||
function copyToClipboard(text: string) {
|
||||
navigator.clipboard.writeText(text);
|
||||
}
|
||||
|
||||
const validStops = stops.filter((s) => s.name.trim() && s.city.trim() && s.state.trim());
|
||||
|
||||
return (
|
||||
@@ -1621,10 +1612,6 @@ function DemandForecastTool({ brandId }: { brandId: string }) {
|
||||
}
|
||||
}
|
||||
|
||||
function copyToClipboard(text: string) {
|
||||
navigator.clipboard.writeText(text);
|
||||
}
|
||||
|
||||
function addRow() { setHistoricalData([...historicalData, { date: "", quantity_sold: "", stop: "" }]); }
|
||||
function removeRow(i: number) { setHistoricalData(historicalData.filter((_, idx) => idx !== i)); }
|
||||
function updateRow(i: number, field: keyof ForecastEntry, val: string) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
@@ -12,6 +12,20 @@ import {
|
||||
} from "@/actions/water-log/admin";
|
||||
import { AdminButton } from "@/components/admin/design-system";
|
||||
|
||||
async function openPrintWindow(html: string) {
|
||||
const blob = new Blob([html], { type: "text/html;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const w = window.open(url, "_blank", "width=700,height=700");
|
||||
if (w) {
|
||||
// Revoke the blob URL once the new window has had time to load the document.
|
||||
w.addEventListener("load", () => URL.revokeObjectURL(url), { once: true });
|
||||
// Safety net: revoke after 60s even if the load event never fires.
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||
} else {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
}
|
||||
|
||||
type Headgate = {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -122,6 +136,22 @@ export default function HeadgatesManager({ initialHeadgates, brandId }: Props) {
|
||||
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
|
||||
const editDialogRef = useRef<HTMLDialogElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const dialog = editDialogRef.current;
|
||||
if (!dialog) return;
|
||||
if (!dialog.open) dialog.showModal();
|
||||
const onCancel = (e: Event) => {
|
||||
e.preventDefault();
|
||||
setEditHg(null);
|
||||
};
|
||||
dialog.addEventListener("cancel", onCancel);
|
||||
return () => {
|
||||
dialog.removeEventListener("cancel", onCancel);
|
||||
};
|
||||
}, [editHg]);
|
||||
|
||||
async function handleDelete(hg: Headgate) {
|
||||
if (!window.confirm(`Delete headgate "${hg.name}"? Existing log entries will be preserved.`)) return;
|
||||
setDeletingId(hg.id);
|
||||
@@ -171,21 +201,8 @@ export default function HeadgatesManager({ initialHeadgates, brandId }: Props) {
|
||||
}
|
||||
}
|
||||
|
||||
async function openPrintWindow(html: string) {
|
||||
const blob = new Blob([html], { type: "text/html;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const w = window.open(url, "_blank", "width=700,height=700");
|
||||
if (w) {
|
||||
// Revoke the blob URL once the new window has had time to load the document.
|
||||
w.addEventListener("load", () => URL.revokeObjectURL(url), { once: true });
|
||||
// Safety net: revoke after 60s even if the load event never fires.
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||
} else {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
<div className="min-h-screen bg-[var(--admin-bg)]">
|
||||
{/* Header */}
|
||||
<div className="bg-white border-b border-[var(--admin-border)] px-6 py-4">
|
||||
@@ -371,17 +388,12 @@ export default function HeadgatesManager({ initialHeadgates, brandId }: Props) {
|
||||
|
||||
{/* Edit Modal */}
|
||||
{editHg && (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
<dialog
|
||||
ref={editDialogRef}
|
||||
aria-label="Edit Headgate"
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
|
||||
onClick={() => setEditHg(null)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") setEditHg(null);
|
||||
}}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 m-0 p-0 max-w-none max-h-none w-full h-full backdrop:bg-black/50"
|
||||
>
|
||||
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-sm mx-4 overflow-hidden border border-[var(--admin-border)]" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-sm mx-4 overflow-hidden border border-[var(--admin-border)]">
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-[var(--admin-border)]">
|
||||
<p className="font-bold text-[var(--admin-text-primary)]">Edit Headgate</p>
|
||||
<button type="button" onClick={() => setEditHg(null)} className="text-[var(--admin-text-muted)] hover:text-[var(--admin-text-primary)]">
|
||||
@@ -461,7 +473,7 @@ export default function HeadgatesManager({ initialHeadgates, brandId }: Props) {
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
)}
|
||||
|
||||
{/* QR Modal */}
|
||||
@@ -475,6 +487,21 @@ export default function HeadgatesManager({ initialHeadgates, brandId }: Props) {
|
||||
function QRModal({ hg, onClose, onRegenerate }: { hg: Headgate; onClose: () => void; onRegenerate: (hg: Headgate) => void }) {
|
||||
const [tab, setTab] = useState<"preview" | "print" | "download">("preview");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const dialog = dialogRef.current;
|
||||
if (!dialog) return;
|
||||
if (!dialog.open) dialog.showModal();
|
||||
const onCancel = (e: Event) => {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
};
|
||||
dialog.addEventListener("cancel", onCancel);
|
||||
return () => {
|
||||
dialog.removeEventListener("cancel", onCancel);
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
const code = hg.name.replace(/\s+/g, "-").toUpperCase().slice(0, 6) + "-1";
|
||||
const qrUrl = `/api/water-qr?token=${hg.headgate_token}&size=360`;
|
||||
@@ -520,17 +547,12 @@ function QRModal({ hg, onClose, onRegenerate }: { hg: Headgate; onClose: () => v
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
<dialog
|
||||
ref={dialogRef}
|
||||
aria-label="Headgate details"
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
|
||||
onClick={onClose}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
}}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 m-0 p-0 max-w-none max-h-none w-full h-full backdrop:bg-black/50"
|
||||
>
|
||||
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-sm mx-4 overflow-hidden border border-[var(--admin-border)]" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-sm mx-4 overflow-hidden border border-[var(--admin-border)]">
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-[var(--admin-border)]">
|
||||
@@ -616,6 +638,6 @@ function QRModal({ hg, onClose, onRegenerate }: { hg: Headgate; onClose: () => v
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
);
|
||||
}
|
||||
@@ -50,6 +50,24 @@ function WholesaleIcon() {
|
||||
);
|
||||
}
|
||||
|
||||
const STATUS_BADGE_MAP: Record<string, "default" | "success" | "warning" | "danger" | "info"> = {
|
||||
pending: "warning",
|
||||
awaiting_deposit: "info",
|
||||
confirmed: "info",
|
||||
fulfilled: "success",
|
||||
};
|
||||
|
||||
const STATUS_BADGE_LABEL: Record<string, string> = {
|
||||
pending: "Pending",
|
||||
awaiting_deposit: "Awaiting Deposit",
|
||||
confirmed: "Confirmed",
|
||||
fulfilled: "Fulfilled",
|
||||
};
|
||||
|
||||
function isValidEmail(e: string) {
|
||||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e);
|
||||
}
|
||||
|
||||
export default function WholesaleClient({ brandId }: { brandId: string }) {
|
||||
const [tab, setTab] = useState<Tab>("dashboard");
|
||||
const [msg, setMsg] = useState<{ kind: "success" | "error"; text: string } | null>(null);
|
||||
@@ -868,6 +886,21 @@ function CustomerPricingPanel({ customer, products, onClose, onMsg }: {
|
||||
const [overrides, setOverrides] = useState<Record<string, string>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const dialog = dialogRef.current;
|
||||
if (!dialog) return;
|
||||
if (!dialog.open) dialog.showModal();
|
||||
const onCancel = (e: Event) => {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
};
|
||||
dialog.addEventListener("cancel", onCancel);
|
||||
return () => {
|
||||
dialog.removeEventListener("cancel", onCancel);
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
getWholesaleCustomerPricing(customer.id).then(pricing => {
|
||||
@@ -892,14 +925,10 @@ function CustomerPricingPanel({ customer, products, onClose, onMsg }: {
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
<dialog
|
||||
ref={dialogRef}
|
||||
aria-label="Pricing overrides"
|
||||
tabIndex={-1}
|
||||
className="fixed inset-0 bg-black/40 flex items-center justify-center z-50"
|
||||
onClick={(e) => e.target === e.currentTarget && onClose()}
|
||||
onKeyDown={(e) => { if (e.key === "Escape") onClose(); }}
|
||||
className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 m-0 p-0 max-w-none max-h-none w-full h-full backdrop:bg-black/40"
|
||||
>
|
||||
<div className="bg-white rounded-2xl shadow-xl w-full max-w-2xl max-h-[80vh] overflow-hidden flex flex-col border border-[var(--admin-border)]">
|
||||
<div className="px-6 py-4 border-b border-[var(--admin-border)] flex items-center justify-between">
|
||||
@@ -974,7 +1003,7 @@ function CustomerPricingPanel({ customer, products, onClose, onMsg }: {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -994,6 +1023,21 @@ function PriceSheetModal({
|
||||
const [subject, setSubject] = useState(defaultSubject);
|
||||
const [note, setNote] = useState("");
|
||||
const [sending, setSending] = useState(false);
|
||||
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const dialog = dialogRef.current;
|
||||
if (!dialog) return;
|
||||
if (!dialog.open) dialog.showModal();
|
||||
const onCancel = (e: Event) => {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
};
|
||||
dialog.addEventListener("cancel", onCancel);
|
||||
return () => {
|
||||
dialog.removeEventListener("cancel", onCancel);
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
@@ -1003,16 +1047,12 @@ function PriceSheetModal({
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
<dialog
|
||||
ref={dialogRef}
|
||||
aria-label="Send price sheet"
|
||||
tabIndex={-1}
|
||||
className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 px-4"
|
||||
onClick={onClose}
|
||||
onKeyDown={(e) => { if (e.key === "Escape") onClose(); }}
|
||||
className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 px-4 m-0 max-w-none max-h-none w-full h-full backdrop:bg-black/40"
|
||||
>
|
||||
<div className="bg-white rounded-2xl shadow-xl w-full max-w-lg border border-[var(--admin-border)]" onClick={e => e.stopPropagation()}>
|
||||
<div className="bg-white rounded-2xl shadow-xl w-full max-w-lg border border-[var(--admin-border)]">
|
||||
<div className="px-6 py-4 border-b border-[var(--admin-border)] flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-[var(--admin-text-primary)]">Send Price Sheet</h2>
|
||||
@@ -1071,7 +1111,7 @@ function PriceSheetModal({
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2303,10 +2343,6 @@ function AddRecipientForm({ onAdd }: { onAdd: (email: string, name: string) => v
|
||||
const [name, setName] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
|
||||
function isValidEmail(e: string) {
|
||||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e);
|
||||
}
|
||||
|
||||
function handleAdd() {
|
||||
if (!email.trim()) return;
|
||||
if (!isValidEmail(email.trim())) {
|
||||
@@ -2398,21 +2434,9 @@ function WholesaleLoadingSkeleton() {
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
const map: Record<string, "default" | "success" | "warning" | "danger" | "info"> = {
|
||||
pending: "warning",
|
||||
awaiting_deposit: "info",
|
||||
confirmed: "info",
|
||||
fulfilled: "success",
|
||||
};
|
||||
const label: Record<string, string> = {
|
||||
pending: "Pending",
|
||||
awaiting_deposit: "Awaiting Deposit",
|
||||
confirmed: "Confirmed",
|
||||
fulfilled: "Fulfilled",
|
||||
};
|
||||
return (
|
||||
<AdminBadge variant={map[status] ?? "default"}>
|
||||
{label[status] ?? status}
|
||||
<AdminBadge variant={STATUS_BADGE_MAP[status] ?? "default"}>
|
||||
{STATUS_BADGE_LABEL[status] ?? status}
|
||||
</AdminBadge>
|
||||
);
|
||||
}
|
||||
@@ -15,6 +15,8 @@ import { withDb, withPlatformAdmin } from "@/db/client";
|
||||
import { orders } from "@/db/schema/orders";
|
||||
import { customers } from "@/db/schema/customers";
|
||||
|
||||
const REPORT_HEADERS = ["id", "customer_name", "customer_email", "status", "total_cents", "placed_at"];
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) {
|
||||
@@ -74,8 +76,7 @@ export async function GET(req: NextRequest) {
|
||||
);
|
||||
|
||||
if (format === "csv") {
|
||||
const headers = ["id", "customer_name", "customer_email", "status", "total_cents", "placed_at"];
|
||||
const csvRows = [headers.join(",")];
|
||||
const csvRows = [REPORT_HEADERS.join(",")];
|
||||
for (const r of rows) {
|
||||
csvRows.push(
|
||||
[
|
||||
|
||||
@@ -29,6 +29,15 @@ function formatTime(iso: string): string {
|
||||
return new Date(iso).toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit", hour12: true });
|
||||
}
|
||||
|
||||
const QUICKBOOKS_HEADERS = ["Employee Name", "Date", "Clock In", "Clock Out", "Hours", "Task / Service Item", "Overtime Hours", "Notes"];
|
||||
const PAYROLL_HEADERS = ["Worker Name", "Role", "Days Worked", "Total Hours", "Regular Hours", "Overtime Hours", "Tasks"];
|
||||
const SUMMARY_HEADERS = ["Brand", "Worker Name", "Pay Period Start", "Pay Period End", "Total Hours", "Daily OT Hours", "Weekly OT Hours", "Status"];
|
||||
const DETAILED_HEADERS = [
|
||||
"Worker Name", "Task", "Date", "Clock In", "Clock Out",
|
||||
"Lunch (min)", "Total Minutes", "Hours", "Overtime Hours",
|
||||
"Submitted Via", "Notes", "Brand",
|
||||
];
|
||||
|
||||
function csvRow(values: (string | number | boolean | null)[]): string {
|
||||
return values.map(v => `"${String(v ?? "").replace(/"/g, '""')}"`).join(",");
|
||||
}
|
||||
@@ -128,7 +137,7 @@ export async function GET(req: NextRequest) {
|
||||
if (exportType === "quickbooks") {
|
||||
// QuickBooks Time import format
|
||||
// Columns: Employee Name, Date, Clock In, Clock Out, Hours, Task/Service Item, Overtime Hours, Notes
|
||||
const headers = ["Employee Name", "Date", "Clock In", "Clock Out", "Hours", "Task / Service Item", "Overtime Hours", "Notes"];
|
||||
const headers = QUICKBOOKS_HEADERS;
|
||||
const rows = allLogs.map((log, i) => {
|
||||
const worker = allWorkers.flat().find(w => w.id === log.worker_id);
|
||||
const settings = allSettings.find(s => s && s.brand_id === log.brandId);
|
||||
@@ -174,7 +183,7 @@ export async function GET(req: NextRequest) {
|
||||
entry.days.add(formatDate(log.clock_in));
|
||||
entry.tasks.add(log.task_name);
|
||||
}
|
||||
const headers = ["Worker Name", "Role", "Days Worked", "Total Hours", "Regular Hours", "Overtime Hours", "Tasks"];
|
||||
const headers = PAYROLL_HEADERS;
|
||||
const rows = [...workerMap.values()].map(e => {
|
||||
const totalH = e.totalMin / 60;
|
||||
const otH = Math.max(0, totalH - 40); // weekly OT at 40h
|
||||
@@ -195,7 +204,7 @@ export async function GET(req: NextRequest) {
|
||||
else if (exportType === "summary") {
|
||||
// Pay period summary — one row per worker per pay period
|
||||
const brandName = allSettings[0]?.brand_name ?? "Farm";
|
||||
const headers = ["Brand", "Worker Name", "Pay Period Start", "Pay Period End", "Total Hours", "Daily OT Hours", "Weekly OT Hours", "Status"];
|
||||
const headers = SUMMARY_HEADERS;
|
||||
const settingsByBrand = new Map<string, NonNullable<typeof allSettings[number]>>();
|
||||
for (const s of allSettings) {
|
||||
if (s && s.brand_id) settingsByBrand.set(s.brand_id, s);
|
||||
@@ -240,11 +249,7 @@ export async function GET(req: NextRequest) {
|
||||
}
|
||||
else {
|
||||
// Detailed audit log
|
||||
const headers = [
|
||||
"Worker Name", "Task", "Date", "Clock In", "Clock Out",
|
||||
"Lunch (min)", "Total Minutes", "Hours", "Overtime Hours",
|
||||
"Submitted Via", "Notes", "Brand",
|
||||
];
|
||||
const headers = DETAILED_HEADERS;
|
||||
const rows = allLogs.map(log => {
|
||||
const settings = allSettings.find(s => s && s.brand_id === log.brandId);
|
||||
const { regularHours, overtimeHours } = settings ? computeOvertime(log, settings) : { regularHours: 0, overtimeHours: 0 };
|
||||
|
||||
@@ -14,6 +14,23 @@ import type { WaterLogReportRow } from "@/lib/water-log-reporting";
|
||||
|
||||
const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||
|
||||
const WATER_LOG_HEADERS = [
|
||||
"When",
|
||||
"Headgate",
|
||||
"User",
|
||||
"Measurement",
|
||||
"Unit",
|
||||
"Total Gallons",
|
||||
"Method",
|
||||
"Notes",
|
||||
"Via",
|
||||
"Photo URL",
|
||||
];
|
||||
|
||||
function esc(s: string | null | undefined) {
|
||||
return `"${(s ?? "").replace(/"/g, '""')}"`;
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) {
|
||||
@@ -43,21 +60,7 @@ export async function GET(request: NextRequest) {
|
||||
}));
|
||||
|
||||
if (format === "csv") {
|
||||
const headers = [
|
||||
"When",
|
||||
"Headgate",
|
||||
"User",
|
||||
"Measurement",
|
||||
"Unit",
|
||||
"Total Gallons",
|
||||
"Method",
|
||||
"Notes",
|
||||
"Via",
|
||||
"Photo URL",
|
||||
];
|
||||
const esc = (s: string | null | undefined) =>
|
||||
`"${(s ?? "").replace(/"/g, '""')}"`;
|
||||
const lines: string[] = [headers.join(",")];
|
||||
const lines: string[] = [WATER_LOG_HEADERS.join(",")];
|
||||
for (const e of raw) {
|
||||
lines.push(
|
||||
[
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import Link from "next/link";
|
||||
import { useCart } from "@/context/CartContext";
|
||||
import StorefrontHeader from "@/components/storefront/StorefrontHeader";
|
||||
@@ -88,6 +88,23 @@ export default function CartClient() {
|
||||
window.location.href = "/checkout";
|
||||
}, [cart.length, stopBrandMismatch, hasStopPickupItems, selectedStop, incompatibleItems, setSelectedStop, handleOpenStopPicker]);
|
||||
|
||||
const stopPickerDialogRef = useRef<HTMLDialogElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showStopPicker) return;
|
||||
const dialog = stopPickerDialogRef.current;
|
||||
if (!dialog) return;
|
||||
if (!dialog.open) dialog.showModal();
|
||||
const onCancel = (e: Event) => {
|
||||
e.preventDefault();
|
||||
setShowStopPicker(false);
|
||||
};
|
||||
dialog.addEventListener("cancel", onCancel);
|
||||
return () => {
|
||||
dialog.removeEventListener("cancel", onCancel);
|
||||
};
|
||||
}, [showStopPicker]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen relative">
|
||||
{/* Background */}
|
||||
@@ -208,11 +225,10 @@ export default function CartClient() {
|
||||
|
||||
{/* Stop picker modal */}
|
||||
{showStopPicker && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4 pointer-events-none"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
<dialog
|
||||
ref={stopPickerDialogRef}
|
||||
aria-labelledby="stop-picker-title"
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4 m-0 max-w-none max-h-none w-full h-full backdrop:bg-black/60"
|
||||
>
|
||||
<div className="w-full max-w-sm rounded-2xl glass-strong p-6 shadow-2xl pointer-events-auto border border-white/10">
|
||||
<h3 id="stop-picker-title" className="text-xl font-semibold text-white">Choose Pickup Stop</h3>
|
||||
@@ -245,7 +261,7 @@ export default function CartClient() {
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
)}
|
||||
|
||||
{/* Cart items */}
|
||||
|
||||
@@ -2,6 +2,8 @@ import { notFound } from "next/navigation";
|
||||
import { getTraceChain, getLotIdByNumber } from "@/actions/route-trace/lots";
|
||||
import ShareTraceButton from "@/components/route-trace/ShareTraceButton";
|
||||
|
||||
const STATUS_ORDER = ["active", "in_transit", "at_shed", "packed", "delivered"];
|
||||
|
||||
export async function generateMetadata({ params }: { params: Promise<{ lotNumber: string }> }) {
|
||||
const { lotNumber } = await params;
|
||||
|
||||
@@ -77,8 +79,7 @@ export default async function TracePage({ params }: { params: Promise<{ lotNumbe
|
||||
|
||||
const { lot, events, orders } = result.chain;
|
||||
|
||||
const statusOrder = ["active", "in_transit", "at_shed", "packed", "delivered"];
|
||||
const currentStepIndex = statusOrder.indexOf(lot.status);
|
||||
const currentStepIndex = STATUS_ORDER.indexOf(lot.status);
|
||||
const progressPercent = currentStepIndex >= 0 ? ((currentStepIndex + 1) / 5) * 100 : 0;
|
||||
|
||||
const totalQuantity = Number(lot.quantity_lbs ?? 0);
|
||||
@@ -230,12 +231,12 @@ export default async function TracePage({ params }: { params: Promise<{ lotNumbe
|
||||
<div key={i} className="flex-1 flex flex-col items-center gap-1">
|
||||
<div className={`h-9 w-9 rounded-full flex items-center justify-center text-base transition-all ${
|
||||
done ? "bg-green-500 text-white shadow-sm" :
|
||||
active ? `${STEP_COLORS[statusOrder[i]] ?? "bg-stone-400"} text-white shadow-sm ring-3 ring-white ring-offset-1` :
|
||||
active ? `${STEP_COLORS[STATUS_ORDER[i]] ?? "bg-stone-400"} text-white shadow-sm ring-3 ring-white ring-offset-1` :
|
||||
"bg-stone-100 text-stone-300"
|
||||
}`}>
|
||||
{done ? <CheckIcon className="w-3.5 h-3.5 text-white" /> : icon}
|
||||
</div>
|
||||
<div className={`h-0.5 w-full rounded-full ${done ? "bg-green-400" : active ? (STEP_COLORS[statusOrder[i]] ?? "bg-stone-300") : "bg-stone-100"}`} />
|
||||
<div className={`h-0.5 w-full rounded-full ${done ? "bg-green-400" : active ? (STEP_COLORS[STATUS_ORDER[i]] ?? "bg-stone-300") : "bg-stone-100"}`} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
+10
-10
@@ -13,6 +13,16 @@ import StorefrontFooter from "@/components/storefront/StorefrontFooter";
|
||||
import BrandStylesProvider from "@/components/storefront/BrandStylesProvider";
|
||||
import { ScrollReveal, ParallaxLayer, FadeOnScroll } from "@/components/ui/ScrollAnimations";
|
||||
import { supabase } from "@/lib/supabase";
|
||||
|
||||
function scrollToStops() {
|
||||
document.getElementById("stops")?.scrollIntoView({ behavior: "smooth" });
|
||||
}
|
||||
function scrollToStory() {
|
||||
document.getElementById("story")?.scrollIntoView({ behavior: "smooth" });
|
||||
}
|
||||
function scrollToProducts() {
|
||||
document.getElementById("products")?.scrollIntoView({ behavior: "smooth" });
|
||||
}
|
||||
import { getBrandSettingsPublic } from "@/actions/brand-settings";
|
||||
import { gsap } from "gsap";
|
||||
import { ScrollTrigger } from "gsap/ScrollTrigger";
|
||||
@@ -495,16 +505,6 @@ export default function TuxedoPage() {
|
||||
load();
|
||||
}, []);
|
||||
|
||||
function scrollToStops() {
|
||||
document.getElementById("stops")?.scrollIntoView({ behavior: "smooth" });
|
||||
}
|
||||
function scrollToStory() {
|
||||
document.getElementById("story")?.scrollIntoView({ behavior: "smooth" });
|
||||
}
|
||||
function scrollToProducts() {
|
||||
document.getElementById("products")?.scrollIntoView({ behavior: "smooth" });
|
||||
}
|
||||
|
||||
// ─── GSAP SCROLL ANIMATIONS ──────────────────────────────────────────────
|
||||
// Now explicitly scoped + resilient guards (no more "invalid scope" warnings)
|
||||
useEffect(() => {
|
||||
|
||||
@@ -22,16 +22,17 @@ type Props = {
|
||||
|
||||
type Queue = "past_due" | "today" | "upcoming";
|
||||
|
||||
const STATUS_BADGE_MAP: Record<string, string> = {
|
||||
pending: "bg-yellow-100 text-yellow-700",
|
||||
awaiting_deposit: "bg-purple-100 text-purple-700",
|
||||
confirmed: "bg-blue-100 text-blue-700",
|
||||
fulfilled: "bg-green-100 text-green-700",
|
||||
cancelled: "bg-red-100 text-red-700",
|
||||
};
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
const map: Record<string, string> = {
|
||||
pending: "bg-yellow-100 text-yellow-700",
|
||||
awaiting_deposit: "bg-purple-100 text-purple-700",
|
||||
confirmed: "bg-blue-100 text-blue-700",
|
||||
fulfilled: "bg-green-100 text-green-700",
|
||||
cancelled: "bg-red-100 text-red-700",
|
||||
};
|
||||
return (
|
||||
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${map[status] ?? "bg-slate-100 text-slate-600"}`}>
|
||||
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${STATUS_BADGE_MAP[status] ?? "bg-slate-100 text-slate-600"}`}>
|
||||
{status.replace(/_/g, " ")}
|
||||
</span>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user