diff --git a/src/app/(portal)/dashboard/loading.tsx b/src/app/(portal)/dashboard/loading.tsx new file mode 100644 index 0000000..804a6f4 --- /dev/null +++ b/src/app/(portal)/dashboard/loading.tsx @@ -0,0 +1,42 @@ +import { Card, CardContent, CardHeader } from '@/components/ui/card'; + +export default function DashboardLoading() { + return ( +
+

Dashboard

+
+
+ {[...Array(4)].map((_, i) => ( + + +
+ + +
+ + + ))} +
+
+ {[...Array(2)].map((_, i) => ( + + +
+ + +
+ {[...Array(5)].map((_, j) => ( +
+ ))} +
+ + + ))} +
+
+
+ ); +} diff --git a/src/app/(portal)/inventory/[category]/detail/loading.tsx b/src/app/(portal)/inventory/[category]/detail/loading.tsx new file mode 100644 index 0000000..c3cb890 --- /dev/null +++ b/src/app/(portal)/inventory/[category]/detail/loading.tsx @@ -0,0 +1,27 @@ +import { Card, CardContent } from '@/components/ui/card'; + +export default function InventoryDetailLoading() { + return ( +
+
+
+
+
+
+
+
+ + +
+ {[...Array(8)].map((_, i) => ( +
+ ))} +
+ + +
+ ); +} diff --git a/src/app/(portal)/inventory/[category]/detail/page.tsx b/src/app/(portal)/inventory/[category]/detail/page.tsx index cd03587..7c46536 100644 --- a/src/app/(portal)/inventory/[category]/detail/page.tsx +++ b/src/app/(portal)/inventory/[category]/detail/page.tsx @@ -52,9 +52,11 @@ async function InventoryDetailData({ plant?: string; warehouse?: string; }) { - const session = await getQuestSession(); - const activeCompany = await getActiveCompany(); - const userIsSubUser = await isSubUser(); + const [session, activeCompany, userIsSubUser] = await Promise.all([ + getQuestSession(), + getActiveCompany(), + isSubUser(), + ]); if (!session || !activeCompany) { redirect('/select-company'); diff --git a/src/app/(portal)/inventory/finished-goods/page.tsx b/src/app/(portal)/inventory/finished-goods/page.tsx index 8c8fc96..4548f51 100644 --- a/src/app/(portal)/inventory/finished-goods/page.tsx +++ b/src/app/(portal)/inventory/finished-goods/page.tsx @@ -9,26 +9,29 @@ import { import { redirect } from 'next/navigation'; import { InventorySummaryTable } from '@/components/inventory/inventory-summary-table'; import { Card, CardContent } from '@/components/ui/card'; +import { cachedQuery } from '@/lib/cache'; export const dynamic = 'force-dynamic'; async function FinishedGoodsInventoryData() { - const session = await getQuestSession(); - const activeCompany = await getActiveCompany(); - const userIsSubUser = await isSubUser(); + const [session, activeCompany, userIsSubUser] = await Promise.all([ + getQuestSession(), + getActiveCompany(), + isSubUser(), + ]); if (!session || !activeCompany) { redirect('/select-company'); } + const custId = activeCompany.epicor_cust_id; const dbName = `[${process.env.PORTAL_DB_NAME || 'VorteqPortal'}]`; const sub = userIsSubUser ? 1 : 0; const [inventory, canShip] = await Promise.all([ - getFinishedGoodsSummary( - activeCompany.epicor_cust_id, - dbName, - sub + cachedQuery( + { key: `inv:${custId}:finished-goods:summary:${sub}`, ttlSeconds: 300 }, + () => getFinishedGoodsSummary(custId, dbName, sub) ).catch(() => []), hasPermission('create_shipment_request'), ]); diff --git a/src/app/(portal)/inventory/processed-other/page.tsx b/src/app/(portal)/inventory/processed-other/page.tsx index 6572b0a..8c5af7b 100644 --- a/src/app/(portal)/inventory/processed-other/page.tsx +++ b/src/app/(portal)/inventory/processed-other/page.tsx @@ -8,25 +8,28 @@ import { import { redirect } from 'next/navigation'; import { InventorySummaryTable } from '@/components/inventory/inventory-summary-table'; import { Card, CardContent } from '@/components/ui/card'; +import { cachedQuery } from '@/lib/cache'; export const dynamic = 'force-dynamic'; async function ProcessedOtherInventoryData() { - const session = await getQuestSession(); - const activeCompany = await getActiveCompany(); - const userIsSubUser = await isSubUser(); + const [session, activeCompany, userIsSubUser] = await Promise.all([ + getQuestSession(), + getActiveCompany(), + isSubUser(), + ]); if (!session || !activeCompany) { redirect('/select-company'); } + const custId = activeCompany.epicor_cust_id; const dbName = `[${process.env.PORTAL_DB_NAME || 'VorteqPortal'}]`; const sub = userIsSubUser ? 1 : 0; - const inventory = await getProcessedOtherSummary( - activeCompany.epicor_cust_id, - dbName, - sub + const inventory = await cachedQuery( + { key: `inv:${custId}:processed-other:summary:${sub}`, ttlSeconds: 300 }, + () => getProcessedOtherSummary(custId, dbName, sub) ).catch(() => []); return ; diff --git a/src/app/(portal)/inventory/processed-rr/page.tsx b/src/app/(portal)/inventory/processed-rr/page.tsx index 8bef9a7..174866c 100644 --- a/src/app/(portal)/inventory/processed-rr/page.tsx +++ b/src/app/(portal)/inventory/processed-rr/page.tsx @@ -8,13 +8,16 @@ import { import { redirect } from 'next/navigation'; import { InventorySummaryTable } from '@/components/inventory/inventory-summary-table'; import { Card, CardContent } from '@/components/ui/card'; +import { cachedQuery } from '@/lib/cache'; export const dynamic = 'force-dynamic'; async function ProcessedRRInventoryData() { - const session = await getQuestSession(); - const activeCompany = await getActiveCompany(); - const userIsSubUser = await isSubUser(); + const [session, activeCompany, userIsSubUser] = await Promise.all([ + getQuestSession(), + getActiveCompany(), + isSubUser(), + ]); if (!session || !activeCompany) { redirect('/select-company'); @@ -25,12 +28,12 @@ async function ProcessedRRInventoryData() { redirect('/inventory'); } + const custId = activeCompany.epicor_cust_id; const dbName = `[${process.env.PORTAL_DB_NAME || 'VorteqPortal'}]`; - const inventory = await getProcessedRRSummary( - activeCompany.epicor_cust_id, - dbName, - userIsSubUser + const inventory = await cachedQuery( + { key: `inv:${custId}:processed-rr:summary`, ttlSeconds: 300 }, + () => getProcessedRRSummary(custId, dbName, userIsSubUser) ).catch(() => []); return ; diff --git a/src/app/(portal)/inventory/unprocessed-rr/page.tsx b/src/app/(portal)/inventory/unprocessed-rr/page.tsx index f732bff..f6d1520 100644 --- a/src/app/(portal)/inventory/unprocessed-rr/page.tsx +++ b/src/app/(portal)/inventory/unprocessed-rr/page.tsx @@ -8,13 +8,16 @@ import { import { redirect } from 'next/navigation'; import { InventorySummaryTable } from '@/components/inventory/inventory-summary-table'; import { Card, CardContent } from '@/components/ui/card'; +import { cachedQuery } from '@/lib/cache'; export const dynamic = 'force-dynamic'; async function UnprocessedRRInventoryData() { - const session = await getQuestSession(); - const activeCompany = await getActiveCompany(); - const userIsSubUser = await isSubUser(); + const [session, activeCompany, userIsSubUser] = await Promise.all([ + getQuestSession(), + getActiveCompany(), + isSubUser(), + ]); if (!session || !activeCompany) { redirect('/select-company'); @@ -25,12 +28,12 @@ async function UnprocessedRRInventoryData() { redirect('/inventory'); } + const custId = activeCompany.epicor_cust_id; const dbName = `[${process.env.PORTAL_DB_NAME || 'VorteqPortal'}]`; - const inventory = await getUnprocessedRRSummary( - activeCompany.epicor_cust_id, - dbName, - userIsSubUser + const inventory = await cachedQuery( + { key: `inv:${custId}:unprocessed-rr:summary`, ttlSeconds: 300 }, + () => getUnprocessedRRSummary(custId, dbName, userIsSubUser) ).catch(() => []); return ; diff --git a/src/app/(portal)/inventory/unprocessed/page.tsx b/src/app/(portal)/inventory/unprocessed/page.tsx index 7f4523c..4cb1fe2 100644 --- a/src/app/(portal)/inventory/unprocessed/page.tsx +++ b/src/app/(portal)/inventory/unprocessed/page.tsx @@ -8,13 +8,16 @@ import { import { redirect } from 'next/navigation'; import { InventorySummaryTable } from '@/components/inventory/inventory-summary-table'; import { Card, CardContent } from '@/components/ui/card'; +import { cachedQuery } from '@/lib/cache'; export const dynamic = 'force-dynamic'; async function UnprocessedInventoryData() { - const session = await getQuestSession(); - const activeCompany = await getActiveCompany(); - const userIsSubUser = await isSubUser(); + const [session, activeCompany, userIsSubUser] = await Promise.all([ + getQuestSession(), + getActiveCompany(), + isSubUser(), + ]); if (!session || !activeCompany) { redirect('/select-company'); @@ -25,12 +28,12 @@ async function UnprocessedInventoryData() { redirect('/inventory'); } + const custId = activeCompany.epicor_cust_id; const dbName = `[${process.env.PORTAL_DB_NAME || 'VorteqPortal'}]`; - const inventory = await getUnprocessedSummary( - activeCompany.epicor_cust_id, - dbName, - userIsSubUser + const inventory = await cachedQuery( + { key: `inv:${custId}:unprocessed:summary`, ttlSeconds: 300 }, + () => getUnprocessedSummary(custId, dbName, userIsSubUser) ).catch(() => []); return ; diff --git a/src/app/(portal)/inventory/wip/page.tsx b/src/app/(portal)/inventory/wip/page.tsx index c9755de..92ff228 100644 --- a/src/app/(portal)/inventory/wip/page.tsx +++ b/src/app/(portal)/inventory/wip/page.tsx @@ -6,23 +6,26 @@ import { getQuestSession, getActiveCompany, isSubUser } from '@/lib/permissions' import { redirect } from 'next/navigation'; import { InventorySummaryTable } from '@/components/inventory/inventory-summary-table'; import { Card, CardContent } from '@/components/ui/card'; +import { cachedQuery } from '@/lib/cache'; async function WIPInventoryData() { - const session = await getQuestSession(); - const activeCompany = await getActiveCompany(); - const userIsSubUser = await isSubUser(); + const [session, activeCompany, userIsSubUser] = await Promise.all([ + getQuestSession(), + getActiveCompany(), + isSubUser(), + ]); if (!session || !activeCompany) { redirect('/select-company'); } + const custId = activeCompany.epicor_cust_id; const dbName = `[${process.env.PORTAL_DB_NAME || 'VorteqPortal'}]`; const sub = userIsSubUser ? 1 : 0; - const inventory = await getWorkInProgressSummary( - activeCompany.epicor_cust_id, - dbName, - sub + const inventory = await cachedQuery( + { key: `inv:${custId}:wip:summary:${sub}`, ttlSeconds: 300 }, + () => getWorkInProgressSummary(custId, dbName, sub) ).catch(() => []); return ; diff --git a/src/app/(portal)/orders/loading.tsx b/src/app/(portal)/orders/loading.tsx new file mode 100644 index 0000000..d7d0501 --- /dev/null +++ b/src/app/(portal)/orders/loading.tsx @@ -0,0 +1,24 @@ +import { Card, CardContent } from '@/components/ui/card'; + +export default function OrdersLoading() { + return ( +
+

Orders

+

+ View your most recent orders and order acknowledgements +

+ + +
+ {[...Array(10)].map((_, i) => ( +
+ ))} +
+ + +
+ ); +} diff --git a/src/app/(portal)/orders/page.tsx b/src/app/(portal)/orders/page.tsx index e191cc8..9d33495 100644 --- a/src/app/(portal)/orders/page.tsx +++ b/src/app/(portal)/orders/page.tsx @@ -4,23 +4,29 @@ import { getTop100Orders } from '@/services/orders'; import { getQuestSession, getActiveCompany } from '@/lib/permissions'; import { OrdersTable } from '@/components/orders/orders-table'; import { Card, CardContent } from '@/components/ui/card'; +import { cachedQuery } from '@/lib/cache'; export const dynamic = 'force-dynamic'; async function OrdersData() { - const session = await getQuestSession(); - const activeCompany = await getActiveCompany(); + const [session, activeCompany] = await Promise.all([ + getQuestSession(), + getActiveCompany(), + ]); if (!session || !activeCompany) { redirect('/select-company'); } - const orders = await getTop100Orders(activeCompany.epicor_cust_id).catch( - (err) => { - console.error('Failed to fetch orders:', err); - return []; - } - ); + const custId = activeCompany.epicor_cust_id; + + const orders = await cachedQuery( + { key: `orders:${custId}:top100`, ttlSeconds: 300 }, + () => getTop100Orders(custId) + ).catch((err) => { + console.error('Failed to fetch orders:', err); + return []; + }); return ; } diff --git a/src/app/(portal)/shipments/loading.tsx b/src/app/(portal)/shipments/loading.tsx new file mode 100644 index 0000000..be00c7a --- /dev/null +++ b/src/app/(portal)/shipments/loading.tsx @@ -0,0 +1,24 @@ +import { Card, CardContent } from '@/components/ui/card'; + +export default function ShipmentsLoading() { + return ( +
+

Shipments

+

+ View your most recent shipments and BOL details +

+ + +
+ {[...Array(10)].map((_, i) => ( +
+ ))} +
+ + +
+ ); +} diff --git a/src/app/api/available-coils/route.ts b/src/app/api/available-coils/route.ts index 451f24a..6420c1f 100644 --- a/src/app/api/available-coils/route.ts +++ b/src/app/api/available-coils/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { getQuestSession, getActiveCompany, requirePermission } from '@/lib/permissions'; import { getAvailableCoils } from '@/services/available-coils'; +import { cachedQuery } from '@/lib/cache'; export const dynamic = 'force-dynamic'; @@ -28,12 +29,12 @@ export async function GET(request: NextRequest) { return NextResponse.json({ error: 'jobNumber is required' }, { status: 400 }); } + const custId = activeCompany.epicor_cust_id; const dbName = `[${process.env.PORTAL_DB_NAME || 'VorteqPortal'}]`; - const coils = await getAvailableCoils( - activeCompany.epicor_cust_id, - dbName, - jobNumber + const coils = await cachedQuery( + { key: `avail-coils:${custId}:${jobNumber}`, ttlSeconds: 120 }, + () => getAvailableCoils(custId, dbName, jobNumber) ); return NextResponse.json(coils); diff --git a/src/app/api/coil-activity/coil-by-coil/route.ts b/src/app/api/coil-activity/coil-by-coil/route.ts index bbcb385..d671a16 100644 --- a/src/app/api/coil-activity/coil-by-coil/route.ts +++ b/src/app/api/coil-activity/coil-by-coil/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { getQuestSession, getActiveCompany } from '@/lib/permissions'; import { getCoilByCoil } from '@/services/coil-activity'; +import { cachedQuery } from '@/lib/cache'; export const dynamic = 'force-dynamic'; @@ -19,8 +20,13 @@ export async function GET(request: NextRequest) { return NextResponse.json({ error: 'jobNum is required' }, { status: 400 }); } + const custId = activeCompany.epicor_cust_id; + try { - const data = await getCoilByCoil(jobNum, activeCompany.epicor_cust_id); + const data = await cachedQuery( + { key: `coil:${custId}:cbc:${jobNum}`, ttlSeconds: 300 }, + () => getCoilByCoil(jobNum, custId) + ); return NextResponse.json(data); } catch (err) { const message = err instanceof Error ? err.message : String(err); diff --git a/src/app/api/coil-activity/receipts/route.ts b/src/app/api/coil-activity/receipts/route.ts index 4a3e49c..300be5b 100644 --- a/src/app/api/coil-activity/receipts/route.ts +++ b/src/app/api/coil-activity/receipts/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { getCoilReceipts } from '@/services/coil-activity'; import { getQuestSession, getActiveCompany } from '@/lib/permissions'; +import { cachedQuery } from '@/lib/cache'; export const dynamic = 'force-dynamic'; @@ -44,11 +45,12 @@ export async function GET(request: NextRequest) { ); } + const custId = activeCompany.epicor_cust_id; + try { - const data = await getCoilReceipts( - activeCompany.epicor_cust_id, - startDate, - endDate + const data = await cachedQuery( + { key: `coil:${custId}:receipts:${startDate}:${endDate}`, ttlSeconds: 300 }, + () => getCoilReceipts(custId, startDate, endDate) ); return NextResponse.json(data); } catch (err) { diff --git a/src/app/api/coil-activity/usage/route.ts b/src/app/api/coil-activity/usage/route.ts index d98e1e0..08838f8 100644 --- a/src/app/api/coil-activity/usage/route.ts +++ b/src/app/api/coil-activity/usage/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { getCoilUsage } from '@/services/coil-activity'; import { getQuestSession, getActiveCompany } from '@/lib/permissions'; +import { cachedQuery } from '@/lib/cache'; export const dynamic = 'force-dynamic'; @@ -46,11 +47,12 @@ export async function GET(request: NextRequest) { ); } + const custId = activeCompany.epicor_cust_id; + try { - const data = await getCoilUsage( - activeCompany.epicor_cust_id, - startDate, - endDate + const data = await cachedQuery( + { key: `coil:${custId}:usage:${startDate}:${endDate}`, ttlSeconds: 300 }, + () => getCoilUsage(custId, startDate, endDate) ); return NextResponse.json(data); } catch (err) { diff --git a/src/app/api/dashboard/route.ts b/src/app/api/dashboard/route.ts index 876330c..a521c2a 100644 --- a/src/app/api/dashboard/route.ts +++ b/src/app/api/dashboard/route.ts @@ -6,6 +6,7 @@ import { } from '@/services/dashboard'; import { getQuestSession, getActiveCompany } from '@/lib/permissions'; import { getUnreadAlertCount } from '@/services/notifications'; +import { cachedQuery } from '@/lib/cache'; export const dynamic = 'force-dynamic'; @@ -17,12 +18,23 @@ export async function GET() { return NextResponse.json({ error: 'No active company' }, { status: 401 }); } + const custId = activeCompany.epicor_cust_id; + try { const [orders, shipments, inventorySummary, unreadNotifications] = await Promise.all([ - getRecentOrders(activeCompany.epicor_cust_id).catch(() => []), - getRecentShipments(activeCompany.epicor_cust_id).catch(() => []), - getInventorySummary(activeCompany.epicor_cust_id).catch(() => ({ + cachedQuery( + { key: `dash:${custId}:orders`, ttlSeconds: 180 }, + () => getRecentOrders(custId) + ).catch(() => []), + cachedQuery( + { key: `dash:${custId}:shipments`, ttlSeconds: 180 }, + () => getRecentShipments(custId) + ).catch(() => []), + cachedQuery( + { key: `dash:${custId}:inv-summary`, ttlSeconds: 180 }, + () => getInventorySummary(custId) + ).catch(() => ({ wip_count: 0, finished_goods_count: 0, unprocessed_count: 0, diff --git a/src/app/api/inventory/details/route.ts b/src/app/api/inventory/details/route.ts index fc25732..8950846 100644 --- a/src/app/api/inventory/details/route.ts +++ b/src/app/api/inventory/details/route.ts @@ -8,6 +8,7 @@ import { getInventoryDetails, type InventoryCategory, } from '@/services/inventory'; +import { cachedQuery } from '@/lib/cache'; const VALID_CATEGORIES: InventoryCategory[] = [ 'wip', @@ -60,15 +61,14 @@ export async function GET(request: NextRequest) { return NextResponse.json({ data: [] }); } + const custId = activeCompany.epicor_cust_id; const dbName = `[${process.env.PORTAL_DB_NAME || 'VorteqPortal'}]`; const sub = userIsSubUser ? 1 : 0; - const details = await getInventoryDetails( - category, - activeCompany.epicor_cust_id, - dbName, - sub, - { partNum: part, plant, warehouse } + const filterKey = [part, plant, warehouse].filter(Boolean).join(':') || 'all'; + const details = await cachedQuery( + { key: `inv:${custId}:${category}:detail:${sub}:${filterKey}`, ttlSeconds: 300 }, + () => getInventoryDetails(category, custId, dbName, sub, { partNum: part, plant, warehouse }) ); return NextResponse.json({ data: details }); diff --git a/src/app/api/jobs/[jobNum]/traveler/route.ts b/src/app/api/jobs/[jobNum]/traveler/route.ts index 810f8b2..77f1634 100644 --- a/src/app/api/jobs/[jobNum]/traveler/route.ts +++ b/src/app/api/jobs/[jobNum]/traveler/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { getQuestSession, getActiveCompany } from '@/lib/permissions'; import { getJobTraveler } from '@/services/jobs'; +import { cachedQuery } from '@/lib/cache'; export const dynamic = 'force-dynamic'; @@ -16,9 +17,13 @@ export async function GET( } const { jobNum } = await params; + const custId = activeCompany.epicor_cust_id; try { - const data = await getJobTraveler(jobNum); + const data = await cachedQuery( + { key: `jobs:${custId}:traveler:${jobNum}`, ttlSeconds: 600 }, + () => getJobTraveler(jobNum) + ); if (data === null) { return NextResponse.json({ error: 'Job not found' }, { status: 404 }); diff --git a/src/app/api/jobs/status/route.ts b/src/app/api/jobs/status/route.ts index d17562f..59bd83c 100644 --- a/src/app/api/jobs/status/route.ts +++ b/src/app/api/jobs/status/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { getQuestSession, getActiveCompany } from '@/lib/permissions'; import { getJobStatusByPlant } from '@/services/jobs'; +import { cachedQuery } from '@/lib/cache'; export const dynamic = 'force-dynamic'; @@ -12,8 +13,13 @@ export async function GET(request: NextRequest) { return NextResponse.json({ error: 'No active company' }, { status: 401 }); } + const custId = activeCompany.epicor_cust_id; + try { - const data = await getJobStatusByPlant(activeCompany.epicor_cust_id); + const data = await cachedQuery( + { key: `jobs:${custId}:status`, ttlSeconds: 300 }, + () => getJobStatusByPlant(custId) + ); return NextResponse.json(data); } catch (err) { const message = err instanceof Error ? err.message : String(err); diff --git a/src/app/api/orders/[orderNum]/route.ts b/src/app/api/orders/[orderNum]/route.ts index 6727a22..0114e43 100644 --- a/src/app/api/orders/[orderNum]/route.ts +++ b/src/app/api/orders/[orderNum]/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { getOrderAcknowledgement } from '@/services/orders'; import { getQuestSession, getActiveCompany } from '@/lib/permissions'; +import { cachedQuery } from '@/lib/cache'; export const dynamic = 'force-dynamic'; @@ -25,10 +26,12 @@ export async function GET( ); } + const custId = activeCompany.epicor_cust_id; + try { - const data = await getOrderAcknowledgement( - orderNumInt, - activeCompany.epicor_cust_id + const data = await cachedQuery( + { key: `order:${custId}:ack:${orderNumInt}`, ttlSeconds: 600 }, + () => getOrderAcknowledgement(orderNumInt, custId) ); if (!data) { diff --git a/src/app/api/ship-to-addresses/route.ts b/src/app/api/ship-to-addresses/route.ts index 06d02b0..d76ea2c 100644 --- a/src/app/api/ship-to-addresses/route.ts +++ b/src/app/api/ship-to-addresses/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from 'next/server'; import { getQuestSession, getActiveCompany, isSubUser } from '@/lib/permissions'; import { getShipToAddresses } from '@/services/ship-to-addresses'; +import { cachedQuery } from '@/lib/cache'; export const dynamic = 'force-dynamic'; @@ -16,13 +17,14 @@ export async function GET() { return NextResponse.json({ error: 'No active company' }, { status: 401 }); } + const custId = activeCompany.epicor_cust_id; const dbName = `[${process.env.PORTAL_DB_NAME || 'VorteqPortal'}]`; const sub = await isSubUser(); + const subKey = sub ? 1 : 0; - const addresses = await getShipToAddresses( - activeCompany.epicor_cust_id, - dbName, - sub + const addresses = await cachedQuery( + { key: `shipto:${custId}:${subKey}`, ttlSeconds: 900 }, + () => getShipToAddresses(custId, dbName, sub) ); return NextResponse.json(addresses); diff --git a/src/app/api/shipments/[bol]/route.ts b/src/app/api/shipments/[bol]/route.ts index 03f436a..f5b01f5 100644 --- a/src/app/api/shipments/[bol]/route.ts +++ b/src/app/api/shipments/[bol]/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from 'next/server'; import { getBOLDetail } from '@/services/shipments'; import { getQuestSession, getActiveCompany } from '@/lib/permissions'; +import { cachedQuery } from '@/lib/cache'; export const dynamic = 'force-dynamic'; @@ -21,8 +22,13 @@ export async function GET( return NextResponse.json({ error: 'Invalid BOL number' }, { status: 400 }); } + const custId = activeCompany.epicor_cust_id; + try { - const data = await getBOLDetail(bolNum, activeCompany.epicor_cust_id); + const data = await cachedQuery( + { key: `bol:${custId}:${bolNum}`, ttlSeconds: 600 }, + () => getBOLDetail(bolNum, custId) + ); if (!data) { return NextResponse.json({ error: 'BOL not found' }, { status: 404 }); diff --git a/src/app/api/shipments/route.ts b/src/app/api/shipments/route.ts index 435176b..c138637 100644 --- a/src/app/api/shipments/route.ts +++ b/src/app/api/shipments/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from 'next/server'; import { getTop100Shipments } from '@/services/shipments'; import { getQuestSession, getActiveCompany } from '@/lib/permissions'; +import { cachedQuery } from '@/lib/cache'; export const dynamic = 'force-dynamic'; @@ -12,8 +13,13 @@ export async function GET() { return NextResponse.json({ error: 'No active company' }, { status: 401 }); } + const custId = activeCompany.epicor_cust_id; + try { - const shipments = await getTop100Shipments(activeCompany.epicor_cust_id); + const shipments = await cachedQuery( + { key: `ship:${custId}:top100`, ttlSeconds: 300 }, + () => getTop100Shipments(custId) + ); return NextResponse.json(shipments); } catch (err) { const message = err instanceof Error ? err.message : String(err); diff --git a/src/lib/cache.ts b/src/lib/cache.ts new file mode 100644 index 0000000..9663edc --- /dev/null +++ b/src/lib/cache.ts @@ -0,0 +1,75 @@ +import { ensureRedisConnected } from '@/lib/redis'; + +type CachedQueryOptions = { + key: string; + ttlSeconds: number; +}; + +/** + * Wraps any async data fetch with Redis caching. + * + * - On cache hit: returns parsed JSON from Redis + * - On cache miss: calls fetchFn, stores result in Redis with TTL, returns result + * - On any Redis error: falls through to fetchFn (graceful degradation) + */ +export async function cachedQuery( + options: CachedQueryOptions, + fetchFn: () => Promise +): Promise { + const { key, ttlSeconds } = options; + + try { + const redis = await ensureRedisConnected(); + const cached = await redis.get(key); + + if (cached !== null) { + return JSON.parse(cached) as T; + } + } catch { + // Redis unavailable — fall through to fetchFn + } + + const result = await fetchFn(); + + try { + const redis = await ensureRedisConnected(); + await redis.set(key, JSON.stringify(result), { EX: ttlSeconds }); + } catch { + // Redis unavailable — result is still returned + } + + return result; +} + +/** + * Delete all keys matching a pattern (e.g. `inv:ACM:*`). + * Uses SCAN to avoid blocking Redis. + */ +export async function invalidatePattern(pattern: string): Promise { + try { + const redis = await ensureRedisConnected(); + let deleted = 0; + + for await (const key of redis.scanIterator({ MATCH: pattern })) { + await redis.del(key); + deleted++; + } + + return deleted; + } catch { + return 0; + } +} + +/** + * Delete a single cache key. + */ +export async function invalidateKey(key: string): Promise { + try { + const redis = await ensureRedisConnected(); + const result = await redis.del(key); + return result > 0; + } catch { + return false; + } +} diff --git a/src/lib/epicor.ts b/src/lib/epicor.ts index e3b53df..09c9cda 100644 --- a/src/lib/epicor.ts +++ b/src/lib/epicor.ts @@ -93,7 +93,15 @@ async function getPool(): Promise { } // Create new connection - connectionPromise = new sql.ConnectionPool(config) + connectionPromise = new sql.ConnectionPool({ + ...config, + pool: { + max: 15, + min: 2, + idleTimeoutMillis: 60000, + acquireTimeoutMillis: 30000, + }, + }) .connect() .then((connectedPool) => { pool = connectedPool; diff --git a/src/lib/redis.ts b/src/lib/redis.ts new file mode 100644 index 0000000..b70a195 --- /dev/null +++ b/src/lib/redis.ts @@ -0,0 +1,29 @@ +import { createClient, type RedisClientType } from 'redis'; + +const globalForRedis = globalThis as unknown as { + redis: RedisClientType | undefined; +}; + +function createRedisClient(): RedisClientType { + const client = createClient({ + url: process.env.REDIS_URL || 'redis://localhost:6379', + }); + + client.on('error', (err) => { + console.error('Redis client error:', err); + }); + + return client as RedisClientType; +} + +export const redis: RedisClientType = + globalForRedis.redis ?? createRedisClient(); + +if (process.env.NODE_ENV !== 'production') globalForRedis.redis = redis; + +export async function ensureRedisConnected(): Promise { + if (!redis.isOpen) { + await redis.connect(); + } + return redis; +} diff --git a/src/services/dashboard.ts b/src/services/dashboard.ts index 84be6be..29b61a7 100644 --- a/src/services/dashboard.ts +++ b/src/services/dashboard.ts @@ -9,8 +9,7 @@ * we need into plain objects. */ -import sql from 'mssql'; -import { execQuery, getPortalDbName } from '@/lib/epicor'; +import { execQuery, execStoredProc, getPortalDbName } from '@/lib/epicor'; import { getTop100Shipments } from '@/services/shipments'; export type DashboardOrder = { @@ -107,7 +106,7 @@ export async function getRecentShipments( /** * Get inventory summary counts for dashboard. * - * Calls the real portal inventory stored procedures: + * Calls the real portal inventory stored procedures via the shared connection pool: * - PortalWorkInProgressInventorySummaryV6 (@Customer, @DBNAME, @SUBUSER) * - PortalFinishedGoodsInventorySummaryV6 (@Customer, @DBNAME, @SUBUSER) * - PortalUnprocessedInventorySummary (@CUSTID, @DBNAME) @@ -120,89 +119,54 @@ export async function getInventorySummary( ): Promise { const dbName = getPortalDbName(); - const config = { - server: process.env.MSSQL_HOST || '', - database: process.env.MSSQL_DATABASE || '', - user: process.env.MSSQL_USER || '', - password: process.env.MSSQL_PASSWORD || '', - port: parseInt(process.env.MSSQL_PORT || '1433', 10), - options: { - encrypt: false, - trustServerCertificate: true, - connectTimeout: 30000, - requestTimeout: 120000, - }, - }; + const [wipRows, fgRows, unprocessedRows] = await Promise.all([ + execStoredProc[]>( + 'PortalWorkInProgressInventorySummaryV6', + { Customer: custId, DBNAME: dbName, SUBUSER: 0 } + ).catch(() => []), + execStoredProc[]>( + 'PortalFinishedGoodsInventorySummaryV6', + { Customer: custId, DBNAME: dbName, SUBUSER: 0 } + ).catch(() => []), + execStoredProc[]>( + 'PortalUnprocessedInventorySummary', + { CUSTID: custId, DBNAME: dbName } + ).catch(() => []), + ]); - const pool = await sql.connect(config); - try { - // Run all three inventory SPs in parallel - const [wipResult, fgResult, unprocessedResult] = await Promise.all([ - pool - .request() - .input('Customer', custId) - .input('DBNAME', dbName) - .input('SUBUSER', 0) - .execute('PortalWorkInProgressInventorySummaryV6') - .catch(() => null), - pool - .request() - .input('Customer', custId) - .input('DBNAME', dbName) - .input('SUBUSER', 0) - .execute('PortalFinishedGoodsInventorySummaryV6') - .catch(() => null), - pool - .request() - .input('CUSTID', custId) - .input('DBNAME', dbName) - .execute('PortalUnprocessedInventorySummary') - .catch(() => null), - ]); + let wipCount = 0; + let fgCount = 0; + let unprocessedCount = 0; + let totalWeight = 0; - // Aggregate: sum Rows for counts, sum OnHandQty for total weight - let wipCount = 0; - let fgCount = 0; - let unprocessedCount = 0; - let totalWeight = 0; - - if (wipResult) { - for (const r of wipResult.recordset) { - wipCount += Number(r.Rows ?? 0); - totalWeight += Number(r.OnHandQty ?? 0); - } - } - - if (fgResult) { - for (const r of fgResult.recordset) { - fgCount += Number(r.Rows ?? 0); - totalWeight += Number(r.OnHandQty ?? 0); - } - } - - if (unprocessedResult) { - for (const r of unprocessedResult.recordset) { - unprocessedCount += Number(r.Rows ?? 0); - totalWeight += Number(r.OnHandQty ?? 0); - } - } - - return { - wip_count: wipCount, - finished_goods_count: fgCount, - unprocessed_count: unprocessedCount, - total_weight: totalWeight, - }; - } finally { - // Don't close the pool — mssql reuses it globally + for (const r of wipRows) { + wipCount += Number(r.Rows ?? 0); + totalWeight += Number(r.OnHandQty ?? 0); } + + for (const r of fgRows) { + fgCount += Number(r.Rows ?? 0); + totalWeight += Number(r.OnHandQty ?? 0); + } + + for (const r of unprocessedRows) { + unprocessedCount += Number(r.Rows ?? 0); + totalWeight += Number(r.OnHandQty ?? 0); + } + + return { + wip_count: wipCount, + finished_goods_count: fgCount, + unprocessed_count: unprocessedCount, + total_weight: totalWeight, + }; } /** * Get unread notification count */ export async function getUnreadNotificationCount( - userId: string + _userId: string ): Promise { // This would query the quest_user_notification_alert_read table // For now, return 0 as placeholder diff --git a/src/services/shipments.ts b/src/services/shipments.ts index f65aec9..1d41345 100644 --- a/src/services/shipments.ts +++ b/src/services/shipments.ts @@ -5,8 +5,7 @@ * SP columns are PascalCase; we map them to snake_case for the UI. */ -import sql from 'mssql'; -import { execQuery, getPortalDbName } from '@/lib/epicor'; +import { execQuery, execStoredProc, getPortalDbName } from '@/lib/epicor'; import type { BOLData } from '@/types/shipments'; export type ShipmentRow = { @@ -20,56 +19,32 @@ export type ShipmentRow = { /** * Get top 100 shipments for a customer. * - * Bypasses execStoredProc to avoid holding 7k+ mssql row objects in scope - * (which causes Next.js RSC serialization to blow the stack for large customers). - * Instead, we query directly and immediately extract only the fields we need. + * Uses the shared connection pool via execStoredProc, then immediately extracts + * only the fields we need into plain objects (RSC-safe). */ export async function getTop100Shipments( custId: string ): Promise { const dbName = getPortalDbName(); - const config = { - server: process.env.MSSQL_HOST || '', - database: process.env.MSSQL_DATABASE || '', - user: process.env.MSSQL_USER || '', - password: process.env.MSSQL_PASSWORD || '', - port: parseInt(process.env.MSSQL_PORT || '1433', 10), - options: { - encrypt: false, - trustServerCertificate: true, - connectTimeout: 30000, - requestTimeout: 120000, - }, - }; + const result = await execStoredProc[]>( + 'portal_GetShipmentsV1', + { CustID: custId, DBNAME: dbName } + ); - const pool = await sql.connect(config); - try { - const result = await pool.request() - .input('CustID', custId) - .input('DBNAME', dbName) - .execute('portal_GetShipmentsV1'); + // Immediately extract only the fields we need into plain objects. + // This prevents mssql row metadata from leaking into RSC serialization. + const rows: ShipmentRow[] = result.map((r) => ({ + bol_num: String(r.BOLNum ?? ''), + ship_date: r.ShipDate ? new Date(r.ShipDate as string).toISOString() : '', + ship_to: String(r.ShipToLoc ?? '').replace(/, /g, '\n'), + plant: String(r.PlantName ?? ''), + weight: Number(r.Pounds ?? 0), + })); - // Immediately extract only the fields we need into plain objects. - // This prevents mssql row metadata from leaking into RSC serialization. - const rows: ShipmentRow[] = []; - for (let i = 0; i < result.recordset.length; i++) { - const r = result.recordset[i]; - rows.push({ - bol_num: String(r.BOLNum ?? ''), - ship_date: r.ShipDate ? new Date(r.ShipDate as string).toISOString() : '', - ship_to: String(r.ShipToLoc ?? '').replace(/, /g, '\n'), - plant: String(r.PlantName ?? ''), - weight: Number(r.Pounds ?? 0), - }); - } - - // Sort by ship date descending and take top 100 - rows.sort((a, b) => b.ship_date.localeCompare(a.ship_date)); - return rows.slice(0, 100); - } finally { - // Don't close the pool — mssql reuses it globally - } + // Sort by ship date descending and take top 100 + rows.sort((a, b) => b.ship_date.localeCompare(a.ship_date)); + return rows.slice(0, 100); } /**