Admin AI Tools page: unified design system styling, provider selector with OpenAI turd emoji, free-text model input with exact ID warning

This commit is contained in:
2026-06-02 02:21:11 +00:00
parent 809e0061ca
commit 15e939ad7e
116 changed files with 14991 additions and 5326 deletions
@@ -0,0 +1,204 @@
import { svcHeaders } from "@/lib/svc-headers";
const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const SUPABASE_PAT = process.env.SUPABASE_PAT!;
interface LotRow {
lot_id: string;
lot_number: string;
crop_type: string;
variety: string | null;
harvest_date: string;
field_location: string | null;
field_block: string | null;
worker_name: string | null;
packer_name: string | null;
quantity_lbs: number | null;
quantity_used_lbs: number | null;
yield_estimate_lbs: number | null;
yield_unit: string | null;
bin_id: string | null;
status: string;
}
interface EventRow {
lot_id: string;
event_type: string;
event_time: string;
location: string | null;
notes: string | null;
bin_id: string | null;
created_by_name: string | null;
}
async function adminFetchJson(endpoint: string, body: unknown) {
const res = await fetch(`${SUPABASE_URL}/rest/v1/rpc/${endpoint}`, {
method: "POST",
headers: {
...svcHeaders(SUPABASE_PAT),
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
if (!res.ok) return null;
return res.json();
}
function assessCompliance(lot: LotRow, eventCount: number): {
compliance_status: "compliant" | "non_compliant" | "pending";
issues: string[];
} {
const issues: string[] = [];
// Check for required traceability fields
if (!lot.harvest_date) {
issues.push("Missing harvest date");
}
if (!lot.field_location) {
issues.push("Missing field location");
}
if (!lot.quantity_lbs || lot.quantity_lbs <= 0) {
issues.push("Missing quantity");
}
// Check traceability chain
if (eventCount === 0) {
issues.push("No trace events");
}
// Check for worker/packer info
if (!lot.worker_name && !lot.packer_name) {
issues.push("No worker/packer info");
}
// Check yield variance
if (lot.yield_estimate_lbs && lot.yield_estimate_lbs > 0 && lot.quantity_lbs) {
const variance = Math.abs((lot.quantity_lbs - lot.yield_estimate_lbs) / lot.yield_estimate_lbs);
if (variance > 0.2) {
issues.push("High yield variance");
}
}
// Determine compliance status
let compliance_status: "compliant" | "non_compliant" | "pending";
if (issues.length === 0) {
compliance_status = "compliant";
} else if (issues.some(i => i === "No trace events" || i === "Missing harvest date")) {
compliance_status = "non_compliant";
} else {
compliance_status = "pending";
}
return { compliance_status, issues };
}
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const brandId = searchParams.get("brandId");
const startDate = searchParams.get("startDate");
const endDate = searchParams.get("endDate");
if (!brandId || !startDate || !endDate) {
return new Response(JSON.stringify({ error: "Missing brandId, startDate, or endDate" }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
// Fetch all lots for the brand
const allLots = await adminFetchJson("get_harvest_lots", {
p_brand_id: brandId,
p_status: null,
});
const lots: LotRow[] = (allLots ?? []) as LotRow[];
// Filter by date range
const filteredLots = lots.filter((lot) => {
const harvestDate = lot.harvest_date;
if (!harvestDate) return false;
return harvestDate >= startDate && harvestDate <= endDate;
});
if (filteredLots.length === 0) {
return new Response(
JSON.stringify({
lots: [],
summary: {
total_lots: 0,
compliant: 0,
non_compliant: 0,
pending: 0,
total_weight: 0,
used_weight: 0,
remaining_weight: 0,
crops: [],
},
}),
{ headers: { "Content-Type": "application/json" } }
);
}
// Fetch events for all lots
const lotIds = filteredLots.map((l) => l.lot_id);
const eventsRaw = await adminFetchJson("get_events_for_lots", { p_lot_ids: lotIds });
const allEvents: EventRow[] = (eventsRaw ?? []) as EventRow[];
// Group events by lot
const eventsByLot: Record<string, EventRow[]> = {};
for (const evt of allEvents) {
if (!eventsByLot[evt.lot_id]) eventsByLot[evt.lot_id] = [];
eventsByLot[evt.lot_id].push(evt);
}
// Process each lot for compliance
const complianceLots = filteredLots.map((lot) => {
const events = eventsByLot[lot.lot_id] ?? [];
const { compliance_status, issues } = assessCompliance(lot, events.length);
return {
lot_id: lot.lot_id,
lot_number: lot.lot_number,
crop_type: lot.crop_type,
variety: lot.variety,
harvest_date: lot.harvest_date,
field_location: lot.field_location,
field_block: lot.field_block,
worker_name: lot.worker_name,
packer_name: lot.packer_name,
quantity_lbs: lot.quantity_lbs,
quantity_used_lbs: lot.quantity_used_lbs,
yield_estimate_lbs: lot.yield_estimate_lbs,
yield_unit: lot.yield_unit,
bin_id: lot.bin_id,
status: lot.status,
event_count: events.length,
has_traceability: events.length > 0,
compliance_status,
issues,
};
});
// Calculate summary
const summary = {
total_lots: complianceLots.length,
compliant: complianceLots.filter((l) => l.compliance_status === "compliant").length,
non_compliant: complianceLots.filter((l) => l.compliance_status === "non_compliant").length,
pending: complianceLots.filter((l) => l.compliance_status === "pending").length,
total_weight: filteredLots.reduce((s, l) => s + (l.quantity_lbs ?? 0), 0),
used_weight: filteredLots.reduce((s, l) => s + (l.quantity_used_lbs ?? 0), 0),
remaining_weight: filteredLots.reduce(
(s, l) => s + Math.max(0, (l.quantity_lbs ?? 0) - (l.quantity_used_lbs ?? 0)),
0
),
crops: [...new Set(filteredLots.map((l) => l.crop_type))],
};
return new Response(
JSON.stringify({
lots: complianceLots,
summary,
}),
{ headers: { "Content-Type": "application/json" } }
);
}
+20 -13
View File
@@ -54,18 +54,25 @@ export async function GET(request: Request) {
return new Response("Missing brandId, startDate, or endDate", { status: 400 });
}
const lotsRaw = await adminFetchJson("get_lots_for_period", {
// Use existing get_harvest_lots RPC and filter by date in JavaScript
const allLots = await adminFetchJson("get_harvest_lots", {
p_brand_id: brandId,
p_start_date: startDate,
p_end_date: endDate,
p_status: null,
});
const lots: LotRow[] = (lotsRaw ?? []) as LotRow[];
if (!lots || lots.length === 0) {
const lots: LotRow[] = (allLots ?? []) as LotRow[];
// Filter by date range
const filteredLots = lots.filter(lot => {
const harvestDate = lot.harvest_date;
if (!harvestDate) return false;
return harvestDate >= startDate && harvestDate <= endDate;
});
if (filteredLots.length === 0) {
return new Response("No lots found for this period", { status: 404 });
}
const lotIds = lots.map((l) => l.lot_id);
const lotIds = filteredLots.map((l) => l.lot_id);
const eventsRaw = await adminFetchJson("get_events_for_lots", { p_lot_ids: lotIds });
const allEvents: EventRow[] = (eventsRaw ?? []) as EventRow[];
@@ -81,16 +88,16 @@ export async function GET(request: Request) {
lines.push(`Brand ID,${brandId}`);
lines.push(`Report Period,${startDate} to ${endDate}`);
lines.push(`Generated,${new Date().toLocaleString("en-US")}`);
lines.push(`Total Lots,${lots.length}`);
lines.push(`Total Lots,${filteredLots.length}`);
lines.push("");
const totalQty = lots.reduce((s, l) => s + (l.quantity_lbs ?? 0), 0);
const totalUsed = lots.reduce((s, l) => s + (l.quantity_used_lbs ?? 0), 0);
const crops = [...new Set(lots.map((l) => l.crop_type))];
const units = [...new Set(lots.map((l) => l.yield_unit ?? "lbs"))];
const totalQty = filteredLots.reduce((s, l) => s + (l.quantity_lbs ?? 0), 0);
const totalUsed = filteredLots.reduce((s, l) => s + (l.quantity_used_lbs ?? 0), 0);
const crops = [...new Set(filteredLots.map((l) => l.crop_type))];
const units = [...new Set(filteredLots.map((l) => l.yield_unit ?? "lbs"))];
const primaryUnit = units.length === 1 ? units[0] : "lbs";
lines.push("SUMMARY");
lines.push(`Total Lots Harvested,${lots.length}`);
lines.push(`Total Lots Harvested,${filteredLots.length}`);
lines.push(`Total Weight (${primaryUnit}),${totalQty.toLocaleString()}`);
lines.push(`Total Used (${primaryUnit}),${totalUsed.toLocaleString()}`);
lines.push(`Remaining (${primaryUnit}),${(totalQty - totalUsed).toLocaleString()}`);
@@ -102,7 +109,7 @@ export async function GET(request: Request) {
lines.push("ONE-UP / ONE-DOWN TRACEABILITY");
lines.push("Lot Number,Crop,Variety,Harvest Date,Field,Block,Worker,Packer,Qty (lbs),Used (lbs),Remaining (lbs),Yield Est. (lbs),Yield Unit,Yield Variance %,Bin ID,Status,Event,Event Time,Location,Bin ID,Notes,Recorded By");
for (const lot of lots) {
for (const lot of filteredLots) {
const evts = eventsByLot[lot.lot_id] ?? [];
const remaining = (lot.quantity_lbs ?? 0) - (lot.quantity_used_lbs ?? 0);
if (evts.length === 0) {
+101
View File
@@ -0,0 +1,101 @@
import { NextResponse } from "next/server";
import { getAdminUser } from "@/lib/admin-permissions";
import { savePaymentSettings } from "@/actions/payments";
export async function GET(req: Request) {
const adminUser = await getAdminUser();
if (!adminUser || !adminUser.brand_id) {
return NextResponse.redirect(new URL("/admin/settings/payments?error=unauthorized", req.url));
}
const url = new URL(req.url);
const code = url.searchParams.get("code");
const error = url.searchParams.get("error");
const state = url.searchParams.get("state");
// Handle error from Stripe
if (error) {
return NextResponse.redirect(
new URL(`/admin/settings/payments?stripe_error=${encodeURIComponent(error)}`, req.url)
);
}
if (!code) {
return NextResponse.redirect(
new URL("/admin/settings/payments?stripe_error=no_code", req.url)
);
}
// Decode state to get brandId
let brandId = adminUser.brand_id;
if (state) {
try {
const decoded = JSON.parse(Buffer.from(state, "base64").toString());
if (decoded.brandId) {
brandId = decoded.brandId;
}
} catch {}
}
// Exchange code for access token
const clientId = process.env.STRIPE_CLIENT_ID;
const clientSecret = process.env.STRIPE_CLIENT_SECRET;
if (!clientId || !clientSecret) {
return NextResponse.redirect(
new URL("/admin/settings/payments?stripe_error=oauth_not_configured", req.url)
);
}
try {
// Exchange authorization code for access token
const tokenResponse = await fetch("https://connect.stripe.com/oauth/token", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
grant_type: "authorization_code",
code,
client_id: clientId,
client_secret: clientSecret,
}),
});
const tokenData = await tokenResponse.json();
if (tokenData.error) {
return NextResponse.redirect(
new URL(`/admin/settings/payments?stripe_error=${encodeURIComponent(tokenData.error_description || tokenData.error)}`, req.url)
);
}
// Save the Stripe credentials
const stripeUserId = tokenData.stripe_user_id; // This is the connected account ID
const accessToken = tokenData.access_token;
const publishableKey = tokenData.stripe_publishable_key;
// Save to database via server action
const result = await savePaymentSettings({
brandId,
provider: "stripe",
stripePublishableKey: publishableKey || undefined,
stripeSecretKey: accessToken, // Store access token as secret key
stripeUserId, // Store the connected account ID
});
if (result.success) {
return NextResponse.redirect(
new URL("/admin/settings/payments?stripe_connected=true", req.url)
);
} else {
return NextResponse.redirect(
new URL(`/admin/settings/payments?stripe_error=${encodeURIComponent(result.error || "Failed to save")}`, req.url)
);
}
} catch (err) {
return NextResponse.redirect(
new URL(`/admin/settings/payments?stripe_error=${encodeURIComponent("Failed to complete OAuth")}`, req.url)
);
}
}
+42
View File
@@ -0,0 +1,42 @@
import { NextResponse } from "next/server";
import { getAdminUser } from "@/lib/admin-permissions";
export async function GET(req: Request) {
const adminUser = await getAdminUser();
if (!adminUser || !adminUser.brand_id) {
return NextResponse.redirect(new URL("/admin/settings/payments?error=unauthorized", req.url));
}
if (!adminUser.can_manage_settings) {
return NextResponse.redirect(new URL("/admin/settings/payments?error=forbidden", req.url));
}
const clientId = process.env.STRIPE_CLIENT_ID;
const clientSecret = process.env.STRIPE_CLIENT_SECRET;
const env = process.env.STRIPE_ENVIRONMENT ?? "test";
if (!clientId || !clientSecret) {
return NextResponse.redirect(
new URL("/admin/settings/payments?error=stripe_oauth_not_configured", req.url)
);
}
const baseUrl = env === "production"
? "https://connect.stripe.com"
: "https://connect.stripe.com/oauth/authorize";
const origin = new URL(req.url).origin;
const redirectUri = `${origin}/api/stripe/oauth/callback`;
// Encode brand_id in state so callback knows which brand to associate
const state = Buffer.from(JSON.stringify({ brandId: adminUser.brand_id })).toString("base64");
const params = new URLSearchParams({
response_type: "code",
client_id: clientId,
scope: "read_only", // Use read_only for safety - can upgrade to full later
redirect_uri: redirectUri,
state,
});
return NextResponse.redirect(`${baseUrl}?${params}`);
}