feat(frontend): scaffold vite spa and ci (AP-4)

This commit is contained in:
Adam Moussa 2026-08-10 16:42:19 -04:00
parent f350ae9b51
commit bff873c2df
No known key found for this signature in database
29 changed files with 6275 additions and 1 deletions

2
.env.example Normal file
View file

@ -0,0 +1,2 @@
# Default data path until the API is wired (AP later tickets).
VITE_USE_MOCKS=true

24
.github/dependabot.yml vendored Normal file
View file

@ -0,0 +1,24 @@
version: 2
updates:
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "weekly"
groups:
minor-and-patch:
update-types:
- "minor"
- "patch"
commit-message:
prefix: "chore(deps)"
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
groups:
minor-and-patch:
update-types:
- "minor"
- "patch"
commit-message:
prefix: "chore(deps)"

16
.github/workflows/ci.yaml vendored Normal file
View file

@ -0,0 +1,16 @@
name: CI
on:
pull_request:
branches: [main]
permissions:
contents: read
jobs:
ci:
# Job id MUST stay `ci`: the reusable's job is also `ci`, so the check
# context resolves to the required `ci / ci`.
uses: Sea-Haven-Industries/.github/.github/workflows/ci-typescript-frontend.yaml@7ac3528750b346f181347bb09f6af927a1c0aa14 # v1.0.6
with:
node-version: "24"

10
.github/workflows/dependency-review.yml vendored Normal file
View file

@ -0,0 +1,10 @@
name: Dependency Review
on:
pull_request:
permissions:
contents: read
jobs:
review:
uses: Sea-Haven-Industries/.github/.github/workflows/callable-dependency-review.yaml@7ac3528750b346f181347bb09f6af927a1c0aa14 # v1.0.6

13
.github/workflows/labeler.yml vendored Normal file
View file

@ -0,0 +1,13 @@
name: Labeler
on:
pull_request:
branches: [main]
permissions:
contents: read
pull-requests: write
issues: write
jobs:
label:
uses: Sea-Haven-Industries/.github/.github/workflows/callable-labeler.yaml@7ac3528750b346f181347bb09f6af927a1c0aa14 # v1.0.6

29
.github/workflows/policy.yaml vendored Normal file
View file

@ -0,0 +1,29 @@
name: PR Policy
on:
pull_request:
types:
- opened
- reopened
- synchronize
- edited
- labeled
- unlabeled
- ready_for_review
concurrency:
group: "policy-${{ github.event.pull_request.number }}"
cancel-in-progress: true
permissions:
contents: read
issues: read
pull-requests: read
jobs:
policy:
uses: Sea-Haven-Industries/.github/.github/workflows/callable-pr-policy.yaml@7ac3528750b346f181347bb09f6af927a1c0aa14 # v1.0.6
secrets:
JIRA_CLOUD_ID: ${{ secrets.JIRA_CLOUD_ID }}
JIRA_SERVICE_ACCOUNT_EMAIL: ${{ secrets.JIRA_SERVICE_ACCOUNT_EMAIL }}
JIRA_API_TOKEN: ${{ secrets.JIRA_API_TOKEN }}

3
.gitignore vendored
View file

@ -27,3 +27,6 @@ npm-debug.log*
# TypeScript
*.tsbuildinfo
# Generouted (regenerated by Vite plugin)
src/router.ts

9
.prettierignore Normal file
View file

@ -0,0 +1,9 @@
dist
build
node_modules
coverage
package-lock.json
*.tsbuildinfo
src/router.ts
playwright-report
test-results

7
.prettierrc Normal file
View file

@ -0,0 +1,7 @@
{
"semi": true,
"singleQuote": false,
"trailingComma": "all",
"printWidth": 100,
"tabWidth": 2
}

View file

@ -6,13 +6,15 @@ Internal accounts payable automation for Sea Haven Industries (`ap.seahaven.com`
```bash
npm ci
cp .env.example .env # optional; defaults already use mocks
npm run dev
```
`VITE_USE_MOCKS=true` is the default data path until the API is wired.
App serves at http://localhost:3000. `VITE_USE_MOCKS=true` is the default data path until the API is wired (`src/mocks`).
## Verify
```bash
npm run verify
npm run test:e2e
```

7
e2e/home.spec.ts Normal file
View file

@ -0,0 +1,7 @@
import { expect, test } from "@playwright/test";
test("home page shows Sea Haven AP brand", async ({ page }) => {
await page.goto("/");
await expect(page.getByRole("heading", { name: "Sea Haven AP" })).toBeVisible();
await expect(page.getByTestId("mocks-status")).toContainText("Mocks enabled");
});

36
eslint.config.js Normal file
View file

@ -0,0 +1,36 @@
import js from "@eslint/js";
import eslintConfigPrettier from "eslint-config-prettier";
import pluginReactHooks from "eslint-plugin-react-hooks";
import pluginReactRefresh from "eslint-plugin-react-refresh";
import globals from "globals";
import tseslint from "typescript-eslint";
export default tseslint.config(
{
ignores: ["dist/**", "build/**", "node_modules/**", "coverage/**", "src/router.ts"],
},
js.configs.recommended,
...tseslint.configs.recommended,
eslintConfigPrettier,
{
files: ["src/**/*.{ts,tsx}", "vite.config.ts", "vitest.config.ts", "playwright.config.ts"],
languageOptions: {
ecmaVersion: "latest",
sourceType: "module",
globals: globals.browser,
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
plugins: {
"react-hooks": pluginReactHooks,
"react-refresh": pluginReactRefresh,
},
rules: {
...pluginReactHooks.configs.recommended.rules,
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
"@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
},
},
);

15
index.html Normal file
View file

@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#0b3d5c" />
<meta name="description" content="Sea Haven AP — internal accounts payable" />
<title>Sea Haven AP</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

5733
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

58
package.json Normal file
View file

@ -0,0 +1,58 @@
{
"name": "seahaven-ap",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest",
"test:e2e": "playwright test",
"lint": "eslint . --max-warnings=0",
"lint:fix": "eslint . --fix --max-warnings=0",
"format": "prettier --write .",
"format:check": "prettier --check .",
"verify": "npm run format:check && npm run lint && npm run build && npm test"
},
"engines": {
"node": ">=22.22.1"
},
"packageManager": "npm@11.16.0",
"dependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@generouted/react-router": "^1.20.2",
"@mui/icons-material": "^7.3.2",
"@mui/material": "^7.3.2",
"@mui/x-data-grid": "^8.11.0",
"@tanstack/react-query": "^5.85.5",
"@tanstack/react-query-devtools": "^5.85.5",
"ky": "^1.10.0",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-router": "^7.8.2"
},
"devDependencies": {
"@eslint/js": "^9.34.0",
"@playwright/test": "^1.55.0",
"@testing-library/jest-dom": "^6.8.0",
"@testing-library/react": "^16.3.0",
"@types/node": "^24.3.0",
"@types/react": "^19.1.12",
"@types/react-dom": "^19.1.9",
"@vitejs/plugin-react": "^5.0.2",
"eslint": "^9.34.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"globals": "^16.3.0",
"jsdom": "^26.1.0",
"prettier": "^3.6.2",
"typescript": "^5.9.2",
"typescript-eslint": "^8.41.0",
"vite": "^7.1.3",
"vitest": "^3.2.4"
}
}

26
playwright.config.ts Normal file
View file

@ -0,0 +1,26 @@
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./e2e",
fullyParallel: true,
forbidOnly: Boolean(process.env.CI),
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: process.env.CI ? "github" : "list",
use: {
baseURL: "http://localhost:4173",
trace: "on-first-retry",
},
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
],
webServer: {
command: "npm run dev -- --port 4173 --strictPort",
url: "http://localhost:4173",
reuseExistingServer: !process.env.CI,
timeout: 120_000,
},
});

27
src/lib/mui-theme.ts Normal file
View file

@ -0,0 +1,27 @@
import { createTheme } from "@mui/material/styles";
export const muiTheme = createTheme({
palette: {
mode: "light",
primary: {
main: "#0b3d5c",
},
secondary: {
main: "#1f6f8b",
},
background: {
default: "#f5f7fa",
paper: "#ffffff",
},
},
typography: {
fontFamily: '"IBM Plex Sans", "Helvetica Neue", Arial, sans-serif',
h1: {
fontFamily: '"IBM Plex Serif", Georgia, serif',
fontWeight: 600,
},
},
shape: {
borderRadius: 8,
},
});

12
src/lib/query-client.ts Normal file
View file

@ -0,0 +1,12 @@
import { QueryClient } from "@tanstack/react-query";
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000,
gcTime: 10 * 60 * 1000,
retry: 1,
refetchOnWindowFocus: false,
},
},
});

18
src/main.tsx Normal file
View file

@ -0,0 +1,18 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { Routes } from "@generouted/react-router";
import { AppProviders } from "@/providers/AppProviders";
const rootElement = document.getElementById("root");
if (!rootElement) {
throw new Error("Root element #root not found");
}
ReactDOM.createRoot(rootElement).render(
<React.StrictMode>
<AppProviders>
<Routes />
</AppProviders>
</React.StrictMode>,
);

13
src/mocks/index.test.ts Normal file
View file

@ -0,0 +1,13 @@
import { describe, expect, it } from "vitest";
import { areMocksEnabled, getMockInvoices, getMockVendors } from "@/mocks";
describe("mocks module", () => {
it("defaults to mocks enabled", () => {
expect(areMocksEnabled()).toBe(true);
});
it("exposes sample invoices and vendors", () => {
expect(getMockVendors().length).toBeGreaterThan(0);
expect(getMockInvoices().length).toBeGreaterThan(0);
});
});

77
src/mocks/index.ts Normal file
View file

@ -0,0 +1,77 @@
export type MockVendor = {
id: string;
name: string;
};
export type MockInvoice = {
id: string;
invoiceNumber: string;
vendorId: string;
vendorName: string;
amountDue: number;
dueDate: string;
payDate: string | null;
status: "pending_approval" | "approved" | "scheduled" | "paid";
paymentMethod: "check" | "ach";
};
export const mockVendors: MockVendor[] = [
{ id: "vendor-1", name: "Acme Supplies Co" },
{ id: "vendor-2", name: "Harbor Maintenance LLC" },
{ id: "vendor-3", name: "Northwind Utilities" },
];
export const mockInvoices: MockInvoice[] = [
{
id: "inv-1",
invoiceNumber: "INV-1001",
vendorId: "vendor-1",
vendorName: "Acme Supplies Co",
amountDue: 1250.5,
dueDate: "2026-08-20",
payDate: "2026-08-15",
status: "approved",
paymentMethod: "check",
},
{
id: "inv-2",
invoiceNumber: "INV-1002",
vendorId: "vendor-2",
vendorName: "Harbor Maintenance LLC",
amountDue: 4800,
dueDate: "2026-08-25",
payDate: null,
status: "pending_approval",
paymentMethod: "ach",
},
{
id: "inv-3",
invoiceNumber: "INV-1003",
vendorId: "vendor-3",
vendorName: "Northwind Utilities",
amountDue: 312.75,
dueDate: "2026-08-12",
payDate: "2026-08-10",
status: "approved",
paymentMethod: "check",
},
];
/** Mocks are the default data path until the API is wired. */
export function areMocksEnabled(): boolean {
return import.meta.env.VITE_USE_MOCKS !== "false";
}
export function getMockInvoices(): MockInvoice[] {
if (!areMocksEnabled()) {
return [];
}
return mockInvoices;
}
export function getMockVendors(): MockVendor[] {
if (!areMocksEnabled()) {
return [];
}
return mockVendors;
}

26
src/pages/index.tsx Normal file
View file

@ -0,0 +1,26 @@
import { Box, Container, Stack, Typography } from "@mui/material";
import { areMocksEnabled, getMockInvoices, getMockVendors } from "@/mocks";
export default function HomePage() {
const invoices = getMockInvoices();
const vendors = getMockVendors();
return (
<Box component="main" sx={{ minHeight: "100vh", py: 8 }}>
<Container maxWidth="md">
<Stack spacing={2}>
<Typography component="h1" variant="h1">
Sea Haven AP
</Typography>
<Typography color="text.secondary">
Internal accounts payable automation. Dashboard grids land in AP-5.
</Typography>
<Typography variant="body2" data-testid="mocks-status">
Mocks {areMocksEnabled() ? "enabled" : "disabled"} — {vendors.length} vendors,{" "}
{invoices.length} invoices
</Typography>
</Stack>
</Container>
</Box>
);
}

View file

@ -0,0 +1,17 @@
import { QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { CssBaseline, ThemeProvider } from "@mui/material";
import { queryClient } from "@/lib/query-client";
import { muiTheme } from "@/lib/mui-theme";
export function AppProviders({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
<ThemeProvider theme={muiTheme}>
<CssBaseline />
{children}
{import.meta.env.DEV ? <ReactQueryDevtools initialIsOpen={false} /> : null}
</ThemeProvider>
</QueryClientProvider>
);
}

1
src/test/setup.ts Normal file
View file

@ -0,0 +1 @@
import "@testing-library/jest-dom/vitest";

9
src/vite-env.d.ts vendored Normal file
View file

@ -0,0 +1,9 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_USE_MOCKS: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

26
tsconfig.json Normal file
View file

@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"composite": true,
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

20
tsconfig.node.json Normal file
View file

@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"strict": true,
"composite": true,
"declaration": true,
"declarationMap": true,
"emitDeclarationOnly": true,
"outDir": "./node_modules/.tmp/tsconfig.node",
"types": ["node"]
},
"include": ["vite.config.ts", "vitest.config.ts", "playwright.config.ts"]
}

19
vite.config.ts Normal file
View file

@ -0,0 +1,19 @@
import generouted from "@generouted/react-router/plugin";
import react from "@vitejs/plugin-react";
import path from "node:path";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [react(), generouted()],
resolve: {
alias: {
"@": path.resolve(__dirname, "src"),
},
},
server: {
port: 3000,
},
build: {
outDir: "dist",
},
});

19
vitest.config.ts Normal file
View file

@ -0,0 +1,19 @@
import react from "@vitejs/plugin-react";
import path from "node:path";
import { defineConfig } from "vitest/config";
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
"@": path.resolve(__dirname, "src"),
},
},
test: {
environment: "jsdom",
setupFiles: ["./src/test/setup.ts"],
globals: false,
include: ["src/**/*.test.{ts,tsx}"],
exclude: ["e2e/**", "node_modules/**"],
},
});