diff --git a/middleware.ts b/middleware.ts new file mode 100644 index 00000000..791ba8d3 --- /dev/null +++ b/middleware.ts @@ -0,0 +1,16 @@ +import { NextResponse } from 'next/server'; +import type { NextRequest } from 'next/server'; + +export function middleware(request: NextRequest) { + if (request.nextUrl.pathname.startsWith('/grants/internal')) { + const token = request.cookies.get('esp-internal-auth'); + if (!token) { + return NextResponse.redirect(new URL('/api/auth/google', request.url)); + } + } + return NextResponse.next(); +} + +export const config = { + matcher: ['/grants/internal/:path*'] +}; diff --git a/package.json b/package.json index 2065e562..dbf8de8d 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "react-hook-form": "^7.22.1", "react-intersection-observer": "^8.33.1", "react-markdown": "^8.0.0", + "recharts": "^3.6.0", "redaxios": "^0.4.1", "sharp": "^0.33.2" }, diff --git a/plans/feat-private-grants-explorer.md b/plans/feat-private-grants-explorer.md new file mode 100644 index 00000000..7bd6321d --- /dev/null +++ b/plans/feat-private-grants-explorer.md @@ -0,0 +1,337 @@ +# feat: Private Grants Explorer (Internal Route) + +**Type:** Enhancement +**Priority:** High +**Affects:** `src/pages/grants/internal.tsx`, `src/lib/sf/grants.ts`, `src/components/grants/GrantsTable.tsx` + +--- + +## Overview + +Add a protected `/grants/internal` route to the existing ESP website for internal team members. Requires Google OAuth with `@ethereum.org` domain restriction. Shows extended Salesforce fields not visible on the public grants page. + +## Problem Statement + +The ESP team needs to view grant data with additional internal fields (Cost Center, Grant Evaluator, Grant Round, Budget) without building and maintaining a separate application. + +## Proposed Solution + +Add a protected route to the existing Next.js application: +- Google OAuth with `@ethereum.org` domain restriction +- Middleware protecting `/grants/internal` route +- Extended Salesforce query with private fields +- Existing `GrantsTable` component extended with `showPrivateFields` prop + +### Architecture + +``` +src/ +├── pages/ +│ ├── grants/ +│ │ ├── index.tsx # Existing public page +│ │ └── internal.tsx # NEW: Protected internal page +│ └── api/ +│ ├── auth/ +│ │ ├── google.ts # NEW: OAuth redirect +│ │ └── callback.ts # NEW: OAuth callback + domain check +│ └── grants/ +│ └── internal.ts # NEW: Protected API route +├── lib/sf/ +│ └── grants.ts # EXTEND: Add getPrivateGrants() +├── components/grants/ +│ ├── GrantsTable.tsx # MODIFY: Add privateFields prop +│ └── GrantDetailModal.tsx # MODIFY: Show private fields +├── types/ +│ └── grants.ts # EXTEND: Add private field types +└── middleware.ts # NEW: Protect /grants/internal +``` + +## Technical Approach + +### 1. Middleware Protection + +```typescript +// middleware.ts +import { NextResponse } from 'next/server'; +import type { NextRequest } from 'next/server'; + +export function middleware(request: NextRequest) { + if (request.nextUrl.pathname.startsWith('/grants/internal')) { + const token = request.cookies.get('esp-internal-auth'); + if (!token) { + return NextResponse.redirect(new URL('/api/auth/google', request.url)); + } + } + return NextResponse.next(); +} + +export const config = { + matcher: ['/grants/internal/:path*'], +}; +``` + +### 2. Simple OAuth API Routes + +```typescript +// pages/api/auth/google.ts +export default function handler(req: NextApiRequest, res: NextApiResponse) { + const params = new URLSearchParams({ + client_id: process.env.GOOGLE_CLIENT_ID!, + redirect_uri: `${process.env.NEXT_PUBLIC_BASE_URL}/api/auth/callback`, + response_type: 'code', + scope: 'email profile', + hd: 'ethereum.org', // Restrict to ethereum.org domain + }); + res.redirect(`https://accounts.google.com/o/oauth2/v2/auth?${params}`); +} +``` + +```typescript +// pages/api/auth/callback.ts +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + const { code } = req.query; + + // Exchange code for tokens + const tokenRes = await fetch('https://oauth2.googleapis.com/token', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + code: code as string, + client_id: process.env.GOOGLE_CLIENT_ID!, + client_secret: process.env.GOOGLE_CLIENT_SECRET!, + redirect_uri: `${process.env.NEXT_PUBLIC_BASE_URL}/api/auth/callback`, + grant_type: 'authorization_code', + }), + }); + + const { access_token } = await tokenRes.json(); + + // Get user info + const userRes = await fetch('https://www.googleapis.com/oauth2/v2/userinfo', { + headers: { Authorization: `Bearer ${access_token}` }, + }); + const user = await userRes.json(); + + // Verify domain + if (!user.email?.endsWith('@ethereum.org')) { + return res.redirect('/grants/internal/unauthorized'); + } + + // Set auth cookie (httpOnly, secure, 7 days) + res.setHeader('Set-Cookie', + `esp-internal-auth=${Buffer.from(JSON.stringify({ email: user.email })).toString('base64')}; HttpOnly; Secure; Path=/; Max-Age=604800` + ); + + res.redirect('/grants/internal'); +} +``` + +### 3. Extended Salesforce Query + +```typescript +// src/lib/sf/grants.ts - ADD this function + +const PRIVATE_FIELDS = [ + // Existing public fields + 'Id', + 'Name', + 'Project_Description_Public__c', + 'Opportunity_Domain__c', + 'Opportunity_Output__c', + 'CloseDate', + 'Project_Website__c', + 'Project_Repo_Link__c', + // Private fields (verify exact API names in Salesforce) + 'Cost_Center_Lookup__c', + 'Opportunity_Grant_Evaluator_Lookup__c', + 'Proactive_Community_Grants_Round__c', + 'Amount', + 'StageName', +]; + +export async function getPrivateGrants(): Promise { + const conn = await getSalesforceConnection(); + + const query = ` + SELECT ${PRIVATE_FIELDS.join(', ')} + FROM Opportunity + WHERE RecordType.Name IN ('Sponsorships', 'Proactive community grants', 'Financial support', 'Matching funds') + AND (StageName = 'Closed Won' OR StageName = 'In Progress' OR StageName = 'Pending') + ORDER BY CloseDate DESC + `; + + let allRecords: SFOpportunityRecord[] = []; + let result = await conn.query(query); + allRecords.push(...result.records); + + while (!result.done && result.nextRecordsUrl) { + result = await conn.queryMore(result.nextRecordsUrl); + allRecords.push(...result.records); + } + + return allRecords.map(mapToPrivateGrantRecord); +} +``` + +### 4. Extend GrantsTable Component + +```typescript +// src/types/grants.ts - EXTEND +export interface PrivateGrantFields { + costCenter: string | null; + grantEvaluator: string | null; + grantRound: string | null; + budgetAmount: number | null; + status: string | null; +} + +export type PrivateGrantRecord = GrantRecord & PrivateGrantFields; +``` + +```typescript +// src/components/grants/GrantsTable.tsx - MODIFY props +interface GrantsTableProps { + grants: GrantRecord[] | PrivateGrantRecord[]; + showPrivateFields?: boolean; // NEW + // ... existing props +} + +// In table header, conditionally render private columns: +{showPrivateFields && ( + <> + Cost Center + Evaluator + Budget + Status + +)} + +// In table body, conditionally render private cells: +{showPrivateFields && ( + <> + {(grant as PrivateGrantRecord).costCenter || '-'} + {(grant as PrivateGrantRecord).grantEvaluator || '-'} + {(grant as PrivateGrantRecord).budgetAmount?.toLocaleString() || '-'} + {(grant as PrivateGrantRecord).status || '-'} + +)} +``` + +### 5. Internal Grants Page + +```typescript +// src/pages/grants/internal.tsx +import { GrantsExplorer } from '../../components/grants/GrantsExplorer'; +import { getPrivateGrants } from '../../lib/sf/grants'; + +export default function InternalGrantsPage({ grants }: { grants: PrivateGrantRecord[] }) { + return ( + + +
+ +
+
+ ); +} + +export const getServerSideProps: GetServerSideProps = async ({ req }) => { + // Double-check auth cookie (middleware should have already checked) + const authCookie = req.cookies['esp-internal-auth']; + if (!authCookie) { + return { redirect: { destination: '/api/auth/google', permanent: false } }; + } + + const grants = await getPrivateGrants(); + return { props: { grants } }; +}; +``` + +## Acceptance Criteria + +### Authentication +- [ ] `/grants/internal` redirects unauthenticated users to Google OAuth +- [ ] Only `@ethereum.org` emails can access the page +- [ ] Non-org emails see unauthorized message +- [ ] Auth cookie persists for 7 days +- [ ] Logout clears cookie + +### Private Data +- [ ] Extended Salesforce fields fetched correctly +- [ ] Cost Center, Evaluator, Budget, Status columns visible +- [ ] Private fields shown in detail modal +- [ ] Public grants page unchanged + +### UI/UX +- [ ] Same look and feel as public explorer +- [ ] All existing features work (search, filter, sort, pagination) +- [ ] Table handles additional columns responsively +- [ ] Fixed column widths prevent layout shift + +## Files to Modify/Create + +| File | Change | +|------|--------| +| `middleware.ts` | NEW - Route protection | +| `src/pages/api/auth/google.ts` | NEW - OAuth redirect | +| `src/pages/api/auth/callback.ts` | NEW - OAuth callback | +| `src/pages/grants/internal.tsx` | NEW - Internal page | +| `src/pages/grants/internal/unauthorized.tsx` | NEW - Error page | +| `src/lib/sf/grants.ts` | EXTEND - Add getPrivateGrants() | +| `src/types/grants.ts` | EXTEND - Add private field types | +| `src/components/grants/GrantsTable.tsx` | MODIFY - Add showPrivateFields | +| `src/components/grants/GrantsExplorer.tsx` | MODIFY - Pass showPrivateFields | +| `src/components/grants/GrantDetailModal.tsx` | MODIFY - Show private fields | + +## Environment Variables + +```bash +# Add to .env.local +GOOGLE_CLIENT_ID=your-google-client-id +GOOGLE_CLIENT_SECRET=your-google-client-secret +NEXT_PUBLIC_BASE_URL=http://localhost:3000 # or production URL +``` + +## Google OAuth Setup + +1. Go to [Google Cloud Console](https://console.cloud.google.com) +2. Create OAuth 2.0 credentials +3. Add authorized redirect URI: `https://your-domain.com/api/auth/callback` +4. Copy Client ID and Secret to `.env.local` + +## Pre-Implementation Checklist + +Before coding, verify these Salesforce field names: +- [ ] Cost Center field API name (is it `Cost_Center_Lookup__c` or `Cost_Center__c`?) +- [ ] Grant Evaluator field API name +- [ ] Grant Round field API name +- [ ] Budget/Amount field API name +- [ ] Status field values (picklist options) + +## Out of Scope + +- Role-based permissions (all @ethereum.org users have same access) +- Edit/create grant functionality +- Data export to CSV +- Audit logging + +## Design Decisions + +| Decision | Choice | Rationale | +|----------|--------|-----------| +| Separate app vs route | Protected route | Same codebase, no component duplication, single deployment | +| Auth library | Simple API routes | No new dependency for basic OAuth flow | +| Component sharing | Extend with prop | One source of truth, no drift | +| Data fetching | getServerSideProps | Real-time data for internal use | + +## References + +### Internal +- Public Grants Explorer: `src/pages/grants/index.tsx` +- Salesforce integration: `src/lib/sf/grants.ts` +- Grant types: `src/types/grants.ts` +- Existing components: `src/components/grants/` + +### External +- [Google OAuth 2.0](https://developers.google.com/identity/protocols/oauth2/web-server) +- [Next.js Middleware](https://nextjs.org/docs/app/building-your-application/routing/middleware) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e15c2fd9..7ded7878 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -95,6 +95,9 @@ importers: react-markdown: specifier: ^8.0.0 version: 8.0.7(@types/react@18.3.23)(react@18.3.1) + recharts: + specifier: ^3.6.0 + version: 3.6.0(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react-is@18.3.1)(react@18.3.1)(redux@5.0.1) redaxios: specifier: ^0.4.1 version: 0.4.1 @@ -874,6 +877,17 @@ packages: '@popperjs/core@2.11.8': resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==} + '@reduxjs/toolkit@2.11.2': + resolution: {integrity: sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==} + peerDependencies: + react: ^16.9.0 || ^17.0.0 || ^18 || ^19 + react-redux: ^7.2.1 || ^8.1.3 || ^9.0.0 + peerDependenciesMeta: + react: + optional: true + react-redux: + optional: true + '@rollup/rollup-android-arm-eabi@4.55.1': resolution: {integrity: sha512-9R0DM/ykwfGIlNu6+2U09ga0WXeZ9MRC2Ter8jnz8415VbuIykVuc6bhdrbORFZANDmTDvq26mJrEVTl8TdnDg==} cpu: [arm] @@ -1010,6 +1024,12 @@ packages: peerDependencies: next: '>= 9.5.5' + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@standard-schema/utils@0.3.0': + resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} + '@swc/counter@0.1.3': resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==} @@ -1022,6 +1042,33 @@ packages: '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/d3-array@3.2.2': + resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} + + '@types/d3-color@3.1.3': + resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + + '@types/d3-ease@3.0.2': + resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==} + + '@types/d3-interpolate@3.0.4': + resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} + + '@types/d3-path@3.1.1': + resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==} + + '@types/d3-scale@4.0.9': + resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==} + + '@types/d3-shape@3.1.8': + resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==} + + '@types/d3-time@3.0.4': + resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==} + + '@types/d3-timer@3.0.2': + resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} + '@types/debug@4.1.12': resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} @@ -1107,6 +1154,9 @@ packages: '@types/unist@2.0.11': resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} + '@types/use-sync-external-store@0.0.6': + resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==} + '@types/validator@13.15.2': resolution: {integrity: sha512-y7pa/oEJJ4iGYBxOpfAKn5b9+xuihvzDVnC/OSvlVnGxVg0pOqmjiMafiJ1KVNQEaPZf9HsEp5icEwGg8uIe5Q==} @@ -1538,6 +1588,10 @@ packages: client-only@0.0.1: resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + co-prompt@1.0.0: resolution: {integrity: sha512-uKmEbjDnL9SJTb+TNfIFsATe1F3IsNsR7KDGUG1hq7ColkMV0MSn7dg3eKVS+3wwtyvVqrgfIwi39NOJiknO7Q==} @@ -1627,6 +1681,50 @@ packages: csv-stringify@1.1.2: resolution: {integrity: sha512-3NmNhhd+AkYs5YtM1GEh01VR6PKj6qch2ayfQaltx5xpcAdThjnbbI5eT8CzRVpXfGKAxnmrSYLsNl/4f3eWiw==} + d3-array@3.2.4: + resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} + engines: {node: '>=12'} + + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + + d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} + engines: {node: '>=12'} + + d3-format@3.1.2: + resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==} + engines: {node: '>=12'} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + + d3-path@3.1.0: + resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} + engines: {node: '>=12'} + + d3-scale@4.0.2: + resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} + engines: {node: '>=12'} + + d3-shape@3.2.0: + resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} + engines: {node: '>=12'} + + d3-time-format@4.1.0: + resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==} + engines: {node: '>=12'} + + d3-time@3.1.0: + resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} + engines: {node: '>=12'} + + d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + damerau-levenshtein@1.0.8: resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==} @@ -1672,6 +1770,9 @@ packages: supports-color: optional: true + decimal.js-light@2.5.1: + resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==} + decode-named-character-reference@1.2.0: resolution: {integrity: sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q==} @@ -1796,6 +1897,9 @@ packages: resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} engines: {node: '>= 0.4'} + es-toolkit@1.44.0: + resolution: {integrity: sha512-6penXeZalaV88MM3cGkFZZfOoLGWshWWfdy0tWw/RlVVyhvMaWSBTOvXNeiW3e5FwdS5ePW0LGEu17zT139ktg==} + esbuild@0.27.2: resolution: {integrity: sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==} engines: {node: '>=18'} @@ -1934,6 +2038,9 @@ packages: resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} engines: {node: '>=6'} + eventemitter3@5.0.4: + resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + expect-type@1.3.0: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} @@ -2235,6 +2342,12 @@ packages: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} + immer@10.2.0: + resolution: {integrity: sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==} + + immer@11.1.3: + resolution: {integrity: sha512-6jQTc5z0KJFtr1UgFpIL3N9XSC3saRaI9PwWtzM2pSqkNGtiNkYY2OSwkOGDK2XcTRcLb1pi/aNkKZz0nxVH4Q==} + import-fresh@3.3.1: resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} engines: {node: '>=6'} @@ -2257,6 +2370,10 @@ packages: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} + internmap@2.0.3: + resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} + engines: {node: '>=12'} + is-array-buffer@3.0.5: resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} engines: {node: '>= 0.4'} @@ -2982,6 +3099,18 @@ packages: '@types/react': '>=16' react: '>=16' + react-redux@9.2.0: + resolution: {integrity: sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==} + peerDependencies: + '@types/react': ^18.2.25 || ^19 + react: ^18.0 || ^19 + redux: ^5.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + redux: + optional: true + react-remove-scroll-bar@2.3.8: resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} engines: {node: '>=10'} @@ -3031,9 +3160,25 @@ packages: readable-stream@2.3.8: resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + recharts@3.6.0: + resolution: {integrity: sha512-L5bjxvQRAe26RlToBAziKUB7whaGKEwD3znoM6fz3DrTowCIC/FnJYnuq1GEzB8Zv2kdTfaxQfi5GoH0tBinyg==} + engines: {node: '>=18'} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-is: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + redaxios@0.4.1: resolution: {integrity: sha512-+Jhh1j8/H0KBro+Hih/MrDEJ1PICaU10JA6iu5b3+uvgRI+5n2M7qpMNXq7eC/0fspP0tTq49ONXlGWFdRoNLg==} + redux-thunk@3.1.0: + resolution: {integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==} + peerDependencies: + redux: ^5.0.0 + + redux@5.0.1: + resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==} + reflect.getprototypeof@1.0.10: resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} engines: {node: '>= 0.4'} @@ -3053,6 +3198,9 @@ packages: engines: {node: '>= 6'} deprecated: request has been deprecated, see https://github.com/request/request/issues/3142 + reselect@5.1.1: + resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==} + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -3489,6 +3637,11 @@ packages: '@types/react': optional: true + use-sync-external-store@1.6.0: + resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} @@ -3516,6 +3669,9 @@ packages: vfile@5.3.7: resolution: {integrity: sha512-r7qlzkgErKjobAmyNIkkSpizsFPYiUPuJb5pNW1RB4JcYVZhs4lIbVqk8XPk033CV/1z8ss5pkax8SuhGpcG8g==} + victory-vendor@37.3.6: + resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==} + vite-node@3.2.4: resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} @@ -4351,6 +4507,18 @@ snapshots: '@popperjs/core@2.11.8': {} + '@reduxjs/toolkit@2.11.2(react-redux@9.2.0(@types/react@18.3.23)(react@18.3.1)(redux@5.0.1))(react@18.3.1)': + dependencies: + '@standard-schema/spec': 1.1.0 + '@standard-schema/utils': 0.3.0 + immer: 11.1.3 + redux: 5.0.1 + redux-thunk: 3.1.0(redux@5.0.1) + reselect: 5.1.1 + optionalDependencies: + react: 18.3.1 + react-redux: 9.2.0(@types/react@18.3.23)(react@18.3.1)(redux@5.0.1) + '@rollup/rollup-android-arm-eabi@4.55.1': optional: true @@ -4434,6 +4602,10 @@ snapshots: dependencies: next: 14.2.35(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@standard-schema/spec@1.1.0': {} + + '@standard-schema/utils@0.3.0': {} + '@swc/counter@0.1.3': {} '@swc/helpers@0.5.5': @@ -4451,6 +4623,30 @@ snapshots: '@types/deep-eql': 4.0.2 assertion-error: 2.0.1 + '@types/d3-array@3.2.2': {} + + '@types/d3-color@3.1.3': {} + + '@types/d3-ease@3.0.2': {} + + '@types/d3-interpolate@3.0.4': + dependencies: + '@types/d3-color': 3.1.3 + + '@types/d3-path@3.1.1': {} + + '@types/d3-scale@4.0.9': + dependencies: + '@types/d3-time': 3.0.4 + + '@types/d3-shape@3.1.8': + dependencies: + '@types/d3-path': 3.1.1 + + '@types/d3-time@3.0.4': {} + + '@types/d3-timer@3.0.2': {} + '@types/debug@4.1.12': dependencies: '@types/ms': 2.1.0 @@ -4535,6 +4731,8 @@ snapshots: '@types/unist@2.0.11': {} + '@types/use-sync-external-store@0.0.6': {} + '@types/validator@13.15.2': {} '@typescript-eslint/eslint-plugin@5.62.0(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@5.9.2))(eslint@8.57.1)(typescript@5.9.2)': @@ -4987,6 +5185,8 @@ snapshots: client-only@0.0.1: {} + clsx@2.1.1: {} + co-prompt@1.0.0: dependencies: keypress: 0.2.1 @@ -5073,6 +5273,44 @@ snapshots: dependencies: lodash.get: 4.4.2 + d3-array@3.2.4: + dependencies: + internmap: 2.0.3 + + d3-color@3.1.0: {} + + d3-ease@3.0.1: {} + + d3-format@3.1.2: {} + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-path@3.1.0: {} + + d3-scale@4.0.2: + dependencies: + d3-array: 3.2.4 + d3-format: 3.1.2 + d3-interpolate: 3.0.1 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + + d3-shape@3.2.0: + dependencies: + d3-path: 3.1.0 + + d3-time-format@4.1.0: + dependencies: + d3-time: 3.1.0 + + d3-time@3.1.0: + dependencies: + d3-array: 3.2.4 + + d3-timer@3.0.1: {} + damerau-levenshtein@1.0.8: {} dashdash@1.14.1: @@ -5109,6 +5347,8 @@ snapshots: dependencies: ms: 2.1.3 + decimal.js-light@2.5.1: {} + decode-named-character-reference@1.2.0: dependencies: character-entities: 2.0.2 @@ -5295,6 +5535,8 @@ snapshots: is-date-object: 1.1.0 is-symbol: 1.1.1 + es-toolkit@1.44.0: {} + esbuild@0.27.2: optionalDependencies: '@esbuild/aix-ppc64': 0.27.2 @@ -5539,6 +5781,8 @@ snapshots: event-target-shim@5.0.1: {} + eventemitter3@5.0.4: {} + expect-type@1.3.0: {} extend@3.0.2: {} @@ -5882,6 +6126,10 @@ snapshots: ignore@5.3.2: {} + immer@10.2.0: {} + + immer@11.1.3: {} + import-fresh@3.3.1: dependencies: parent-module: 1.0.1 @@ -5904,6 +6152,8 @@ snapshots: hasown: 2.0.2 side-channel: 1.1.0 + internmap@2.0.3: {} + is-array-buffer@3.0.5: dependencies: call-bind: 1.0.8 @@ -6718,6 +6968,15 @@ snapshots: transitivePeerDependencies: - supports-color + react-redux@9.2.0(@types/react@18.3.23)(react@18.3.1)(redux@5.0.1): + dependencies: + '@types/use-sync-external-store': 0.0.6 + react: 18.3.1 + use-sync-external-store: 1.6.0(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.23 + redux: 5.0.1 + react-remove-scroll-bar@2.3.8(@types/react@18.3.23)(react@18.3.1): dependencies: react: 18.3.1 @@ -6785,8 +7044,34 @@ snapshots: string_decoder: 1.1.1 util-deprecate: 1.0.2 + recharts@3.6.0(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react-is@18.3.1)(react@18.3.1)(redux@5.0.1): + dependencies: + '@reduxjs/toolkit': 2.11.2(react-redux@9.2.0(@types/react@18.3.23)(react@18.3.1)(redux@5.0.1))(react@18.3.1) + clsx: 2.1.1 + decimal.js-light: 2.5.1 + es-toolkit: 1.44.0 + eventemitter3: 5.0.4 + immer: 10.2.0 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-is: 18.3.1 + react-redux: 9.2.0(@types/react@18.3.23)(react@18.3.1)(redux@5.0.1) + reselect: 5.1.1 + tiny-invariant: 1.3.3 + use-sync-external-store: 1.6.0(react@18.3.1) + victory-vendor: 37.3.6 + transitivePeerDependencies: + - '@types/react' + - redux + redaxios@0.4.1: {} + redux-thunk@3.1.0(redux@5.0.1): + dependencies: + redux: 5.0.1 + + redux@5.0.1: {} + reflect.getprototypeof@1.0.10: dependencies: call-bind: 1.0.8 @@ -6845,6 +7130,8 @@ snapshots: tunnel-agent: 0.6.0 uuid: 3.4.0 + reselect@5.1.1: {} + resolve-from@4.0.0: {} resolve-pkg-maps@1.0.0: {} @@ -7397,6 +7684,10 @@ snapshots: optionalDependencies: '@types/react': 18.3.23 + use-sync-external-store@1.6.0(react@18.3.1): + dependencies: + react: 18.3.1 + util-deprecate@1.0.2: {} uuid@3.4.0: {} @@ -7428,6 +7719,23 @@ snapshots: unist-util-stringify-position: 3.0.3 vfile-message: 3.1.4 + victory-vendor@37.3.6: + dependencies: + '@types/d3-array': 3.2.2 + '@types/d3-ease': 3.0.2 + '@types/d3-interpolate': 3.0.4 + '@types/d3-scale': 4.0.9 + '@types/d3-shape': 3.1.8 + '@types/d3-time': 3.0.4 + '@types/d3-timer': 3.0.2 + d3-array: 3.2.4 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-scale: 4.0.2 + d3-shape: 3.2.0 + d3-time: 3.1.0 + d3-timer: 3.0.1 + vite-node@3.2.4(@types/node@17.0.0)(tsx@4.21.0): dependencies: cac: 6.7.14 diff --git a/src/components/grants/GrantDetailModal.tsx b/src/components/grants/GrantDetailModal.tsx new file mode 100644 index 00000000..aeeca862 --- /dev/null +++ b/src/components/grants/GrantDetailModal.tsx @@ -0,0 +1,191 @@ +import { + Box, + Button, + Divider, + Flex, + Heading, + HStack, + Link, + Modal, + ModalBody, + ModalCloseButton, + ModalContent, + ModalOverlay, + Stack, + Text +} from '@chakra-ui/react'; +import { ExternalLink, Github, Mail } from 'lucide-react'; +import { FC } from 'react'; + +import { GrantRecord, PrivateGrantRecord } from '../../types/grants'; + +interface GrantDetailModalProps { + grant: GrantRecord | PrivateGrantRecord | null; + isOpen: boolean; + onClose: () => void; + showPrivateFields?: boolean; +} + +export const GrantDetailModal: FC = ({ + grant, + isOpen, + onClose, + showPrivateFields = false +}) => { + if (!grant) return null; + + const activatedDate = new Date(grant.activatedDate).toLocaleDateString('en-US', { + year: 'numeric', + month: 'long' + }); + + return ( + + + + + + + + + {grant.projectName} + + + + + {grant.description && ( + + {grant.description} + + )} + + + + + Domain + + + {grant.domain || '-'} + + + + + + Output + + + {grant.output || '-'} + + + + + + Awarded + + + {activatedDate} + + + + + {showPrivateFields && ( + <> + + + + Internal Information + + + + + Cost Center + + + {(grant as PrivateGrantRecord).costCenter || '-'} + + + + + + Evaluator + + + {(grant as PrivateGrantRecord).grantEvaluator || '-'} + + + + + + Grant Round + + + {(grant as PrivateGrantRecord).grantRound || '-'} + + + + + + Budget + + + {(grant as PrivateGrantRecord).budgetAmount + ? `$${(grant as PrivateGrantRecord).budgetAmount!.toLocaleString()}` + : '-'} + + + + + + Status + + + {(grant as PrivateGrantRecord).status || '-'} + + + + + )} + + + {grant.projectRepo && ( + + )} + + {grant.publicContact && ( + + )} + + + + + + ); +}; diff --git a/src/components/grants/GrantsDashboard.tsx b/src/components/grants/GrantsDashboard.tsx new file mode 100644 index 00000000..0a7d4e6d --- /dev/null +++ b/src/components/grants/GrantsDashboard.tsx @@ -0,0 +1,151 @@ +import { Box, Grid, Heading, Stat, StatLabel, StatNumber, Text } from '@chakra-ui/react'; +import { FC, useMemo } from 'react'; +import { BarChart, Bar, XAxis, YAxis, ResponsiveContainer, PieChart, Pie, Cell, Tooltip } from 'recharts'; + +import { GrantRecord } from '../../types/grants'; +import { extractFiscalYear } from '../../utils/fiscalYear'; + +interface GrantsDashboardProps { + grants: GrantRecord[]; +} + +const CHART_COLORS = [ + '#e44550', + '#ff4d15', + '#f87045', + '#fb7971', + '#232264', + '#7c7ba1', + '#a9a9b8', + '#30354b' +]; + +export const GrantsDashboard: FC = ({ grants }) => { + const fiscalYearData = useMemo(() => { + const counts: Record = {}; + grants.forEach(grant => { + const fy = extractFiscalYear(grant.fiscalQuarter); + counts[fy] = (counts[fy] || 0) + 1; + }); + return Object.entries(counts) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([name, count]) => ({ name, count })); + }, [grants]); + + const activeThisMonth = useMemo(() => { + const thirtyDaysAgo = new Date(); + thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); + return grants.filter(grant => new Date(grant.activatedDate) >= thirtyDaysAgo).length; + }, [grants]); + + const categoryData = useMemo(() => { + const counts: Record = {}; + grants.forEach(grant => { + const domain = grant.domain || 'Other'; + counts[domain] = (counts[domain] || 0) + 1; + }); + return Object.entries(counts) + .sort(([, a], [, b]) => b - a) + .slice(0, 8) + .map(([name, value]) => ({ name, value })); + }, [grants]); + + return ( + + + + Grants by Fiscal Year + + + + + + + + + + + + + + + + + Active This Month + + + {activeThisMonth} + + + new contracts + + + + + + + By Category + + + + + + {categoryData.map((_, index) => ( + + ))} + + + + + + + {grants.length.toLocaleString()} + + + total + + + + + + ); +}; diff --git a/src/components/grants/GrantsExplorer.tsx b/src/components/grants/GrantsExplorer.tsx new file mode 100644 index 00000000..a3a23fe1 --- /dev/null +++ b/src/components/grants/GrantsExplorer.tsx @@ -0,0 +1,104 @@ +import { Box, Stack, useDisclosure } from '@chakra-ui/react'; +import { FC, useEffect, useMemo, useState } from 'react'; + +import { GrantRecord, PrivateGrantRecord } from '../../types/grants'; +import { GrantsDashboard } from './GrantsDashboard'; +import { GrantsTable } from './GrantsTable'; +import { GrantDetailModal } from './GrantDetailModal'; + +interface GrantsExplorerProps { + grants: GrantRecord[] | PrivateGrantRecord[]; + showPrivateFields?: boolean; +} + +export const GrantsExplorer: FC = ({ grants, showPrivateFields = false }) => { + const [searchQuery, setSearchQuery] = useState(''); + const [domainFilter, setDomainFilter] = useState(null); + const [outputFilter, setOutputFilter] = useState(null); + const [yearFilter, setYearFilter] = useState(null); + const [selectedGrant, setSelectedGrant] = useState(null); + const [currentPage, setCurrentPage] = useState(1); + const { isOpen, onOpen, onClose } = useDisclosure(); + + // Reset to page 1 when filters change + useEffect(() => { + setCurrentPage(1); + }, [searchQuery, domainFilter, outputFilter, yearFilter]); + + const domainOptions = useMemo(() => { + const domains = Array.from(new Set(grants.map(g => g.domain).filter((d): d is string => d !== null))); + // Ensure "Other" is always available + if (!domains.includes('Other')) { + domains.push('Other'); + } + return domains.sort(); + }, [grants]); + + const outputOptions = useMemo(() => { + return Array.from(new Set(grants.map(g => g.output).filter((o): o is string => o !== null))).sort(); + }, [grants]); + + const yearOptions = useMemo(() => { + // Extract fiscal year from fiscalQuarter (e.g., "FY25 Q3" -> "FY25") + return Array.from(new Set(grants.map(g => g.fiscalQuarter.split(' ')[0]))).sort().reverse(); + }, [grants]); + + const filteredGrants = useMemo(() => { + return grants.filter(grant => { + const matchesSearch = + searchQuery === '' || + grant.projectName.toLowerCase().includes(searchQuery.toLowerCase()) || + grant.description?.toLowerCase().includes(searchQuery.toLowerCase()); + + const matchesDomain = domainFilter === null || grant.domain === domainFilter; + const matchesOutput = outputFilter === null || grant.output === outputFilter; + const matchesYear = yearFilter === null || grant.fiscalQuarter.startsWith(yearFilter); + + return matchesSearch && matchesDomain && matchesOutput && matchesYear; + }); + }, [grants, searchQuery, domainFilter, outputFilter, yearFilter]); + + const handleGrantClick = (grant: GrantRecord) => { + setSelectedGrant(grant); + onOpen(); + }; + + const handleCloseModal = () => { + setSelectedGrant(null); + onClose(); + }; + + return ( + + + + + + + + + + ); +}; diff --git a/src/components/grants/GrantsTable.tsx b/src/components/grants/GrantsTable.tsx new file mode 100644 index 00000000..9c36a58c --- /dev/null +++ b/src/components/grants/GrantsTable.tsx @@ -0,0 +1,432 @@ +import { + Box, + Flex, + IconButton, + Input, + InputGroup, + InputLeftElement, + Menu, + MenuButton, + MenuItem, + MenuList, + Stack, + Table, + Tbody, + Td, + Text, + Th, + Thead, + Tr, + chakra +} from '@chakra-ui/react'; +import { ChevronDown, ChevronLeft, ChevronRight, ChevronUp, Search } from 'lucide-react'; +import { FC, useMemo, useRef, useState } from 'react'; + +import { GrantRecord, PrivateGrantRecord } from '../../types/grants'; +import { SelectArrowIcon } from '../UI/icons'; + +const Button = chakra('button'); + +const PAGE_SIZE = 15; + +interface GrantsTableProps { + grants: GrantRecord[] | PrivateGrantRecord[]; + searchQuery: string; + onSearchChange: (query: string) => void; + domainFilter: string | null; + onDomainFilterChange: (domain: string | null) => void; + domainOptions: string[]; + outputFilter: string | null; + onOutputFilterChange: (output: string | null) => void; + outputOptions: string[]; + yearFilter: string | null; + onYearFilterChange: (year: string | null) => void; + yearOptions: string[]; + onGrantClick: (grant: GrantRecord | PrivateGrantRecord) => void; + currentPage: number; + onPageChange: (page: number) => void; + showPrivateFields?: boolean; +} + +export const GrantsTable: FC = ({ + grants, + searchQuery, + onSearchChange, + domainFilter, + onDomainFilterChange, + domainOptions, + outputFilter, + onOutputFilterChange, + outputOptions, + yearFilter, + onYearFilterChange, + yearOptions, + onGrantClick, + currentPage, + onPageChange, + showPrivateFields = false +}) => { + const tableRef = useRef(null); + const [sortColumn, setSortColumn] = useState(null); + const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc'); + + // Sort grants + const sortedGrants = useMemo(() => { + if (!sortColumn) return grants; + + return [...grants].sort((a, b) => { + const aVal = a[sortColumn] ?? ''; + const bVal = b[sortColumn] ?? ''; + + if (aVal < bVal) return sortDirection === 'asc' ? -1 : 1; + if (aVal > bVal) return sortDirection === 'asc' ? 1 : -1; + return 0; + }); + }, [grants, sortColumn, sortDirection]); + + const handleSort = (column: keyof GrantRecord) => { + if (sortColumn === column) { + if (sortDirection === 'asc') { + setSortDirection('desc'); + } else { + // Third click clears sort + setSortColumn(null); + setSortDirection('asc'); + } + } else { + setSortColumn(column); + setSortDirection('asc'); + } + onPageChange(1); // Reset to first page on sort + }; + + const SortIcon = ({ column }: { column: keyof GrantRecord }) => { + if (sortColumn !== column) return null; + return sortDirection === 'asc' ? : ; + }; + + // Pagination calculations + const totalPages = Math.max(1, Math.ceil(sortedGrants.length / PAGE_SIZE)); + const safePage = Math.min(currentPage, totalPages); + const startIndex = (safePage - 1) * PAGE_SIZE; + const endIndex = Math.min(startIndex + PAGE_SIZE, sortedGrants.length); + const paginatedGrants = sortedGrants.slice(startIndex, endIndex); + + const handlePageChange = (page: number) => { + onPageChange(page); + tableRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + }; + + return ( + + + + + + + onSearchChange(e.target.value)} + borderColor='brand.border' + _focus={{ borderColor: 'brand.heading', boxShadow: 'none' }} + /> + + + + + + + {domainFilter || 'All Domains'} + + + + + onDomainFilterChange(null)} fontWeight={!domainFilter ? 'bold' : 'normal'}> + All Domains + + {domainOptions.map(domain => ( + onDomainFilterChange(domain)} + fontWeight={domainFilter === domain ? 'bold' : 'normal'} + > + {domain} + + ))} + + + + + + + {outputFilter || 'All Outputs'} + + + + + onOutputFilterChange(null)} fontWeight={!outputFilter ? 'bold' : 'normal'}> + All Outputs + + {outputOptions.map(output => ( + onOutputFilterChange(output)} + fontWeight={outputFilter === output ? 'bold' : 'normal'} + > + {output} + + ))} + + + + + + + {yearFilter || 'All Years'} + + + + + onYearFilterChange(null)} fontWeight={!yearFilter ? 'bold' : 'normal'}> + All Years + + {yearOptions.map(year => ( + onYearFilterChange(year)} + fontWeight={yearFilter === year ? 'bold' : 'normal'} + > + {year} + + ))} + + + + + + + Showing {grants.length === 0 ? 0 : startIndex + 1}-{endIndex} of {grants.length.toLocaleString()} projects + + + + + + {showPrivateFields ? ( + <> + + + + + + + + + + ) : ( + <> + + + + + + )} + + + + + + + + {showPrivateFields && ( + <> + + + + + + )} + + + + {paginatedGrants.length === 0 ? ( + + + + ) : ( + paginatedGrants.map(grant => ( + onGrantClick(grant)} + cursor='pointer' + _hover={{ bg: 'orange.50' }} + transition='background 0.15s' + > + + + + + {showPrivateFields && ( + <> + + + + + + )} + + )) + )} + +
handleSort('projectName')} + _hover={{ color: 'brand.heading' }} + > + + Project Name + + + handleSort('domain')} + _hover={{ color: 'brand.heading' }} + > + + Domain + + + handleSort('output')} + _hover={{ color: 'brand.heading' }} + > + + Output + + + handleSort('fiscalQuarter')} + _hover={{ color: 'brand.heading' }} + > + + Date + + + + Cost Center + + Evaluator + + Budget + + Status +
+ No projects found matching your criteria +
+ + {grant.projectName} + + + { + if (grant.domain) { + e.stopPropagation(); + onDomainFilterChange(grant.domain); + } + }} + > + {grant.domain || '-'} + + + { + if (grant.output) { + e.stopPropagation(); + onOutputFilterChange(grant.output); + } + }} + > + {grant.output || '-'} + + + + {grant.fiscalQuarter} + + + + {(grant as PrivateGrantRecord).costCenter || '-'} + + + + {(grant as PrivateGrantRecord).grantEvaluator || '-'} + + + + {(grant as PrivateGrantRecord).budgetAmount + ? `$${(grant as PrivateGrantRecord).budgetAmount!.toLocaleString()}` + : '-'} + + + + {(grant as PrivateGrantRecord).status || '-'} + +
+
+ + {/* Pagination controls - only show if more than one page */} + {totalPages > 1 && ( + + } + size='sm' + variant='outline' + onClick={() => handlePageChange(safePage - 1)} + isDisabled={safePage === 1} + /> + + Page {safePage} of {totalPages} + + } + size='sm' + variant='outline' + onClick={() => handlePageChange(safePage + 1)} + isDisabled={safePage === totalPages} + /> + + )} +
+ ); +}; diff --git a/src/components/grants/index.ts b/src/components/grants/index.ts new file mode 100644 index 00000000..f030fb91 --- /dev/null +++ b/src/components/grants/index.ts @@ -0,0 +1,4 @@ +export { GrantsExplorer } from './GrantsExplorer'; +export { GrantsDashboard } from './GrantsDashboard'; +export { GrantsTable } from './GrantsTable'; +export { GrantDetailModal } from './GrantDetailModal'; diff --git a/src/lib/sf/grants.ts b/src/lib/sf/grants.ts new file mode 100644 index 00000000..752e9135 --- /dev/null +++ b/src/lib/sf/grants.ts @@ -0,0 +1,291 @@ +import { + GrantRecord, + PrivateGrantRecord, + SFOpportunityRecord, + SFPrivateOpportunityRecord +} from '../../types/grants'; +import { deriveFiscalQuarter, getFiscalYearStart } from '../../utils/fiscalYear'; +import { createConnection, loginToSalesforce } from './index'; + +/** + * Whitelist of record types to show in public grants explorer + */ +const PUBLIC_RECORD_TYPES = [ + 'Sponsorships', + 'Proactive community grants', + 'Financial support', + 'Matching funds' +]; + +/** + * Stages to exclude from public view + */ +const EXCLUDED_STAGES = ['In Progress', 'Prospecting']; + +/** + * Get all public grants from Salesforce + * Fetches Opportunity records with whitelisted record types + * from the last 2 fiscal years + */ +export async function getPublicGrants(): Promise { + const conn = createConnection(); + + try { + await loginToSalesforce(conn); + } catch { + console.warn('Salesforce login failed, returning mock grants for development'); + return getMockGrants(); + } + + const twoFYAgo = getFiscalYearStart(2); + + const recordTypesFilter = PUBLIC_RECORD_TYPES.map(t => `'${t}'`).join(', '); + const stagesFilter = EXCLUDED_STAGES.map(s => `'${s}'`).join(', '); + + const query = ` + SELECT + Id, + Name, + Project_Description__c, + Opportunity_Domain__c, + Opportunity_Output__c, + Grantee_Contact_Details__c, + Project_Repo__c, + CloseDate + FROM Opportunity + WHERE + RecordType.Name IN (${recordTypesFilter}) + AND Type != 'Impact Gift' + AND CloseDate != NULL + AND CloseDate >= ${twoFYAgo} + AND StageName NOT IN (${stagesFilter}) + ORDER BY CloseDate DESC + `; + + try { + let allRecords: SFOpportunityRecord[] = []; + let result = await conn.query(query); + allRecords.push(...result.records); + + // Paginate through all results (default batch is ~2000) + while (!result.done && result.nextRecordsUrl) { + result = await conn.queryMore(result.nextRecordsUrl); + allRecords.push(...result.records); + } + + return allRecords + .map(mapSFRecordToGrant) + .filter((r): r is GrantRecord => r !== null); + } catch (error) { + console.error('Salesforce query failed:', error); + console.warn('Returning mock grants for development'); + return getMockGrants(); + } +} + +/** + * Private fields to include for internal grants view + * Matching June's exact query (Jan 2026) + */ +const PRIVATE_FIELDS = [ + 'Id', + 'Name', + 'Project_Description__c', + 'Opportunity_Domain__c', + 'Opportunity_Output__c', + 'Grantee_Contact_Details__c', + 'Project_Repo__c', + 'CloseDate', + 'Cost_Center_Lookup__r.Name', + 'Opportunity_Grant_Evaluator_Lookup__r.Name', + 'Amount', + 'StageName' +]; + +/** + * Record types to exclude from internal grants view + */ +const EXCLUDED_RECORD_TYPES = ['Private Grant', 'Non-Financial Support']; + +/** + * Get all grants with private fields from Salesforce + * For internal use only - requires authentication + * Uses exclusion list rather than whitelist for broader coverage + */ +export async function getPrivateGrants(): Promise { + const conn = createConnection(); + + try { + await loginToSalesforce(conn); + } catch { + console.warn('Salesforce login failed, returning mock private grants for development'); + return getMockPrivateGrants(); + } + + const excludedTypesFilter = EXCLUDED_RECORD_TYPES.map(t => `'${t}'`).join(', '); + + const query = ` + SELECT + ${PRIVATE_FIELDS.join(',\n ')} + FROM Opportunity + WHERE + RecordType.Name NOT IN (${excludedTypesFilter}) + AND Type != 'Impact Gift' + AND CloseDate != NULL + AND CloseDate >= 2024-01-01 + ORDER BY CloseDate DESC + `; + + try { + let allRecords: SFPrivateOpportunityRecord[] = []; + let result = await conn.query(query); + allRecords.push(...result.records); + + // Paginate through all results + while (!result.done && result.nextRecordsUrl) { + result = await conn.queryMore(result.nextRecordsUrl); + allRecords.push(...result.records); + } + + console.log(`Raw Salesforce records: ${allRecords.length}`); + const grants = allRecords + .map(mapSFRecordToPrivateGrant) + .filter((r): r is PrivateGrantRecord => r !== null); + console.log(`After filtering: ${grants.length} private grants`); + return grants; + } catch (error) { + console.error('Salesforce query failed:', error); + console.warn('Returning mock private grants for development'); + return getMockPrivateGrants(); + } +} + +/** + * Map Salesforce record to frontend PrivateGrantRecord + */ +function mapSFRecordToPrivateGrant(record: SFPrivateOpportunityRecord): PrivateGrantRecord | null { + if (!record.CloseDate) { + console.warn(`Grant ${record.Id} excluded: missing close date`); + return null; + } + + return { + id: record.Id, + projectName: record.Name, + description: record.Project_Description__c || null, + domain: record.Opportunity_Domain__c || null, + output: record.Opportunity_Output__c || null, + publicContact: record.Grantee_Contact_Details__c || null, + projectRepo: record.Project_Repo__c || null, + activatedDate: record.CloseDate, + fiscalQuarter: deriveFiscalQuarter(record.CloseDate), + // Private fields + costCenter: record.Cost_Center_Lookup__r?.Name || null, + grantEvaluator: record.Opportunity_Grant_Evaluator_Lookup__r?.Name || null, + grantRound: null, + budgetAmount: record.Amount || null, + status: record.StageName || null + }; +} + +/** + * Mock private grants data for development + */ +function getMockPrivateGrants(): PrivateGrantRecord[] { + const baseGrants = getMockGrants(); + return baseGrants.map((grant, index) => ({ + ...grant, + costCenter: ['CC-001', 'CC-002', 'CC-003'][index % 3], + grantEvaluator: ['Alice Smith', 'Bob Johnson', 'Carol Williams'][index % 3], + grantRound: ['Round 1', 'Round 2', null][index % 3], + budgetAmount: [50000, 100000, 250000, 75000, 150000][index % 5], + status: ['Closed Won', 'In Progress', 'Pending'][index % 3] + })); +} + +/** + * Mock grants data for development and build + * Used when SF is not configured or query fails + */ +function getMockGrants(): GrantRecord[] { + return [ + { + id: '1', + projectName: 'zkEVM Research Initiative', + description: 'Developing novel zero-knowledge proof techniques for EVM compatibility and scaling solutions.', + domain: 'Zero-knowledge Proofs', + output: 'Research', + publicContact: 'contact@example.com', + projectRepo: 'https://github.com/example/zkevm', + activatedDate: '2025-01-15', + fiscalQuarter: 'FY25 Q3' + }, + { + id: '2', + projectName: 'Beacon Chain Monitoring Tools', + description: 'Building comprehensive monitoring and analytics tools for Ethereum consensus layer.', + domain: 'Ethereum Protocol', + output: 'Developer tooling', + publicContact: null, + projectRepo: 'https://github.com/example/beacon-tools', + activatedDate: '2024-11-20', + fiscalQuarter: 'FY25 Q2' + }, + { + id: '3', + projectName: 'Ethereum Developer Education', + description: 'Creating educational resources and workshops for new Ethereum developers.', + domain: 'Community and education', + output: 'Ecosystem Development', + publicContact: 'edu@example.org', + projectRepo: null, + activatedDate: '2024-08-10', + fiscalQuarter: 'FY25 Q1' + }, + { + id: '4', + projectName: 'DeFi Security Auditing Framework', + description: 'Open-source framework for automated smart contract security analysis.', + domain: 'Security', + output: 'Developer tooling', + publicContact: null, + projectRepo: 'https://github.com/example/defi-audit', + activatedDate: '2024-05-22', + fiscalQuarter: 'FY24 Q4' + }, + { + id: '5', + projectName: 'Layer 2 Bridge Standards', + description: 'Research and specification work on cross-L2 bridge standards and interoperability.', + domain: 'Layer 2', + output: 'Research', + publicContact: 'bridges@example.io', + projectRepo: null, + activatedDate: '2024-02-14', + fiscalQuarter: 'FY24 Q3' + } + ]; +} + +/** + * Map Salesforce record to frontend GrantRecord + * Handles null fields gracefully + */ +function mapSFRecordToGrant(record: SFOpportunityRecord): GrantRecord | null { + if (!record.CloseDate) { + console.warn(`Grant ${record.Id} excluded: missing close date`); + return null; + } + + return { + id: record.Id, + projectName: record.Name, + description: record.Project_Description__c || null, + domain: record.Opportunity_Domain__c || null, + output: record.Opportunity_Output__c || null, + publicContact: record.Grantee_Contact_Details__c || null, + projectRepo: record.Project_Repo__c || null, + activatedDate: record.CloseDate, + fiscalQuarter: deriveFiscalQuarter(record.CloseDate) + }; +} diff --git a/src/pages/api/auth/callback.ts b/src/pages/api/auth/callback.ts new file mode 100644 index 00000000..994b9b21 --- /dev/null +++ b/src/pages/api/auth/callback.ts @@ -0,0 +1,70 @@ +import type { NextApiRequest, NextApiResponse } from 'next'; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + const { code, error } = req.query; + const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'; + + // Handle OAuth errors (e.g., user denied consent) + if (error) { + return res.redirect('/grants/internal/unauthorized?error=oauth_denied'); + } + + if (!code || typeof code !== 'string') { + return res.redirect('/grants/internal/unauthorized?error=no_code'); + } + + try { + // Exchange code for tokens + const tokenRes = await fetch('https://oauth2.googleapis.com/token', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + code, + client_id: process.env.GOOGLE_CLIENT_ID!, + client_secret: process.env.GOOGLE_CLIENT_SECRET!, + redirect_uri: `${baseUrl}/api/auth/callback`, + grant_type: 'authorization_code' + }) + }); + + if (!tokenRes.ok) { + console.error('Token exchange failed:', await tokenRes.text()); + return res.redirect('/grants/internal/unauthorized?error=token_exchange'); + } + + const { access_token } = await tokenRes.json(); + + // Get user info + const userRes = await fetch('https://www.googleapis.com/oauth2/v2/userinfo', { + headers: { Authorization: `Bearer ${access_token}` } + }); + + if (!userRes.ok) { + console.error('User info fetch failed:', await userRes.text()); + return res.redirect('/grants/internal/unauthorized?error=user_info'); + } + + const user = await userRes.json(); + + // Verify domain + if (!user.email?.endsWith('@ethereum.org')) { + return res.redirect('/grants/internal/unauthorized?error=invalid_domain'); + } + + // Set auth cookie (httpOnly, secure in production, 7 days) + const isProduction = process.env.NODE_ENV === 'production'; + const cookieValue = Buffer.from( + JSON.stringify({ email: user.email, name: user.name }) + ).toString('base64'); + + res.setHeader( + 'Set-Cookie', + `esp-internal-auth=${cookieValue}; HttpOnly; ${isProduction ? 'Secure; ' : ''}Path=/; Max-Age=604800; SameSite=Lax` + ); + + res.redirect('/grants/internal'); + } catch (error) { + console.error('OAuth callback error:', error); + return res.redirect('/grants/internal/unauthorized?error=unknown'); + } +} diff --git a/src/pages/api/auth/google.ts b/src/pages/api/auth/google.ts new file mode 100644 index 00000000..845f7d42 --- /dev/null +++ b/src/pages/api/auth/google.ts @@ -0,0 +1,15 @@ +import type { NextApiRequest, NextApiResponse } from 'next'; + +export default function handler(req: NextApiRequest, res: NextApiResponse) { + const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'; + + const params = new URLSearchParams({ + client_id: process.env.GOOGLE_CLIENT_ID!, + redirect_uri: `${baseUrl}/api/auth/callback`, + response_type: 'code', + scope: 'email profile', + hd: 'ethereum.org' // Restrict to ethereum.org domain + }); + + res.redirect(`https://accounts.google.com/o/oauth2/v2/auth?${params}`); +} diff --git a/src/pages/api/auth/logout.ts b/src/pages/api/auth/logout.ts new file mode 100644 index 00000000..c80dfe4c --- /dev/null +++ b/src/pages/api/auth/logout.ts @@ -0,0 +1,11 @@ +import type { NextApiRequest, NextApiResponse } from 'next'; + +export default function handler(req: NextApiRequest, res: NextApiResponse) { + // Clear the auth cookie + res.setHeader( + 'Set-Cookie', + 'esp-internal-auth=; HttpOnly; Path=/; Max-Age=0; SameSite=Lax' + ); + + res.redirect('/grants'); +} diff --git a/src/pages/grants/index.tsx b/src/pages/grants/index.tsx new file mode 100644 index 00000000..3d484034 --- /dev/null +++ b/src/pages/grants/index.tsx @@ -0,0 +1,64 @@ +import { Box, Container, Heading, Stack, Text } from '@chakra-ui/react'; +import type { GetStaticProps, NextPage } from 'next'; + +import { PageMetadata } from '../../components/UI'; +import { GrantsExplorer } from '../../components/grants'; +import { getPublicGrants } from '../../lib/sf/grants'; +import { GrantRecord } from '../../types/grants'; + +interface GrantsPageProps { + grants: GrantRecord[]; +} + +const Grants: NextPage = ({ grants }) => { + return ( + <> + + + + + + + + Funded Projects + + + Explore grants awarded by the Ethereum Foundation Ecosystem Support Program. + These projects span research, development, community building, and more across + the Ethereum ecosystem. + + + + + + + + + ); +}; + +export const getStaticProps: GetStaticProps = async () => { + const grants = await getPublicGrants(); + + return { + props: { + grants + }, + revalidate: 3600 + }; +}; + +export default Grants; diff --git a/src/pages/grants/internal.tsx b/src/pages/grants/internal.tsx new file mode 100644 index 00000000..3ea2a4fd --- /dev/null +++ b/src/pages/grants/internal.tsx @@ -0,0 +1,98 @@ +import { Box, Button, Container, Flex, Heading, Stack, Text } from '@chakra-ui/react'; +import type { GetServerSideProps, NextPage } from 'next'; +import { LogOut } from 'lucide-react'; + +import { PageMetadata } from '../../components/UI'; +import { GrantsExplorer } from '../../components/grants'; +import { getPrivateGrants } from '../../lib/sf/grants'; +import { PrivateGrantRecord } from '../../types/grants'; + +interface InternalGrantsPageProps { + grants: PrivateGrantRecord[]; + userEmail: string; +} + +const InternalGrants: NextPage = ({ grants, userEmail }) => { + return ( + <> + + + + + + + + + Internal Grants Explorer + + + Internal dashboard with extended grant information. Only accessible to + @ethereum.org team members. + + + + + + {userEmail} + + + + + + + + + + + ); +}; + +export const getServerSideProps: GetServerSideProps = async ({ req }) => { + // Verify auth cookie (middleware should have already checked, but double-check) + const authCookie = req.cookies['esp-internal-auth']; + + if (!authCookie) { + return { + redirect: { + destination: '/api/auth/google', + permanent: false + } + }; + } + + // Decode the auth cookie to get user email + let userEmail = ''; + try { + const decoded = JSON.parse(Buffer.from(authCookie, 'base64').toString()); + userEmail = decoded.email || ''; + } catch { + return { + redirect: { + destination: '/api/auth/google', + permanent: false + } + }; + } + + const grants = await getPrivateGrants(); + + return { + props: { + grants, + userEmail + } + }; +}; + +export default InternalGrants; diff --git a/src/pages/grants/internal/unauthorized.tsx b/src/pages/grants/internal/unauthorized.tsx new file mode 100644 index 00000000..ba6c2e35 --- /dev/null +++ b/src/pages/grants/internal/unauthorized.tsx @@ -0,0 +1,76 @@ +import { Box, Button, Container, Heading, Stack, Text } from '@chakra-ui/react'; +import type { NextPage } from 'next'; +import { useRouter } from 'next/router'; +import { AlertCircle } from 'lucide-react'; + +import { PageMetadata } from '../../../components/UI'; + +const errorMessages: Record = { + invalid_domain: 'Only @ethereum.org email addresses can access this dashboard.', + oauth_denied: 'You cancelled the sign-in process.', + token_exchange: 'Failed to authenticate with Google. Please try again.', + user_info: 'Failed to retrieve your user information. Please try again.', + no_code: 'Invalid authentication request. Please try again.', + unknown: 'An unexpected error occurred. Please try again.' +}; + +const Unauthorized: NextPage = () => { + const router = useRouter(); + const { error } = router.query; + + const errorMessage = typeof error === 'string' && error in errorMessages + ? errorMessages[error] + : errorMessages.unknown; + + return ( + <> + + + + + + + + + + + Access Denied + + + + {errorMessage} + + + + + + + + + If you believe you should have access, please contact your administrator. + + + + + + ); +}; + +export default Unauthorized; diff --git a/src/types/grants.ts b/src/types/grants.ts new file mode 100644 index 00000000..a626e51e --- /dev/null +++ b/src/types/grants.ts @@ -0,0 +1,67 @@ +/** + * Grant record from Salesforce Opportunity object + * Used in the public Grants Explorer + */ +export interface GrantRecord { + id: string; + projectName: string; + description: string | null; + domain: string | null; + output: string | null; + publicContact: string | null; + projectRepo: string | null; + activatedDate: string; + fiscalQuarter: string; +} + +/** + * Additional fields for internal grants view + * Only visible to authenticated @ethereum.org users + */ +export interface PrivateGrantFields { + costCenter: string | null; + grantEvaluator: string | null; + grantRound: string | null; + budgetAmount: number | null; + status: string | null; +} + +/** + * Full grant record including private fields + * Used in the internal Grants Explorer + */ +export type PrivateGrantRecord = GrantRecord & PrivateGrantFields; + +/** + * Raw Salesforce Opportunity record + * Maps directly to SF API fields + */ +export interface SFOpportunityRecord { + Id: string; + Name: string; + Project_Description__c: string | null; + Opportunity_Domain__c: string | null; + Opportunity_Output__c: string | null; + Grantee_Contact_Details__c: string | null; + Project_Repo__c: string | null; + CloseDate: string | null; +} + +/** + * Extended Salesforce record with private fields + * Matching June's exact query (Jan 2026) + */ +export interface SFPrivateOpportunityRecord { + Id: string; + Name: string; + Project_Description__c: string | null; + Opportunity_Domain__c: string | null; + Opportunity_Output__c: string | null; + Grantee_Contact_Details__c: string | null; + Project_Repo__c: string | null; + CloseDate: string | null; + Cost_Center_Lookup__r?: { Name: string } | null; + Opportunity_Grant_Evaluator_Lookup__r?: { Name: string } | null; + Amount: number | null; + StageName: string | null; +} diff --git a/src/utils/fiscalYear.ts b/src/utils/fiscalYear.ts new file mode 100644 index 00000000..83b5104b --- /dev/null +++ b/src/utils/fiscalYear.ts @@ -0,0 +1,33 @@ +/** + * Derive fiscal quarter from activation date + * Calendar year: January - December + * Q1 = Jan-Mar, Q2 = Apr-Jun, Q3 = Jul-Sep, Q4 = Oct-Dec + */ +export function deriveFiscalQuarter(dateStr: string): string { + const [year, month] = dateStr.split('-').map(Number); + + let quarter: number; + if (month >= 1 && month <= 3) quarter = 1; + else if (month >= 4 && month <= 6) quarter = 2; + else if (month >= 7 && month <= 9) quarter = 3; + else quarter = 4; + + return `${year} Q${quarter}`; +} + +/** + * Get the start date for a fiscal year (calendar year) + * @param yearsAgo - Number of years to go back (0 = current) + */ +export function getFiscalYearStart(yearsAgo: number = 0): string { + const now = new Date(); + const targetYear = now.getFullYear() - yearsAgo; + return `${targetYear}-01-01`; +} + +/** + * Extract year from a fiscal quarter string (e.g., "2025 Q1" -> "2025") + */ +export function extractFiscalYear(fiscalQuarter: string): string { + return fiscalQuarter.split(' ')[0]; +}