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:
@@ -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" } }
|
||||
);
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
Reference in New Issue
Block a user