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);
}
/**